3 # © Copyright 2021-2022, Scott Gasch
5 """These are helpers for commandline argument parsing meant to work
6 with Python's :mod:`argparse` module from the standard library. It
7 contains validators for new argument types (such as free-form dates,
8 durations, IP addresses, etc...) and an action that creates a pair of
9 flags: one to disable a feature and another to enable it.
16 from typing import Any
18 from overrides import overrides
20 # This module is commonly used by others in here and should avoid
21 # taking any unnecessary dependencies back on them.
23 logger = logging.getLogger(__name__)
26 class ActionNoYes(argparse.Action):
27 """An argparse Action that allows for commandline arguments like this::
33 help='Should we enable the thing?'
36 This creates the following cmdline arguments::
41 These arguments can be used to indicate the inclusion or exclusion of
42 binary exclusive behaviors.
45 def __init__(self, option_strings, dest, default=None, required=False, help=None):
47 msg = 'You must provide a default with Yes/No action'
50 if len(option_strings) != 1:
51 msg = 'Only single argument is allowed with NoYes action'
54 opt = option_strings[0]
55 if not opt.startswith('--'):
56 msg = 'Yes/No arguments must be prefixed with --'
61 opts = ['--' + opt, '--no_' + opt]
73 def __call__(self, parser, namespace, values, option_strings=None):
74 if option_strings.startswith('--no-') or option_strings.startswith('--no_'):
75 setattr(namespace, self.dest, False)
77 setattr(namespace, self.dest, True)
80 def valid_bool(v: Any) -> bool:
82 If the string is a valid bool, return its value. Otherwise raise.
85 v: data passed to an argument expecting a bool on the cmdline.
88 The boolean value of v or raises an ArgumentTypeError on error.
94 type=argparse_utils.valid_bool,
97 help='Use your best judgement about --primary and --secondary',
103 >>> valid_bool("true")
106 >>> valid_bool("yes")
115 >>> valid_bool("off") # Note: expect False; invalid would raise.
118 >>> valid_bool(12345)
119 Traceback (most recent call last):
121 argparse.ArgumentTypeError: 12345
124 if isinstance(v, bool):
126 from pyutils.string_utils import to_bool
130 except Exception as e:
131 raise argparse.ArgumentTypeError(v) from e
134 def valid_ip(ip: str) -> str:
136 If the string is a valid IPv4 address, return it. Otherwise raise
137 an ArgumentTypeError.
140 ip: data passed to a commandline arg expecting an IP(v4) address.
143 The IP address, if valid. Raises ArgumentTypeError otherwise.
150 metavar="TARGET_IP_ADDRESS",
151 help="Target IP Address",
152 type=argparse_utils.valid_ip,
155 >>> valid_ip("1.2.3.4")
158 >>> valid_ip("localhost")
159 Traceback (most recent call last):
161 argparse.ArgumentTypeError: localhost is an invalid IP address
164 from pyutils.string_utils import extract_ip_v4
166 s = extract_ip_v4(ip.strip())
169 msg = f"{ip} is an invalid IP address"
171 raise argparse.ArgumentTypeError(msg)
174 def valid_mac(mac: str) -> str:
176 If the string is a valid MAC address, return it. Otherwise raise
177 an ArgumentTypeError.
180 mac: a value passed to a commandline flag expecting a MAC address.
183 The MAC address passed or raises ArgumentTypeError on error.
190 metavar="MAC_ADDRESS",
191 help="Target MAC Address",
192 type=argparse_utils.valid_mac,
195 >>> valid_mac('12:23:3A:4F:55:66')
198 >>> valid_mac('12-23-3A-4F-55-66')
202 Traceback (most recent call last):
204 argparse.ArgumentTypeError: big is an invalid MAC address
207 from pyutils.string_utils import extract_mac_address
209 s = extract_mac_address(mac)
212 msg = f"{mac} is an invalid MAC address"
214 raise argparse.ArgumentTypeError(msg)
217 def valid_percentage(num: str) -> float:
219 If the string is a valid (0 <= n <= 100) percentage, return it.
220 Otherwise raise an ArgumentTypeError.
223 num: data passed to a flag expecting a percentage with a value
224 between 0 and 100 inclusive.
227 The number if valid, otherwise raises ArgumentTypeError.
233 type=argparse_utils.valid_percentage,
235 help='The percent change (0<=n<=100) of foobar',
238 >>> valid_percentage("15%")
241 >>> valid_percentage('40')
244 >>> valid_percentage('115')
245 Traceback (most recent call last):
247 argparse.ArgumentTypeError: 115 is an invalid percentage; expected 0 <= n <= 100.0
252 if 0.0 <= n <= 100.0:
254 msg = f"{num} is an invalid percentage; expected 0 <= n <= 100.0"
256 raise argparse.ArgumentTypeError(msg)
259 def valid_filename(filename: str) -> str:
261 If the string contains a valid filename that exists on the filesystem,
262 return it. Otherwise raise an ArgumentTypeError.
266 This method will accept directories that exist on the filesystem
267 in addition to files.
270 filename: data passed to a flag expecting a valid filename.
273 The filename if valid, otherwise raises ArgumentTypeError.
278 '--network_mac_addresses_file',
279 default='/home/scott/bin/network_mac_addresses.txt',
281 help='Location of the network_mac_addresses file (must exist!).',
282 type=argparse_utils.valid_filename,
285 >>> valid_filename('/tmp')
288 >>> valid_filename('wfwefwefwefwefwefwefwefwef')
289 Traceback (most recent call last):
291 argparse.ArgumentTypeError: wfwefwefwefwefwefwefwefwef was not found and is therefore invalid.
295 if os.path.exists(s):
297 msg = f"{filename} was not found and is therefore invalid."
299 raise argparse.ArgumentTypeError(msg)
302 def valid_date(txt: str) -> datetime.date:
303 """If the string is a valid date, return it. Otherwise raise
304 an ArgumentTypeError.
307 txt: data passed to a commandline flag expecting a date.
310 the datetime.date described by txt or raises ArgumentTypeError on error.
317 type=argparse_utils.valid_date,
318 metavar="DATE STRING",
322 >>> valid_date('6/5/2021')
323 datetime.date(2021, 6, 5)
326 dates like 'next wednesday' work fine, they are just
327 hard to doctest for without knowing when the testcase will be
330 >>> valid_date('next wednesday') # doctest: +ELLIPSIS
333 from pyutils.string_utils import to_date
338 msg = f'Cannot parse argument as a date: {txt}'
340 raise argparse.ArgumentTypeError(msg)
343 def valid_datetime(txt: str) -> datetime.datetime:
344 """If the string is a valid datetime, return it. Otherwise raise
345 an ArgumentTypeError.
348 txt: data passed to a commandline flag expecting a valid datetime.datetime.
351 The datetime.datetime described by txt or raises ArgumentTypeError on error.
356 "--override_timestamp",
358 type=argparse_utils.valid_datetime,
359 help="Don't use the current datetime, override to argument.",
360 metavar="DATE/TIME STRING",
364 >>> valid_datetime('6/5/2021 3:01:02')
365 datetime.datetime(2021, 6, 5, 3, 1, 2)
368 Because this code uses an English date-expression parsing grammar
369 internally, much more complex datetimes can be expressed in free form.
370 See: `tests/datetimez/dateparse_utils_test.py` for examples. These
371 are not included in here because they are hard to write valid doctests
374 >>> valid_datetime('next christmas at 4:15am') # doctest: +ELLIPSIS
377 from pyutils.string_utils import to_datetime
379 dt = to_datetime(txt)
382 msg = f'Cannot parse argument as datetime: {txt}'
384 raise argparse.ArgumentTypeError(msg)
387 def valid_duration(txt: str) -> datetime.timedelta:
388 """If the string is a valid time duration, return a
389 datetime.timedelta representing the duration described.
390 This uses `datetime_utils.parse_duration` to parse durations
391 and expects data such as:
393 - 15 days, 3 hours, 15 minutes
394 - 15 days 3 hours 15 minutes
400 If the duration is not parsable, raise an ArgumentTypeError.
403 txt: data passed to a commandline arg expecting a duration.
406 The datetime.timedelta described by txt or raises ArgumentTypeError
412 '--ip_cache_max_staleness',
413 type=argparse_utils.valid_duration,
414 default=datetime.timedelta(seconds=60 * 60 * 4),
416 help='Max acceptable age of the IP address cache'
419 >>> valid_duration('15d3h5m')
420 datetime.timedelta(days=15, seconds=11100)
422 >>> valid_duration('15 days 3 hours 5 min')
423 datetime.timedelta(days=15, seconds=11100)
425 >>> valid_duration('3m')
426 datetime.timedelta(seconds=180)
428 >>> valid_duration('3 days, 2 hours')
429 datetime.timedelta(days=3, seconds=7200)
431 >>> valid_duration('a little while')
432 Traceback (most recent call last):
434 argparse.ArgumentTypeError: a little while is not a valid duration.
436 from pyutils.datetimez.datetime_utils import parse_duration
439 secs = parse_duration(txt, raise_on_error=True)
440 return datetime.timedelta(seconds=secs)
441 except Exception as e:
443 raise argparse.ArgumentTypeError(e) from e
446 if __name__ == '__main__':
449 doctest.ELLIPSIS_MARKER = '-ANYTHING-'