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 click 

23from collections import defaultdict 

24import logging 

25import os 

26import yaml 

27 

28from . import cmd as butlerCommands 

29from .utils import to_upper 

30from lsst.utils import doImport 

31 

32# localCmdPkg identifies commands that are in this package, in the dict of 

33# commands used in this file. This string is used in error reporting. 

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

35 

36log = logging.getLogger(__name__) 

37 

38 

39def _initLogging(logLevel): 

40 numeric_level = getattr(logging, logLevel, None) 

41 if not isinstance(numeric_level, int): 

42 raise click.ClickException(f"Invalid log level: {logLevel}") 

43 logging.basicConfig(level=numeric_level) 

44 

45 

46def funcNameToCmdName(functionName): 

47 """Convert function name to the butler command name: change underscores, 

48 (used in functions) to dashes (used in commands), and change local-package 

49 command names that conflict with python keywords to a leagal function name. 

50 """ 

51 # The "import" command name and "butler_import" function name are defined 

52 # in cli/cmd/commands.py, and if those names are changed they must be 

53 # changed here as well. 

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

55 # to be changed because of e.g. conflicts with python keywords (as is done 

56 # here and in cmdNameToFuncName for the 'import' command). If this becomes 

57 # a common need then some way of doing this should be invented that is 

58 # better than hard coding the function names into these conversion 

59 # functions. An extension of the 'cli/resources.yaml' file (as is currently 

60 # used in obs_base) might be a good way to do it. 

61 if functionName == "butler_import": 

62 functionName = "import" 

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

64 

65 

66def cmdNameToFuncName(commandName): 

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

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

69 commands names that conflict with python keywords, change the local, legal, 

70 function name to the command name.""" 

71 if commandName == "import": 

72 commandName = "butler_import" 

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

74 

75 

76class LoaderCLI(click.MultiCommand): 

77 

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

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

80 

81 @staticmethod 

82 def _getPluginList(): 

83 """Get the list of importable yaml files that contain butler cli data. 

84 

85 Returns 

86 ------- 

87 `list` [`str`] 

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

89 """ 

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

91 if pluginModules: 

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

93 return [] 

94 

95 @staticmethod 

96 def _importPlugin(pluginName): 

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

98 

99 Parameters 

100 ---------- 

101 pluginName : string 

102 An importable module whose __all__ parameter contains the commands 

103 that can be called. 

104 

105 Returns 

106 ------- 

107 An imported module or None 

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

109 """ 

110 try: 

111 return doImport(pluginName) 

112 except Exception as err: 

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

114 log.debug("Plugin import exception: %s", err) 

115 return None 

116 

117 @staticmethod 

118 def _mergeCommandLists(a, b): 

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

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

121 

122 Parameters 

123 ---------- 

124 a : `defaultdict` [`str`: `list` [`str`]] 

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

126 contains the command. 

127 b : (same as a) 

128 

129 Returns 

130 ------- 

131 commands : `defaultdict` [`str`: [`str`]] 

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

133 place.) 

134 """ 

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

136 a[key].extend(val) 

137 return a 

138 

139 @staticmethod 

140 def _getLocalCommands(): 

141 """Get the commands offered by daf_butler. 

142 

143 Returns 

144 ------- 

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

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

147 contains the command. 

148 """ 

149 return defaultdict(list, {funcNameToCmdName(f): [localCmdPkg] for f in butlerCommands.__all__}) 

150 

151 @classmethod 

152 def _getPluginCommands(cls): 

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

154 

155 Returns 

156 ------- 

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

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

159 contains the command. 

160 """ 

161 commands = defaultdict(list) 

162 for pluginName in cls._getPluginList(): 

163 try: 

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

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

166 except Exception as err: 

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

168 continue 

169 if "cmd" not in resources: 

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

171 continue 

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

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

174 return commands 

175 

176 @classmethod 

177 def _getCommands(cls): 

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

179 

180 Returns 

181 ------- 

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

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

184 contains the command. 

185 """ 

186 return cls._mergeCommandLists(cls._getLocalCommands(), cls._getPluginCommands()) 

187 

188 @staticmethod 

189 def _raiseIfDuplicateCommands(commands): 

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

191 exception. 

192 

193 Parameters 

194 ---------- 

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

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

197 contains the command. 

198 

199 Raises 

200 ------ 

201 click.ClickException 

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

203 error message to be displayed to the user. 

204 """ 

205 

206 msg = "" 

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

208 if len(packages) > 1: 

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

210 if msg: 

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

212 

213 def list_commands(self, ctx): 

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

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

216 

217 Parameters 

218 ---------- 

219 ctx : click.Context 

220 The current Click context. 

221 

222 Returns 

223 ------- 

224 commands : `list` [`str`] 

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

226 """ 

227 commands = self._getCommands() 

228 self._raiseIfDuplicateCommands(commands) 

229 log.debug(commands.keys()) 

230 return sorted(commands) 

231 

232 def get_command(self, context, name): 

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

234 

235 Parameters 

236 ---------- 

237 ctx : click.Context 

238 The current Click context. 

239 name : string 

240 The name of the command to return. 

241 

242 Returns 

243 ------- 

244 command : click.Command 

245 A Command that wraps a callable command function. 

246 """ 

247 commands = self._getCommands() 

248 if name not in commands: 

249 return None 

250 self._raiseIfDuplicateCommands(commands) 

251 if commands[name][0] == localCmdPkg: 

252 return getattr(butlerCommands, cmdNameToFuncName(name)) 

253 return self._importPlugin(commands[name][0] + "." + cmdNameToFuncName(name)) 

254 

255 

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

257@click.option("--log-level", 

258 type=click.Choice(["critical", "error", "warning", "info", "debug", 

259 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]), 

260 default="warning", 

261 help="The Python log level to use.", 

262 callback=to_upper) 

263def cli(log_level): 

264 _initLogging(log_level) 

265 

266 

267def main(): 

268 return cli()