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 4a73cb7d5fa Keep builtin annotations from resolving to project 
attributes in Sphinx 9 (#74168)
4a73cb7d5fa is described below

commit 4a73cb7d5fa94cbd1ce7b3a9250331f59685f6d7
Author: Shahar Epstein <[email protected]>
AuthorDate: Sat Oct 3 23:01:13 2026 +0300

    Keep builtin annotations from resolving to project attributes in Sphinx 9 
(#74168)
    
    * Keep builtin annotations from resolving to project attributes in Sphinx 9
    
    Sphinx 9 falls back from a class lookup to a fuzzy data/attribute search
    when resolving annotation cross-references. A builtin such as type or
    object in an annotation then matches every documented attribute with
    that name, and the docs build fails with "more than one target found"
    (sphinx-doc/sphinx#14223). Python 3.11 and newer resolve Sphinx 9 from
    the lock file, so the docs build breaks as soon as it moves off 3.10.
    Skipping that fallback for builtin names restores the Sphinx 8 behaviour
    of linking them to the Python documentation.
    
    Claude-Session: https://claude.ai/code/session_01SkLWWaTT1cnFqTT1jhFGxe
    
    * Type the Sphinx domain override for both Sphinx 8 and Sphinx 9
    
    The docs build still type-checks against Sphinx 8 on Python 3.10, where
    PythonDomain.resolve_xref returns Element | None, while Sphinx 9 narrows
    it to reference | None. No single precise annotation is a valid override
    for both.
---
 devel-common/src/docs/utils/conf_constants.py      |  1 +
 .../src/sphinx_exts/python_builtin_xrefs.py        | 66 ++++++++++++++++
 .../unit/sphinx_exts/test_python_builtin_xrefs.py  | 92 ++++++++++++++++++++++
 3 files changed, 159 insertions(+)

diff --git a/devel-common/src/docs/utils/conf_constants.py 
b/devel-common/src/docs/utils/conf_constants.py
index 33f8d300f31..9ae6c0f2187 100644
--- a/devel-common/src/docs/utils/conf_constants.py
+++ b/devel-common/src/docs/utils/conf_constants.py
@@ -86,6 +86,7 @@ BASIC_SPHINX_EXTENSIONS = [
     "removemarktransform",
     "sphinx_copybutton",
     "airflow_intersphinx",
+    "python_builtin_xrefs",
     "common_compat_alias",
     "sphinxcontrib.mermaid",
     "sphinxcontrib.spelling",
diff --git a/devel-common/src/sphinx_exts/python_builtin_xrefs.py 
b/devel-common/src/sphinx_exts/python_builtin_xrefs.py
new file mode 100644
index 00000000000..09047ad0c85
--- /dev/null
+++ b/devel-common/src/sphinx_exts/python_builtin_xrefs.py
@@ -0,0 +1,66 @@
+# 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.
+"""
+Keep builtin names in annotations from resolving to same-named project 
attributes.
+
+Sphinx 9 falls back from a ``py:class`` lookup to a fuzzy 
``py:data``/``py:attr`` search, so a
+builtin such as ``type`` or ``object`` in an annotation matches every 
documented attribute with
+that name and fails the build with "more than one target found". Leaving the 
builtin unresolved
+lets intersphinx link it to the Python docs, as Sphinx 8 did.
+
+Remove once https://github.com/sphinx-doc/sphinx/issues/14223 is fixed in 
every Sphinx version
+the docs build uses; tracked at https://github.com/apache/airflow/issues/74167
+"""
+
+from __future__ import annotations
+
+import builtins
+from typing import TYPE_CHECKING, Any
+
+from sphinx.domains.python import PythonDomain
+
+if TYPE_CHECKING:
+    from docutils.nodes import Element
+    from sphinx.addnodes import pending_xref
+    from sphinx.application import Sphinx
+    from sphinx.builders import Builder
+    from sphinx.environment import BuildEnvironment
+
+_BUILTIN_NAMES = frozenset(dir(builtins))
+
+
+class _PythonDomainWithBuiltinXrefs(PythonDomain):
+    def resolve_xref(
+        self,
+        env: BuildEnvironment,
+        fromdocname: str,
+        builder: Builder,
+        type: str,
+        target: str,
+        node: pending_xref,
+        contnode: Element,
+    ) -> Any:  # Sphinx 8 returns ``Element | None`` here, Sphinx 9 
``reference | None``.
+        if type == "class" and target in _BUILTIN_NAMES:
+            searchmode = 1 if node.hasattr("refspecific") else 0
+            if not self.find_obj(env, node.get("py:module"), 
node.get("py:class"), target, type, searchmode):
+                return None
+        return super().resolve_xref(env, fromdocname, builder, type, target, 
node, contnode)
+
+
+def setup(app: Sphinx) -> dict[str, Any]:
+    app.add_domain(_PythonDomainWithBuiltinXrefs, override=True)
+    return {"parallel_read_safe": True, "parallel_write_safe": True}
diff --git a/devel-common/tests/unit/sphinx_exts/test_python_builtin_xrefs.py 
b/devel-common/tests/unit/sphinx_exts/test_python_builtin_xrefs.py
new file mode 100644
index 00000000000..2c3c9a5d46a
--- /dev/null
+++ b/devel-common/tests/unit/sphinx_exts/test_python_builtin_xrefs.py
@@ -0,0 +1,92 @@
+# 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 io
+import sys
+from pathlib import Path
+
+from sphinx.application import Sphinx
+
+SPHINX_EXTS_PATH = Path(__file__).parents[3] / "src" / "sphinx_exts"
+if SPHINX_EXTS_PATH.as_posix() not in sys.path:
+    # The extensions are loaded by Sphinx from this directory and import each 
other by bare name.
+    sys.path.append(SPHINX_EXTS_PATH.as_posix())
+
+# Two documented attributes named like builtins, plus annotations that use the 
builtins
+# and a project class. Sphinx 9 resolves the builtins to these attributes 
ambiguously.
+INDEX_RST = """\
+Index
+=====
+
+.. py:module:: pkg
+
+.. py:class:: First
+
+   .. py:attribute:: type
+      :type: str
+
+   .. py:attribute:: object
+      :type: str
+
+.. py:class:: Second
+
+   .. py:attribute:: type
+      :type: str
+
+   .. py:attribute:: object
+      :type: str
+
+.. py:function:: make(kind: type, value: object, first: First) -> None
+"""
+
+
+def _build(tmp_path: Path) -> tuple[str, str]:
+    """Build the project and return the warnings and the HTML of the ``make`` 
signature."""
+    src = tmp_path / "src"
+    src.mkdir()
+    (src / "conf.py").write_text('extensions = ["python_builtin_xrefs"]\n')
+    (src / "index.rst").write_text(INDEX_RST)
+    warnings = io.StringIO()
+    app = Sphinx(
+        srcdir=src,
+        confdir=src,
+        outdir=tmp_path / "out",
+        doctreedir=tmp_path / "doctrees",
+        buildername="html",
+        status=None,
+        warning=warnings,
+        freshenv=True,
+    )
+    app.build()
+    html = (tmp_path / "out" / "index.html").read_text()
+    signature = html[html.index('id="pkg.make"') :]
+    return warnings.getvalue(), signature[: signature.index("</dt>")]
+
+
+def test_builtin_annotations_do_not_resolve_to_same_named_attributes(tmp_path):
+    warnings, signature = _build(tmp_path)
+
+    assert "more than one target found" not in warnings
+    assert "#pkg.First.type" not in signature
+    assert "#pkg.First.object" not in signature
+
+
+def test_project_class_annotations_still_resolve(tmp_path):
+    _, signature = _build(tmp_path)
+
+    assert 'href="#pkg.First"' in signature

Reply via email to