Coverage for src/su6/plugins.py: 100%

136 statements  

« prev     ^ index     » next       coverage.py v7.8.1, created at 2025-09-19 22:52 +0200

1""" 

2Provides a register decorator for third party plugins, and a `include_plugins` (used in cli.py) that loads them. 

3""" 

4 

5import typing 

6from dataclasses import dataclass 

7from importlib.metadata import EntryPoint, entry_points 

8 

9from rich import print 

10from typer import Typer 

11 

12from .core import ( 

13 AbstractConfig, 

14 ApplicationState, 

15 T_Command, 

16 print_json, 

17 run_tool, 

18 state, 

19 with_exit_code, 

20) 

21 

22__all__ = ["register", "run_tool", "PluginConfig", "print", "print_json"] 

23 

24 

25class PluginConfig(AbstractConfig): 

26 """ 

27 Can be inherited in plugin to load in plugin-specific config. 

28 

29 The config class is a Singleton, which means multiple instances can be created, but they always have the same state. 

30 

31 Example: 

32 @register() 

33 class DemoConfig(PluginConfig): 

34 required_arg: str 

35 boolean_arg: bool 

36 # ... 

37 

38 

39 config = DemoConfig() 

40 

41 @register() 

42 def with_arguments(required_arg: str, boolean_arg: bool = False) -> None: 

43 config.update(required_arg=required_arg, boolean_arg=boolean_arg) 

44 print(config) 

45 """ 

46 

47 extras: dict[str, typing.Any] 

48 state: typing.Optional[ApplicationState] # only with @register(with_state=True) or after self.attach_state 

49 

50 def __init__(self, **kw: typing.Any) -> None: 

51 """ 

52 Initial variables can be passed on instance creation. 

53 """ 

54 super().__init__() 

55 self.update(**kw) 

56 self.extras = {} 

57 

58 def attach_extra(self, name: str, obj: typing.Any) -> None: 

59 """ 

60 Add a non-annotated variable. 

61 """ 

62 self.extras[name] = obj 

63 

64 def attach_state(self, global_state: ApplicationState) -> None: 

65 """ 

66 Connect the global state to the plugin config. 

67 """ 

68 self.state = global_state 

69 self.attach_extra("state", global_state) 

70 

71 def _fields(self) -> typing.Generator[str, typing.Any, None]: 

72 yield from self.__annotations__.keys() 

73 if self.extras: 

74 yield from self.extras.keys() # -> self.state in _values 

75 

76 def _get(self, key: str, strict: bool = True) -> typing.Any: 

77 notfound = object() 

78 

79 value = getattr(self, key, notfound) 

80 if value is not notfound: 

81 return value 

82 

83 if self.extras: 

84 value = self.extras.get(key, notfound) 

85 if value is not notfound: 

86 return value 

87 

88 if strict: 

89 msg = f"{key} not found in `self {self.__class__.__name__}`" 

90 if self.extras: 

91 msg += f" or `extra's {self.extras.keys()}`" 

92 raise KeyError(msg) 

93 

94 def _values(self) -> typing.Generator[typing.Any, typing.Any, None]: 

95 yield from (self._get(k, False) for k in self._fields()) 

96 

97 def __repr__(self) -> str: 

98 """ 

99 Create a readable representation of this class with its data. 

100 

101 Stolen from dataclasses._repr_fn. 

102 """ 

103 fields = self._fields() 

104 values = self._values() 

105 args = ", ".join([f"{f}={v!r}" for f, v in zip(fields, values)]) 

106 name = self.__class__.__qualname__ 

107 return f"{name}({args})" 

108 

109 def __str__(self) -> str: 

110 """ 

111 Alias for repr. 

112 """ 

113 return repr(self) 

114 

115 

116T_PluginConfig = typing.Type[PluginConfig] 

117 

118U_Wrappable = typing.Union[T_PluginConfig, T_Command] 

119T_Wrappable = typing.TypeVar("T_Wrappable", T_PluginConfig, T_Command) 

120 

121 

122@dataclass() 

123class Registration(typing.Generic[T_Wrappable]): 

124 wrapped: T_Wrappable 

125 

126 # Command: 

127 add_to_all: bool 

128 add_to_fix: bool 

129 

