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

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, long_log_option
32from lsst.utils import doImport
35log = logging.getLogger(__name__)
38class LoaderCLI(click.MultiCommand, abc.ABC):
39 """Extends `click.MultiCommand`, which dispatches to subcommands, to load
40 subcommands at runtime."""
42 def __init__(self, *args, **kwargs):
43 super().__init__(*args, **kwargs)
45 @property
46 @abc.abstractmethod
47 def localCmdPkg(self):
48 """localCmdPkg identifies the location of the commands that are in this
49 package. `getLocalCommands` assumes that the commands can be found in
50 `localCmdPkg.__all__`, if this is not the case then getLocalCommands
51 should be overridden.
53 Returns
54 -------
55 package : `str`
56 The fully qualified location of this package.
57 """
58 pass
60 def getLocalCommands(self):
61 """Get the commands offered by the local package. This assumes that the
62 commands can be found in `localCmdPkg.__all__`, if this is not the case
63 then this function should be overridden.
65 Returns
66 -------
67 commands : `defaultdict` [`str`, `list` [`str`]]
68 The key is the command name. The value is a list of package(s) that
69 contains the command.
70 """
71 commandsLocation = self._importPlugin(self.localCmdPkg)
72 if commandsLocation is None:
73 # _importPlugins logs an error, don't need to do it again here.
74 return {}
75 return defaultdict(list, {self._funcNameToCmdName(f):
76 [self.localCmdPkg] for f in commandsLocation.__all__})
78 def list_commands(self, ctx):
79 """Used by Click to get all the commands that can be called by the
80 butler command, it is used to generate the --help output.
82 Parameters
83 ----------
84 ctx : `click.Context`
85 The current Click context.
87 Returns
88 -------
89 commands : `list` [`str`]
90 The names of the commands that can be called by the butler command.
91 """
92 self._setupLogging(ctx)
93 commands = self._getCommands()
94 self._raiseIfDuplicateCommands(commands)
95 return sorted(commands)
97 def get_command(self, ctx, name):
98 """Used by Click to get a single command for execution.
100 Parameters
101 ----------
102 ctx : `click.Context`
103 The current Click context.
104 name : `str`
105 The name of the command to return.
107 Returns
108 -------
109 command : `click.Command`
110 A Command that wraps a callable command function.
111 """
112 self._setupLogging(ctx)
113 commands = self._getCommands()
114 if name not in commands:
115 return None
116 self._raiseIfDuplicateCommands(commands)
117 return self._importPlugin(commands[name][0] + "." + self._cmdNameToFuncName(name))
119 def _setupLogging(self, ctx):
120 """Init the logging system and config it for the command.
122 Subcommands may further configure the log settings."""
123 if isinstance(ctx, click.Context):
124 CliLog.initLog(longlog=ctx.params.get(long_log_option.name(), False))
125 if log_level_option.name() in ctx.params:
126 CliLog.setLogLevels(ctx.params[log_level_option.name()])
127 else:
128 # This works around a bug in sphinx-click, where it passes in the
129 # click.MultiCommand instead of the context.
130 # https://github.com/click-contrib/sphinx-click/issues/70
131 CliLog.initLog(longlog=False)
132 logging.debug("The passed-in context was not a click.Context, could not determine --long-log or "
133 "--log-level values.")
135 @classmethod
136 def getPluginList(cls):
137 """Get the list of importable yaml files that contain cli data for this
138 command.
140 Returns
141 -------
142 `list` [`str`]
143 The list of files that contain yaml data about a cli plugin.
144 """
145 if not hasattr(cls, "pluginEnvVar"):
146 return []
147 pluginModules = os.environ.get(cls.pluginEnvVar)
148 if pluginModules:
149 return [p for p in pluginModules.split(":") if p != '']
150 return []
152 @classmethod
153 def _funcNameToCmdName(cls, functionName):
154 """Convert function name to the butler command name: change
155 underscores, (used in functions) to dashes (used in commands), and
156 change local-package command names that conflict with python keywords
157 to a legal function name.
158 """
159 return functionName.replace("_", "-")
161 @classmethod
162 def _cmdNameToFuncName(cls, commandName):
163 """Convert butler command name to function name: change dashes (used in
164 commands) to underscores (used in functions), and for local-package
165 commands names that conflict with python keywords, change the local,
166 legal, function name to the command name."""
167 return commandName.replace("-", "_")
169 @staticmethod
170 def _importPlugin(pluginName):
171 """Import a plugin that contains Click commands.
173 Parameters
174 ----------
175 pluginName : `str`
176 An importable module whose __all__ parameter contains the commands
177 that can be called.
179 Returns
180 -------
181 An imported module or None
182 The imported module, or None if the module could not be imported.
183 """
184 try:
185 return doImport(pluginName)
186 except Exception as err:
187 log.warning("Could not import plugin from %s, skipping.", pluginName)
188 log.debug("Plugin import exception: %s\nTraceback:\n%s", err,
189 "".join(traceback.format_tb(err.__traceback__)))
190 return None
192 @staticmethod
193 def _mergeCommandLists(a, b):
194 """Combine two dicts whose keys are strings (command name) and values
195 are list of string (the package(s) that provide the named command).
197 Parameters
198 ----------
199 a : `defaultdict` [`str`, `list` [`str`]]
200 The key is the command name. The value is a list of package(s) that
201 contains the command.
202 b : (same as a)
204 Returns
205 -------
206 commands : `defaultdict` [`str`: [`str`]]
207 For convenience, returns a extended with b. ('a' is modified in
208 place.)
209 """
210 for key, val in b.items():
211 a[key].extend(val)
212 return a
214 @classmethod
215 def _getPluginCommands(cls):
216 """Get the commands offered by plugin packages.
218 Returns
219 -------
220 commands : `defaultdict` [`str`, `list` [`str`]]
221 The key is the command name. The value is a list of package(s) that
222 contains the command.
223 """
224 commands = defaultdict(list)
225 for pluginName in cls.getPluginList():
226 try:
227 with open(pluginName, "r") as resourceFile:
228 resources = defaultdict(list, yaml.safe_load(resourceFile))
229 except Exception as err:
230 log.warning(f"Error loading commands from {pluginName}, skipping. {err}")
231 continue
232 if "cmd" not in resources:
233 log.warning(f"No commands found in {pluginName}, skipping.")
234 continue
235 pluginCommands = {cmd: [resources["cmd"]["import"]] for cmd in resources["cmd"]["commands"]}
236 cls._mergeCommandLists(commands, defaultdict(list, pluginCommands))
237 return commands
239 def _getCommands(self):
240 """Get the commands offered by daf_butler and plugin packages.
242 Returns
243 -------
244 commands : `defaultdict` [`str`, `list` [`str`]]
245 The key is the command name. The value is a list of package(s) that
246 contains the command.
247 """
248 return self._mergeCommandLists(self.getLocalCommands(), self._getPluginCommands())
250 @staticmethod
251 def _raiseIfDuplicateCommands(commands):
252 """If any provided command is offered by more than one package raise an
253 exception.
255 Parameters
256 ----------
257 commands : `defaultdict` [`str`, `list` [`str`]]
258 The key is the command name. The value is a list of package(s) that
259 contains the command.
261 Raises
262 ------
263 click.ClickException
264 Raised if a command is offered by more than one package, with an
265 error message to be displayed to the user.
266 """
268 msg = ""
269 for command, packages in commands.items():
270 if len(packages) > 1:
271 msg += f"Command '{command}' exists in packages {', '.join(packages)}. "
272 if msg:
273 raise click.ClickException(msg + "Duplicate commands are not supported, aborting.")
276class ButlerCLI(LoaderCLI):
278 localCmdPkg = "lsst.daf.butler.cli.cmd"
280 pluginEnvVar = "DAF_BUTLER_PLUGINS"
282 @classmethod
283 def _funcNameToCmdName(cls, functionName):
284 # Docstring inherited from base class.
286 # The "import" command name and "butler_import" function name are
287 # defined in cli/cmd/commands.py, and if those names are changed they
288 # must be changed here as well.
289 # It is expected that there will be very few butler command names that
290 # need to be changed because of e.g. conflicts with python keywords (as
291 # is done here and in _cmdNameToFuncName for the 'import' command). If
292 # this becomes a common need then some way of doing this should be
293 # invented that is better than hard coding the function names into
294 # these conversion functions. An extension of the 'cli/resources.yaml'
295 # file (as is currently used in obs_base) might be a good way to do it.
296 if functionName == "butler_import":
297 return "import"
298 return super()._funcNameToCmdName(functionName)
300 @classmethod
301 def _cmdNameToFuncName(cls, commandName):
302 # Docstring inherited from base class.
303 if commandName == "import":
304 return "butler_import"
305 return super()._cmdNameToFuncName(commandName)
308@click.command(cls=ButlerCLI, context_settings=dict(help_option_names=["-h", "--help"]))
309@log_level_option()
310@long_log_option()
311def cli(log_level, long_log):
312 # log_level is handled by get_command and list_commands, and is called in
313 # one of those functions before this is called. long_log is handled by
314 # setup_logging.
315 pass
318def main():
319 return cli()