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