130 # Config: 

131 with_state: bool 

132 strict: bool 

133 config_key: typing.Optional[str] 

134 

135 args: tuple[typing.Any, ...] 

136 kwargs: dict[str, typing.Any] 

137 

138 @property 

139 def what(self) -> typing.Literal["command", "config"] | None: 

140 if isinstance(self.wrapped, type) and issubclass(self.wrapped, PluginConfig): 

141 return "config" 

142 elif callable(self.wrapped): 

143 return "command" 

144 

145 

146AnyRegistration = Registration[T_PluginConfig] | Registration[T_Command] 

147 

148# WeakValueDictionary() does not work since it removes the references too soon :( 

149registrations: dict[int, AnyRegistration] = {} 

150 

151 

152def _register( 

153 wrapped: T_Wrappable, 

154 add_to_all: bool, 

155 add_to_fix: bool, 

156 with_state: bool, 

157 strict: bool, 

158 config_key: typing.Optional[str], 

159 *a: typing.Any, 

160 **kw: typing.Any, 

161) -> T_Wrappable: 

162 registration = Registration( 

163 wrapped, 

164 # Command: 

165 add_to_all=add_to_all, 

166 add_to_fix=add_to_fix, 

167 # Config: 

168 with_state=with_state, 

169 strict=strict, 

170 config_key=config_key, 

171 # passed to Typer 

172 args=a, 

173 kwargs=kw, 

174 ) 

175 

176 registrations[id(wrapped)] = registration 

177 state.register_plugin(wrapped.__name__, registration) 

178 

179 return wrapped 

180 

181 

182@typing.overload 

183def register(wrappable: T_Wrappable, *a_outer: typing.Any, **kw_outer: typing.Any) -> T_Wrappable: 

184 """ 

185 If wrappable is passed, it returns the same type. 

186 

187 @register 

188 def func(): ... 

189 

190 -> register(func) is called 

191 """ 

192 

193 

194@typing.overload 

195def register( 

196 wrappable: None = None, *a_outer: typing.Any, **kw_outer: typing.Any 

197) -> typing.Callable[[T_Wrappable], T_Wrappable]: 

198 """ 

199 If wrappable is None (empty), it returns a callback that will wrap the function later. 

200 

201 @register() 

202 def func(): ... 

203 

204 -> register() is called 

205 """ 

206 

207 

208def register( 

209 wrappable: T_Wrappable = None, 

210 # only used when @registering a Command: 

211 add_to_all: bool = False, 

212 add_to_fix: bool = False, 

213 # only used when @registering a PluginConfig: 

214 with_state: bool = False, 

215 strict: bool = True, 

216 config_key: typing.Optional[str] = None, 

217 *a_outer: typing.Any, 

218 **kw_outer: typing.Any, 

219) -> T_Wrappable | typing.Callable[[T_Wrappable], T_Wrappable]: 

220 """ 

221 Register a top-level Plugin command or a Plugin Config. 

222 

223 Examples: 

224 @register() # () are optional, but you can add Typer keyword arguments if needed. 

225 def command(): 

226 # 'su6 command' is now registered! 

227 ... 

228 

229 @register() # () are optional, but extra keyword arguments can be passed to configure the config. 

230 class MyConfig(PluginConfig): 

231 property: str 

232 """ 

233 

234 def inner(func: T_Wrappable) -> T_Wrappable: 

235 return _register(func, add_to_all, add_to_fix, with_state, strict, config_key, *a_outer, **kw_outer) 

236 

237 if wrappable: 

238 return inner(wrappable) 

239 else: 

240 return inner 

241 

242 

243T = typing.TypeVar("T") 

244 

245 

246class BoundMethodOf(typing.Protocol[T]): 

247 """ 

248 Protocol to define properties that a bound method has. 

249 """ 

250 

251 __self__: T 

252 __name__: str 

253 __doc__: typing.Optional[str] 

254 

255 def __call__(self, a: int) -> str: # pragma: no cover 

256 """ 

257 Indicates this Protocol type can be called. 

258 """ 

259 

260 

261Unbound = typing.Callable[..., typing.Any] 

262 

263 

264def unbind(meth: BoundMethodOf[typing.Any] | Unbound) -> typing.Optional[Unbound]: 

