Skip to content

Commit 3543196

Browse files
authored
feature: support editable entrypoints (#1766)
* feat: support editable entrypoint installs * tests: Add and consolidate entrypoint tests * docs: add info to CHANGES * fix: add logging to spec-based exception
1 parent 95b773c commit 3543196

4 files changed

Lines changed: 240 additions & 29 deletions

File tree

CHANGES.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ v9.9.9 (unreleased)
22
-------------------
33
- chore: add releases script (#1767)
44
- refactor: remove webtest dependency (#1769)
5+
- feat: support editable installed entrypoint plugins (#1766)
56

67

78
v6.2.1 (2026-06-06)

errbot/utils.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import collections
22
import fnmatch
33
import importlib.metadata
4+
import importlib.util
45
import inspect
56
import logging
67
import os
8+
import pathlib
79
import re
810
import sys
911
import time
@@ -200,13 +202,21 @@ def collect_roots(base_paths: List, file_sig: str = "*.plug") -> List:
200202
def entry_point_plugins(group):
201203
paths = set()
202204

203-
for entry_point in importlib.metadata.entry_points(group=group):
204-
files = entry_point.dist.files
205-
if files:
206-
for file in files:
207-
if "__pycache__" not in file.parts:
208-
parent = file.locate().absolute().resolve().parent
209-
paths.add(str(parent))
205+
for ep in importlib.metadata.entry_points(group=group):
206+
# 1. Spec-based discovery (Handles editable installs)
207+
try:
208+
spec = ep.module and importlib.util.find_spec(ep.module)
209+
if spec and spec.origin:
210+
paths.add(str(pathlib.Path(spec.origin).resolve().parent))
211+
continue
212+
except Exception:
213+
log.debug(f"Spec-based discovery failed for entry point {ep.name} (module {ep.module})", exc_info=True)
214+
215+
# 2. Files-based discovery (Fallback for regular installs)
216+
for f in (ep.dist and ep.dist.files) or ():
217+
if "__pycache__" not in f.parts:
218+
paths.add(str(f.locate().absolute().resolve().parent))
219+
210220
return list(paths)
211221

212222

tests/plugin_entrypoint_test.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import unittest
2+
from unittest.mock import MagicMock, patch
3+
from pathlib import Path
4+
from errbot.utils import entry_point_plugins
5+
6+
7+
def test_entry_point_plugins_no_groups():
8+
result = entry_point_plugins("does_not_exist")
9+
assert [] == result
10+
11+
12+
def test_entry_point_plugins_valid_groups():
13+
results = entry_point_plugins("console_scripts")
14+
match = False
15+
for result in results:
16+
if "errbot" in result:
17+
match = True
18+
assert match
19+
20+
21+
def test_entry_point_paths_empty():
22+
groups = ["errbot.plugins", "errbot.backend_plugins"]
23+
for entry_point_group in groups:
24+
plugins = entry_point_plugins(entry_point_group)
25+
# Note: this test assumes no real backend plugins are installed in the test environment.
26+
assert plugins == []
27+
28+
29+
class TestEntryPointDiscovery(unittest.TestCase):
30+
@patch("importlib.metadata.entry_points")
31+
@patch("importlib.util.find_spec")
32+
def test_entry_point_discovery_editable_logic(
33+
self, mock_find_spec, mock_entry_points
34+
):
35+
"""
36+
Test that discovery works when dist.files is missing (typical of editable installs)
37+
but the module is findable via find_spec.
38+
"""
39+
# Mock entry point
40+
mock_ep = MagicMock()
41+
mock_ep.name = "testplugin"
42+
mock_ep.module = "test_module"
43+
# Simulate editable install: dist exists but has no files attribute or it's empty
44+
mock_ep.dist = MagicMock()
45+
mock_ep.dist.files = None
46+
47+
mock_entry_points.return_value = [mock_ep]
48+
49+
# Mock find_spec to return a valid path
50+
mock_spec = MagicMock()
51+
fake_path = Path("/tmp/fake_dir/test_module.py")
52+
mock_spec.origin = str(fake_path)
53+
mock_spec.submodule_search_locations = None # It's a module, not a package
54+
mock_find_spec.return_value = mock_spec
55+
56+
paths = entry_point_plugins("errbot.backend_plugins")
57+
58+
# Should have found the parent directory of the module
59+
# Note: on macOS /tmp is a symlink to /private/tmp, resolve() handles this.
60+
expected = str(Path("/tmp/fake_dir").resolve())
61+
self.assertIn(expected, paths)
62+
mock_find_spec.assert_called_with("test_module")
63+
64+
@patch("importlib.metadata.entry_points")
65+
@patch("importlib.util.find_spec")
66+
def test_entry_point_discovery_package_logic(
67+
self, mock_find_spec, mock_entry_points
68+
):
69+
"""
70+
Test that discovery works for packages via find_spec.
71+
"""
72+
mock_ep = MagicMock()
73+
mock_ep.name = "testpackage"
74+
mock_ep.module = "test_pkg"
75+
mock_ep.dist = None # No distribution info at all
76+
77+
mock_entry_points.return_value = [mock_ep]
78+
79+
mock_spec = MagicMock()
80+
mock_spec.origin = "/tmp/fake_pkg/__init__.py"
81+
mock_spec.submodule_search_locations = ["/tmp/fake_pkg"]
82+
mock_find_spec.return_value = mock_spec
83+
84+
paths = entry_point_plugins("errbot.backend_plugins")
85+
86+
expected = str(Path("/tmp/fake_pkg").resolve())
87+
self.assertIn(expected, paths)
88+
89+
@patch("importlib.metadata.entry_points")
90+
@patch("importlib.util.find_spec")
91+
def test_entry_point_discovery_fallback_to_files(
92+
self, mock_find_spec, mock_entry_points
93+
):
94+
"""
95+
Test that it still falls back to files if find_spec fails.
96+
"""
97+
mock_ep = MagicMock()
98+
mock_ep.name = "fallback"
99+
mock_ep.module = "nonexistent"
100+
101+
# Method 1 fails
102+
mock_find_spec.side_effect = Exception("Import error")
103+
104+
# Method 2 (files) should work
105+
mock_file = MagicMock()
106+
mock_file.parts = ["fallback", "plugin.py"]
107+
# Ensure the mock returns a resolved path consistent with expectations
108+
mock_file.locate.return_value.absolute.return_value.resolve.return_value.parent = Path(
109+
"/tmp/installed_dir"
110+
).resolve()
111+
112+
mock_ep.dist.files = [mock_file]
113+
mock_entry_points.return_value = [mock_ep]
114+
115+
paths = entry_point_plugins("errbot.backend_plugins")
116+
117+
expected = str(Path("/tmp/installed_dir").resolve())
118+
self.assertIn(expected, paths)
119+
120+
@patch("importlib.metadata.entry_points")
121+
@patch("importlib.util.find_spec")
122+
@patch("errbot.utils.log")
123+
def test_entry_point_discovery_logs_failure(
124+
self, mock_log, mock_find_spec, mock_entry_points
125+
):
126+
"""
127+
Test that failure to find spec is logged.
128+
"""
129+
mock_ep = MagicMock()
130+
mock_ep.name = "fallback"
131+
mock_ep.module = "nonexistent"
132+
mock_ep.dist = MagicMock()
133+
mock_ep.dist.files = []
134+
135+
# Method 1 fails
136+
mock_find_spec.side_effect = Exception("Import error")
137+
138+
mock_entry_points.return_value = [mock_ep]
139+
140+
entry_point_plugins("errbot.backend_plugins")
141+
142+
mock_log.debug.assert_called_with(
143+
"Spec-based discovery failed for entry point fallback (module nonexistent)",
144+
exc_info=True,
145+
)
146+
147+
@patch("importlib.metadata.entry_points")
148+
@patch("importlib.util.find_spec")
149+
def test_entry_point_discovery_deduplication(
150+
self, mock_find_spec, mock_entry_points
151+
):
152+
"""
153+
Test that if both methods find the same path, it is deduplicated.
154+
"""
155+
mock_ep = MagicMock()
156+
mock_ep.name = "double"
157+
mock_ep.module = "double_mod"
158+
159+
# Both methods point to the same directory
160+
same_dir = Path("/tmp/same_dir").resolve()
161+
162+
# Method 1 setup
163+
mock_spec = MagicMock()
164+
mock_spec.origin = str(same_dir / "double_mod.py")
165+
mock_spec.submodule_search_locations = None
166+
mock_find_spec.return_value = mock_spec
167+
168+
# Method 2 setup
169+
mock_file = MagicMock()
170+
mock_file.parts = ["double_mod.py"]
171+
mock_file.locate.return_value.absolute.return_value.resolve.return_value.parent = same_dir
172+
mock_ep.dist.files = [mock_file]
173+
174+
mock_entry_points.return_value = [mock_ep]
175+
176+
paths = entry_point_plugins("errbot.backend_plugins")
177+
178+
self.assertEqual(len(paths), 1)
179+
self.assertEqual(paths[0], str(same_dir))
180+
181+
@patch("importlib.metadata.entry_points")
182+
@patch("importlib.util.find_spec")
183+
def test_entry_point_discovery_multiple_entry_points_same_package(
184+
self, mock_find_spec, mock_entry_points
185+
):
186+
"""
187+
Test that multiple entry points in the same package (common for complex backends)
188+
are handled and paths are correctly collected/deduplicated.
189+
"""
190+
# Package with two backends
191+
ep1 = MagicMock()
192+
ep1.name = "backend_one"
193+
ep1.module = "multibackend.one"
194+
ep1.dist.files = None
195+
196+
ep2 = MagicMock()
197+
ep2.name = "backend_two"
198+
ep2.module = "multibackend.two"
199+
ep2.dist.files = None
200+
201+
mock_entry_points.return_value = [ep1, ep2]
202+
203+
# Both live in the same source directory
204+
base_dir = Path("/tmp/multibackend").resolve()
205+
206+
def side_effect(module_name):
207+
spec = MagicMock()
208+
spec.origin = str(base_dir / module_name.split(".")[-1] / "__init__.py")
209+
spec.submodule_search_locations = [
210+
str(base_dir / module_name.split(".")[-1])
211+
]
212+
return spec
213+
214+
mock_find_spec.side_effect = side_effect
215+
216+
paths = entry_point_plugins("errbot.backend_plugins")
217+
218+
# We expect paths to both submodules (or the parent if logic was different,
219+
# but our current logic adds the parent of spec.origin)
220+
self.assertIn(str(base_dir / "one"), paths)
221+
self.assertIn(str(base_dir / "two"), paths)
222+
self.assertEqual(len(paths), 2)

tests/utils_test.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from errbot.storage import StoreMixin
1212
from errbot.storage.base import StoragePluginBase
1313
from errbot.utils import (
14-
entry_point_plugins,
1514
format_timedelta,
1615
split_string_after,
1716
version2tuple,
@@ -107,24 +106,3 @@ def test_split_string_after_returns_two_chunks_when_chunksize_equals_half_length
107106
splitter = split_string_after(str_, int(len(str_) / 2))
108107
split = [chunk for chunk in splitter]
109108
assert ["foobar2000", "foobar2000"] == split
110-
111-
112-
def test_entry_point_plugins_no_groups():
113-
result = entry_point_plugins("does_not_exist")
114-
assert [] == result
115-
116-
117-
def test_entry_point_plugins_valid_groups():
118-
results = entry_point_plugins("console_scripts")
119-
match = False
120-
for result in results:
121-
if "errbot" in result:
122-
match = True
123-
assert match
124-
125-
126-
def test_entry_point_paths_empty():
127-
groups = ["errbot.plugins", "errbot.backend_plugins"]
128-
for entry_point_group in groups:
129-
plugins = entry_point_plugins(entry_point_group)
130-
assert plugins == []

0 commit comments

Comments
 (0)