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 .progress import ClickProgressHandler 

33from lsst.utils import doImport 

34 

35 

36log = logging.getLogger(__name__) 

37 

38 

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

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

41 subcommands at runtime.""" 

42 

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

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

45 

46 @property 

47 @abc.abstractmethod 

48 def localCmdPkg(self): 

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

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

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

52 should be overridden. 

53 

54 Returns 

55 ------- 

56 package : `str` 

57 The fully qualified location of this package. 

58 """ 

59 pass 

60 

61 def getLocalCommands(self): 

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

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

64 then this function should be overridden. 

65 

66 Returns 

67 ------- 

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

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

70 contains the command. 

71 """ 

72 commandsLocation = self._importPlugin(self.localCmdPkg) 

73 if commandsLocation is None: 

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

75 return {} 

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

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

78 

79 def list_commands(self, ctx): 

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

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

82 

83 Parameters 

84 ---------- 

85 ctx : `click.Context` 

86 The current Click context. 

87 

88 Returns 

89 ------- 

90 commands : `list` [`str`] 

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

92 """ 

93 self._setupLogging(ctx) 

94 commands = self._getCommands() 

95 self._raiseIfDuplicateCommands(commands) 

96 return sorted(commands) 

97 

98 def get_command(self, ctx, name): 

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

100 

101 Parameters 

102 ---------- 

103 ctx : `click.Context` 

104 The current Click context. 

105 name : `str` 

106 The name of the command to return. 

107 

108 Returns 

109 ------- 

110 command : `click.Command` 

111 A Command that wraps a callable command function. 

112 """ 

113 self._setupLogging(ctx) 

114 commands = self._getCommands() 

115 if name not in commands: 

116 return None 

117 self._raiseIfDuplicateCommands(commands) 

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

119 

120 def _setupLogging(self, ctx): 

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

122 

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

124 if isinstance(ctx, click.Context): 

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

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

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

128 else: 

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

130 # click.MultiCommand instead of the context. 

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

132 CliLog.initLog(longlog=False) 

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

134 "--log-level values.") 

135 

136 @classmethod 

137 def getPluginList(cls): 

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

139 command. 

140 

141 Returns 

142 ------- 

143 `list` [`str`] 

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

145 """ 

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

147 return [] 

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

149 if pluginModules: 

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

151 return [] 

152 

153 @classmethod 

154 def _funcNameToCmdName(cls, functionName): 

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

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

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

158 to a legal function name. 

159 """ 

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

161 

162 @classmethod 

163 def _cmdNameToFuncName(cls, commandName): 

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

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

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

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

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

169 

170 @staticmethod 

171 def _importPlugin(pluginName): 

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

173 

174 Parameters 

175 ---------- 

176 pluginName : `str` 

177 An importable module whose __all__ parameter contains the commands 

178 that can be called. 

179 

180 Returns 

181 ------- 

182 An imported module or None 

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

184 """ 

185 try: 

186 return doImport(pluginName) 

187 except Exception as err: 

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

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

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

191 return None 

192 

193 @staticmethod 

194 def _mergeCommandLists(a, b): 

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

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

197 

198 Parameters 

199 ---------- 

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

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

202 contains the command. 

203 b : (same as a) 

204 

205 Returns 

206 ------- 

207 commands : `defaultdict` [`str`: [`str`]] 

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

209 place.) 

210 """ 

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

212 a[key].extend(val) 

213 return a 

214 

215 @classmethod 

216 def _getPluginCommands(cls): 

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

218 

219 Returns 

220 ------- 

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

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

223 contains the command. 

224 """ 

225 commands = defaultdict(list) 

226 for pluginName in cls.getPluginList(): 

227 try: 

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

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

230 except Exception as err: 

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

232 continue 

233 if "cmd" not in resources: 

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

235 continue 

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

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

238 return commands 

239 

240 def _getCommands(self): 

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

242 

243 Returns 

244 ------- 

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

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

247 contains the command. 

248 """ 

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

250 

251 @staticmethod 

252 def _raiseIfDuplicateCommands(commands): 

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

254 exception. 

255 

256 Parameters 

257 ---------- 

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

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

260 contains the command. 

261 

262 Raises 

263 ------ 

264 click.ClickException 

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

266 error message to be displayed to the user. 

267 """ 

268 

269 msg = "" 

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

271 if len(packages) > 1: 

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

273 if msg: 

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

275 

276 

277class ButlerCLI(LoaderCLI): 

278 

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

280 

281 pluginEnvVar = "DAF_BUTLER_PLUGINS" 

282 

283 @classmethod 

284 def _funcNameToCmdName(cls, functionName): 

285 # Docstring inherited from base class. 

286 

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

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

289 # must be changed here as well. 

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

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

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

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

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

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

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

297 if functionName == "butler_import": 

298 return "import" 

299 return super()._funcNameToCmdName(functionName) 

300 

301 @classmethod 

302 def _cmdNameToFuncName(cls, commandName): 

303 # Docstring inherited from base class. 

304 if commandName == "import": 

305 return "butler_import" 

306 return super()._cmdNameToFuncName(commandName) 

307 

308 

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

310@log_level_option() 

311@long_log_option() 

312@ClickProgressHandler.option 

313def cli(log_level, long_log, progress): 

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

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

316 # setup_logging. 

317 pass 

318 

319 

320def main(): 

321 return cli()