Coverage for python / lsst / daf / butler / cli / butler.py: 35%

169 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-12 01:12 -0800

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27from __future__ import annotations 

28 

29__all__ = ( 

30 "ButlerCLI", 

31 "LoaderCLI", 

32 "cli", 

33 "main", 

34) 

35 

36import abc 

37import dataclasses 

38import functools 

39import logging 

40import os 

41import traceback 

42import types 

43from collections import defaultdict 

44from functools import cache 

45from importlib.metadata import entry_points 

46from typing import Any 

47 

48import click 

49import yaml 

50 

51from lsst.resources import ResourcePath 

52from lsst.utils import doImport 

53from lsst.utils.introspection import get_full_type_name 

54from lsst.utils.timer import time_this 

55 

56from .cliLog import CliLog 

57from .opt import log_file_option, log_label_option, log_level_option, log_tty_option, long_log_option 

58from .progress import ClickProgressHandler 

59 

60log = logging.getLogger(__name__) 

61 

62 

63@functools.lru_cache 

64def _importPlugin(pluginName: str) -> types.ModuleType | type | None | click.Command: 

65 """Import a plugin that contains Click commands. 

66 

67 Parameters 

68 ---------- 

69 pluginName : `str` 

70 An importable module whose __all__ parameter contains the commands 

71 that can be called. 

72 

73 Returns 

74 ------- 

75 An imported module or None 

76 The imported module, or None if the module could not be imported. 

77 

78 Notes 

79 ----- 

80 A cache is used in order to prevent repeated reports of failure 

81 to import a module that can be triggered by ``butler --help``. 

82 """ 

83 try: 

84 return doImport(pluginName) 

85 except Exception as err: 

86 log.warning("Could not import plugin from %s, skipping.", pluginName) 

87 log.debug( 

88 "Plugin import exception: %s\nTraceback:\n%s", 

89 err, 

90 "".join(traceback.format_tb(err.__traceback__)), 

91 ) 

92 return None 

93 

94 

95@dataclasses.dataclass(frozen=True) 

96class PluginCommand: 

97 """A click Command and the plugin it came from.""" 

98 

99 command: click.Command 

100 """The command (`click.Command`).""" 

101 source: str 

102 """Where the command came from (`str`).""" 

103 

104 

105class LoaderCLI(click.Group, abc.ABC): 

106 """Extends `click.MultiCommand`, which dispatches to subcommands, to load 

107 subcommands at runtime. 

108 

109 Parameters 

110 ---------- 

111 *args : `typing.Any` 

112 Arguments passed to parent constructor. 

113 **kwargs : `typing.Any` 

114 Keyword arguments passed to parent constructor. 

115 """ 

116 

117 def __init__(self, *args: Any, **kwargs: Any) -> None: 

118 super().__init__(*args, **kwargs) 

119 

120 @property 

121 @abc.abstractmethod 

122 def localCmdPkg(self) -> str: 

123 """Identifies the location of the commands that are in this 

124 package. 

125 

126 `getLocalCommands` assumes that the commands can be found in 

127 `localCmdPkg.__all__`, if this is not the case then getLocalCommands 

128 should be overridden. 

129 

130 Returns 

131 ------- 

132 package : `str` 

133 The fully qualified location of this package. 

134 """ 

135 raise NotImplementedError() 

136 

137 def getLocalCommands(self) -> defaultdict[str, list[str | PluginCommand]]: 

138 """Get the commands offered by the local package. This assumes that the 

139 commands can be found in `localCmdPkg.__all__`, if this is not the case 

140 then this function should be overridden. 

141 

142 Returns 

143 ------- 

144 commands : `defaultdict` [`str`, `list` [`str` | `PluginCommand` ]] 

145 The key is the command name. The value is a list of package(s) that 

146 contains the command. 

147 """ 

148 commandsLocation = _importPlugin(self.localCmdPkg) 

149 if commandsLocation is None: 

150 # _importPlugins logs an error, don't need to do it again here. 

151 return defaultdict(list) 

