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

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
23import click.testing
24from contextlib import contextmanager
25import enum
26import io
27import os
28import textwrap
29import traceback
30from unittest.mock import MagicMock, patch
31import yaml
33from .cliLog import CliLog
34from ..core.utils import iterable
37# CLI_MOCK_ENV is set by some tests as an environment variable, it
38# indicates to the cli_handle_exception function that instead of executing the
39# command implementation function it should use the Mocker class for unit test
40# verification.
41mockEnvVarKey = "CLI_MOCK_ENV"
42mockEnvVar = {mockEnvVarKey: "1"}
44# This is used as the metavar argument to Options that accept multiple string
45# inputs, which may be comma-separarated. For example:
46# --my-opt foo,bar --my-opt baz.
47# Other arguments to the Option should include multiple=true and
48# callback=split_kv.
49typeStrAcceptsMultiple = "TEXT ..."
50typeStrAcceptsSingle = "TEXT"
53def textTypeStr(multiple):
54 """Get the text type string for CLI help documentation.
56 Parameters
57 ----------
58 multiple : `bool`
59 True if multiple text values are allowed, False if only one value is
60 allowed.
62 Returns
63 -------
64 textTypeStr : `str`
65 The type string to use.
66 """
67 return typeStrAcceptsMultiple if multiple else typeStrAcceptsSingle
70# For parameters that support key-value inputs, this defines the separator
71# for those inputs.
72split_kv_separator = "="
75# The ParameterType enum is used to indicate a click Argument or Option (both
76# of which are subclasses of click.Parameter).
77class ParameterType(enum.Enum):
78 ARGUMENT = 0
79 OPTION = 1
82class Mocker:
84 mock = MagicMock()
86 def __init__(self, *args, **kwargs):
87 """Mocker is a helper class for unit tests. It can be imported and
88 called and later imported again and call can be verified.
90 For convenience, constructor arguments are forwarded to the call
91 function.
92 """
93 self.__call__(*args, **kwargs)
95 def __call__(self, *args, **kwargs):
96 """Creates a MagicMock and stores it in a static variable that can
97 later be verified.
98 """
99 Mocker.mock(*args, **kwargs)
102class LogCliRunner(click.testing.CliRunner):
103 """A test runner to use when the logging system will be initialized by code
104 under test, calls CliLog.resetLog(), which undoes any logging setup that
105 was done with the CliLog interface.
107 lsst.log modules can not be set back to an uninitialized state (python
108 logging modules can be set back to NOTSET), instead they are set to
109 `CliLog.defaultLsstLogLevel`."""
111 def invoke(self, *args, **kwargs):
112 result = super().invoke(*args, **kwargs)
113 CliLog.resetLog()
114 return result
117def clickResultMsg(result):
118 """Get a standard assert message from a click result
120 Parameters
121 ----------
122 result : click.Result
123 The result object returned from click.testing.CliRunner.invoke
125 Returns
126 -------
127 msg : `str`
128 The message string.
129 """
130 msg = io.StringIO()
131 if result.exception:
132 traceback.print_tb(result.exception.__traceback__, file=msg)
133 msg.seek(0)
134 return f"\noutput: {result.output}\nexception: {result.exception}\ntraceback: {msg.read()}"
137@contextmanager
138def command_test_env(runner, commandModule, commandName):
139 """A context manager that creates (and then cleans up) an environment that
140 provides a CLI plugin command with the given name.
142 Parameters
143 ----------
144 runner : click.testing.CliRunner
145 The test runner to use to create the isolated filesystem.
146 commandModule : `str`
147 The importable module that the command can be imported from.
148 commandName : `str`
149 The name of the command being published to import.
150 """
151 with runner.isolated_filesystem():
152 with open("resources.yaml", "w") as f:
153 f.write(yaml.dump({"cmd": {"import": commandModule, "commands": [commandName]}}))
154 # Add a colon to the end of the path on the next line, this tests the
155 # case where the lookup in LoaderCLI._getPluginList generates an empty
156 # string in one of the list entries and verifies that the empty string
157 # is properly stripped out.
158 with patch.dict("os.environ", {"DAF_BUTLER_PLUGINS": f"{os.path.realpath(f.name)}:"}):
159 yield
162def addArgumentHelp(doc, helpText):
163 """Add a Click argument's help message to a function's documentation.
165 This is needed because click presents arguments in the order the argument
166 decorators are applied to a function, top down. But, the evaluation of the
167 decorators happens bottom up, so if arguments just append their help to the
168 function's docstring, the argument descriptions appear in reverse order
169 from the order they are applied in.
171 Parameters
172 ----------
173 doc : `str`
174 The function's docstring.
175 helpText : `str`
176 The argument's help string to be inserted into the function's
177 docstring.
179 Returns
180 -------
181 doc : `str`
182 Updated function documentation.
183 """
184 if doc is None:
185 doc = helpText
186 else:
187 doclines = doc.splitlines()
188 doclines.insert(1, helpText)
189 doclines.insert(1, "\n")
190 doc = "\n".join(doclines)
191 return doc
194def split_commas(context, param, values):
195 """Process a tuple of values, where each value may contain comma-separated
196 values, and return a single list of all the passed-in values.
198 This function can be passed to the 'callback' argument of a click.option to
199 allow it to process comma-separated values (e.g. "--my-opt a,b,c").
201 Parameters
202 ----------
203 context : `click.Context` or `None`
204 The current execution context. Unused, but Click always passes it to
205 callbacks.
206 param : `click.core.Option` or `None`
207 The parameter being handled. Unused, but Click always passes it to
208 callbacks.
209 values : [`str`]
210 All the values passed for this option. Strings may contain commas,
211 which will be treated as delimiters for separate values.
213 Returns
214 -------
215 list of string
216 The passed in values separated by commas and combined into a single
217 list.
218 """
219 if values is None:
220 return values
221 valueList = []
222 for value in iterable(values):
223 valueList.extend(value.split(","))
224 return tuple(valueList)
227def split_kv(context, param, values, choice=None, multiple=True, normalize=False, separator="=",
228 unseparated_okay=False):
229 """Process a tuple of values that are key-value pairs separated by a given
230 separator. Multiple pairs may be comma separated. Return a dictionary of
231 all the passed-in values.
233 This function can be passed to the 'callback' argument of a click.option to
234 allow it to process comma-separated values (e.g. "--my-opt a=1,b=2").
236 Parameters
237 ----------
238 context : `click.Context` or `None`
239 The current execution context. Unused, but Click always passes it to
240 callbacks.
241 param : `click.core.Option` or `None`
242 The parameter being handled. Unused, but Click always passes it to
243 callbacks.
244 values : [`str`]
245 All the values passed for this option. Strings may contain commas,
246 which will be treated as delimiters for separate values.
247 choice : `click.Choice`, optional
248 If provided, verify each value is a valid choice using the provided
249 `click.Choice` instance. If None, no verification will be done.
250 multiple : `bool`, optional
251 If true, the value may contain multiple comma-separated values.
252 normalize : `bool`, optional
253 If True and `choice.case_sensitive == False`, normalize the string the
254 user provided to match the choice's case.
255 separator : str, optional
256 The character that separates key-value pairs. May not be a comma or an
257 empty space (for space separators use Click's default implementation
258 for tuples; `type=(str, str)`). By default "=".
259 unseparated_okay : `bool`, optional
260 If True, allow values that do not have a separator. They will be
261 returned in the values dict as a tuple of values in the key '', that
262 is: `values[''] = (unseparated_values, )`.
264 Returns
265 -------
266 values : `dict` [`str`, `str`]
267 The passed-in values in dict form.
269 Raises
270 ------
271 `click.ClickException`
272 Raised if the separator is not found in an entry, or if duplicate keys
273 are encountered.
274 """
276 def norm(val):
277 """If `normalize` is True and `choice` is not `None`, find the value
278 in the available choices and return the value as spelled in the
279 choices.
281 Assumes that val exists in choices; `split_kv` uses the `choice`
282 instance to verify val is a valid choice.
283 """
284 if normalize and choice is not None:
285 v = val.casefold()
286 for opt in choice.choices:
287 if opt.casefold() == v:
288 return opt
289 return val
291 if separator in (",", " "):
292 raise RuntimeError(f"'{separator}' is not a supported separator for key-value pairs.")
293 vals = values # preserve the original argument for error reporting below.
294 if multiple:
295 vals = split_commas(context, param, vals)
296 ret = {}
297 for val in iterable(vals):
298 if unseparated_okay and separator not in val:
299 if choice is not None:
300 choice(val) # will raise if val is an invalid choice
301 ret[""] = norm(val)
302 else:
303 try:
304 k, v = val.split(separator)
305 if choice is not None:
306 choice(v) # will raise if val is an invalid choice
307 except ValueError:
308 raise click.ClickException(
309 f"Could not parse key-value pair '{val}' using separator '{separator}', "
310 f"with multiple values {'allowed' if multiple else 'not allowed'}.")
311 if k in ret:
312 raise click.ClickException(f"Duplicate entries for '{k}' in '{values}'")
313 ret[k] = norm(v)
314 return ret
317def to_upper(context, param, value):
318 """Convert a value to upper case.
320 Parameters
321 ----------
322 context : click.Context
324 values : string
325 The value to be converted.
327 Returns
328 -------
329 string
330 A copy of the passed-in value, converted to upper case.
331 """
332 return value.upper()
335def unwrap(val):
336 """Remove newlines and leading whitespace from a multi-line string with
337 a consistent indentation level.
339 The first line of the string may be only a newline or may contain text
340 followed by a newline, either is ok. After the first line, each line must
341 begin with a consistant amount of whitespace. So, content of a
342 triple-quoted string may begin immediately after the quotes, or the string
343 may start with a newline. Each line after that must be the same amount of
344 indentation/whitespace followed by text and a newline. The last line may
345 end with a new line but is not required to do so.
347 Parameters
348 ----------
349 val : `str`
350 The string to change.
352 Returns
353 -------
354 strippedString : `str`
355 The string with newlines, indentation, and leading and trailing
356 whitespace removed.
357 """
358 if not val.startswith("\n"):
359 firstLine, _, val = val.partition("\n")
360 firstLine += " "
361 else:
362 firstLine = ""
363 return (firstLine + textwrap.dedent(val).replace("\n", " ")).strip()
366def cli_handle_exception(func, *args, **kwargs):
367 """Wrap a function call in an exception handler that raises a
368 ClickException if there is an Exception.
370 Also provides support for unit testing by testing for an environment
371 variable, and if it is present prints the function name, args, and kwargs
372 to stdout so they can be read and verified by the unit test code.
374 Parameters
375 ----------
376 func : function
377 A function to be called and exceptions handled. Will pass args & kwargs
378 to the function.
380 Returns
381 -------
382 The result of calling func.
384 Raises
385 ------
386 click.ClickException
387 An exception to be handled by the Click CLI tool.
388 """
389 if mockEnvVarKey in os.environ:
390 Mocker(*args, **kwargs)
391 return
392 try:
393 return func(*args, **kwargs)
394 except Exception:
395 msg = io.StringIO()
396 msg.write("An error occurred during command execution:\n")
397 traceback.print_exc(file=msg)
398 raise click.ClickException(msg.getvalue())
401class MWOption(click.Option):
402 """Overrides click.Option with desired behaviors."""
404 def make_metavar(self):
405 """Overrides `click.Option.make_metavar`. Makes the metavar for the
406 help menu. Adds a space and an elipsis after the metavar name if
407 the option accepts multiple inputs, otherwise defers to the base
408 implementation.
410 By default click does not add an elipsis when multiple is True and
411 nargs is 1. And when nargs does not equal 1 click adds an elipsis
412 without a space between the metavar and the elipsis, but we prefer a
413 space between.
414 """
415 metavar = super().make_metavar()
416 if self.multiple and self.nargs == 1:
417 metavar += " ..."
418 elif self.nargs != 1:
419 metavar = f"{metavar[:-3]} ..."
420 return metavar
423class MWArgument(click.Argument):
424 """Overrides click.Argument with desired behaviors."""
426 def make_metavar(self):
427 """Overrides `click.Option.make_metavar`. Makes the metavar for the
428 help menu. Always adds a space and an elipsis (' ...') after the
429 metavar name if the option accepts multiple inputs.
431 By default click adds an elipsis without a space between the metavar
432 and the elipsis, but we prefer a space between.
434 Returns
435 -------
436 metavar : `str`
437 The metavar value.
438 """
439 metavar = super().make_metavar()
440 if self.nargs != 1:
441 metavar = f"{metavar[:-3]} ..."
442 return metavar