Skip to content

Commit cf4c769

Browse files
authored
Require a lower bound on every dependency in pyproject.toml (#71378)
An unbounded requirement lets the resolver answer with any version that has ever been published, so what our constraints pin - and what a user ends up installing - depends on how the resolution went rather than on what the code needs. Four such requirements were found and fixed by hand while pinning providers in constraints (#71324); they sat in four different dependency tables, which is why they went unnoticed for so long. Distributions that are members of the uv workspace are exempt - they resolve from the checkout, so a version range would say nothing - as are direct URL requirements, where the URL already names the exact artifact.
1 parent fe9ca48 commit cf4c769

8 files changed

Lines changed: 332 additions & 21 deletions

File tree

.pre-commit-config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,13 @@ repos:
10811081
pass_filenames: false
10821082
require_serial: true
10831083
additional_dependencies: ['packaging>=25', 'pyyaml', 'tomli>=2.0.1', 'rich>=13.6.0']
1084+
- id: check-dependency-lower-bounds
1085+
name: Check that dependencies in pyproject.toml have lower bounds
1086+
language: python
1087+
entry: ./scripts/ci/prek/check_dependency_lower_bounds.py
1088+
files: (^|/)pyproject\.toml$
1089+
require_serial: true
1090+
additional_dependencies: ['packaging>=25', 'tomli>=2.0.1', 'rich>=13.6.0']
10841091
- id: update-reproducible-source-date-epoch
10851092
name: Update Source Date Epoch for reproducible builds
10861093
language: python

clients/python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ classifiers = [
5959

6060
dependencies = [
6161
"pydantic >= 2.11.0",
62-
"python-dateutil",
62+
"python-dateutil>=2.7.0",
6363
"urllib3>=2.1.0,!=2.6.0",
6464
]
6565

contributing-docs/13_airflow_dependencies_and_extras.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,21 @@ rules to remember:
239239
stopped working (like in case of ``amazon``, ``fab``). You are free to modify those versions to higher
240240
versions if you need to, and ``prek`` will remove those comments automatically.
241241

242+
* Every dependency we resolve from PyPI must have a lower bound. Without one the resolver is free to answer
243+
with any version that has ever been published, so what our constraints pin - and what a user ends up
244+
installing - depends on how the resolution went rather than on what the code needs. The
245+
``check-dependency-lower-bounds`` prek hook enforces this across ``project.dependencies``,
246+
``project.optional-dependencies``, ``dependency-groups`` and ``build-system.requires`` of every
247+
``pyproject.toml``. Use the oldest version you are willing to test against:
248+
249+
.. code-block:: python
250+
251+
"pyspark>=4.0.0",
252+
253+
Two kinds of requirement are exempt: distributions that are members of our ``uv`` workspace (they are
254+
resolved from the checkout, so a version range would say nothing) and direct URL requirements (the URL
255+
already names the exact artifact).
256+
242257
Our CI system will do all the tests for you anyway - including running some lower-bind checks on dependencies.
243258
For example it will take each provider in a turn and will try to resolve lowest-possible dependencies defined
244259
for that provider and see if the tests are still passing, so we should be relatively protected against putting

providers/common/ai/docs/index.rst

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,11 @@ Install them when installing from PyPI. For example:
241241
============== =======================================================================================================================================
242242
Extra Dependencies
243243
============== =======================================================================================================================================
244-
``anthropic`` ``pydantic-ai-slim[anthropic]``
245-
``bedrock`` ``pydantic-ai-slim[bedrock]``
246-
``google`` ``pydantic-ai-slim[google]``
247-
``openai`` ``pydantic-ai-slim[openai]``
248-
``mcp`` ``pydantic-ai-slim[mcp]``
244+
``anthropic`` ``pydantic-ai-slim[anthropic]>=2.0.0``
245+
``bedrock`` ``pydantic-ai-slim[bedrock]>=2.0.0``
246+
``google`` ``pydantic-ai-slim[google]>=2.0.0``
247+
``openai`` ``pydantic-ai-slim[openai]>=2.0.0``
248+
``mcp`` ``pydantic-ai-slim[mcp]>=2.0.0``
249249
``code-mode`` ``pydantic-ai-harness[codemode]>=0.3.0``
250250
``shields`` ``pydantic-ai-shields>=0.3.4``
251251
``skills`` ``apache-airflow-providers-git>=0.4.0``, ``pydantic-ai-skills>=1.2.0``

providers/common/ai/pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,11 @@ dependencies = [
7676
# The optional dependencies should be modified in place in the generated file
7777
# Any change in the dependencies is preserved when the file is regenerated
7878
[project.optional-dependencies]
79-
"anthropic" = ["pydantic-ai-slim[anthropic]"]
80-
"bedrock" = ["pydantic-ai-slim[bedrock]"]
81-
"google" = ["pydantic-ai-slim[google]"]
82-
"openai" = ["pydantic-ai-slim[openai]"]
83-
"mcp" = ["pydantic-ai-slim[mcp]"]
79+
"anthropic" = ["pydantic-ai-slim[anthropic]>=2.0.0"]
80+
"bedrock" = ["pydantic-ai-slim[bedrock]>=2.0.0"]
81+
"google" = ["pydantic-ai-slim[google]>=2.0.0"]
82+
"openai" = ["pydantic-ai-slim[openai]>=2.0.0"]
83+
"mcp" = ["pydantic-ai-slim[mcp]>=2.0.0"]
8484
# Code mode: collapse tool calls into a single `run_code` tool that the model
8585
# drives by writing Python, executed in the Monty sandbox (pydantic-monty).
8686
# Enables AgentOperator(code_mode=True). Monty is pre-1.0; pinned here as an
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
#!/usr/bin/env python
2+
#
3+
# Licensed to the Apache Software Foundation (ASF) under one
4+
# or more contributor license agreements. See the NOTICE file
5+
# distributed with this work for additional information
6+
# regarding copyright ownership. The ASF licenses this file
7+
# to you under the Apache License, Version 2.0 (the
8+
# "License"); you may not use this file except in compliance
9+
# with the License. You may obtain a copy of the License at
10+
#
11+
# http://www.apache.org/licenses/LICENSE-2.0
12+
#
13+
# Unless required by applicable law or agreed to in writing,
14+
# software distributed under the License is distributed on an
15+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
# KIND, either express or implied. See the License for the
17+
# specific language governing permissions and limitations
18+
# under the License.
19+
# /// script
20+
# requires-python = ">=3.10,<3.11"
21+
# dependencies = [
22+
# "packaging>=25",
23+
# "rich>=13.6.0",
24+
# "tomli>=2.0.1",
25+
# ]
26+
# ///
27+
"""
28+
Validate that every external dependency declared in a ``pyproject.toml`` has a lower bound.
29+
30+
An unbounded requirement lets the resolver answer with any version that happens to be on
31+
PyPI, so what a constraints file pins - and what a user ends up installing - depends on how
32+
the resolution went rather than on what the code needs. Naming the oldest supported version
33+
makes that answer deterministic and documents the floor the code is tested against.
34+
35+
Checked locations: ``project.dependencies``, ``project.optional-dependencies``,
36+
``dependency-groups`` and ``build-system.requires``.
37+
38+
Requirements resolved from the uv workspace rather than from PyPI are exempt - their source
39+
is the checkout, so a version range would say nothing. Direct URL requirements are exempt
40+
too, since the URL already names the exact artifact.
41+
"""
42+
43+
from __future__ import annotations
44+
45+
import sys
46+
from functools import cache
47+
from pathlib import Path
48+
49+
from common_prek_utils import AIRFLOW_ROOT_PATH, console
50+
from packaging.requirements import InvalidRequirement, Requirement
51+
from packaging.utils import canonicalize_name
52+
from rich.markup import escape
53+
54+
try:
55+
import tomllib
56+
except ImportError:
57+
import tomli as tomllib # type: ignore[no-redef]
58+
59+
# Operators that place a floor under the resolved version; anything else (``!=``, ``<``,
60+
# ``<=``) leaves the resolver free to reach back to the oldest release ever published.
61+
LOWER_BOUND_OPERATORS = {">=", ">", "==", "===", "~="}
62+
63+
64+
def _load_toml(path: Path) -> dict:
65+
return tomllib.loads(path.read_text())
66+
67+
68+
@cache
69+
def get_workspace_distribution_names() -> frozenset[str]:
70+
"""Return the canonical names of the distributions uv resolves from this checkout."""
71+
root_pyproject = AIRFLOW_ROOT_PATH / "pyproject.toml"
72+
root_data = _load_toml(root_pyproject)
73+
members = root_data.get("tool", {}).get("uv", {}).get("workspace", {}).get("members", [])
74+
names = set()
75+
for member in members:
76+
for member_path in sorted(AIRFLOW_ROOT_PATH.glob(f"{member}/pyproject.toml")):
77+
if name := _load_toml(member_path).get("project", {}).get("name"):
78+
names.add(canonicalize_name(name))
79+
return frozenset(names)
80+
81+
82+
def extract_requirements(data: dict) -> list[tuple[str, str]]:
83+
"""Return ``(section, requirement)`` pairs for every dependency table we guard."""
84+
requirements: list[tuple[str, str]] = []
85+
project = data.get("project") or {}
86+
for dependency in project.get("dependencies") or []:
87+
requirements.append(("project.dependencies", dependency))
88+
for extra, dependencies in (project.get("optional-dependencies") or {}).items():
89+
for dependency in dependencies:
90+
requirements.append((f'project.optional-dependencies."{extra}"', dependency))
91+
for group, dependencies in (data.get("dependency-groups") or {}).items():
92+
for dependency in dependencies:
93+
# A group may also pull in another group via ``{include-group = "..."}``.
94+
if isinstance(dependency, str):
95+
requirements.append((f'dependency-groups."{group}"', dependency))
96+
for dependency in (data.get("build-system") or {}).get("requires") or []:
97+
requirements.append(("build-system.requires", dependency))
98+
return requirements
99+
100+
101+
def check_requirement(section: str, dependency: str, workspace_names: frozenset[str]) -> str | None:
102+
"""Return an error message when ``dependency`` needs a lower bound, otherwise ``None``."""
103+
try:
104+
requirement = Requirement(dependency)
105+
except InvalidRequirement as error:
106+
return f"[{section}] {dependency!r} is not a valid requirement: {error}"
107+
if (
108+
requirement.url
109+
or canonicalize_name(requirement.name) in workspace_names
110+
or any(specifier.operator in LOWER_BOUND_OPERATORS for specifier in requirement.specifier)
111+
):
112+
return None
113+
return f"[{section}] {dependency!r} has no lower bound - add one, for example {requirement.name}>=X.Y.Z"
114+
115+
116+
def check_pyproject_file(path: Path, workspace_names: frozenset[str]) -> list[str]:
117+
return [
118+
error
119+
for section, dependency in extract_requirements(_load_toml(path))
120+
if (error := check_requirement(section, dependency, workspace_names))
121+
]
122+
123+
124+
def main() -> int:
125+
workspace_names = get_workspace_distribution_names()
126+
failed = False
127+
for file in sys.argv[1:]:
128+
path = Path(file)
129+
if errors := check_pyproject_file(path, workspace_names):
130+
failed = True
131+
console.print(f"\n[red]Missing lower bounds in {file}:[/]\n")
132+
for error in errors:
133+
console.print(f" {escape(error)}")
134+
if failed:
135+
console.print(
136+
"\n[bright_yellow]Every dependency resolved from PyPI needs a lower bound.[/]\n"
137+
"Without one the resolver may pick any published version, so constraints pin\n"
138+
"whatever the resolution happened to produce rather than the oldest version the\n"
139+
"code supports. Use the oldest version you are willing to test against.\n"
140+
)
141+
return 1
142+
return 0
143+
144+
145+
if __name__ == "__main__":
146+
sys.exit(main())
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 textwrap
20+
21+
import pytest
22+
from check_dependency_lower_bounds import check_pyproject_file, check_requirement, extract_requirements
23+
24+
WORKSPACE_NAMES = frozenset({"apache-airflow-core", "apache-airflow-devel-common"})
25+
26+
27+
class TestCheckRequirement:
28+
@pytest.mark.parametrize(
29+
"dependency",
30+
[
31+
pytest.param("pyspark>=4.0.0", id="greater-or-equal"),
32+
pytest.param("pyspark>4.0.0", id="greater"),
33+
pytest.param("hatchling==1.31.0", id="pinned"),
34+
pytest.param("hatchling===1.31.0", id="arbitrary-equality"),
35+
pytest.param("hatchling~=1.31", id="compatible-release"),
36+
pytest.param("urllib3>=2.1.0,!=2.6.0", id="lower-bound-with-exclusion"),
37+
pytest.param("pydantic-ai-slim[mcp]>=2.0.0", id="extras"),
38+
pytest.param('fastavro>=1.10.0; python_version < "3.14"', id="marker"),
39+
],
40+
)
41+
def test_no_error_when_lower_bound_present(self, dependency):
42+
assert check_requirement("project.dependencies", dependency, WORKSPACE_NAMES) is None
43+
44+
@pytest.mark.parametrize(
45+
"dependency",
46+
[
47+
pytest.param("pyspark", id="bare"),
48+
pytest.param("pydantic-ai-slim[mcp]", id="extras"),
49+
pytest.param("pyspark<5.0.0", id="upper-bound-only"),
50+
pytest.param("pyspark!=4.1.0", id="exclusion-only"),
51+
pytest.param('krb5; python_version < "3.14"', id="marker-only"),
52+
],
53+
)
54+
def test_error_when_lower_bound_missing(self, dependency):
55+
error = check_requirement("project.dependencies", dependency, WORKSPACE_NAMES)
56+
assert error is not None
57+
assert "has no lower bound" in error
58+
assert "[project.dependencies]" in error
59+
60+
@pytest.mark.parametrize(
61+
"dependency",
62+
[
63+
pytest.param("apache-airflow-core", id="plain"),
64+
pytest.param("apache_airflow_core", id="non-canonical-name"),
65+
pytest.param("apache-airflow-devel-common[mypy]", id="extras"),
66+
],
67+
)
68+
def test_no_error_for_workspace_distribution(self, dependency):
69+
assert check_requirement("dependency-groups.dev", dependency, WORKSPACE_NAMES) is None
70+
71+
def test_no_error_for_direct_url_requirement(self):
72+
dependency = (
73+
"sphinx-airflow-theme@https://airflow.apache.org/sphinx-airflow-theme/"
74+
"sphinx_airflow_theme-0.3.13-py3-none-any.whl"
75+
)
76+
assert check_requirement("project.optional-dependencies.docs", dependency, WORKSPACE_NAMES) is None
77+
78+
def test_error_for_invalid_requirement(self):
79+
error = check_requirement("project.dependencies", "not a requirement!", WORKSPACE_NAMES)
80+
assert error is not None
81+
assert "is not a valid requirement" in error
82+
83+
84+
class TestExtractRequirements:
85+
def test_extracts_every_guarded_table(self):
86+
data = {
87+
"build-system": {"requires": ["hatchling"]},
88+
"project": {
89+
"dependencies": ["pyspark"],
90+
"optional-dependencies": {"kerberos": ["krb5"]},
91+
},
92+
"dependency-groups": {"dev": ["pytest", {"include-group": "docs"}]},
93+
}
94+
assert extract_requirements(data) == [
95+
("project.dependencies", "pyspark"),
96+
('project.optional-dependencies."kerberos"', "krb5"),
97+
('dependency-groups."dev"', "pytest"),
98+
("build-system.requires", "hatchling"),
99+
]
100+
101+
def test_no_requirements_when_tables_absent(self):
102+
assert extract_requirements({"tool": {"uv": {"required-version": ">=0.9.0"}}}) == []
103+
104+
105+
class TestCheckPyprojectFile:
106+
def _write(self, tmp_path, content):
107+
path = tmp_path / "pyproject.toml"
108+
path.write_text(textwrap.dedent(content))
109+
return path
110+
111+
def test_reports_every_unbounded_dependency(self, tmp_path):
112+
path = self._write(
113+
tmp_path,
114+
"""
115+
[project]
116+
name = "apache-airflow-providers-samba"
117+
dependencies = ["smbprotocol>=1.5.0"]
118+
119+
[project.optional-dependencies]
120+
"kerberos" = ["krb5", "apache-airflow-core"]
121+
122+
[dependency-groups]
123+
dev = ["pytest"]
124+
""",
125+
)
126+
errors = check_pyproject_file(path, WORKSPACE_NAMES)
127+
assert len(errors) == 2
128+
assert "krb5" in errors[0]
129+
assert "pytest" in errors[1]
130+
131+
def test_no_errors_when_all_bounded(self, tmp_path):
132+
path = self._write(
133+
tmp_path,
134+
"""
135+
[project]
136+
name = "apache-airflow-providers-samba"
137+
dependencies = ["smbprotocol>=1.5.0"]
138+
139+
[dependency-groups]
140+
dev = ["pytest>=9.1.1"]
141+
""",
142+
)
143+
assert check_pyproject_file(path, WORKSPACE_NAMES) == []

0 commit comments

Comments
 (0)