3 # © Copyright 2021-2022, Scott Gasch
5 """Helpers for commandline argument parsing."""
11 from typing import Any
13 from overrides import overrides
15 # This module is commonly used by others in here and should avoid
16 # taking any unnecessary dependencies back on them.
18 logger = logging.getLogger(__name__)
21 class ActionNoYes(argparse.Action):
22 """An argparse Action that allows for commandline arguments like this::
28 help='Should we enable the thing?'
31 This creates the following cmdline arguments::
36 These arguments can be used to indicate the inclusion or exclusion of
37 binary exclusive behaviors.
40 def __init__(self, option_strings, dest, default=None, required=False, help=None):
42 msg = 'You must provide a default with Yes/No action'
45 if len(option_strings) != 1:
46 msg = 'Only single argument is allowed with NoYes action'
49 opt = option_strings[0]
50 if not opt.startswith('--'):
51 msg = 'Yes/No arguments must be prefixed with --'
56 opts = ['--' + opt, '--no_' + opt]
68 def __call__(self, parser, namespace, values, option_strings=None):
69 if option_strings.startswith('--no-') or option_strings.startswith('--no_'):
70 setattr(namespace, self.dest, False)
72 setattr(namespace, self.dest, True)
75 def valid_bool(v: Any) -> bool:
77 If the string is a valid bool, return its value. Sample usage::
81 type=argparse_utils.valid_bool,
84 help='Use your best judgement about --primary and --secondary',
90 >>> valid_bool("true")
102 >>> valid_bool(12345)
103 Traceback (most recent call last):
105 argparse.ArgumentTypeError: 12345
108 if isinstance(v, bool):
110 from pyutils.string_utils import to_bool
114 except Exception as e:
115 raise argparse.ArgumentTypeError(v) from e
118 def valid_ip(ip: str) -> str:
120 If the string is a valid IPv4 address, return it. Otherwise raise
121 an ArgumentTypeError. Sample usage::
126 metavar="TARGET_IP_ADDRESS",
127 help="Target IP Address",
128 type=argparse_utils.valid_ip,
131 >>> valid_ip("1.2.3.4")
134 >>> valid_ip("localhost")
135 Traceback (most recent call last):
137 argparse.ArgumentTypeError: localhost is an invalid IP address
140 from pyutils.string_utils import extract_ip_v4
142 s = extract_ip_v4(ip.strip())
145 msg = f"{ip} is an invalid IP address"
147 raise argparse.ArgumentTypeError(msg)
150 def valid_mac(mac: str) -> str:
152 If the string is a valid MAC address, return it. Otherwise raise
153 an ArgumentTypeError. Sample usage::
158 metavar="MAC_ADDRESS",
159 help="Target MAC Address",
160 type=argparse_utils.valid_mac,
163 >>> valid_mac('12:23:3A:4F:55:66')
166 >>> valid_mac('12-23-3A-4F-55-66')
170 Traceback (most recent call last):
172 argparse.ArgumentTypeError: big is an invalid MAC address
175 from pyutils.string_utils import extract_mac_address
177 s = extract_mac_address(mac)
180 msg = f"{mac} is an invalid MAC address"
182 raise argparse.ArgumentTypeError(msg)
185 def valid_percentage(num: str) -> float:
187 If the string is a valid (0 <= n <= 100) percentage, return it.
188 Otherwise raise an ArgumentTypeError. Sample usage::
192 type=argparse_utils.valid_percentage,
194 help='The percent change (0<=n<=100) of foobar',
197 >>> valid_percentage("15%")
200 >>> valid_percentage('40')
203 >>> valid_percentage('115')
204 Traceback (most recent call last):
206 argparse.ArgumentTypeError: 115 is an invalid percentage; expected 0 <= n <= 100.0
211 if 0.0 <= n <= 100.0:
213 msg = f"{num} is an invalid percentage; expected 0 <= n <= 100.0"
215 raise argparse.ArgumentTypeError(msg)
218 def valid_filename(filename: str) -> str:
220 If the string is a valid filename, return it. Otherwise raise
221 an ArgumentTypeError. Sample usage::
224 '--network_mac_addresses_file',
225 default='/home/scott/bin/network_mac_addresses.txt',
227 help='Location of the network_mac_addresses file (must exist!).',
228 type=argparse_utils.valid_filename,
231 >>> valid_filename('/tmp')
234 >>> valid_filename('wfwefwefwefwefwefwefwefwef')
235 Traceback (most recent call last):
237 argparse.ArgumentTypeError: wfwefwefwefwefwefwefwefwef was not found and is therefore invalid.
241 if os.path.exists(s):
243 msg = f"{filename} was not found and is therefore invalid."
245 raise argparse.ArgumentTypeError(msg)
248 def valid_date(txt: str) -> datetime.date:
249 """If the string is a valid date, return it. Otherwise raise
250 an ArgumentTypeError. Sample usage::
255 type=argparse_utils.valid_date,
256 metavar="DATE STRING",
260 >>> valid_date('6/5/2021')
261 datetime.date(2021, 6, 5)
263 Note: dates like 'next wednesday' work fine, they are just
264 hard to test for without knowing when the testcase will be
267 >>> valid_date('next wednesday') # doctest: +ELLIPSIS
270 from pyutils.string_utils import to_date
275 msg = f'Cannot parse argument as a date: {txt}'
277 raise argparse.ArgumentTypeError(msg)
280 def valid_datetime(txt: str) -> datetime.datetime:
281 """If the string is a valid datetime, return it. Otherwise raise
282 an ArgumentTypeError. Sample usage::
285 "--override_timestamp",
287 type=argparse_utils.valid_datetime,
288 help="Don't use the current datetime, override to argument.",
289 metavar="DATE/TIME STRING",
293 >>> valid_datetime('6/5/2021 3:01:02')
294 datetime.datetime(2021, 6, 5, 3, 1, 2)
296 Because this thing uses an English date-expression parsing grammar
297 internally, much more complex datetimes can be expressed in free form.
298 See: `tests/datetimez/dateparse_utils_test.py` for examples. These
299 are not included in here because they are hard to write valid doctests
302 >>> valid_datetime('next christmas at 4:15am') # doctest: +ELLIPSIS
305 from pyutils.string_utils import to_datetime
307 dt = to_datetime(txt)
310 msg = f'Cannot parse argument as datetime: {txt}'
312 raise argparse.ArgumentTypeError(msg)
315 def valid_duration(txt: str) -> datetime.timedelta:
316 """If the string is a valid time duration, return a
317 datetime.timedelta representing the period of time.
321 '--ip_cache_max_staleness',
322 type=argparse_utils.valid_duration,
323 default=datetime.timedelta(seconds=60 * 60 * 12),
325 help='Max acceptable age of the IP address cache'
328 >>> valid_duration('3m')
329 datetime.timedelta(seconds=180)
331 >>> valid_duration('3 days, 2 hours')
332 datetime.timedelta(days=3, seconds=7200)
334 >>> valid_duration('a little while')
335 Traceback (most recent call last):
337 argparse.ArgumentTypeError: a little while is not a valid duration.
340 from pyutils.datetimez.datetime_utils import parse_duration
343 secs = parse_duration(txt, raise_on_error=True)
344 return datetime.timedelta(seconds=secs)
345 except Exception as e:
347 raise argparse.ArgumentTypeError(e) from e
350 if __name__ == '__main__':
353 doctest.ELLIPSIS_MARKER = '-ANYTHING-'