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 

32from lsst.utils import doImport 

33 

34 

35log = logging.getLogger(__name__) 

36 

37LONG_LOG_FLAG = "--long-log" 

38 

39 

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

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

42 subcommands at runtime.""" 

43 

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

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

46 

47 @property 

48 @abc.abstractmethod 

49 def localCmdPkg(self): 

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

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

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

53 should be overridden. 

54 

55 Returns 

56 ------- 

57 package : `str` 

58 The fully qualified location of this package. 

59 """ 

60 pass 

61 

62 def getLocalCommands(self): 

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

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

65 then this function should be overridden. 

66 

67 Returns 

68 ------- 

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

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

71 contains the command. 

72 """ 

73 commandsLocation = self._importPlugin(self.localCmdPkg) 

74 if commandsLocation is None: 

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

76 return {} 

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

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

79 

80 def list_commands(self, ctx): 

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

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

83 

84 Parameters 

85 ---------- 

86 ctx : `click.Context` 

87 The current Click context. 

88 

89 Returns 

90 ------- 

91 commands : `list` [`str`] 

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

93 """ 

94 self._setupLogging(ctx) 

95 commands = self._getCommands() 

96 self._raiseIfDuplicateCommands(commands) 

97 return sorted(commands) 

98 

99 def get_command(self, ctx, name): 

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

101 

102 Parameters 

103 ---------- 

104 ctx : `click.Context` 

105 The current Click context. 

106 name : `str` 

107 The name of the command to return. 

108 

109 Returns 

110 ------- 

111 command : `click.Command` 

112 A Command that wraps a callable command function. 

113 """ 

114 self._setupLogging(ctx) 

115 commands = self._getCommands() 

116 if name not in commands: 

117 return None 

118 self._raiseIfDuplicateCommands(commands) 

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

120 

121 def _setupLogging(self, ctx): 

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

123 

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

125 CliLog.initLog(longlog=LONG_LOG_FLAG in ctx.params) 

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

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

128 

129 @classmethod 

130 def getPluginList(cls): 

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

132 command. 

133 

134 Returns 

135 ------- 

136 `list` [`str`] 

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

138 """ 

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

140 return [] 

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

142 if pluginModules: 

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

144 return [] 

145 

146 @classmethod 

147 def _funcNameToCmdName(cls, functionName): 

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

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

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

151 to a legal function name. 

152 """ 

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

154 

155 @classmethod 

156 def _cmdNameToFuncName(cls, commandName): 

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

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

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

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

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

162 

163 @staticmethod 

164 def _importPlugin(pluginName): 

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

166 

167 Parameters 

168 ---------- 

169 pluginName : `str` 

170 An importable module whose __all__ parameter contains the commands 

171 that can be called. 

172 

173 Returns 

174 ------- 

175 An imported module or None 

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

177 """ 

178 try: 

179 return doImport(pluginName) 

180 except Exception as err: 

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

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

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

184 return None 

185 

186 @staticmethod 

187 def _mergeCommandLists(a, b): 

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

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

190 

191 Parameters 

192 ---------- 

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

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

195 contains the command. 

196 b : (same as a) 

197 

198 Returns 

199 ------- 

200 commands : `defaultdict` [`str`: [`str`]] 

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

202 place.) 

203 """ 

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

205 a[key].extend(val) 

206 return a 

207 

208 @classmethod 

209 def _getPluginCommands(cls): 

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

211 

212 Returns 

213 ------- 

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

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

216 contains the command. 

217 """ 

218 commands = defaultdict(list) 

219 for pluginName in cls.getPluginList(): 

220 try: 

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

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

223 except Exception as err: 

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

225 continue 

226 if "cmd" not in resources: 

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

228 continue 

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

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

231 return commands 

232 

233 def _getCommands(self): 

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

235 

236 Returns 

237 ------- 

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

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

240 contains the command. 

241 """ 

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

243 

244 @staticmethod 

245 def _raiseIfDuplicateCommands(commands): 

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

247 exception. 

248 

249 Parameters 

250 ---------- 

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

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

253 contains the command. 

254 

255 Raises 

256 ------ 

257 click.ClickException 

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

259 error message to be displayed to the user. 

260 """ 

261 

262 msg = "" 

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

264 if len(packages) > 1: 

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

266 if msg: 

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

268 

269 

270class ButlerCLI(LoaderCLI): 

271 

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

273 

274 pluginEnvVar = "DAF_BUTLER_PLUGINS" 

275 

276 @classmethod 

277 def _funcNameToCmdName(cls, functionName): 

278 # Docstring inherited from base class. 

279 

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

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

282 # must be changed here as well. 

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

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

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

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

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

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

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

290 if functionName == "butler_import": 

291 return "import" 

292 return super()._funcNameToCmdName(functionName) 

293 

294 @classmethod 

295 def _cmdNameToFuncName(cls, commandName): 

296 # Docstring inherited from base class. 

297 if commandName == "import": 

298 return "butler_import" 

299 return super()._cmdNameToFuncName(commandName) 

300 

301 

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

303@log_level_option() 

304def cli(log_level): 

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

306 # one of those functions before this is called. 

307 pass 

308 

309 

310def main(): 

311 return cli()