Skip to content

Commit b6b17dc

Browse files
flound1129claude
andcommitted
chore: migrate pkg_resources to importlib.metadata and importlib.resources
Remove the try/except importlib.metadata shim in common.py, clean up unused os.path imports from all plugin common.py files, update docstring comments referencing pkg_resources, and drop the setuptools<82 runtime ceiling now that pkg_resources is no longer used at runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 79e5301 commit b6b17dc

16 files changed

Lines changed: 255 additions & 66 deletions

File tree

deluge/common.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,7 @@
3333

3434
from deluge.error import InvalidPathError
3535

36-
try:
37-
from importlib.metadata import distribution
38-
except ImportError:
39-
from pkg_resources import get_distribution as distribution
36+
from importlib.metadata import distribution
4037

4138

4239
try:
@@ -97,7 +94,7 @@ def get_version():
9794
Returns:
9895
str: The version of Deluge.
9996
"""
100-
return distribution('Deluge').version
97+
return distribution('deluge').version
10198

10299

103100
def get_default_config_dir(filename: Optional[str] = None) -> str:
@@ -300,9 +297,7 @@ def get_pixmap(fname):
300297
def resource_filename(module: str, path: str) -> str:
301298
"""Get filesystem path for a non-python resource.
302299
303-
Abstracts getting module resource files. Originally created to
304-
workaround pkg_resources.resource_filename limitations with
305-
multiple Deluge packages installed.
300+
Abstracts getting module resource files using importlib.resources.
306301
"""
307302
path = Path(path)
308303

deluge/pluginmanagerbase.py

Lines changed: 228 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99

1010
"""PluginManagerBase"""
1111

12+
import configparser
1213
import email
1314
import logging
15+
import os
1416
import os.path
17+
import sys
18+
import zipfile
19+
from importlib import import_module
1520

16-
import pkg_resources
1721
from twisted.internet import defer
1822
from twisted.python.failure import Failure
1923

@@ -46,6 +50,208 @@
4650
"""
4751

4852

