Coverage for python/lsst/daf/butler/cli/butler.py : 28%

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/>.
22import abc
23import click
24from collections import defaultdict
25import logging
26import os
27import traceback
28import yaml
30from .cliLog import CliLog
31from .opt import log_level_option
32from lsst.utils import doImport
35log = logging.getLogger(__name__)
37LONG_LOG_FLAG = "--long-log"
40class LoaderCLI(click.MultiCommand, abc.ABC):
41 """Extends `click.MultiCommand`, which dispatches to subcommands, to load
42 subcommands at runtime."""
44 def __init__(self, *args, **kwargs):
45 super().__init__(*args, **kwargs)
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.
55 Returns
56 -------
57 package : `str`
58 The fully qualified location of this package.
59 """
60 pass
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.
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__})
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.
84 Parameters
85 ----------
86 ctx : `click.Context`
87 The current Click context.
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)
99 def get_command(self, ctx, name):
100 """Used by Click to get a single command for execution.
102 Parameters
103 ----------
104 ctx : `click.Context`
105 The current Click context.
106 name : `str`
107 The name of the command to return.
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))
121 def _setupLogging(self, ctx):
122 """Init the logging system and config it for the command.
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()])
129 @staticmethod
130 def getPluginList():
131 """Get the list of importable yaml files that contain cli data for this
132 command.
134 Returns
135 -------
136 `list` [`str`]
137 The list of files that contain yaml data about a cli plugin.
138 """
139 return []
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("_", "-")
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("-", "_")
158 @staticmethod
159 def _importPlugin(pluginName):
160 """Import a plugin that contains Click commands.
162 Parameters
163 ----------
164 pluginName : `str`
165 An importable module whose __all__ parameter contains the commands
166 that can be called.
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
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).
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)
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
203 @classmethod
204 def _getPluginCommands(cls):
205 """Get the commands offered by plugin packages.
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
228 def _getCommands(self):
229 """Get the commands offered by daf_butler and plugin packages.
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())
239 @staticmethod
240 def _raiseIfDuplicateCommands(commands):
241 """If any provided command is offered by more than one package raise an
242 exception.
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.
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 """
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.")
265class ButlerCLI(LoaderCLI):
267 localCmdPkg = "lsst.daf.butler.cli.cmd"
269 @classmethod
270 def _funcNameToCmdName(cls, functionName):
271 # Docstring inherited from base class.
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)
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)
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 []
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
311def main():
312 return cli()