Skip to content

Commit 48ec002

Browse files
authored
Fix test infrastructure for Python-version-excluded providers (#63793)
* Skip provider tests when all test directories are excluded When running Providers[google] or Providers[amazon] on Python 3.14, generate_args_for_pytest removes the test folders for excluded providers, but the skip check in _run_test only triggered when the --ignore filter itself removed something. Since the folders were already removed upstream, the guard condition was never met, leaving pytest with only flags and no test directories — causing it to crash on unrecognized custom arguments. Remove the overly strict guard so the skip fires whenever no test directories remain in the args. * Fix PROD image docker tests for Python-version-excluded providers The docker tests expected all providers from prod_image_installed_providers.txt to be present, but providers like google and amazon declare excluded-python-versions in their provider.yaml. On Python 3.14, these providers are correctly excluded from the PROD image at build time, but the tests didn't account for this. Read provider.yaml exclusions and filter expected providers/imports based on the Docker image's Python version. * Skip Python-incompatible provider wheels during PROD image build get_distribution_specs.py now reads Requires-Python metadata from each wheel and skips wheels that are incompatible with the running interpreter. This prevents excluded providers (e.g. amazon on 3.14) from being passed to pip/uv and installed despite their exclusion. Also fix the requires-python specifier generation in packages.py: !=3.14 per PEP 440 only excludes 3.14.0, not 3.14.2. Use !=3.14.* wildcard to exclude the entire minor version.
1 parent 210158e commit 48ec002

7 files changed

Lines changed: 273 additions & 13 deletions

File tree

Dockerfile

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,15 +1114,35 @@ from __future__ import annotations
11141114

11151115
import os
11161116
import sys
1117+
import zipfile
1118+
from email.parser import HeaderParser
11171119
from pathlib import Path
11181120

1121+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
11191122
from packaging.utils import (
11201123
InvalidSdistFilename,
11211124
InvalidWheelFilename,
11221125
parse_sdist_filename,
11231126
parse_wheel_filename,
11241127
)
11251128

1129+
_CURRENT_PYTHON = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
1130+
1131+
1132+
def _compatible_with_current_python(wheel_path: str) -> bool:
1133+
"""Return False if the wheel's Requires-Python excludes the running interpreter."""
1134+
try:
1135+
with zipfile.ZipFile(wheel_path) as zf:
1136+
for name in zf.namelist():
1137+
if name.endswith(".dist-info/METADATA"):
1138+
requires = HeaderParser().parsestr(zf.read(name).decode("utf-8")).get("Requires-Python")
1139+
if requires:
1140+
return _CURRENT_PYTHON in SpecifierSet(requires)
1141+
return True
1142+
except (zipfile.BadZipFile, InvalidSpecifier, KeyError) as exc:
1143+
print(f"Warning: could not check Requires-Python for {wheel_path}: {exc}", file=sys.stderr)
1144+
return True
1145+
11261146

11271147
def print_package_specs(extras: str = "") -> None:
11281148
for package_path in sys.argv[1:]:
@@ -1134,6 +1154,12 @@ def print_package_specs(extras: str = "") -> None:
11341154
except InvalidSdistFilename:
11351155
print(f"Could not parse package name from {package_path}", file=sys.stderr)
11361156
continue
1157+
if package_path.endswith(".whl") and not _compatible_with_current_python(package_path):
1158+
print(
1159+
f"Skipping {package} (Requires-Python not satisfied by {_CURRENT_PYTHON})",
1160+
file=sys.stderr,
1161+
)
1162+
continue
11371163
print(f"{package}{extras} @ file://{package_path}")
11381164

11391165

dev/breeze/src/airflow_breeze/commands/testing_commands.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,9 @@ def _run_test(
249249
pytest_args.extend(extra_pytest_args)
250250
# Skip "FOLDER" in case "--ignore=FOLDER" is passed as an argument
251251
# Which might be the case if we are ignoring some providers during compatibility checks
252-
pytest_args_before_skip = pytest_args
253252
pytest_args = [arg for arg in pytest_args if f"--ignore={arg}" not in pytest_args]
254-
# Double check: If no test is leftover we can skip running the test
255-
if pytest_args_before_skip != pytest_args and pytest_args[0].startswith("--"):
253+
# If no test directory is left (all positional args were excluded/ignored), skip
254+
if pytest_args and pytest_args[0].startswith("--"):
256255
return 0, f"Skipped test, no tests needed: {shell_params.test_type}"
257256
run_cmd.extend(pytest_args)
258257
try:

dev/breeze/src/airflow_breeze/utils/packages.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -705,7 +705,7 @@ def get_provider_jinja_context(
705705
requires_python_version: str = f">={DEFAULT_PYTHON_MAJOR_MINOR_VERSION}"
706706
# Most providers require the same python versions, but some may have exclusions
707707
for excluded_python_version in provider_details.excluded_python_versions:
708-
requires_python_version += f",!={excluded_python_version}"
708+
requires_python_version += f",!={excluded_python_version}.*"
709709

710710
context: dict[str, Any] = {
711711
"PROVIDER_ID": provider_details.provider_id,

docker-tests/tests/docker_tests/test_prod_image.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from importlib.util import find_spec
2222

2323
import pytest
24+
import yaml
2425
from python_on_whales import DockerException
2526

2627
from docker_tests.constants import AIRFLOW_ROOT_PATH
@@ -57,6 +58,39 @@
5758
testing_slim_image = os.environ.get("TEST_SLIM_IMAGE", str(False)).lower() in ("true", "1", "yes")
5859

5960

61+
def _get_provider_python_exclusions() -> dict[str, list[str]]:
62+
"""Return mapping of provider_id -> list of excluded Python minor versions from provider.yaml."""
63+
exclusions: dict[str, list[str]] = {}
64+
providers_root = AIRFLOW_ROOT_PATH / "providers"
65+
for line in PROD_IMAGE_PROVIDERS_FILE_PATH.read_text().splitlines():
66+
provider_id = line.split(">=")[0].strip()
67+
if not provider_id or provider_id.startswith("#"):
68+
continue
69+
provider_yaml_path = providers_root / provider_id.replace(".", "/") / "provider.yaml"
70+
if not provider_yaml_path.exists():
71+
continue
72+
with open(provider_yaml_path) as f:
73+
data = yaml.safe_load(f)
74+
excluded = data.get("excluded-python-versions", [])
75+
if excluded:
76+
exclusions[provider_id] = [str(v) for v in excluded]
77+
return exclusions
78+
79+
80+
PROVIDER_PYTHON_EXCLUSIONS = _get_provider_python_exclusions()
81+
82+
83+
def _get_python_minor_version(default_docker_image: str) -> str:
84+
"""Get Python minor version (e.g. '3.14') from the Docker image."""
85+
python_version = run_bash_in_docker("python --version", image=default_docker_image)
86+
return ".".join(python_version.strip().split()[1].split(".")[:2])
87+
88+
89+
def _get_excluded_provider_ids(python_minor: str) -> set[str]:
90+
"""Return set of provider IDs excluded for the given Python minor version."""
91+
return {pid for pid, versions in PROVIDER_PYTHON_EXCLUSIONS.items() if python_minor in versions}
92+
93+
6094
class TestCommands:
6195
def test_without_command(self, default_docker_image):
6296
"""Checking the image without a command. It should return non-zero exit code."""
@@ -91,7 +125,10 @@ def test_required_providers_are_installed(self, default_docker_image):
91125
if testing_slim_image:
92126
packages_to_install = set(SLIM_IMAGE_PROVIDERS)
93127
else:
94-
packages_to_install = set(REGULAR_IMAGE_PROVIDERS)
128+
python_minor = _get_python_minor_version(default_docker_image)
129+
excluded_ids = _get_excluded_provider_ids(python_minor)
130+
excluded_packages = {f"apache-airflow-providers-{pid.replace('.', '-')}" for pid in excluded_ids}
131+
packages_to_install = set(REGULAR_IMAGE_PROVIDERS) - excluded_packages
95132
assert len(packages_to_install) != 0
96133
output = run_bash_in_docker(
97134
"airflow providers list --output json",
@@ -197,15 +234,19 @@ def test_pip_dependencies_conflict(self, default_docker_image):
197234
def test_check_dependencies_imports(
198235
self, package_name: str, import_names: list[str], default_docker_image: str
199236
):
237+
python_minor = _get_python_minor_version(default_docker_image)
238+
excluded_ids = _get_excluded_provider_ids(python_minor)
239+
# Skip individual provider test cases if the provider is excluded for this Python version
240+
if package_name in excluded_ids:
241+
pytest.skip(f"Provider {package_name} is excluded for Python {python_minor}")
200242
if package_name == "providers":
201-
python_version = run_bash_in_docker(
202-
"python --version",
203-
image=default_docker_image,
204-
)
205-
if python_version.startswith("Python 3.13"):
206-
if "airflow.providers.fab" in import_names:
207-
import_names.remove("airflow.providers.fab")
208-
run_python_in_docker(f"import {','.join(import_names)}", image=default_docker_image)
243+
excluded_imports = {f"airflow.providers.{pid}" for pid in excluded_ids}
244+
import_names = [name for name in import_names if name not in excluded_imports]
245+
# FAB provider has import issues on Python 3.13
246+
if python_minor == "3.13":
247+
import_names = [name for name in import_names if name != "airflow.providers.fab"]
248+
if import_names:
249+
run_python_in_docker(f"import {','.join(import_names)}", image=default_docker_image)
209250

210251
def test_there_is_no_opt_airflow_airflow_folder(self, default_docker_image):
211252
output = run_bash_in_docker(

scripts/docker/get_distribution_specs.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,35 @@
1919

2020
import os
2121
import sys
22+
import zipfile
23+
from email.parser import HeaderParser
2224
from pathlib import Path
2325

26+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
2427
from packaging.utils import (
2528
InvalidSdistFilename,
2629
InvalidWheelFilename,
2730
parse_sdist_filename,
2831
parse_wheel_filename,
2932
)
3033

34+
_CURRENT_PYTHON = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
35+
36+
37+
def _compatible_with_current_python(wheel_path: str) -> bool:
38+
"""Return False if the wheel's Requires-Python excludes the running interpreter."""
39+
try:
40+
with zipfile.ZipFile(wheel_path) as zf:
41+
for name in zf.namelist():
42+
if name.endswith(".dist-info/METADATA"):
43+
requires = HeaderParser().parsestr(zf.read(name).decode("utf-8")).get("Requires-Python")
44+
if requires:
45+
return _CURRENT_PYTHON in SpecifierSet(requires)
46+
return True
47+
except (zipfile.BadZipFile, InvalidSpecifier, KeyError) as exc:
48+
print(f"Warning: could not check Requires-Python for {wheel_path}: {exc}", file=sys.stderr)
49+
return True
50+
3151

3252
def print_package_specs(extras: str = "") -> None:
3353
for package_path in sys.argv[1:]:
@@ -39,6 +59,12 @@ def print_package_specs(extras: str = "") -> None:
3959
except InvalidSdistFilename:
4060
print(f"Could not parse package name from {package_path}", file=sys.stderr)
4161
continue
62+
if package_path.endswith(".whl") and not _compatible_with_current_python(package_path):
63+
print(
64+
f"Skipping {package} (Requires-Python not satisfied by {_CURRENT_PYTHON})",
65+
file=sys.stderr,
66+
)
67+
continue
4268
print(f"{package}{extras} @ file://{package_path}")
4369

4470

scripts/tests/docker/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
from __future__ import annotations
18+
19+
import subprocess
20+
import sys
21+
import zipfile
22+
from pathlib import Path
23+
24+
import pytest
25+
26+
SCRIPT_PATH = Path(__file__).resolve().parents[2] / "docker" / "get_distribution_specs.py"
27+
28+
CURRENT_PYTHON_MAJOR_MINOR = f"{sys.version_info.major}.{sys.version_info.minor}"
29+
30+
31+
def _make_wheel(directory: Path, name: str, version: str, requires_python: str | None = None) -> Path:
32+
"""Create a minimal .whl (zip) with METADATA."""
33+
safe_name = name.replace("-", "_")
34+
wheel_path = directory / f"{safe_name}-{version}-py3-none-any.whl"
35+
dist_info = f"{safe_name}-{version}.dist-info"
36+
metadata_lines = [
37+
"Metadata-Version: 2.1",
38+
f"Name: {name}",
39+
f"Version: {version}",
40+
]
41+
if requires_python is not None:
42+
metadata_lines.append(f"Requires-Python: {requires_python}")
43+
with zipfile.ZipFile(wheel_path, "w") as zf:
44+
zf.writestr(f"{dist_info}/METADATA", "\n".join(metadata_lines))
45+
zf.writestr(f"{dist_info}/RECORD", "")
46+
return wheel_path
47+
48+
49+
def _run_script(*wheel_paths: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
50+
return subprocess.run(
51+
[sys.executable, str(SCRIPT_PATH), *(str(p) for p in wheel_paths)],
52+
capture_output=True,
53+
text=True,
54+
check=False,
55+
env=env,
56+
)
57+
58+
59+
class TestRequiresPythonFiltering:
60+
def test_wheel_without_requires_python_is_included(self, tmp_path):
61+
whl = _make_wheel(tmp_path, "some-package", "1.0.0")
62+
result = _run_script(whl)
63+
assert result.returncode == 0
64+
assert f"some-package @ file://{whl}" in result.stdout
65+
66+
def test_wheel_matching_current_python_is_included(self, tmp_path):
67+
whl = _make_wheel(tmp_path, "some-package", "1.0.0", requires_python=">=3.10")
68+
result = _run_script(whl)
69+
assert result.returncode == 0
70+
assert f"some-package @ file://{whl}" in result.stdout
71+
72+
def test_wheel_excluding_current_python_is_skipped(self, tmp_path):
73+
whl = _make_wheel(
74+
tmp_path,
75+
"excluded-package",
76+
"2.0.0",
77+
requires_python=f">=3.10,!={CURRENT_PYTHON_MAJOR_MINOR}.*",
78+
)
79+
result = _run_script(whl)
80+
assert result.returncode == 0
81+
assert result.stdout == ""
82+
assert "Skipping" in result.stderr
83+
assert "excluded-package" in result.stderr
84+
85+
def test_corrupt_wheel_is_included_with_warning(self, tmp_path):
86+
bad_whl = tmp_path / "bad_package-1.0.0-py3-none-any.whl"
87+
bad_whl.write_bytes(b"not a zip")
88+
result = _run_script(bad_whl)
89+
assert result.returncode == 0
90+
assert f"bad-package @ file://{bad_whl}" in result.stdout
91+
assert "Warning" in result.stderr
92+
93+
94+
class TestMixedInputs:
95+
def test_compatible_and_incompatible_together(self, tmp_path):
96+
good_whl = _make_wheel(
97+
tmp_path, "apache-airflow-providers-standard", "1.0.0", requires_python=">=3.10"
98+
)
99+
bad_whl = _make_wheel(
100+
tmp_path,
101+
"apache-airflow-providers-google",
102+
"21.0.0",
103+
requires_python=f">=3.10,!={CURRENT_PYTHON_MAJOR_MINOR}.*",
104+
)
105+
result = _run_script(good_whl, bad_whl)
106+
assert result.returncode == 0
107+
assert "apache-airflow-providers-standard" in result.stdout
108+
assert "apache-airflow-providers-google" not in result.stdout
109+
assert "Skipping" in result.stderr
110+
111+
def test_sdist_is_not_filtered(self, tmp_path):
112+
"""Sdists cannot be inspected for Requires-Python without building, so they pass through."""
113+
import tarfile
114+
115+
sdist = tmp_path / "apache_airflow_providers_google-21.0.0.tar.gz"
116+
with tarfile.open(sdist, "w:gz"):
117+
pass
118+
result = _run_script(sdist)
119+
assert result.returncode == 0
120+
assert "apache-airflow-providers-google" in result.stdout
121+
122+
123+
class TestExtrasEnvVar:
124+
def test_extras_appended_to_spec(self, tmp_path):
125+
import os
126+
127+
whl = _make_wheel(tmp_path, "apache-airflow", "3.0.0", requires_python=">=3.10")
128+
env = {**os.environ, "EXTRAS": "[celery,google]"}
129+
result = _run_script(whl, env=env)
130+
assert result.returncode == 0
131+
assert f"apache-airflow[celery,google] @ file://{whl}" in result.stdout
132+
133+
134+
@pytest.mark.parametrize(
135+
("requires_python", "should_include"),
136+
[
137+
pytest.param(">=3.10", True, id="lower-bound-satisfied"),
138+
pytest.param(f"!={CURRENT_PYTHON_MAJOR_MINOR}.*", False, id="wildcard-minor-excluded"),
139+
pytest.param(f">=3.10,!={CURRENT_PYTHON_MAJOR_MINOR}.*", False, id="range-with-exclusion"),
140+
pytest.param("<3.10", False, id="upper-bound-below-current"),
141+
pytest.param(None, True, id="no-requires-python"),
142+
],
143+
)
144+
def test_requires_python_specifiers(tmp_path, requires_python, should_include):
145+
whl = _make_wheel(tmp_path, "test-package", "1.0.0", requires_python=requires_python)
146+
result = _run_script(whl)
147+
assert result.returncode == 0
148+
if should_include:
149+
assert f"test-package @ file://{whl}" in result.stdout
150+
else:
151+
assert result.stdout == ""
152+
assert "Skipping" in result.stderr

0 commit comments

Comments
 (0)