Merge branch '21850-banner-exception' closes #21850
[arvados.git] / sdk / python / arvados / commands / _util.py
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: Apache-2.0
4
5 import argparse
6 import errno
7 import json
8 import logging
9 import os
10 import re
11 import signal
12 import sys
13
14 FILTER_STR_RE = re.compile(r'''
15 ^\(
16 \ *(\w+)
17 \ *(<|<=|=|>=|>)
18 \ *(\w+)
19 \ *\)$
20 ''', re.ASCII | re.VERBOSE)
21
22 def _pos_int(s):
23     num = int(s)
24     if num < 0:
25         raise ValueError("can't accept negative value: %s" % (num,))
26     return num
27
28 retry_opt = argparse.ArgumentParser(add_help=False)
29 retry_opt.add_argument('--retries', type=_pos_int, default=10, help="""
30 Maximum number of times to retry server requests that encounter temporary
31 failures (e.g., server down).  Default 10.""")
32
33 def _ignore_error(error):
34     return None
35
36 def _raise_error(error):
37     raise error
38
39 CAUGHT_SIGNALS = [signal.SIGINT, signal.SIGQUIT, signal.SIGTERM]
40
41 def exit_signal_handler(sigcode, frame):
42     logging.getLogger('arvados').error("Caught signal {}, exiting.".format(sigcode))
43     sys.exit(-sigcode)
44
45 def install_signal_handlers():
46     global orig_signal_handlers
47     orig_signal_handlers = {sigcode: signal.signal(sigcode, exit_signal_handler)
48                             for sigcode in CAUGHT_SIGNALS}
49
50 def restore_signal_handlers():
51     for sigcode, orig_handler in orig_signal_handlers.items():
52         signal.signal(sigcode, orig_handler)
53
54 def validate_filters(filters):
55     """Validate user-provided filters
56
57     This function validates that a user-defined object represents valid
58     Arvados filters that can be passed to an API client: that it's a list of
59     3-element lists with the field name and operator given as strings. If any
60     of these conditions are not true, it raises a ValueError with details about
61     the problem.
62
63     It returns validated filters. Currently the provided filters are returned
64     unmodified. Future versions of this function may clean up the filters with
65     "obvious" type conversions, so callers SHOULD use the returned value for
66     Arvados API calls.
67     """
68     if not isinstance(filters, list):
69         raise ValueError(f"filters are not a list: {filters!r}")
70     for index, f in enumerate(filters):
71         if isinstance(f, str):
72             match = FILTER_STR_RE.fullmatch(f)
73             if match is None:
74                 raise ValueError(f"filter at index {index} has invalid syntax: {f!r}")
75             s, op, o = match.groups()
76             if s[0].isdigit():
77                 raise ValueError(f"filter at index {index} has invalid syntax: bad field name {s!r}")
78             if o[0].isdigit():
79                 raise ValueError(f"filter at index {index} has invalid syntax: bad field name {o!r}")
80             continue
81         elif not isinstance(f, list):
82             raise ValueError(f"filter at index {index} is not a string or list: {f!r}")
83         try:
84             s, op, o = f
85         except ValueError:
86             raise ValueError(
87                 f"filter at index {index} does not have three items (field name, operator, operand): {f!r}",
88             ) from None
89         if not isinstance(s, str):
90             raise ValueError(f"filter at index {index} field name is not a string: {s!r}")
91         if not isinstance(op, str):
92             raise ValueError(f"filter at index {index} operator is not a string: {op!r}")
93     return filters
94
95
96 class JSONArgument:
97     """Parse a JSON file from a command line argument string or path
98
99     JSONArgument objects can be called with a string and return an arbitrary
100     object. First it will try to decode the string as JSON. If that fails, it
101     will try to open a file at the path named by the string, and decode it as
102     JSON. If that fails, it raises ValueError with more detail.
103
104     This is designed to be used as an argparse argument type.
105     Typical usage looks like:
106
107         parser = argparse.ArgumentParser()
108         parser.add_argument('--object', type=JSONArgument(), ...)
109
110     You can construct JSONArgument with an optional validation function. If
111     given, it is called with the object decoded from user input, and its
112     return value replaces it. It should raise ValueError if there is a problem
113     with the input. (argparse turns ValueError into a useful error message.)
114
115         filters_type = JSONArgument(validate_filters)
116         parser.add_argument('--filters', type=filters_type, ...)
117     """
118     def __init__(self, validator=None):
119         self.validator = validator
120
121     def __call__(self, value):
122         try:
123             retval = json.loads(value)
124         except json.JSONDecodeError:
125             try:
126                 with open(value, 'rb') as json_file:
127                     retval = json.load(json_file)
128             except json.JSONDecodeError as error:
129                 raise ValueError(f"error decoding JSON from file {value!r}: {error}") from None
130             except (FileNotFoundError, ValueError):
131                 raise ValueError(f"not a valid JSON string or file path: {value!r}") from None
132             except OSError as error:
133                 raise ValueError(f"error reading JSON file path {value!r}: {error.strerror}") from None
134         if self.validator is not None:
135             retval = self.validator(retval)
136         return retval