Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ v9.9.9 (unreleased)
-------------------
- chore: add releases script (#1767)
- refactor: remove webtest dependency (#1769)
- feat: support editable installed entrypoint plugins (#1766)


v6.2.1 (2026-06-06)
Expand Down
24 changes: 17 additions & 7 deletions errbot/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import collections
import fnmatch
import importlib.metadata
import importlib.util
import inspect
import logging
import os
import pathlib
import re
import sys
import time
Expand Down Expand Up @@ -200,13 +202,21 @@ def collect_roots(base_paths: List, file_sig: str = "*.plug") -> List:
def entry_point_plugins(group):
paths = set()

for entry_point in importlib.metadata.entry_points(group=group):
files = entry_point.dist.files
if files:
for file in files:
if "__pycache__" not in file.parts:
parent = file.locate().absolute().resolve().parent
paths.add(str(parent))
for ep in importlib.metadata.entry_points(group=group):
# 1. Spec-based discovery (Handles editable installs)
try:
spec = ep.module and importlib.util.find_spec(ep.module)
if spec and spec.origin:
paths.add(str(pathlib.Path(spec.origin).resolve().parent))
continue
except Exception:
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to tighten the exception range to avoid debugging frustration due to relevant errors being silently drop? Alternatively add a debug log to hint an error occurred in that part of the code.


# 2. Files-based discovery (Fallback for regular installs)
for f in (ep.dist and ep.dist.files) or ():
if "__pycache__" not in f.parts:
paths.add(str(f.locate().absolute().resolve().parent))

return list(paths)


Expand Down
195 changes: 195 additions & 0 deletions tests/plugin_entrypoint_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import unittest
from unittest.mock import MagicMock, patch
from pathlib import Path
from errbot.utils import entry_point_plugins


def test_entry_point_plugins_no_groups():
result = entry_point_plugins("does_not_exist")
assert [] == result


def test_entry_point_plugins_valid_groups():
results = entry_point_plugins("console_scripts")
match = False
for result in results:
if "errbot" in result:
match = True
assert match


def test_entry_point_paths_empty():
groups = ["errbot.plugins", "errbot.backend_plugins"]
for entry_point_group in groups:
plugins = entry_point_plugins(entry_point_group)
# Note: this test assumes no real backend plugins are installed in the test environment.
assert plugins == []


class TestEntryPointDiscovery(unittest.TestCase):
@patch("importlib.metadata.entry_points")
@patch("importlib.util.find_spec")
def test_entry_point_discovery_editable_logic(
self, mock_find_spec, mock_entry_points
):
"""
Test that discovery works when dist.files is missing (typical of editable installs)
but the module is findable via find_spec.
"""
# Mock entry point
mock_ep = MagicMock()
mock_ep.name = "testplugin"
mock_ep.module = "test_module"
# Simulate editable install: dist exists but has no files attribute or it's empty
mock_ep.dist = MagicMock()
mock_ep.dist.files = None

mock_entry_points.return_value = [mock_ep]

# Mock find_spec to return a valid path
mock_spec = MagicMock()
fake_path = Path("/tmp/fake_dir/test_module.py")
mock_spec.origin = str(fake_path)
mock_spec.submodule_search_locations = None # It's a module, not a package
mock_find_spec.return_value = mock_spec

paths = entry_point_plugins("errbot.backend_plugins")

# Should have found the parent directory of the module
# Note: on macOS /tmp is a symlink to /private/tmp, resolve() handles this.
expected = str(Path("/tmp/fake_dir").resolve())
self.assertIn(expected, paths)
mock_find_spec.assert_called_with("test_module")

@patch("importlib.metadata.entry_points")
@patch("importlib.util.find_spec")
def test_entry_point_discovery_package_logic(
self, mock_find_spec, mock_entry_points
):
"""
Test that discovery works for packages via find_spec.
"""
mock_ep = MagicMock()
mock_ep.name = "testpackage"
mock_ep.module = "test_pkg"
mock_ep.dist = None # No distribution info at all

mock_entry_points.return_value = [mock_ep]

mock_spec = MagicMock()
mock_spec.origin = "/tmp/fake_pkg/__init__.py"
mock_spec.submodule_search_locations = ["/tmp/fake_pkg"]
mock_find_spec.return_value = mock_spec

paths = entry_point_plugins("errbot.backend_plugins")

expected = str(Path("/tmp/fake_pkg").resolve())
self.assertIn(expected, paths)

@patch("importlib.metadata.entry_points")
@patch("importlib.util.find_spec")
def test_entry_point_discovery_fallback_to_files(
self, mock_find_spec, mock_entry_points
):
"""
Test that it still falls back to files if find_spec fails.
"""
mock_ep = MagicMock()
mock_ep.name = "fallback"
mock_ep.module = "nonexistent"

# Method 1 fails
mock_find_spec.side_effect = Exception("Import error")

# Method 2 (files) should work
mock_file = MagicMock()
mock_file.parts = ["fallback", "plugin.py"]
# Ensure the mock returns a resolved path consistent with expectations
mock_file.locate.return_value.absolute.return_value.resolve.return_value.parent = Path(
"/tmp/installed_dir"
).resolve()

mock_ep.dist.files = [mock_file]
mock_entry_points.return_value = [mock_ep]

paths = entry_point_plugins("errbot.backend_plugins")

expected = str(Path("/tmp/installed_dir").resolve())
self.assertIn(expected, paths)

@patch("importlib.metadata.entry_points")
@patch("importlib.util.find_spec")
def test_entry_point_discovery_deduplication(
self, mock_find_spec, mock_entry_points
):
"""
Test that if both methods find the same path, it is deduplicated.
"""
mock_ep = MagicMock()
mock_ep.name = "double"
mock_ep.module = "double_mod"

# Both methods point to the same directory
same_dir = Path("/tmp/same_dir").resolve()

# Method 1 setup
mock_spec = MagicMock()
mock_spec.origin = str(same_dir / "double_mod.py")
mock_spec.submodule_search_locations = None
mock_find_spec.return_value = mock_spec

# Method 2 setup
mock_file = MagicMock()
mock_file.parts = ["double_mod.py"]
mock_file.locate.return_value.absolute.return_value.resolve.return_value.parent = same_dir
mock_ep.dist.files = [mock_file]

mock_entry_points.return_value = [mock_ep]

paths = entry_point_plugins("errbot.backend_plugins")

self.assertEqual(len(paths), 1)
self.assertEqual(paths[0], str(same_dir))

@patch("importlib.metadata.entry_points")
@patch("importlib.util.find_spec")
def test_entry_point_discovery_multiple_entry_points_same_package(
self, mock_find_spec, mock_entry_points
):
"""
Test that multiple entry points in the same package (common for complex backends)
are handled and paths are correctly collected/deduplicated.
"""
# Package with two backends
ep1 = MagicMock()
ep1.name = "backend_one"
ep1.module = "multibackend.one"
ep1.dist.files = None

ep2 = MagicMock()
ep2.name = "backend_two"
ep2.module = "multibackend.two"
ep2.dist.files = None

mock_entry_points.return_value = [ep1, ep2]

# Both live in the same source directory
base_dir = Path("/tmp/multibackend").resolve()

def side_effect(module_name):
spec = MagicMock()
spec.origin = str(base_dir / module_name.split(".")[-1] / "__init__.py")
spec.submodule_search_locations = [
str(base_dir / module_name.split(".")[-1])
]
return spec

mock_find_spec.side_effect = side_effect

paths = entry_point_plugins("errbot.backend_plugins")

# We expect paths to both submodules (or the parent if logic was different,
# but our current logic adds the parent of spec.origin)
self.assertIn(str(base_dir / "one"), paths)
self.assertIn(str(base_dir / "two"), paths)
self.assertEqual(len(paths), 2)
22 changes: 0 additions & 22 deletions tests/utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from errbot.storage import StoreMixin
from errbot.storage.base import StoragePluginBase
from errbot.utils import (
entry_point_plugins,
format_timedelta,
split_string_after,
version2tuple,
Expand Down Expand Up @@ -107,24 +106,3 @@ def test_split_string_after_returns_two_chunks_when_chunksize_equals_half_length
splitter = split_string_after(str_, int(len(str_) / 2))
split = [chunk for chunk in splitter]
assert ["foobar2000", "foobar2000"] == split


def test_entry_point_plugins_no_groups():
result = entry_point_plugins("does_not_exist")
assert [] == result


def test_entry_point_plugins_valid_groups():
results = entry_point_plugins("console_scripts")
match = False
for result in results:
if "errbot" in result:
match = True
assert match


def test_entry_point_paths_empty():
groups = ["errbot.plugins", "errbot.backend_plugins"]
for entry_point_group in groups:
plugins = entry_point_plugins(entry_point_group)
assert plugins == []
Loading