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

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 click
23from collections import defaultdict
24import logging
25import os
26import yaml
28from . import cmd as butlerCommands
29from .utils import to_upper
30from lsst.utils import doImport
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"
36log = logging.getLogger(__name__)
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)
46def funcNameToCmdName(functionName):
47 """Convert function name to the butler command name: change underscores,
48 (used in functions) to dashes (used in commands), and change local-package
49 command names that conflict with python keywords to a leagal function name.
50 """
51 # The "import" command name and "butler_import" function name are defined
52 # in cli/cmd/commands.py, and if those names are changed they must be
53 # changed here as well.
54 # It is expected that there will be very few butler command names that need
55 # to be changed because of e.g. conflicts with python keywords (as is done
56 # here and in cmdNameToFuncName for the 'import' command). If this becomes
57 # a common need then some way of doing this should be invented that is
58 # better than hard coding the function names into these conversion
59 # functions. An extension of the 'cli/resources.yaml' file (as is currently
60 # used in obs_base) might be a good way to do it.
61 if functionName == "butler_import":
62 functionName = "import"
63 return functionName.replace("_", "-")
66def cmdNameToFuncName(commandName):
67 """Convert butler command name to function name: change dashes (used in
68 commands) to underscores (used in functions), and for local-package
69 commands names that conflict with python keywords, change the local, legal,
70 function name to the command name."""
71 if commandName == "import":
72 commandName = "butler_import"
73 return commandName.replace("-", "_")
76class LoaderCLI(click.MultiCommand):
78 def __init__(self, *args, **kwargs):
79 super().__init__(*args, **kwargs)
81 @staticmethod
82 def _getPluginList():
83 """Get the list of importable yaml files that contain butler cli data.
85 Returns
86 -------
87 `list` [`str`]
88 The list of files that contain yaml data about a cli plugin.
89 """
90 pluginModules = os.environ.get("DAF_BUTLER_PLUGINS")
91 if pluginModules:
92 return pluginModules.split(":")
93 return []
95 @staticmethod
96 def _importPlugin(pluginName):
97 """Import a plugin that contains Click commands.
99 Parameters
100 ----------
101 pluginName : string
102 An importable module whose __all__ parameter contains the commands
103 that can be called.
105 Returns
106 -------
107 An imported module or None
108 The imported module, or None if the module could not be imported.
109 """
110 try:
111 return doImport(pluginName)
112 except (TypeError, ModuleNotFoundError, ImportError) as err:
113 log.warning("Could not import plugin from %s, skipping.", pluginName)
114 log.debug("Plugin import exception: %s", err)
115 return None
117 @staticmethod
118 def _mergeCommandLists(a, b):
119 """Combine two dicts whose keys are strings (command name) and values
120 are list of string (the package(s) that provide the named command).
122 Parameters
123 ----------
124 a : `defaultdict` [`str`: `list` [`str`]]
125 The key is the command name. The value is a list of package(s) that
126 contains the command.
127 b : (same as a)
129 Returns
130 -------
131 commands : `defaultdict` [`str`: [`str`]]
132 For convenience, returns a extended with b. ('a' is modified in
133 place.)
134 """
135 for key, val in b.items():
136 a[key].extend(val)
137 return a
139 @staticmethod
140 def _getLocalCommands():
141 """Get the commands offered by daf_butler.
143 Returns
144 -------
145 commands : `defaultdict` [`str`, `list` [`str`]]
146 The key is the command name. The value is a list of package(s) that
147 contains the command.
148 """
149 return defaultdict(list, {funcNameToCmdName(f): [localCmdPkg] for f in butlerCommands.__all__})
151 @classmethod
152 def _getPluginCommands(cls):
153 """Get the commands offered by plugin packages.
155 Returns
156 -------
157 commands : `defaultdict` [`str`, `list` [`str`]]
158 The key is the command name. The value is a list of package(s) that
159 contains the command.
160 """
161 commands = defaultdict(list)
162 for pluginName in cls._getPluginList():
163 try:
164 with open(pluginName, "r") as resourceFile:
165 resources = defaultdict(list, yaml.safe_load(resourceFile))
166 except Exception as err:
167 log.warning(f"Error loading commands from {pluginName}, skipping. {err}")
168 continue
169 if "cmd" not in resources:
170 log.warning(f"No commands found in {pluginName}, skipping.")
171 continue
172 pluginCommands = {cmd: [resources["cmd"]["import"]] for cmd in resources["cmd"]["commands"]}
173 cls._mergeCommandLists(commands, defaultdict(list, pluginCommands))
174 return commands
176 @classmethod
177 def _getCommands(cls):
178 """Get the commands offered by daf_butler and plugin packages.
180 Returns
181 -------
182 commands : `defaultdict` [`str`, `list` [`str`]]
183 The key is the command name. The value is a list of package(s) that
184 contains the command.
185 """
186 return cls._mergeCommandLists(cls._getLocalCommands(), cls._getPluginCommands())
188 @staticmethod
189 def _raiseIfDuplicateCommands(commands):
190 """If any provided command is offered by more than one package raise an
191 exception.
193 Parameters
194 ----------
195 commands : `defaultdict` [`str`, `list` [`str`]]
196 The key is the command name. The value is a list of package(s) that
197 contains the command.
199 Raises
200 ------
201 click.ClickException
202 Raised if a command is offered by more than one package, with an
203 error message to be displayed to the user.
204 """
206 msg = ""
207 for command, packages in commands.items():
208 if len(packages) > 1:
209 msg += f"Command '{command}' exists in packages {', '.join(packages)}. "
210 if msg:
211 raise click.ClickException(msg + "Duplicate commands are not supported, aborting.")
213 def list_commands(self, ctx):
214 """Used by Click to get all the commands that can be called by the
215 butler command, it is used to generate the --help output.
217 Parameters
218 ----------
219 ctx : click.Context
220 The current Click context.
222 Returns
223 -------
224 commands : `list` [`str`]
225 The names of the commands that can be called by the butler command.
226 """
227 commands = self._getCommands()
228 self._raiseIfDuplicateCommands(commands)
229 log.debug(commands.keys())
230 return commands.keys()
232 def get_command(self, context, name):
233 """Used by Click to get a single command for execution.
235 Parameters
236 ----------
237 ctx : click.Context
238 The current Click context.
239 name : string
240 The name of the command to return.
242 Returns
243 -------
244 command : click.Command
245 A Command that wraps a callable command function.
246 """
247 commands = self._getCommands()
248 if name not in commands:
249 return None
250 self._raiseIfDuplicateCommands(commands)
251 if commands[name][0] == localCmdPkg:
252 return getattr(butlerCommands, cmdNameToFuncName(name))
253 return doImport(commands[name][0] + "." + cmdNameToFuncName(name))
256@click.command(cls=LoaderCLI, context_settings=dict(help_option_names=["-h", "--help"]))
257@click.option("--log-level",
258 type=click.Choice(["critical", "error", "warning", "info", "debug",
259 "CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]),
260 default="warning",
261 help="The Python log level to use.",
262 callback=to_upper)
263def cli(log_level):
264 _initLogging(log_level)
267def main():
268 return cli()