53+
class _PluginDistribution:
54+
"""Lightweight distribution object for plugin discovery.
55+
56+
Holds the metadata and entry points for a single plugin discovered
57+
from an egg-info directory, egg zip/directory, or egg-link file.
58+
"""
59+
60+
__slots__ = ('project_name', 'version', 'location', '_metadata', '_entry_points')
61+
62+
def __init__(self, project_name, version, location, metadata, entry_points):
63+
self.project_name = project_name
64+
self.version = version
65+
self.location = location
66+
self._metadata = metadata
67+
self._entry_points = entry_points
68+
69+
def get_metadata(self, name):
70+
if name == 'PKG-INFO':
71+
return self._metadata
72+
raise FileNotFoundError(name)
73+
74+
def get_entry_map(self, group):
75+
return self._entry_points.get(group, {})
76+
77+
def load_entry_point(self, group, name):
78+
ep = self._entry_points.get(group, {}).get(name)
79+
if ep is None:
80+
raise KeyError(f'No entry point {name!r} in group {group!r}')
81+
module_path, attr = ep.rsplit(':', 1)
82+
mod = import_module(module_path)
83+
return getattr(mod, attr)
84+
85+
86+
def _parse_entry_points_txt(text):
87+
"""Parse an entry_points.txt file into {group: {name: value}} dict."""
88+
result = {}
89+
cp = configparser.ConfigParser()
90+
cp.read_string(text)
91+
for section in cp.sections():
92+
group = {}
93+
for name, value in cp.items(section):
94+
group[name] = value.strip()
95+
result[section] = group
96+
return result
97+
98+
99+
def _read_egg_info(egg_info_dir):
100+
"""Read a .egg-info directory and return a _PluginDistribution or None."""
101+
pkg_info_path = os.path.join(egg_info_dir, 'PKG-INFO')
102+
if not os.path.isfile(pkg_info_path):
103+
return None
104+
105+
with open(pkg_info_path, encoding='utf-8') as f:
106+
metadata_text = f.read()
107+
108+
msg = email.message_from_string(metadata_text)
109+
project_name = msg.get('Name', '')
110+
version = msg.get('Version', '')
111+
112+
entry_points = {}
113+
ep_path = os.path.join(egg_info_dir, 'entry_points.txt')
114+
if os.path.isfile(ep_path):
115+
with open(ep_path, encoding='utf-8') as f:
116+
entry_points = _parse_entry_points_txt(f.read())
117+
118+
location = os.path.dirname(egg_info_dir)
119+
return _PluginDistribution(project_name, version, location, metadata_text, entry_points)
120+
121+
122+
def _read_egg_zip(egg_path):
123+
"""Read a .egg zip file and return a _PluginDistribution or None."""
124+
if not zipfile.is_zipfile(egg_path):
125+
return None
126+
127+
try:
128+
with zipfile.ZipFile(egg_path, 'r') as zf:
129+
try:
130+
metadata_text = zf.read('EGG-INFO/PKG-INFO').decode('utf-8')
131+
except KeyError:
132+
return None
133+
134+
msg = email.message_from_string(metadata_text)
135+
project_name = msg.get('Name', '')
136+
version = msg.get('Version', '')
137+
138+
entry_points = {}
139+
try:
140+
ep_text = zf.read('EGG-INFO/entry_points.txt').decode('utf-8')
141+
entry_points = _parse_entry_points_txt(ep_text)
142+
except KeyError:
143+
pass
144+
145+
return _PluginDistribution(
146+
project_name, version, egg_path, metadata_text, entry_points
147+
)
148+
except (zipfile.BadZipFile, OSError):
149+
return None
150+
151+
152+
def _read_egg_dir(egg_dir):
153+
"""Read an unpacked .egg directory (with EGG-INFO/) and return a _PluginDistribution or None."""
154+
egg_info = os.path.join(egg_dir, 'EGG-INFO')
155+
if not os.path.isdir(egg_info):
156+
return None
157+
158+
pkg_info_path = os.path.join(egg_info, 'PKG-INFO')
159+
if not os.path.isfile(pkg_info_path):
160+
return None
161+
162+
with open(pkg_info_path, encoding='utf-8') as f:
163+
metadata_text = f.read()
164+
165+
msg = email.message_from_string(metadata_text)
166+
project_name = msg.get('Name', '')
167+
version = msg.get('Version', '')
168+
169+
entry_points = {}
170+
ep_path = os.path.join(egg_info, 'entry_points.txt')
171+
if os.path.isfile(ep_path):
172+
with open(ep_path, encoding='utf-8') as f:
173+
entry_points = _parse_entry_points_txt(f.read())
174+
175+
return _PluginDistribution(project_name, version, egg_dir, metadata_text, entry_points)
176+
177+
178+
def _scan_plugin_dirs(dirs):
179+
"""Scan directories for plugin distributions.
180+
181+
Discovers plugins from:
182+
- .egg-info directories (setuptools develop/egg_info installs)
183+
- .egg zip files (bdist_egg)
184+
- unpacked .egg directories (Ubuntu-style installs)
185+
- .egg-link files (develop installs pointing to source directories)
186+
187+
Returns a dict mapping normalised plugin names to lists of _PluginDistribution.
188+
"""
189+
found = {}
190+
191+
for scan_dir in dirs:
192+
if not os.path.isdir(scan_dir):
193+
continue
194+
195+
for entry in os.listdir(scan_dir):
196+
full_path = os.path.join(scan_dir, entry)
197+
dist = None
198+
199+
if entry.endswith('.egg-info') and os.path.isdir(full_path):
200+
dist = _read_egg_info(full_path)
201+
202+
elif entry.endswith('.egg'):
203+
if os.path.isfile(full_path):
204+
dist = _read_egg_zip(full_path)
205+
elif os.path.isdir(full_path):
206+
dist = _read_egg_dir(full_path)
207+
208+
elif entry.endswith('.egg-link') and os.path.isfile(full_path):
209+
with open(full_path, encoding='utf-8') as f:
210+
lines = f.read().splitlines()
211+
if lines:
212+
source_dir = lines[0].strip()
213+
if os.path.isdir(source_dir):
214+
if source_dir not in sys.path:
215+
sys.path.insert(0, source_dir)
216+
# Look for .egg-info dirs inside the source directory
217+
for sub in os.listdir(source_dir):
218+
sub_path = os.path.join(source_dir, sub)
219+
if sub.endswith('.egg-info') and os.path.isdir(sub_path):
220+
dist = _read_egg_info(sub_path)
221+
if dist:
222+
break
223+
224+
if dist and dist.project_name:
225+
norm_name = dist.project_name.lower().replace('-', ' ').replace('_', ' ')
226+
if norm_name not in found:
227+
found[norm_name] = []
228+
found[norm_name].append(dist)
229+
230+
return found
231+
232+
233+
class _PluginEnvironment:
234+
"""Dict-like lookup of plugin distributions by name.
235+
236+
Keys are case-insensitive and dash/underscore/space normalised.
237+
"""
238+
239+
def __init__(self, distributions):
240+
self._dists = distributions
241+
242+
def _normalise(self, name):
243+
return name.lower().replace('-', ' ').replace('_', ' ')
244+
245+
def __getitem__(self, name):
246+
return self._dists.get(self._normalise(name), [])
247+
248+
def __iter__(self):
249+
return iter(self._dists)
250+
251+
def __contains__(self, name):
252+
return self._normalise(name) in self._dists
253+
254+
49255
class PluginManagerBase:
50256
"""PluginManagerBase is a base class for PluginManagers to inherit"""
51257

