Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 program is free software: you can redistribute it and/or modify 

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22import abc 

23import click 

24from collections import defaultdict 

25import logging 

26import os 

27import traceback 

28import yaml 

29 

30from .cliLog import CliLog 

31from .opt import log_level_option, long_log_option 

32from lsst.utils import doImport 

33 

34 

35log = logging.getLogger(__name__) 

36 

37 

38class LoaderCLI(click.MultiCommand, abc.ABC): 

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

40 subcommands at runtime.""" 

41 

42 def __init__(self, *args, **kwargs): 

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

44 

45 @property 

46 @abc.abstractmethod 

47 def localCmdPkg(self): 

48 """localCmdPkg identifies the location of the commands that are in this 

49 package. `getLocalCommands` assumes that the commands can be found in 

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

51 should be overridden. 

52 

53 Returns 

54 ------- 

55 package : `str` 

56 The fully qualified location of this package. 

57 """ 

58 pass 

59 

60 def getLocalCommands(self): 

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

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

63 then this function should be overridden. 

64 

65 Returns 

66 ------- 

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

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

69 contains the command. 

70 """ 

71 commandsLocation = self._importPlugin(self.localCmdPkg) 

72 if commandsLocation is None: 

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

74 return {} 

75 return defaultdict(list, {self._funcNameToCmdName(f): 

76 [self.localCmdPkg] for f in commandsLocation.__all__}) 

77 

78 def list_commands(self, ctx): 

79 """Used by Click to get all the commands that can be called by the 

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

81 

82 Parameters 

83 ---------- 

84 ctx : `click.Context` 

85 The current Click context. 

86 

87 Returns 

88 ------- 

89 commands : `list` [`str`] 

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

91 """ 

92 self._setupLogging(ctx) 

93 commands = self._getCommands() 

94 self._raiseIfDuplicateCommands(commands) 

95 return sorted(commands) 

96 

97 def get_command(self, ctx, name): 

98 """Used by Click to get a single command for execution. 

99 

100 Parameters 

101 ---------- 

102 ctx : `click.Context` 

103 The current Click context. 

104 name : `str` 

105 The name of the command to return. 

106 

107 Returns 

108 ------- 

109 command : `click.Command` 

110 A Command that wraps a callable command function. 

111 """ 

112 self._setupLogging(ctx) 

113 commands = self._getCommands() 

114 if name not in commands: 

115 return None 

116 self._raiseIfDuplicateCommands(commands) 

117 return self._importPlugin(commands[name][0] + "." + self._cmdNameToFuncName(name)) 

118 

119 def _setupLogging(self, ctx): 

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

121 

122 Subcommands may further configure the log settings.""" 

123 if isinstance(ctx, click.Context): 

124 CliLog.initLog(longlog=ctx.params.get(long_log_option.name(), False)) 

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

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

127 else: 

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

129 # click.MultiCommand instead of the context. 

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

131 CliLog.initLog(longlog=False) 

132 logging.debug("The passed-in context was not a click.Context, could not determine --long-log or " 

133 "--log-level values.") 

134 

135 @classmethod 

136 def getPluginList(cls): 

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

138 command. 

139 

140 Returns 

141 ------- 

142 `list` [`str`] 

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

144 """ 

145 if not hasattr(cls, "pluginEnvVar"): 

146 return [] 

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

148 if pluginModules: 

149 return [p for p in pluginModules.split(":") if p != ''] 

150 return [] 

151 

152 @classmethod 

153 def _funcNameToCmdName(cls, functionName): 

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

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

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

157 to a legal function name. 

158 """ 

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

160 

161 @classmethod 

162 def _cmdNameToFuncName(cls, commandName): 

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

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

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

166 legal, function name to the command name.""" 

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

168 

169 @staticmethod 

170 def _importPlugin(pluginName): 

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

172 

173 Parameters 

174 ---------- 

175 pluginName : `str` 

176 An importable module whose __all__ parameter contains the commands 

177 that can be called. 

178 

179 Returns 

180 ------- 

181 An imported module or None 

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

183 """ 

184 try: 

185 return doImport(pluginName) 

186 except Exception as err: 

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

188 log.debug("Plugin import exception: %s\nTraceback:\n%s", err, 

189 "".join(traceback.format_tb(err.__traceback__))) 

190 return None 

191 

192 @staticmethod 

193 def _mergeCommandLists(a, b): 

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

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

196 

197 Parameters 

198 ---------- 

199 a : `defaultdict` [`str`, `list` [`str`]] 

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

201 contains the command. 

202 b : (same as a) 

203 

204 Returns 

205 ------- 

206 commands : `defaultdict` [`str`: [`str`]] 

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

208 place.) 

209 """ 

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

211 a[key].extend(val) 

212 return a 

213 

214 @classmethod 

215 def _getPluginCommands(cls): 

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

217 

218 Returns 

219 ------- 

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

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

222 contains the command. 

223 """ 

224 commands = defaultdict(list) 

225 for pluginName in cls.getPluginList(): 

226 try: 

227 with open(pluginName, "r") as resourceFile: 

228 resources = defaultdict(list, yaml.safe_load(resourceFile)) 

229 except Exception as err: 

230 log.warning(f"Error loading commands from {pluginName}, skipping. {err}") 

231 continue 

232 if "cmd" not in resources: 

233 log.warning(f"No commands found in {pluginName}, skipping.") 

234 continue 

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

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

237 return commands 

238 

239 def _getCommands(self): 

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

241 

242 Returns 

243 ------- 

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

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

246 contains the command. 

247 """ 

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

249 

250 @staticmethod 

251 def _raiseIfDuplicateCommands(commands): 

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

253 exception. 

254 

255 Parameters 

256 ---------- 

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

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

259 contains the command. 

260 

261 Raises 

262 ------ 

263 click.ClickException 

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

265 error message to be displayed to the user. 

266 """ 

267 

268 msg = "" 

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

270 if len(packages) > 1: 

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

272 if msg: 

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

274 

275 

276class ButlerCLI(LoaderCLI): 

277 

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

279 

280 pluginEnvVar = "DAF_BUTLER_PLUGINS" 

281 

282 @classmethod 

283 def _funcNameToCmdName(cls, functionName): 

284 # Docstring inherited from base class. 

285 

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

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

288 # must be changed here as well. 

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

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

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

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

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

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

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

296 if functionName == "butler_import": 

297 return "import" 

298 return super()._funcNameToCmdName(functionName) 

299 

300 @classmethod 

301 def _cmdNameToFuncName(cls, commandName): 

302 # Docstring inherited from base class. 

303 if commandName == "import": 

304 return "butler_import" 

305 return super()._cmdNameToFuncName(commandName) 

306 

307 

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

309@log_level_option() 

310@long_log_option() 

311def cli(log_level, long_log): 

312 # log_level is handled by get_command and list_commands, and is called in 

313 # one of those functions before this is called. long_log is handled by 

314 # setup_logging. 

315 pass 

316 

317 

318def main(): 

319 return cli()