Coverage for agentos/plugins/discovery.py: 0%

111 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +0800

1""" 

2Plugin Discovery — entry_points based plugin auto-discovery for AgentOS. 

3 

4Scans installed packages for entry_points registered under the 

5'agentos.plugins' group and loads them without manual registration. 

6""" 

7 

8from __future__ import annotations 

9 

10import importlib.metadata 

11import logging 

12from dataclasses import dataclass, field 

13from typing import Any, Callable, Dict, List, Optional, Protocol 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18class PluginProtocol(Protocol): 

19 """Minimal protocol that discovered plugins must satisfy.""" 

20 

21 name: str 

22 version: str 

23 

24 def initialize(self) -> None: ... 

25 def shutdown(self) -> None: ... 

26 

27 

28@dataclass 

29class DiscoveredPlugin: 

30 """Represents a plugin discovered via entry_points.""" 

31 

32 name: str 

33 version: str 

34 entry_point_group: str 

35 entry_point_name: str 

36 package_name: str 

37 module_path: str 

38 metadata: Dict[str, Any] = field(default_factory=dict) 

39 instance: Optional[Any] = field(default=None, repr=False) 

40 

41 @property 

42 def is_loaded(self) -> bool: 

43 return self.instance is not None 

44 

45 

46@dataclass 

47class DiscoveryResult: 

48 """Result of a plugin discovery scan.""" 

49 

50 plugins: List[DiscoveredPlugin] 

51 total_found: int 

52 total_loaded: int 

53 errors: List[str] = field(default_factory=list) 

54 scan_duration_ms: float = 0.0 

55 

56 

57class PluginDiscovery: 

58 """Scans installed packages for AgentOS plugins via entry_points.""" 

59 

60 DEFAULT_GROUPS = [ 

61 "agentos.plugins", 

62 "agentos.tools", 

63 "agentos.models", 

64 "agentos.middleware", 

65 ] 

66 

67 def __init__(self, groups: Optional[List[str]] = None): 

68 self._groups = groups or self.DEFAULT_GROUPS 

69 self._discovered: Dict[str, DiscoveredPlugin] = {} 

70 self._loaders: Dict[str, Callable] = {} 

71 

72 @property 

73 def discovered(self) -> Dict[str, DiscoveredPlugin]: 

74 return dict(self._discovered) 

75 

76 @property 

77 def groups(self) -> List[str]: 

78 return list(self._groups) 

79 

80 def register_loader(self, group: str, loader: Callable) -> None: 

81 """Register a custom loader for a specific entry_point group.""" 

82 self._loaders[group] = loader 

83 

84 def scan(self, groups: Optional[List[str]] = None) -> DiscoveryResult: 

85 """Scan for plugins across specified (or all registered) groups.""" 

86 import time 

87 start = time.perf_counter() 

88 target_groups = groups or self._groups 

89 plugins: List[DiscoveredPlugin] = [] 

90 errors: List[str] = [] 

91 

92 for group in target_groups: 

93 try: 

94 entry_points = importlib.metadata.entry_points(group=group) 

95 except TypeError: 

96 # Python 3.11 fallback 

97 all_eps = importlib.metadata.entry_points() 

98 entry_points = [] 

99 for ep in all_eps: 

100 if ep.group == group: 

101 entry_points.append(ep) 

102 

103 for ep in entry_points: 

104 try: 

105 pkg = ep.dist.name if ep.dist else "unknown" 

106 plugin = DiscoveredPlugin( 

107 name=ep.name, 

108 version=ep.dist.version if ep.dist else "0.0.0", 

109 entry_point_group=group, 

110 entry_point_name=ep.name, 

111 package_name=pkg, 

112 module_path=ep.value, 

113 metadata={"group": group}, 

114 ) 

115 plugins.append(plugin) 

116 self._discovered[f"{group}:{ep.name}"] = plugin 

117 except Exception as e: 

118 errors.append(f"Failed to parse {ep.name} in {group}: {e}") 

119 

120 elapsed = (time.perf_counter() - start) * 1000 

121 return DiscoveryResult( 

122 plugins=plugins, 

123 total_found=len(plugins), 

124 total_loaded=0, 

125 errors=errors, 

126 scan_duration_ms=elapsed, 

127 ) 

128 

129 def load_plugin( 

130 self, name: str, group: str = "agentos.plugins" 

131 ) -> Optional[DiscoveredPlugin]: 

132 """Load a specific discovered plugin by name and group.""" 

133 key = f"{group}:{name}" 

134 plugin = self._discovered.get(key) 

135 if plugin is None: 

136 logger.warning(f"Plugin '{key}' not found in discovered set.") 

137 return None 

138 

139 try: 

140 loader = self._loaders.get(group, _default_plugin_loader) 

141 instance = loader(plugin.module_path) 

142 plugin.instance = instance 

143 if hasattr(instance, "initialize"): 

144 instance.initialize() 

145 return plugin 

146 except Exception as e: 

147 logger.error(f"Failed to load plugin '{key}': {e}") 

148 return None 

149 

150 def load_all( 

151 self, group: Optional[str] = None 

152 ) -> Dict[str, DiscoveredPlugin]: 

153 """Load all discovered plugins, optionally scoped to one group.""" 

154 loaded: Dict[str, DiscoveredPlugin] = {} 

155 for key, plugin in self._discovered.items(): 

156 if group and not key.startswith(f"{group}:"): 

157 continue 

158 result = self.load_plugin(plugin.name, plugin.entry_point_group) 

159 if result and result.is_loaded: 

160 loaded[key] = result 

161 return loaded 

162 

163 def get_by_package(self, package_name: str) -> List[DiscoveredPlugin]: 

164 """Get all discovered plugins from a specific package.""" 

165 return [ 

166 p for p in self._discovered.values() 

167 if p.package_name == package_name 

168 ] 

169 

170 def get_by_group(self, group: str) -> List[DiscoveredPlugin]: 

171 """Get all discovered plugins in a specific group.""" 

172 return [ 

173 p for p in self._discovered.values() 

174 if p.entry_point_group == group 

175 ] 

176 

177 def summary(self) -> Dict[str, Any]: 

178 """Return a summary of all discovered plugins.""" 

179 groups_summary: Dict[str, int] = {} 

180 for p in self._discovered.values(): 

181 groups_summary[p.entry_point_group] = ( 

182 groups_summary.get(p.entry_point_group, 0) + 1 

183 ) 

184 return { 

185 "total_plugins": len(self._discovered), 

186 "by_group": groups_summary, 

187 "loaded": sum(1 for p in self._discovered.values() if p.is_loaded), 

188 } 

189 

190 def clear(self) -> None: 

191 """Clear all discovered plugins.""" 

192 self._discovered.clear() 

193 

194 

195def _default_plugin_loader(module_path: str) -> Any: 

196 """Default loader: import the module and look for a Plugin class.""" 

197 import importlib 

198 

199 module = importlib.import_module(module_path) 

200 # Try common class names 

201 for attr_name in ("Plugin", "AgentOSPlugin", "plugin", "__plugin__"): 

202 if hasattr(module, attr_name): 

203 return getattr(module, attr_name) 

204 return module