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 750617c3867 Fix entry-point plugins reported as duplicates on
RHEL/Fedora venvs (#74531)
750617c3867 is described below
commit 750617c3867ea632a59d1a56ffa33b43397aa767
Author: Shikhar Goel <[email protected]>
AuthorDate: Thu Nov 5 18:29:59 2026 +0530
Fix entry-point plugins reported as duplicates on RHEL/Fedora venvs (#74531)
* Fix entry-point plugins reported as duplicates on RHEL/Fedora venvs
On RHEL and Fedora, sys.path in a virtualenv holds both lib64/ and lib/
site-packages, and lib64 is a symlink to lib. importlib.metadata returns
every
distribution once per sys.path entry, so each entry-point plugin was loaded
twice. Since duplicate plugin names are recorded as plugin import errors, a
fresh install with e.g. the Kafka provider showed "Plugin
'kafka_event_producer' already registered" in the UI and logs.
* Deduplicate distributions when collecting entry points
Skipping identical entry points in the plugins manager only hid
byte-identical copies. The same package found on two sys.path entries
in different versions (a venv with --system-site-packages, a user-site
copy) still loaded twice, and two different packages declaring the
same plugin were silently collapsed instead of surfacing an error.
Keeping only the first distribution per normalized name, as
importlib.metadata.entry_points() does, fixes this for every caller of
entry_points_with_dist at once.
* Keep entry points of distributions without a Name when deduplicating
dist.metadata can be None, and on Python 3.11 the importlib_metadata
backport raises KeyError for a missing field instead of returning None.
Indexing it directly made such a distribution fall into the error handler,
so its entry points were dropped instead of being collected undeduplicated,
and mypy rejected the possibly-None metadata.
---------
Co-authored-by: sgoel2be24-cyber
<[email protected]>
---
.../src/airflow_shared/module_loading/__init__.py | 23 ++++--
.../tests/module_loading/test_entry_points.py | 83 +++++++++++++++++++---
2 files changed, 91 insertions(+), 15 deletions(-)
diff --git
a/shared/module_loading/src/airflow_shared/module_loading/__init__.py
b/shared/module_loading/src/airflow_shared/module_loading/__init__.py
index 60bead945e3..7bf01c475ac 100644
--- a/shared/module_loading/src/airflow_shared/module_loading/__init__.py
+++ b/shared/module_loading/src/airflow_shared/module_loading/__init__.py
@@ -21,6 +21,7 @@ import functools
import inspect
import logging
import pkgutil
+import re
import sys
from collections import defaultdict
from collections.abc import Callable, Iterator
@@ -149,8 +150,6 @@ def is_valid_dotpath(path: str) -> bool:
:param path: String to check
"""
- import re
-
if not isinstance(path, str):
return False
@@ -165,11 +164,26 @@ def is_valid_dotpath(path: str) -> bool:
return bool(re.match(pattern, path))
+def _normalize_dist_name(name: str) -> str:
+ # PEP 503 normalization, the same key importlib.metadata.entry_points()
deduplicates on.
+ return re.sub(r"[-_.]+", "-", name).lower()
+
+
@functools.cache
def _get_grouped_entry_points() -> dict[str, list[EPnD]]:
mapping: dict[str, list[EPnD]] = defaultdict(list)
+ seen_dist_names: set[str] = set()
for dist in metadata.distributions():
try:
+ # A distribution is yielded once per sys.path entry it is found on
(e.g. RHEL/Fedora
+ # venvs list both lib64/ and lib/ site-packages), possibly in
different versions.
+ # Only the first one is importable, so it is the only one whose
entry points count.
+ dist_metadata = dist.metadata
+ if dist_metadata and (name := dist_metadata.get("Name")):
+ normalized_name = _normalize_dist_name(name)
+ if normalized_name in seen_dist_names:
+ continue
+ seen_dist_names.add(normalized_name)
for e in dist.entry_points:
mapping[e.group].append((e, dist))
except Exception as e:
@@ -184,9 +198,8 @@ def entry_points_with_dist(group: str) -> Iterator[EPnD]:
This is like the ``entry_points()`` function from ``importlib.metadata``,
except it also returns the distribution the entry point was loaded from.
- Note that this may return multiple distributions to the same package if
they
- are loaded from different ``sys.path`` entries. The caller site should
- implement appropriate deduplication logic if needed.
+ Like ``entry_points()``, a distribution found on several ``sys.path``
entries
+ is only returned once: the first one found, which is the one that is
imported.
:param group: Filter results to only this entrypoint group
:return: Generator of (EntryPoint, Distribution) objects for the specified
groups
diff --git a/shared/module_loading/tests/module_loading/test_entry_points.py
b/shared/module_loading/tests/module_loading/test_entry_points.py
index 87b9f15b76e..ff22042aefe 100644
--- a/shared/module_loading/tests/module_loading/test_entry_points.py
+++ b/shared/module_loading/tests/module_loading/test_entry_points.py
@@ -20,18 +20,39 @@ from __future__ import annotations
from collections.abc import Iterable
from unittest import mock
-from airflow_shared.module_loading import entry_points_with_dist, metadata
+import pytest
+
+from airflow_shared.module_loading import _get_grouped_entry_points,
entry_points_with_dist, metadata
class MockDistribution:
- def __init__(self, name: str, entry_points: Iterable[metadata.EntryPoint])
-> None:
- self.metadata = {"Name": name}
+ def __init__(
+ self, name: str, entry_points: Iterable[metadata.EntryPoint], version:
str = "1.0.0"
+ ) -> None:
+ self.metadata: dict[str, str] | None = {"Name": name}
self.entry_points = entry_points
+ self.version = version
class MockMetadata:
+ def __init__(self, distributions: list[MockDistribution]) -> None:
+ self._distributions = distributions
+
def distributions(self):
- return [
+ return self._distributions
+
+
[email protected](autouse=True)
+def clear_entry_points_cache():
+ _get_grouped_entry_points.cache_clear()
+ yield
+ _get_grouped_entry_points.cache_clear()
+
+
[email protected](
+ "airflow_shared.module_loading.metadata",
+ MockMetadata(
+ [
MockDistribution(
"dist1",
[metadata.EntryPoint("a", "b", "group_x"),
metadata.EntryPoint("c", "d", "group_y")],
@@ -39,12 +60,54 @@ class MockMetadata:
MockDistribution("Dist2", [metadata.EntryPoint("e", "f",
"group_x")]),
MockDistribution("dist2", [metadata.EntryPoint("g", "h",
"group_x")]), # Duplicated name.
]
-
-
[email protected]("airflow_shared.module_loading.metadata", MockMetadata())
+ ),
+)
def test_entry_points_with_dist():
entries = list(entry_points_with_dist("group_x"))
- # Only "group_x" entries are loaded. Distributions are not deduplicated.
- assert [dist.metadata["Name"] for _, dist in entries] == ["dist1",
"Dist2", "dist2"]
- assert [ep.name for ep, _ in entries] == ["a", "e", "g"]
+ # Only "group_x" entries are loaded, and only from the first "dist2" found.
+ assert [dist.metadata["Name"] for _, dist in entries] == ["dist1", "Dist2"]
+ assert [ep.name for ep, _ in entries] == ["a", "e"]
+
+
[email protected](
+ "airflow_shared.module_loading.metadata",
+ MockMetadata(
+ [
+ # The same distribution on two sys.path entries (e.g. a venv and
the system
+ # site-packages it falls back to), installed there in different
versions.
+ MockDistribution(
+ "apache-airflow-providers-foo",
+ [metadata.EntryPoint("foo",
"airflow.providers.foo.plugins:FooPlugin", "airflow.plugins")],
+ version="2.0.0",
+ ),
+ MockDistribution(
+ "apache_airflow_providers_foo",
+ [metadata.EntryPoint("foo",
"airflow.providers.foo.plugin:FooPlugin", "airflow.plugins")],
+ version="1.0.0",
+ ),
+ ]
+ ),
+)
+def test_entry_points_with_dist_same_dist_in_two_versions_returns_first_only():
+ entries = list(entry_points_with_dist("airflow.plugins"))
+
+ assert [(ep.value, dist.version) for ep, dist in entries] == [
+ ("airflow.providers.foo.plugins:FooPlugin", "2.0.0")
+ ]
+
+
+# No metadata file at all, or metadata without a Name field (the key is
absent, not None).
[email protected]("dist_metadata", [None, {"Version": "1.0.0"}],
ids=["no-metadata", "no-name"])
+def
test_entry_points_with_dist_without_name_are_not_deduplicated(dist_metadata):
+ distributions = [
+ MockDistribution("unnamed", [metadata.EntryPoint("a", "b",
"group_x")]),
+ MockDistribution("unnamed", [metadata.EntryPoint("c", "d",
"group_x")]),
+ ]
+ for dist in distributions:
+ dist.metadata = dist_metadata
+
+ with mock.patch("airflow_shared.module_loading.metadata",
MockMetadata(distributions)):
+ entries = list(entry_points_with_dist("group_x"))
+
+ assert [ep.name for ep, _ in entries] == ["a", "c"]