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 overrideen. 

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 overrideen. 

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 @staticmethod 

130 def getPluginList(): 

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 return [] 

140 

141 @classmethod 

142 def _funcNameToCmdName(cls, functionName): 

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

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

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

146 to a legal function name. 

147 """ 

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

149 

150 @classmethod 

151 def _cmdNameToFuncName(cls, commandName): 

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

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

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

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

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

157 

158 @staticmethod 

159 def _importPlugin(pluginName): 

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

161 

162 Parameters 

163 ---------- 

164 pluginName : `str` 

165 An importable module whose __all__ parameter contains the commands 

166 that can be called. 

167 

168 Returns 

169 ------- 

170 An imported module or None 

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

172 """ 

173 try: 

174 return doImport(pluginName) 

175 except Exception as err: 

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

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

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

179 return None 

180 

181 @staticmethod 

182 def _mergeCommandLists(a, b): 

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

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

185 

186 Parameters 

187 ---------- 

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

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

190 contains the command. 

191 b : (same as a) 

192 

193 Returns 

194 ------- 

195 commands : `defaultdict` [`str`: [`str`]] 

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

197 place.) 

198 """ 

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

200 a[key].extend(val) 

201 return a 

202 

203 @classmethod 

204 def _getPluginCommands(cls): 

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

206 

207 Returns 

208 ------- 

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

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

211 contains the command. 

212 """ 

213 commands = defaultdict(list) 

214 for pluginName in cls.getPluginList(): 

215 try: 

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

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

218 except Exception as err: 

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

220 continue 

221 if "cmd" not in resources: 

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

223 continue 

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

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

226 return commands 

227 

228 def _getCommands(self): 

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

230 

231 Returns 

232 ------- 

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

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

235 contains the command. 

236 """ 

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

238 

239 @staticmethod 

240 def _raiseIfDuplicateCommands(commands): 

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

242 exception. 

243 

244 Parameters 

245 ---------- 

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

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

248 contains the command. 

249 

250 Raises 

251 ------ 

252 click.ClickException 

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

254 error message to be displayed to the user. 

255 """ 

256 

257 msg = "" 

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

259 if len(packages) > 1: 

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

261 if msg: 

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

263 

264 

265class ButlerCLI(LoaderCLI): 

266 

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

268 

269 @classmethod 

270 def _funcNameToCmdName(cls, functionName): 

271 # Docstring inherited from base class. 

272 

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

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

275 # must be changed here as well. 

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

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

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

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

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

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

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

283 if functionName == "butler_import": 

284 return "import" 

285 return super()._funcNameToCmdName(functionName) 

286 

287 @classmethod 

288 def _cmdNameToFuncName(cls, commandName): 

289 # Docstring inherited from base class. 

290 if commandName == "import": 

291 return "butler_import" 

292 return super()._cmdNameToFuncName(commandName) 

293 

294 @staticmethod 

295 def getPluginList(): 

296 # Docstring inherited from base class. 

297 pluginModules = os.environ.get("DAF_BUTLER_PLUGINS") 

298 if pluginModules: 

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

300 return [] 

301 

302 

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

304@log_level_option() 

305def cli(log_level): 

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

307 # one of those functions before this is called. 

308 pass 

309 

310 

311def main(): 

312 return cli()