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 overridden.
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 overridden.
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 @classmethod
130 def getPluginList(cls):
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 if not hasattr(cls, "pluginEnvVar"):
140 return []
141 pluginModules = os.environ.get(cls.pluginEnvVar)
142 if pluginModules:
143 return [p for p in pluginModules.split(":") if p != '']
144 return []
146 @classmethod
147 def _funcNameToCmdName(cls, functionName):
148 """Convert function name to the butler command name: change
149 underscores, (used in functions) to dashes (used in commands), and
150 change local-package command names that conflict with python keywords
151 to a legal function name.
152 """
153 return functionName.replace("_", "-")
155 @classmethod
156 def _cmdNameToFuncName(cls, commandName):
157 """Convert butler command name to function name: change dashes (used in
158 commands) to underscores (used in functions), and for local-package
159 commands names that conflict with python keywords, change the local,
160 legal, function name to the command name."""
161 return commandName.replace("-", "_")
163 @staticmethod
164 def _importPlugin(pluginName):
165 """Import a plugin that contains Click commands.
167 Parameters
168 ----------
169 pluginName : `str`
170 An importable module whose __all__ parameter contains the commands
171 that can be called.
173 Returns
174 -------
175 An imported module or None
176 The imported module, or None if the module could not be imported.
177 """
178 try:
179 return doImport(pluginName)
180 except Exception as err:
181 log.warning("Could not import plugin from %s, skipping.", pluginName)
182 log.debug("Plugin import exception: %s\nTraceback:\n%s", err,
183 "".join(traceback.format_tb(err.__traceback__)))
184 return None
186 @staticmethod
187 def _mergeCommandLists(a, b):
188 """Combine two dicts whose keys are strings (command name) and values
189 are list of string (the package(s) that provide the named command).
191 Parameters
192 ----------
193 a : `defaultdict` [`str`, `list` [`str`]]
194 The key is the command name. The value is a list of package(s) that
195 contains the command.
196 b : (same as a)
198 Returns
199 -------
200 commands : `defaultdict` [`str`: [`str`]]
201 For convenience, returns a extended with b. ('a' is modified in
202 place.)
203 """
204 for key, val in b.items():
205 a[key].extend(val)
206 return a
208 @classmethod
209 def _getPluginCommands(cls):
210 """Get the commands offered by plugin packages.
212 Returns
213 -------
214 commands : `defaultdict` [`str`, `list` [`str`]]
215 The key is the command name. The value is a list of package(s) that
216 contains the command.
217 """
218 commands = defaultdict(list)
219 for pluginName in cls.getPluginList():
220 try:
221 with open(pluginName, "r") as resourceFile:
222 resources = defaultdict(list, yaml.safe_load(resourceFile))
223 except Exception as err:
224 log.warning(f"Error loading commands from {pluginName}, skipping. {err}")
225 continue
226 if "cmd" not in resources:
227 log.warning(f"No commands found in {pluginName}, skipping.")
228 continue
229 pluginCommands = {cmd: [resources["cmd"]["import"]] for cmd in resources["cmd"]["commands"]}
230 cls._mergeCommandLists(commands, defaultdict(list, pluginCommands))
231 return commands
233 def _getCommands(self):
234 """Get the commands offered by daf_butler and plugin packages.
236 Returns
237 -------
238 commands : `defaultdict` [`str`, `list` [`str`]]
239 The key is the command name. The value is a list of package(s) that
240 contains the command.
241 """
242 return self._mergeCommandLists(self.getLocalCommands(), self._getPluginCommands())
244 @staticmethod
245 def _raiseIfDuplicateCommands(commands):
246 """If any provided command is offered by more than one package raise an
247 exception.
249 Parameters
250 ----------
251 commands : `defaultdict` [`str`, `list` [`str`]]
252 The key is the command name. The value is a list of package(s) that
253 contains the command.
255 Raises
256 ------
257 click.ClickException
258 Raised if a command is offered by more than one package, with an
259 error message to be displayed to the user.
260 """
262 msg = ""
263 for command, packages in commands.items():
264 if len(packages) > 1:
265 msg += f"Command '{command}' exists in packages {', '.join(packages)}. "
266 if msg:
267 raise click.ClickException(msg + "Duplicate commands are not supported, aborting.")
270class ButlerCLI(LoaderCLI):
272 localCmdPkg = "lsst.daf.butler.cli.cmd"
274 pluginEnvVar = "DAF_BUTLER_PLUGINS"
276 @classmethod
277 def _funcNameToCmdName(cls, functionName):
278 # Docstring inherited from base class.
280 # The "import" command name and "butler_import" function name are
281 # defined in cli/cmd/commands.py, and if those names are changed they
282 # must be changed here as well.
283 # It is expected that there will be very few butler command names that
284 # need to be changed because of e.g. conflicts with python keywords (as
285 # is done here and in _cmdNameToFuncName for the 'import' command). If
286 # this becomes a common need then some way of doing this should be
287 # invented that is better than hard coding the function names into
288 # these conversion functions. An extension of the 'cli/resources.yaml'
289 # file (as is currently used in obs_base) might be a good way to do it.
290 if functionName == "butler_import":
291 return "import"
292 return super()._funcNameToCmdName(functionName)
294 @classmethod
295 def _cmdNameToFuncName(cls, commandName):
296 # Docstring inherited from base class.
297 if commandName == "import":
298 return "butler_import"
299 return super()._cmdNameToFuncName(commandName)
302@click.command(cls=ButlerCLI, context_settings=dict(help_option_names=["-h", "--help"]))
303@log_level_option()
304def cli(log_level):
305 # log_level is handled by get_command and list_commands, and is called in
306 # one of those functions before this is called.
307 pass
310def main():
311 return cli()