265 """ 

266 Extract the original function (which has a different id) from a class method. 

267 """ 

268 return getattr(meth, "__func__", None) 

269 

270 

271@dataclass() 

272class PluginLoader: 

273 app: Typer 

274 with_exit_code: bool 

275 

276 def main(self) -> None: 

277 """ 

278 Using importlib.metadata, discover available su6 plugins. 

279 

280 Example: 

281 # pyproject.toml 

282 # https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#using-package-metadata 

283 [project.entry-points."su6"] 

284 demo = "su6_plugin_demo.cli" # <- CHANGE ME 

285 """ 

286 discovered_plugins = entry_points(group="su6") 

287 for plugin in discovered_plugins: # pragma: nocover 

288 self._load_plugin(plugin) 

289 

290 self._cleanup() 

291 

292 def _cleanup(self) -> None: 

293 # registrations.clear() 

294 ... 

295 

296 def _load_plugin(self, plugin: EntryPoint) -> list[str]: 

297 """ 

298 Look for typer instances and registered commands and configs in an Entrypoint. 

299 

300 [project.entry-points."su6"] 

301 demo = "su6_plugin_demo.cli" 

302 

303 In this case, the entrypoint 'demo' is defined and points to the cli.py module, 

304 which gets loaded with `plugin.load()` below. 

305 """ 

306 result = [] 

307 plugin_module = plugin.load() 

308 

309 for item in dir(plugin_module): 

310 if item.startswith("_"): 

311 continue 

312 

313 possible_command = getattr(plugin_module, item) 

314 

315 # get method by id (in memory) or first unbind from class and then get by id 

316 registration = registrations.get(id(possible_command)) or registrations.get(id(unbind(possible_command))) 

317 

318 if isinstance(possible_command, Typer): 

319 result += self._add_subcommand(plugin.name, possible_command, plugin_module.__doc__) 

320 elif registration and registration.what == "command": 

321 result += self._add_command(plugin.name, typing.cast(Registration[T_Command], registration)) 

322 elif registration and registration.what == "config": 

323 result += self._add_config(plugin.name, typing.cast(Registration[T_PluginConfig], registration)) 

324 # else: ignore 

325 

326 return result 

327 

328 def _add_command(self, _: str, registration: Registration[T_Command]) -> list[str]: 

329 """ 

330 When a Command Registration is found, it is added to the top-level namespace. 

331 """ 

332 if self.with_exit_code: 

333 registration.wrapped = with_exit_code()(registration.wrapped) 

334 # adding top-level commands 

335 self.app.command(*registration.args, **registration.kwargs)(registration.wrapped) 

336 return [f"command {_}"] 

337 

338 def _add_config(self, name: str, registration: Registration[T_PluginConfig]) -> list[str]: 

339 """ 

340 When a Config Registration is found, the Singleton data is updated with config from pyproject.toml. 

341 

342 Example: 

343 # pyproject.toml 

344 [tool.su6.demo] 

345 boolean-arg = true 

346 optional-with-default = "overridden" 

347 

348 [tool.su6.demo.extra] 

349 more = true 

350 """ 

351 key = registration.config_key or name 

352 

353 cls = registration.wrapped 

354 inst = cls() 

355 

356 if registration.with_state: 

357 inst.attach_state(state) 

358 

359 if registration.strict is False: 

360 inst._strict = False 

361 

362 state.attach_plugin_config(key, inst) 

363 return [f"config {name}"] 

364 

365 def _add_subcommand(self, name: str, subapp: Typer, doc: str) -> list[str]: 

366 self.app.add_typer(subapp, name=name, help=doc) 

367 return [f"subcommand {name}"] 

368 

369 

370def include_plugins(app: Typer, _with_exit_code: bool = True) -> None: 

371 """ 

372 Discover plugins using discover_plugins and add them to either global namespace or as a subcommand. 

373 

374 Args: 

375 app: the top-level Typer app to append commands to 

376 state: top-level application state 

377 _with_exit_code: should the @with_exit_code decorator be applied to the return value of the command? 

378 """ 

379 loader = PluginLoader(app, _with_exit_code) 

380 loader.main() 

381 

382 

383# todo: 

384# - add to 'all' 

385# - add to 'fix'