152 assert hasattr(commandsLocation, "__all__"), f"Must define __all__ in {commandsLocation}" 

153 commands = [getattr(commandsLocation, name) for name in commandsLocation.__all__] 

154 return defaultdict( 

155 list, 

156 { 

157 command.name: [PluginCommand(command, get_full_type_name(commandsLocation))] 

158 for command in commands 

159 }, 

160 ) 

161 

162 def list_commands(self, ctx: click.Context) -> list[str]: 

163 """Get all the commands that can be called by the 

164 butler command, it is used to generate the --help output. 

165 

166 Used by Click. 

167 

168 Parameters 

169 ---------- 

170 ctx : `click.Context` 

171 The current Click context. 

172 

173 Returns 

174 ------- 

175 commands : `list` [`str`] 

176 The names of the commands that can be called by the butler command. 

177 """ 

178 self._setupLogging(ctx) 

179 commands = self._getCommands() 

180 self._raiseIfDuplicateCommands(commands) 

181 return sorted(commands) 

182 

183 def get_command(self, ctx: click.Context, name: str) -> click.Command | None: 

184 """Get a single command for execution. 

185 

186 Used by Click. 

187 

188 Parameters 

189 ---------- 

190 ctx : `click.Context` 

191 The current Click context. 

192 name : `str` 

193 The name of the command to return. 

194 

195 Returns 

196 ------- 

197 command : `click.Command` 

198 A Command that wraps a callable command function. 

199 """ 

200 self._setupLogging(ctx) 

201 commands = self._getCommands() 

202 if name not in commands: 

203 return None 

204 self._raiseIfDuplicateCommands(commands) 

205 command = commands[name][0] 

206 if isinstance(command, str): 

207 module_str = command + "." + self._cmdNameToFuncName(name) 

208 # The click.command decorator returns an instance of a class, which 

209 # is something that doImport is not expecting. We add it in as an 

210 # option here to appease mypy. 

211 with time_this(log, msg="Importing command %s (via %s)", args=(name, module_str)): 

212 plugin = _importPlugin(module_str) 

213 if not plugin: 

214 return None 

215 else: 

216 plugin = command.command 

217 if not isinstance(plugin, click.Command): 

218 raise RuntimeError( 

219 f"Command {name!r} loaded from {module_str} is not a click Command, is {type(plugin)}" 

220 ) 

221 return plugin 

222 

223 def _setupLogging(self, ctx: click.Context | None) -> None: 

224 """Init the logging system and config it for the command. 

225 

226 Subcommands may further configure the log settings. 

227 """ 

228 if isinstance(ctx, click.Context): 

229 CliLog.initLog( 

230 longlog=ctx.params.get(long_log_option.name(), False), 

231 log_tty=ctx.params.get(log_tty_option.name(), True), 

232 log_file=ctx.params.get(log_file_option.name(), ()), 

233 log_label=ctx.params.get(log_label_option.name(), ()), 

234 ) 

235 if log_level_option.name() in ctx.params: 

236 CliLog.setLogLevels(ctx.params[log_level_option.name()]) 

237 else: 

238 # This works around a bug in sphinx-click, where it passes in the 

239 # click.MultiCommand instead of the context. 

240 # https://github.com/click-contrib/sphinx-click/issues/70 

241 CliLog.initLog(longlog=False) 

242 logging.debug( 

243 "The passed-in context was not a click.Context, could not determine --long-log or " 

244 "--log-level values." 

245 ) 

246 

247 @classmethod 

248 def getPluginList(cls) -> list[ResourcePath]: 

249 """Get the list of importable yaml files that contain cli data for this 

250 command. 

251 

252 Returns 

253 ------- 

254 `list` [`lsst.resources.ResourcePath`] 

255 The list of files that contain yaml data about a cli plugin. 

256 """ 

257 yaml_files = [] 

258 if hasattr(cls, "pluginEnvVar"): 

259 pluginModules = os.environ.get(cls.pluginEnvVar) 

260 if pluginModules: 

261 yaml_files.extend([ResourcePath(p) for p in pluginModules.split(":") if p != ""]) 

