99
1010"""PluginManagerBase"""
1111
12+ import configparser
1213import email
1314import logging
15+ import os
1416import os .path
17+ import sys
18+ import zipfile
19+ from importlib import import_module
1520
16- import pkg_resources
1721from twisted .internet import defer
1822from twisted .python .failure import Failure
1923
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+
49255class 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 ):
0 commit comments