3 # © Copyright 2021-2023, Scott Gasch
5 """These are helpers for commandline argument parsing meant to work
6 with Python's :mod:`argparse` module from the standard library (See:
7 https://docs.python.org/3/library/argparse.html). It contains
8 validators for new argument types (such as free-form dates, durations,
9 IP addresses, etc...) and an action that creates a pair of flags: one
10 to disable a feature and another to enable it.
12 See also :py:class:`pyutils.config.OptionalRawFormatter` which is
13 automatically enabled if you use :py:mod:`config` module.
21 from typing import Any
23 from overrides import overrides
25 # This module is commonly used by others in here and should avoid
26 # taking any unnecessary dependencies back on them.
28 logger = logging.getLogger(__name__)
31 class ActionNoYes(argparse.Action):
32 """An argparse Action that allows for commandline arguments like this::
38 help='Should we enable the thing?'
41 This creates the following cmdline arguments::
46 These arguments can be used to indicate the inclusion or exclusion of
47 binary exclusive behaviors.
50 ValueError: illegal argument value or combination
54 def __init__(self, option_strings, dest, default=None, required=False, help=None):
56 msg = "You must provide a default with Yes/No action"
59 if len(option_strings) != 1:
60 msg = "Only single argument is allowed with NoYes action"
63 opt = option_strings[0]
64 if not opt.startswith("--"):
65 msg = "Yes/No arguments must be prefixed with --"
70 opts = ["--" + opt, "--no_" + opt]
82 def __call__(self, parser, namespace, values, option_strings=None):
83 if option_strings.startswith("--no-") or option_strings.startswith("--no_"):
84 setattr(namespace, self.dest, False)
86 setattr(namespace, self.dest, True)
89 def valid_bool(v: Any) -> bool:
91 If the string is a valid bool, return its value. Otherwise raise.
94 v: data passed to an argument expecting a bool on the cmdline.
97 The boolean value of v
100 ArgumentTypeError: parse error (e.g. not a valid bool string)
106 type=argparse_utils.valid_bool,
108 metavar='True|False',
109 help='Use your best judgement about --primary and --secondary',
115 >>> valid_bool("true")
118 >>> valid_bool("yes")
127 >>> valid_bool("off") # Note: expect False; invalid would raise.
130 >>> valid_bool(12345)
131 Traceback (most recent call last):
133 argparse.ArgumentTypeError: 12345
136 if isinstance(v, bool):
138 from pyutils.string_utils import to_bool
142 except Exception as e:
143 raise argparse.ArgumentTypeError(v) from e
146 def valid_ip(ip: str) -> str:
148 If the string is a valid IPv4 address, return it. Otherwise raise
149 an ArgumentTypeError.
152 ip: data passed to a commandline arg expecting an IP(v4) address.
155 The IP address, if valid.
158 ArgumentTypeError: parse error (e.g. not a valid IP address string)
165 metavar="TARGET_IP_ADDRESS",
166 help="Target IP Address",
167 type=argparse_utils.valid_ip,
170 >>> valid_ip("1.2.3.4")
173 >>> valid_ip("localhost")
174 Traceback (most recent call last):
176 argparse.ArgumentTypeError: localhost is an invalid IP address
179 from pyutils.string_utils import extract_ip_v4
181 s = extract_ip_v4(ip.strip())
184 msg = f"{ip} is an invalid IP address"
186 raise argparse.ArgumentTypeError(msg)
189 def valid_mac(mac: str) -> str:
191 If the string is a valid MAC address, return it. Otherwise raise
192 an ArgumentTypeError.
195 mac: a value passed to a commandline flag expecting a MAC address.
198 The MAC address passed or raises ArgumentTypeError on error.
201 ArgumentTypeError: parse error (e.g. not a valid MAC address)
208 metavar="MAC_ADDRESS",
209 help="Target MAC Address",
210 type=argparse_utils.valid_mac,
213 >>> valid_mac('12:23:3A:4F:55:66')
216 >>> valid_mac('12-23-3A-4F-55-66')
220 Traceback (most recent call last):
222 argparse.ArgumentTypeError: big is an invalid MAC address
225 from pyutils.string_utils import extract_mac_address
227 s = extract_mac_address(mac)
230 msg = f"{mac} is an invalid MAC address"
232 raise argparse.ArgumentTypeError(msg)
235 def valid_percentage(num: str) -> float:
237 If the string is a valid (0 <= n <= 100) percentage, return it.
238 Otherwise raise an ArgumentTypeError.
241 num: data passed to a flag expecting a percentage with a value
242 between 0 and 100 inclusive.
245 The number if valid, otherwise raises ArgumentTypeError.
248 ArgumentTypeError: parse error (e.g. not a valid percentage)
254 type=argparse_utils.valid_percentage,
256 help='The percent change (0<=n<=100) of foobar',
259 >>> valid_percentage("15%")
262 >>> valid_percentage('40')
265 >>> valid_percentage('115')
266 Traceback (most recent call last):
268 argparse.ArgumentTypeError: 115 is an invalid percentage; expected 0 <= n <= 100.0
273 if 0.0 <= n <= 100.0:
275 msg = f"{num} is an invalid percentage; expected 0 <= n <= 100.0"
277 raise argparse.ArgumentTypeError(msg)
280 def valid_filename(filename: str) -> str:
282 If the string contains a valid filename that exists on the filesystem,
283 return it. Otherwise raise an ArgumentTypeError.
287 This method will accept directories that exist on the filesystem
288 in addition to files.
291 filename: data passed to a flag expecting a valid filename.
294 The filename if valid, otherwise raises ArgumentTypeError.
297 ArgumentTypeError: parse error (e.g. file doesn't exist)
302 '--network_mac_addresses_file',
303 default='/home/scott/bin/network_mac_addresses.txt',
305 help='Location of the network_mac_addresses file (must exist!).',
306 type=argparse_utils.valid_filename,
309 >>> valid_filename('/tmp')
312 >>> valid_filename('wfwefwefwefwefwefwefwefwef')
313 Traceback (most recent call last):
315 argparse.ArgumentTypeError: wfwefwefwefwefwefwefwefwef was not found and is therefore invalid.
319 if os.path.exists(s):
321 msg = f"{filename} was not found and is therefore invalid."
323 raise argparse.ArgumentTypeError(msg)
326 def valid_date(txt: str) -> datetime.date:
327 """If the string is a valid date, return it. Otherwise raise
328 an ArgumentTypeError.
331 txt: data passed to a commandline flag expecting a date.
334 the datetime.date described by txt or raises ArgumentTypeError on error.
337 ArgumentTypeError: parse error (e.g. date not valid)
344 type=argparse_utils.valid_date,
345 metavar="DATE STRING",
349 >>> valid_date('6/5/2021')
350 datetime.date(2021, 6, 5)
353 dates like 'next wednesday' work fine, they are just
354 hard to doctest for without knowing when the testcase will be
355 executed... See :py:mod:`pyutils.datetimes.dateparse_utils`
356 for other examples of usable expressions.
358 >>> valid_date('next wednesday') # doctest: +ELLIPSIS
361 from pyutils.string_utils import to_date
366 msg = f"Cannot parse argument as a date: {txt}"
368 raise argparse.ArgumentTypeError(msg)
371 def valid_datetime(txt: str) -> datetime.datetime:
372 """If the string is a valid datetime, return it. Otherwise raise
373 an ArgumentTypeError.
376 txt: data passed to a commandline flag expecting a valid datetime.datetime.
379 The datetime.datetime described by txt or raises ArgumentTypeError on error.
382 ArgumentTypeError: parse error (e.g. invalid datetime string)
387 "--override_timestamp",
389 type=argparse_utils.valid_datetime,
390 help="Don't use the current datetime, override to argument.",
391 metavar="DATE/TIME STRING",
395 >>> valid_datetime('6/5/2021 3:01:02')
396 datetime.datetime(2021, 6, 5, 3, 1, 2)
398 >>> valid_datetime('Sun Dec 11 11:50:00 PST 2022')
399 datetime.datetime(2022, 12, 11, 11, 50)
402 Because this code uses an English date-expression parsing grammar
403 internally, much more complex datetimes can be expressed in free form.
404 See :mod:`pyutils.datetimes.dateparse_utils` for details. These
405 are not included in here because they are hard to write valid doctests
408 >>> valid_datetime('next christmas at 4:15am') # doctest: +ELLIPSIS
411 from pyutils.string_utils import to_datetime
413 dt = to_datetime(txt)
417 # Don't choke on the default format of unix date.
419 return datetime.datetime.strptime(txt, "%a %b %d %H:%M:%S %Z %Y")
423 msg = f"Cannot parse argument as datetime: {txt}"
425 raise argparse.ArgumentTypeError(msg)
428 def valid_duration(txt: str) -> datetime.timedelta:
429 """If the string is a valid time duration, return a
430 datetime.timedelta representing the duration described.
431 This uses `datetime_utils.parse_duration` to parse durations
432 and expects data such as:
434 - 15 days, 3 hours, 15 minutes
435 - 15 days 3 hours 15 minutes
441 If the duration is not parsable, raise an ArgumentTypeError.
444 txt: data passed to a commandline arg expecting a duration.
447 The datetime.timedelta described by txt or raises ArgumentTypeError
451 ArgumentTypeError: parse error (e.g. invalid duration string)
456 '--ip_cache_max_staleness',
457 type=argparse_utils.valid_duration,
458 default=datetime.timedelta(seconds=60 * 60 * 4),
460 help='Max acceptable age of the IP address cache'
463 >>> valid_duration('15d3h5m')
464 datetime.timedelta(days=15, seconds=11100)
466 >>> valid_duration('15 days 3 hours 5 min')
467 datetime.timedelta(days=15, seconds=11100)
469 >>> valid_duration('3m')
470 datetime.timedelta(seconds=180)
472 >>> valid_duration('3 days, 2 hours')
473 datetime.timedelta(days=3, seconds=7200)
475 >>> valid_duration('a little while')
476 Traceback (most recent call last):
478 argparse.ArgumentTypeError: a little while is not a valid duration.
480 from pyutils.datetimes.datetime_utils import parse_duration
483 secs = parse_duration(txt, raise_on_error=True)
484 return datetime.timedelta(seconds=secs)
485 except Exception as e:
486 logger.exception("Exception while parsing a supposed duration: %s", txt)
487 raise argparse.ArgumentTypeError(e) from e
490 def valid_byte_count(txt: str) -> int:
491 """If the string is a valid number of bytes, return an integer
492 representing the requested byte count. This method uses
493 :meth:`string_utils.suffix_string_to_number` to parse and and
494 accepts / understands:
496 - plain numbers (123456)
497 - numbers with ISO suffixes (Mb, Gb, Pb, etc...)
500 txt: data passed to a commandline arg expecting a duration.
503 An integer number of bytes or raises ArgumentTypeError on
507 ArgumentTypeError: parse error (e.g. byte count not parsable)
513 type=argparse_utils.valid_byte_count,
514 default=(1024 * 1024),
516 help='The largest file we may create',
519 >>> valid_byte_count('1Mb')
522 >>> valid_byte_count("1234567")
525 >>> valid_byte_count("1M")
528 >>> valid_byte_count("1.2Gb")
531 >>> valid_byte_count('1.2') # <--- contains a decimal
532 Traceback (most recent call last):
534 argparse.ArgumentTypeError: Invalid byte count: 1.2
536 >>> valid_byte_count(1234567) # <--- not a string
537 Traceback (most recent call last):
539 argparse.ArgumentTypeError: Invalid byte count: 1234567
541 >>> valid_byte_count('On a dark and stormy night')
542 Traceback (most recent call last):
544 argparse.ArgumentTypeError: Invalid byte count: On a dark and stormy night
547 from pyutils.string_utils import suffix_string_to_number
550 num_bytes = suffix_string_to_number(txt)
553 raise argparse.ArgumentTypeError(f"Invalid byte count: {txt}")
554 except Exception as e:
555 logger.exception("Exception while parsing a supposed byte count: %s", txt)
556 raise argparse.ArgumentTypeError(e) from e
559 if __name__ == "__main__":
562 doctest.ELLIPSIS_MARKER = "-ANYTHING-"