262 

263 return yaml_files 

264 

265 @classmethod 

266 def _funcNameToCmdName(cls, functionName: str) -> str: 

267 """Convert function name to the butler command name: change 

268 underscores, (used in functions) to dashes (used in commands), and 

269 change local-package command names that conflict with python keywords 

270 to a legal function name. 

271 """ 

272 return functionName.replace("_", "-") 

273 

274 @classmethod 

275 def _cmdNameToFuncName(cls, commandName: str) -> str: 

276 """Convert butler command name to function name: change dashes (used in 

277 commands) to underscores (used in functions), and for local-package 

278 commands names that conflict with python keywords, change the local, 

279 legal, function name to the command name. 

280 """ 

281 return commandName.replace("-", "_") 

282 

283 @staticmethod 

284 def _mergeCommandLists( 

285 a: defaultdict[str, list[str | PluginCommand]], b: defaultdict[str, list[str | PluginCommand]] 

286 ) -> defaultdict[str, list[str | PluginCommand]]: 

287 """Combine two dicts whose keys are strings (command name) and values 

288 are list of string (the package(s) that provide the named command). 

289 

290 Parameters 

291 ---------- 

292 a : `defaultdict` [`str`, `list` [`str` | `PluginCommand` ]] 

293 The key is the command name. The value is a list of package(s) that 

294 contains the command. 

295 b : (same as a) 

296 

297 Returns 

298 ------- 

299 commands : `defaultdict` [`str`: [`str` | `PluginCommand` ]] 

300 For convenience, returns a extended with b. ('a' is modified in 

301 place.) 

302 """ 

303 for key, val in b.items(): 

304 a[key].extend(val) 

305 return a 

306 

307 @classmethod 

308 def _getPluginCommands(cls) -> defaultdict[str, list[str | PluginCommand]]: 

309 """Get the commands offered by plugin packages. 

310 

311 Returns 

312 ------- 

313 commands : `defaultdict` [`str`, `list` [`str`]] 

314 The key is the command name. The value is a list of package(s) that 

315 contains the command. 

316 

317 Notes 

318 ----- 

319 Assumes that if entry points are defined, the plugin environment 

320 variable will not be defined for that same package. 

321 """ 

322 commands: defaultdict[str, list[str | PluginCommand]] = defaultdict(list) 

323 for pluginName in cls.getPluginList(): 

324 try: 

325 resources = defaultdict(list, yaml.safe_load(pluginName.read())) 

326 except Exception as err: 

327 log.warning("Error loading commands from %s, skipping. %s", pluginName, err) 

328 continue 

329 if "cmd" not in resources: 

330 log.warning("No commands found in %s, skipping.", pluginName) 

331 continue 

332 pluginCommands = {cmd: [resources["cmd"]["import"]] for cmd in resources["cmd"]["commands"]} 

333 cls._mergeCommandLists(commands, defaultdict(list, pluginCommands)) 

334 

335 if hasattr(cls, "entryPoint"): 

336 plugins = entry_points(group=cls.entryPoint) 

337 for p in plugins: 

338 try: 

339 func = p.load() 

340 except Exception as err: 

341 log.warning("Could not import plugin from entry point %s, skipping.", p) 

342 log.debug( 

343 "Plugin import exception: %s\nTraceback:\n%s", 

344 err, 

345 "".join(traceback.format_tb(err.__traceback__)), 

346 ) 

347 continue 

348 func_name = get_full_type_name(func) 

349 pluginCommands = {cmd.name: [PluginCommand(cmd, func_name)] for cmd in func()} 

350 cls._mergeCommandLists(commands, defaultdict(list, pluginCommands)) 

351 

352 return commands 

353 

354 @cache 

355 def _getCommands(self) -> defaultdict[str, list[str | PluginCommand]]: 

356 """Get the commands offered by daf_butler and plugin packages. 

357 

358 Returns 

359 ------- 

360 commands : `defaultdict` [`str`, `list` [`str` | `PluginCommand` ]] 

361 The key is the command name. The value is a list of package(s) that 

362 contains the command. 

363 """ 

