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 """Change underscores, used in functions, to dashes, used in commands.""" 

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

49 

50 

51def cmdNameToFuncName(commandName): 

52 """Change dashes, used in commands, to underscores, used in functions.""" 

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

54 

55 

56class LoaderCLI(click.MultiCommand): 

57 

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

59 self.commands = None 

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

61 

62 @staticmethod 

63 def _getPluginList(): 

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

65 

66 Returns 

67 ------- 

68 `list` [`str`] 

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

70 """ 

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

72 if pluginModules: 

73 return pluginModules.split(":") 

74 return [] 

75 

76 @staticmethod 

77 def _importPlugin(pluginName): 

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

79 

80 Parameters 

81 ---------- 

82 pluginName : string 

83 An importable module whose __all__ parameter contains the commands 

84 that can be called. 

85 

86 Returns 

87 ------- 

88 An imported module or None 

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

90 """ 

91 try: 

92 return doImport(pluginName) 

93 except (TypeError, ModuleNotFoundError, ImportError) as err: 

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

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

96 return None 

97 

98 @staticmethod 

99 def _mergeCommandLists(a, b): 

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

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

102 

103 Parameters 

104 ---------- 

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

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

107 contains the command. 

108 b : (same as a) 

109 

110 Returns 

111 ------- 

112 commands : `defaultdict` [`str`: [`str`]] 

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

114 place.) 

115 """ 

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

117 a[key].extend(val) 

118 return a 

119 

120 @staticmethod 

121 def _getLocalCommands(): 

122 """Get the commands offered by daf_butler. 

123 

124 Returns 

125 ------- 

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

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

128 contains the command. 

129 """ 

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

131 

132 @classmethod 

133 def _getPluginCommands(cls): 

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

135 

136 Returns 

137 ------- 

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

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

140 contains the command. 

141 """ 

142 commands = defaultdict(list) 

143 for pluginName in cls._getPluginList(): 

144 try: 

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

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

147 except Exception as err: 

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

149 continue 

150 if "cmd" not in resources: 

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

152 continue 

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

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

155 return commands 

156 

157 @classmethod 

158 def _getCommands(cls): 

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

160 

161 Returns 

162 ------- 

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

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

165 contains the command. 

166 """ 

167 commands = cls._mergeCommandLists(cls._getLocalCommands(), cls._getPluginCommands()) 

168 return commands 

169 

170 @staticmethod 

171 def _raiseIfDuplicateCommands(commands): 

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

173 exception. 

174 

175 Parameters 

176 ---------- 

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

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

179 contains the command. 

180 

181 Raises 

182 ------ 

183 click.ClickException 

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

185 error message to be displayed to the user. 

186 """ 

187 

188 msg = "" 

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

190 if len(packages) > 1: 

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

192 if msg: 

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

194 

195 def list_commands(self, ctx): 

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

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

198 

199 Parameters 

200 ---------- 

201 ctx : click.Context 

202 The current Click context. 

203 

204 Returns 

205 ------- 

206 commands : `list` [`str`] 

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

208 """ 

209 if self.commands is None: 

210 self.commands = self._getCommands() 

211 self._raiseIfDuplicateCommands(self.commands) 

212 log.debug(self.commands.keys()) 

213 return self.commands.keys() 

214 

215 def get_command(self, context, name): 

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

217 

218 Parameters 

219 ---------- 

220 ctx : click.Context 

221 The current Click context. 

222 name : string 

223 The name of the command to return. 

224 

225 Returns 

226 ------- 

227 command : click.Command 

228 A Command that wraps a callable command function. 

229 """ 

230 if self.commands is None: 

231 self.commands = self._getCommands() 

232 if name not in self.commands: 

233 return None 

234 self._raiseIfDuplicateCommands(self.commands) 

235 if self.commands[name][0] == localCmdPkg: 

236 return getattr(butlerCommands, cmdNameToFuncName(name)) 

237 return doImport(self.commands[name][0] + "." + cmdNameToFuncName(name)) 

238 

239 

240@click.command(cls=LoaderCLI) 

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

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

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

244 default="warning", 

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

246 callback=to_upper) 

247def cli(log_level): 

248 _initLogging(log_level) 

249 

250 

251def main(): 

252 return cli()