@@ -126,18 +332,22 @@ def scan_for_plugins(self) -> None:
126332
"""Scan plugin_dirs for available plugins."""
127333
str_dirs = [str(d) for d in self.plugin_dirs]
128334
for dirname in str_dirs:
129-
pkg_resources.working_set.add_entry(dirname)
130-
self.pkg_env = pkg_resources.Environment(str_dirs, platform=None, python=None)
335+
if os.path.isdir(dirname) and dirname not in sys.path:
336+
sys.path.insert(0, dirname)
337+
self.pkg_env = _PluginEnvironment(_scan_plugin_dirs(str_dirs))
131338

132339
self.available_plugins = []
133340
for name in self.pkg_env:
134-
log.debug(
135-
'Found plugin: %s %s at %s',
136-
self.pkg_env[name][0].project_name,
137-
self.pkg_env[name][0].version,
138-
self.pkg_env[name][0].location,
139-
)
140-
self.available_plugins.append(self.pkg_env[name][0].project_name)
341+
dists = self.pkg_env[name]
342+
if dists:
343+
dist = dists[0]
344+
log.debug(
345+
'Found plugin: %s %s at %s',
346+
dist.project_name,
347+
dist.version,
348+
dist.location,
349+
)
350+
self.available_plugins.append(dist.project_name)
141351

142352
def enable_plugin(self, plugin_name):
143353
"""Enable a plugin.
@@ -159,9 +369,14 @@ def enable_plugin(self, plugin_name):
159369
return defer.succeed(True)
160370

161371
plugin_name = plugin_name.replace(' ', '-')
162-
egg = self.pkg_env[plugin_name][0]
163-
# Activate is required by non-namespace plugins.
164-
egg.activate()
372+
dists = self.pkg_env[plugin_name]
373+
if not dists:
374+
log.warning('Cannot find distribution for plugin %s', plugin_name)
375+
return defer.succeed(False)
376+
egg = dists[0]
377+
# Ensure the plugin location is importable
378+
if egg.location not in sys.path:
379+
sys.path.insert(0, egg.location)
165380
return_d = defer.succeed(True)
166381

167382
for name in egg.get_entry_map(self.entry_name):

deluge/plugins/AutoAdd/deluge_autoadd/common.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@
1212
#
1313

1414
import os.path
15-
16-
from pkg_resources import resource_filename
15+
from importlib.resources import files
1716

1817

1918
def get_resource(filename, subdir=False):
2019
folder = os.path.join('data', 'autoadd_options') if subdir else 'data'
21-
return resource_filename(__package__, os.path.join(folder, filename))
20+
return str(files(__package__).joinpath(folder, filename))

deluge/plugins/Blocklist/deluge_blocklist/common.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,13 @@
1111
# See LICENSE for more details.
1212
#
1313

14-
import os.path
1514
from functools import wraps
15+
from importlib.resources import files
1616
from sys import exc_info
1717

18-
from pkg_resources import resource_filename
19-
2018

2119
def get_resource(filename):
22-
return resource_filename(__package__, os.path.join('data', filename))
20+
return str(files(__package__).joinpath('data', filename))
2321

2422

2523
def raises_errors_as(error):

deluge/plugins/Execute/deluge_execute/common.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@
1111
# See LICENSE for more details.
1212
#
1313

14-
import os.path
15-
16-
from pkg_resources import resource_filename
14+
from importlib.resources import files
1715

1816

1917
def get_resource(filename):
20-
return resource_filename(__package__, os.path.join('data', filename))
18+
return str(files(__package__).joinpath('data', filename))

deluge/plugins/Extractor/deluge_extractor/common.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@
1111
# See LICENSE for more details.
1212
#
1313

14-
import os.path
15-
16-
from pkg_resources import resource_filename
14+
from importlib.resources import files
1715

1816

1917
def get_resource(filename):
20-
return resource_filename(__package__, os.path.join('data', filename))
18+
return str(files(__package__).joinpath('data', filename))

deluge/plugins/Label/deluge_label/common.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@
1111
# See LICENSE for more details.
1212
#
1313

14-
import os.path
15-
16-
from pkg_resources import resource_filename
14+
from importlib.resources import files
1715

1816

1917
def get_resource(filename):
20-
return resource_filename(__package__, os.path.join('data', filename))
18+
return str(files(__package__).joinpath('data', filename))

deluge/plugins/Notifications/deluge_notifications/common.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@
1212
#
1313

1414
import logging
15-
import os.path
15+
from importlib.resources import files
1616

17-
from pkg_resources import resource_filename
1817
from twisted.internet import defer
1918

2019
from deluge import component
@@ -24,7 +23,7 @@
2423

2524

2625
def get_resource(filename):
27-
return resource_filename(__package__, os.path.join('data', filename))
26+
return str(files(__package__).joinpath('data', filename))
2827

2928

3029
class CustomNotifications:

0 commit comments

Comments
 (0)