364 return self._mergeCommandLists(self.getLocalCommands(), self._getPluginCommands()) 

365 

366 @staticmethod 

367 def _raiseIfDuplicateCommands(commands: defaultdict[str, list[str | PluginCommand]]) -> None: 

368 """If any provided command is offered by more than one package raise an 

369 exception. 

370 

371 Parameters 

372 ---------- 

373 commands : `defaultdict` [`str`, `list` [`str` | `PLuginCommand` ]] 

374 The key is the command name. The value is a list of package(s) that 

375 contains the command. 

376 

377 Raises 

378 ------ 

379 click.ClickException 

380 Raised if a command is offered by more than one package, with an 

381 error message to be displayed to the user. 

382 """ 

383 msg = "" 

384 for command, packages in commands.items(): 

385 if len(packages) > 1: 

386 pkg_names: list[str] = [] 

387 for p in packages: 

388 if not isinstance(p, str): 

389 p = p.source 

390 pkg_names.append(p) 

391 msg += f"Command '{command}' exists in packages {', '.join(pkg_names)}. " 

392 if msg: 

393 raise click.ClickException(message=msg + "Duplicate commands are not supported, aborting.") 

394 

395 

396class ButlerCLI(LoaderCLI): 

397 """Specialized command loader implementing the ``butler`` command.""" 

398 

399 localCmdPkg = "lsst.daf.butler.cli.cmd" 

400 

401 pluginEnvVar = "DAF_BUTLER_PLUGINS" 

402 entryPoint = "butler.cli" 

403 

404 @classmethod 

405 def _funcNameToCmdName(cls, functionName: str) -> str: 

406 # Docstring inherited from base class. 

407 

408 # The "import" command name and "butler_import" function name are 

409 # defined in cli/cmd/commands.py, and if those names are changed they 

410 # must be changed here as well. 

411 # It is expected that there will be very few butler command names that 

412 # need to be changed because of e.g. conflicts with python keywords (as 

413 # is done here and in _cmdNameToFuncName for the 'import' command). If 

414 # this becomes a common need then some way of doing this should be 

415 # invented that is better than hard coding the function names into 

416 # these conversion functions. An extension of the 'cli/resources.yaml' 

417 # file (as is currently used in obs_base) might be a good way to do it. 

418 if functionName == "butler_import": 

419 return "import" 

420 return super()._funcNameToCmdName(functionName) 

421 

422 @classmethod 

423 def _cmdNameToFuncName(cls, commandName: str) -> str: 

424 # Docstring inherited from base class. 

425 if commandName == "import": 

426 return "butler_import" 

427 return super()._cmdNameToFuncName(commandName) 

428 

429 

430class UncachedButlerCLI(ButlerCLI): 

431 """ButlerCLI that can be used where caching of the commands is disabled.""" 

432 

433 def _getCommands(self) -> defaultdict[str, list[str | PluginCommand]]: # type: ignore[override] 

434 """Get the commands offered by daf_butler and plugin packages. 

435 

436 Returns 

437 ------- 

438 commands : `defaultdict` [`str`, `list` [`str` | `PluginCommand` ]] 

439 The key is the command name. The value is a list of package(s) that 

440 contains the command. 

441 """ 

442 return self._mergeCommandLists(self.getLocalCommands(), self._getPluginCommands()) 

443 

444 

445@click.command(cls=ButlerCLI, context_settings=dict(help_option_names=["-h", "--help"])) 

446@log_level_option() 

447@long_log_option() 

448@log_file_option() 

449@log_tty_option() 

450@log_label_option() 

451@ClickProgressHandler.option 

452def cli(log_level: str, long_log: bool, log_file: str, log_tty: bool, log_label: str, progress: bool) -> None: 

453 """Butler command-line tools. 

454 

455 Log options apply to all subcommands. 

456 """ 

457 pass 

458 

459 

460def main() -> click.Command: 

461 """Return main entry point for command-line.""" 

462 return cli()