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

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