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

potiuk 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 cf4c7690e04 Require a lower bound on every dependency in 
pyproject.toml (#71378)
cf4c7690e04 is described below

commit cf4c7690e04ef59871a6fcb94d0752dfdf54db36
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Aug 11 23:32:51 2026 +0800

    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.
---
 .pre-commit-config.yaml                            |   7 +
 clients/python/pyproject.toml                      |   2 +-
 .../13_airflow_dependencies_and_extras.rst         |  15 +++
 providers/common/ai/docs/index.rst                 |  10 +-
 providers/common/ai/pyproject.toml                 |  10 +-
 scripts/ci/prek/check_dependency_lower_bounds.py   | 146 +++++++++++++++++++++
 .../ci/prek/test_check_dependency_lower_bounds.py  | 143 ++++++++++++++++++++
 uv.lock                                            |  20 +--
 8 files changed, 332 insertions(+), 21 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2ff236b9131..4c24bcc2b1d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1081,6 +1081,13 @@ repos:
         pass_filenames: false
         require_serial: true
         additional_dependencies: ['packaging>=25', 'pyyaml', 'tomli>=2.0.1', 
'rich>=13.6.0']
+      - id: check-dependency-lower-bounds
+        name: Check that dependencies in pyproject.toml have lower bounds
+        language: python
+        entry: ./scripts/ci/prek/check_dependency_lower_bounds.py
+        files: (^|/)pyproject\.toml$
+        require_serial: true
+        additional_dependencies: ['packaging>=25', 'tomli>=2.0.1', 
'rich>=13.6.0']
       - id: update-reproducible-source-date-epoch
         name: Update Source Date Epoch for reproducible builds
         language: python
diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml
index 2e246c7778e..0614c5f4529 100644
--- a/clients/python/pyproject.toml
+++ b/clients/python/pyproject.toml
@@ -59,7 +59,7 @@ classifiers = [
 
 dependencies = [
     "pydantic >= 2.11.0",
-    "python-dateutil",
+    "python-dateutil>=2.7.0",
     "urllib3>=2.1.0,!=2.6.0",
 ]
 
diff --git a/contributing-docs/13_airflow_dependencies_and_extras.rst 
b/contributing-docs/13_airflow_dependencies_and_extras.rst
index 7c59ce25d17..1a64d5e2ca9 100644
--- a/contributing-docs/13_airflow_dependencies_and_extras.rst
+++ b/contributing-docs/13_airflow_dependencies_and_extras.rst
@@ -239,6 +239,21 @@ rules to remember:
   stopped working (like in case of ``amazon``, ``fab``). You are free to 
modify those versions to higher
   versions if you need to, and ``prek`` will remove those comments 
automatically.
 
+* Every dependency we resolve from PyPI must have a lower bound. Without one 
the resolver is free to 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. The
+  ``check-dependency-lower-bounds`` prek hook enforces this across 
``project.dependencies``,
+  ``project.optional-dependencies``, ``dependency-groups`` and 
``build-system.requires`` of every
+  ``pyproject.toml``. Use the oldest version you are willing to test against:
+
+  .. code-block:: python
+
+     "pyspark>=4.0.0",
+
+  Two kinds of requirement are exempt: distributions that are members of our 
``uv`` workspace (they are
+  resolved from the checkout, so a version range would say nothing) and direct 
URL requirements (the URL
+  already names the exact artifact).
+
 Our CI system will do all the tests for you anyway - including running some 
lower-bind checks on dependencies.
 For example it will take each provider in a turn and will try to resolve 
lowest-possible dependencies defined
 for that provider and see if the tests are still passing, so we should be 
relatively protected against putting
diff --git a/providers/common/ai/docs/index.rst 
b/providers/common/ai/docs/index.rst
index 41a01074f44..4b0f82608b3 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -241,11 +241,11 @@ Install them when installing from PyPI. For example:
 ==============  
=======================================================================================================================================
 Extra           Dependencies
 ==============  
=======================================================================================================================================
-``anthropic``   ``pydantic-ai-slim[anthropic]``
-``bedrock``     ``pydantic-ai-slim[bedrock]``
-``google``      ``pydantic-ai-slim[google]``
-``openai``      ``pydantic-ai-slim[openai]``
-``mcp``         ``pydantic-ai-slim[mcp]``
+``anthropic``   ``pydantic-ai-slim[anthropic]>=2.0.0``
+``bedrock``     ``pydantic-ai-slim[bedrock]>=2.0.0``
+``google``      ``pydantic-ai-slim[google]>=2.0.0``
+``openai``      ``pydantic-ai-slim[openai]>=2.0.0``
+``mcp``         ``pydantic-ai-slim[mcp]>=2.0.0``
 ``code-mode``   ``pydantic-ai-harness[codemode]>=0.3.0``
 ``shields``     ``pydantic-ai-shields>=0.3.4``
 ``skills``      ``apache-airflow-providers-git>=0.4.0``, 
``pydantic-ai-skills>=1.2.0``
diff --git a/providers/common/ai/pyproject.toml 
b/providers/common/ai/pyproject.toml
index 07a65f18dd4..aa806092833 100644
--- a/providers/common/ai/pyproject.toml
+++ b/providers/common/ai/pyproject.toml
@@ -76,11 +76,11 @@ dependencies = [
 # The optional dependencies should be modified in place in the generated file
 # Any change in the dependencies is preserved when the file is regenerated
 [project.optional-dependencies]
-"anthropic" = ["pydantic-ai-slim[anthropic]"]
-"bedrock" = ["pydantic-ai-slim[bedrock]"]
-"google" = ["pydantic-ai-slim[google]"]
-"openai" = ["pydantic-ai-slim[openai]"]
-"mcp" = ["pydantic-ai-slim[mcp]"]
+"anthropic" = ["pydantic-ai-slim[anthropic]>=2.0.0"]
+"bedrock" = ["pydantic-ai-slim[bedrock]>=2.0.0"]
+"google" = ["pydantic-ai-slim[google]>=2.0.0"]
+"openai" = ["pydantic-ai-slim[openai]>=2.0.0"]
+"mcp" = ["pydantic-ai-slim[mcp]>=2.0.0"]
 # Code mode: collapse tool calls into a single `run_code` tool that the model
 # drives by writing Python, executed in the Monty sandbox (pydantic-monty).
 # Enables AgentOperator(code_mode=True). Monty is pre-1.0; pinned here as an
diff --git a/scripts/ci/prek/check_dependency_lower_bounds.py 
b/scripts/ci/prek/check_dependency_lower_bounds.py
new file mode 100755
index 00000000000..4ec5863d278
--- /dev/null
+++ b/scripts/ci/prek/check_dependency_lower_bounds.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# /// script
+# requires-python = ">=3.10,<3.11"
+# dependencies = [
+#   "packaging>=25",
+#   "rich>=13.6.0",
+#   "tomli>=2.0.1",
+# ]
+# ///
+"""
+Validate that every external dependency declared in a ``pyproject.toml`` has a 
lower bound.
+
+An unbounded requirement lets the resolver answer with any version that 
happens to be on
+PyPI, so what a constraints file pins - and what a user ends up installing - 
depends on how
+the resolution went rather than on what the code needs. Naming the oldest 
supported version
+makes that answer deterministic and documents the floor the code is tested 
against.
+
+Checked locations: ``project.dependencies``, ``project.optional-dependencies``,
+``dependency-groups`` and ``build-system.requires``.
+
+Requirements resolved from the uv workspace rather than from PyPI are exempt - 
their source
+is the checkout, so a version range would say nothing. Direct URL requirements 
are exempt
+too, since the URL already names the exact artifact.
+"""
+
+from __future__ import annotations
+
+import sys
+from functools import cache
+from pathlib import Path
+
+from common_prek_utils import AIRFLOW_ROOT_PATH, console
+from packaging.requirements import InvalidRequirement, Requirement
+from packaging.utils import canonicalize_name
+from rich.markup import escape
+
+try:
+    import tomllib
+except ImportError:
+    import tomli as tomllib  # type: ignore[no-redef]
+
+# Operators that place a floor under the resolved version; anything else 
(``!=``, ``<``,
+# ``<=``) leaves the resolver free to reach back to the oldest release ever 
published.
+LOWER_BOUND_OPERATORS = {">=", ">", "==", "===", "~="}
+
+
+def _load_toml(path: Path) -> dict:
+    return tomllib.loads(path.read_text())
+
+
+@cache
+def get_workspace_distribution_names() -> frozenset[str]:
+    """Return the canonical names of the distributions uv resolves from this 
checkout."""
+    root_pyproject = AIRFLOW_ROOT_PATH / "pyproject.toml"
+    root_data = _load_toml(root_pyproject)
+    members = root_data.get("tool", {}).get("uv", {}).get("workspace", 
{}).get("members", [])
+    names = set()
+    for member in members:
+        for member_path in 
sorted(AIRFLOW_ROOT_PATH.glob(f"{member}/pyproject.toml")):
+            if name := _load_toml(member_path).get("project", {}).get("name"):
+                names.add(canonicalize_name(name))
+    return frozenset(names)
+
+
+def extract_requirements(data: dict) -> list[tuple[str, str]]:
+    """Return ``(section, requirement)`` pairs for every dependency table we 
guard."""
+    requirements: list[tuple[str, str]] = []
+    project = data.get("project") or {}
+    for dependency in project.get("dependencies") or []:
+        requirements.append(("project.dependencies", dependency))
+    for extra, dependencies in (project.get("optional-dependencies") or 
{}).items():
+        for dependency in dependencies:
+            requirements.append((f'project.optional-dependencies."{extra}"', 
dependency))
+    for group, dependencies in (data.get("dependency-groups") or {}).items():
+        for dependency in dependencies:
+            # A group may also pull in another group via ``{include-group = 
"..."}``.
+            if isinstance(dependency, str):
+                requirements.append((f'dependency-groups."{group}"', 
dependency))
+    for dependency in (data.get("build-system") or {}).get("requires") or []:
+        requirements.append(("build-system.requires", dependency))
+    return requirements
+
+
+def check_requirement(section: str, dependency: str, workspace_names: 
frozenset[str]) -> str | None:
+    """Return an error message when ``dependency`` needs a lower bound, 
otherwise ``None``."""
+    try:
+        requirement = Requirement(dependency)
+    except InvalidRequirement as error:
+        return f"[{section}] {dependency!r} is not a valid requirement: 
{error}"
+    if (
+        requirement.url
+        or canonicalize_name(requirement.name) in workspace_names
+        or any(specifier.operator in LOWER_BOUND_OPERATORS for specifier in 
requirement.specifier)
+    ):
+        return None
+    return f"[{section}] {dependency!r} has no lower bound - add one, for 
example {requirement.name}>=X.Y.Z"
+
+
+def check_pyproject_file(path: Path, workspace_names: frozenset[str]) -> 
list[str]:
+    return [
+        error
+        for section, dependency in extract_requirements(_load_toml(path))
+        if (error := check_requirement(section, dependency, workspace_names))
+    ]
+
+
+def main() -> int:
+    workspace_names = get_workspace_distribution_names()
+    failed = False
+    for file in sys.argv[1:]:
+        path = Path(file)
+        if errors := check_pyproject_file(path, workspace_names):
+            failed = True
+            console.print(f"\n[red]Missing lower bounds in {file}:[/]\n")
+            for error in errors:
+                console.print(f"  {escape(error)}")
+    if failed:
+        console.print(
+            "\n[bright_yellow]Every dependency resolved from PyPI needs a 
lower bound.[/]\n"
+            "Without one the resolver may pick any published version, so 
constraints pin\n"
+            "whatever the resolution happened to produce rather than the 
oldest version the\n"
+            "code supports. Use the oldest version you are willing to test 
against.\n"
+        )
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/tests/ci/prek/test_check_dependency_lower_bounds.py 
b/scripts/tests/ci/prek/test_check_dependency_lower_bounds.py
new file mode 100644
index 00000000000..4733e3b3dbc
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_dependency_lower_bounds.py
@@ -0,0 +1,143 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import textwrap
+
+import pytest
+from check_dependency_lower_bounds import check_pyproject_file, 
check_requirement, extract_requirements
+
+WORKSPACE_NAMES = frozenset({"apache-airflow-core", 
"apache-airflow-devel-common"})
+
+
+class TestCheckRequirement:
+    @pytest.mark.parametrize(
+        "dependency",
+        [
+            pytest.param("pyspark>=4.0.0", id="greater-or-equal"),
+            pytest.param("pyspark>4.0.0", id="greater"),
+            pytest.param("hatchling==1.31.0", id="pinned"),
+            pytest.param("hatchling===1.31.0", id="arbitrary-equality"),
+            pytest.param("hatchling~=1.31", id="compatible-release"),
+            pytest.param("urllib3>=2.1.0,!=2.6.0", 
id="lower-bound-with-exclusion"),
+            pytest.param("pydantic-ai-slim[mcp]>=2.0.0", id="extras"),
+            pytest.param('fastavro>=1.10.0; python_version < "3.14"', 
id="marker"),
+        ],
+    )
+    def test_no_error_when_lower_bound_present(self, dependency):
+        assert check_requirement("project.dependencies", dependency, 
WORKSPACE_NAMES) is None
+
+    @pytest.mark.parametrize(
+        "dependency",
+        [
+            pytest.param("pyspark", id="bare"),
+            pytest.param("pydantic-ai-slim[mcp]", id="extras"),
+            pytest.param("pyspark<5.0.0", id="upper-bound-only"),
+            pytest.param("pyspark!=4.1.0", id="exclusion-only"),
+            pytest.param('krb5; python_version < "3.14"', id="marker-only"),
+        ],
+    )
+    def test_error_when_lower_bound_missing(self, dependency):
+        error = check_requirement("project.dependencies", dependency, 
WORKSPACE_NAMES)
+        assert error is not None
+        assert "has no lower bound" in error
+        assert "[project.dependencies]" in error
+
+    @pytest.mark.parametrize(
+        "dependency",
+        [
+            pytest.param("apache-airflow-core", id="plain"),
+            pytest.param("apache_airflow_core", id="non-canonical-name"),
+            pytest.param("apache-airflow-devel-common[mypy]", id="extras"),
+        ],
+    )
+    def test_no_error_for_workspace_distribution(self, dependency):
+        assert check_requirement("dependency-groups.dev", dependency, 
WORKSPACE_NAMES) is None
+
+    def test_no_error_for_direct_url_requirement(self):
+        dependency = (
+            
"sphinx-airflow-theme@https://airflow.apache.org/sphinx-airflow-theme/";
+            "sphinx_airflow_theme-0.3.13-py3-none-any.whl"
+        )
+        assert check_requirement("project.optional-dependencies.docs", 
dependency, WORKSPACE_NAMES) is None
+
+    def test_error_for_invalid_requirement(self):
+        error = check_requirement("project.dependencies", "not a 
requirement!", WORKSPACE_NAMES)
+        assert error is not None
+        assert "is not a valid requirement" in error
+
+
+class TestExtractRequirements:
+    def test_extracts_every_guarded_table(self):
+        data = {
+            "build-system": {"requires": ["hatchling"]},
+            "project": {
+                "dependencies": ["pyspark"],
+                "optional-dependencies": {"kerberos": ["krb5"]},
+            },
+            "dependency-groups": {"dev": ["pytest", {"include-group": 
"docs"}]},
+        }
+        assert extract_requirements(data) == [
+            ("project.dependencies", "pyspark"),
+            ('project.optional-dependencies."kerberos"', "krb5"),
+            ('dependency-groups."dev"', "pytest"),
+            ("build-system.requires", "hatchling"),
+        ]
+
+    def test_no_requirements_when_tables_absent(self):
+        assert extract_requirements({"tool": {"uv": {"required-version": 
">=0.9.0"}}}) == []
+
+
+class TestCheckPyprojectFile:
+    def _write(self, tmp_path, content):
+        path = tmp_path / "pyproject.toml"
+        path.write_text(textwrap.dedent(content))
+        return path
+
+    def test_reports_every_unbounded_dependency(self, tmp_path):
+        path = self._write(
+            tmp_path,
+            """
+            [project]
+            name = "apache-airflow-providers-samba"
+            dependencies = ["smbprotocol>=1.5.0"]
+
+            [project.optional-dependencies]
+            "kerberos" = ["krb5", "apache-airflow-core"]
+
+            [dependency-groups]
+            dev = ["pytest"]
+            """,
+        )
+        errors = check_pyproject_file(path, WORKSPACE_NAMES)
+        assert len(errors) == 2
+        assert "krb5" in errors[0]
+        assert "pytest" in errors[1]
+
+    def test_no_errors_when_all_bounded(self, tmp_path):
+        path = self._write(
+            tmp_path,
+            """
+            [project]
+            name = "apache-airflow-providers-samba"
+            dependencies = ["smbprotocol>=1.5.0"]
+
+            [dependency-groups]
+            dev = ["pytest>=9.1.1"]
+            """,
+        )
+        assert check_pyproject_file(path, WORKSPACE_NAMES) == []
diff --git a/uv.lock b/uv.lock
index 76b7408a20f..940a928dcab 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2958,7 +2958,7 @@ docs = [
 [package.metadata]
 requires-dist = [
     { name = "akeyless", specifier = ">=5.0.0" },
-    { name = "akeyless-cloud-id", marker = "extra == 'cloud-id'" },
+    { name = "akeyless-cloud-id", marker = "extra == 'cloud-id'", specifier = 
">=0.3.0" },
     { name = "apache-airflow", editable = "." },
     { name = "apache-airflow-providers-common-compat", editable = 
"providers/common/compat" },
 ]
@@ -3968,7 +3968,7 @@ dev = [
     { name = "apache-airflow-providers-common-compat", editable = 
"providers/common/compat" },
     { name = "apache-airflow-providers-openlineage", editable = 
"providers/openlineage" },
     { name = "apache-airflow-task-sdk", editable = "task-sdk" },
-    { name = "pyspark" },
+    { name = "pyspark", specifier = ">=4.0.0" },
 ]
 docs = [{ name = "apache-airflow-devel-common", extras = ["docs"], editable = 
"devel-common" }]
 
@@ -4484,11 +4484,11 @@ requires-dist = [
     { name = "pydantic-ai-shields", marker = "extra == 'shields'", specifier = 
">=0.3.4" },
     { name = "pydantic-ai-skills", marker = "extra == 'skills'", specifier = 
">=1.2.0" },
     { name = "pydantic-ai-slim", specifier = ">=2.0.0" },
-    { name = "pydantic-ai-slim", extras = ["anthropic"], marker = "extra == 
'anthropic'" },
-    { name = "pydantic-ai-slim", extras = ["bedrock"], marker = "extra == 
'bedrock'" },
-    { name = "pydantic-ai-slim", extras = ["google"], marker = "extra == 
'google'" },
-    { name = "pydantic-ai-slim", extras = ["mcp"], marker = "extra == 'mcp'" },
-    { name = "pydantic-ai-slim", extras = ["openai"], marker = "extra == 
'openai'" },
+    { name = "pydantic-ai-slim", extras = ["anthropic"], marker = "extra == 
'anthropic'", specifier = ">=2.0.0" },
+    { name = "pydantic-ai-slim", extras = ["bedrock"], marker = "extra == 
'bedrock'", specifier = ">=2.0.0" },
+    { name = "pydantic-ai-slim", extras = ["google"], marker = "extra == 
'google'", specifier = ">=2.0.0" },
+    { name = "pydantic-ai-slim", extras = ["mcp"], marker = "extra == 'mcp'", 
specifier = ">=2.0.0" },
+    { name = "pydantic-ai-slim", extras = ["openai"], marker = "extra == 
'openai'", specifier = ">=2.0.0" },
     { name = "pypdf", marker = "extra == 'pdf'", specifier = ">=4.0.0" },
     { name = "python-docx", marker = "extra == 'docx'", specifier = ">=1.0.0" 
},
     { name = "sqlglot", marker = "extra == 'sql'", specifier = ">=30.0.0" },
@@ -4510,7 +4510,7 @@ dev = [
     { name = "llama-index-embeddings-openai", specifier = ">=0.6.0" },
     { name = "llama-index-llms-openai", specifier = ">=0.6.0" },
     { name = "pydantic-ai-skills", specifier = ">=1.2.0" },
-    { name = "pydantic-ai-slim", extras = ["mcp"] },
+    { name = "pydantic-ai-slim", extras = ["mcp"], specifier = ">=2.0.0" },
     { name = "sqlglot", specifier = ">=30.0.0" },
 ]
 docs = [{ name = "apache-airflow-devel-common", extras = ["docs"], editable = 
"devel-common" }]
@@ -7513,7 +7513,7 @@ requires-dist = [
     { name = "apache-airflow", editable = "." },
     { name = "apache-airflow-providers-common-compat", editable = 
"providers/common/compat" },
     { name = "apache-airflow-providers-google", marker = "extra == 'google'", 
editable = "providers/google" },
-    { name = "krb5", marker = "extra == 'kerberos'" },
+    { name = "krb5", marker = "extra == 'kerberos'", specifier = ">=0.8.0" },
     { name = "smbprotocol", specifier = ">=1.5.0" },
     { name = "smbprotocol", extras = ["kerberos"], marker = "extra == 
'kerberos'", specifier = ">=1.5.0" },
 ]
@@ -8474,7 +8474,7 @@ requires-dist = [
 ]
 
 [package.metadata.requires-dev]
-dev = [{ name = "pytest" }]
+dev = [{ name = "pytest", specifier = ">=9.1.1" }]
 
 [[package]]
 name = "apache-airflow-scripts"

Reply via email to