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, Optional
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
58 default: Optional[str] = None,
59 required: bool = False,
60 help: Optional[str] = None,
63 msg = "You must provide a default with Yes/No action"
66 if len(option_strings) != 1:
67 msg = "Only single argument is allowed with NoYes action"
70 opt = option_strings[0]
71 if not opt.startswith("--"):
72 msg = "Yes/No arguments must be prefixed with --"
77 opts = ["--" + opt, "--no_" + opt]
89 def __call__(self, parser, namespace, values, option_strings: Optional[str] = None):
90 if option_strings is not None:
91 if option_strings.startswith("--no-") or option_strings.startswith("--no_"):
92 setattr(namespace, self.dest, False)
94 setattr(namespace, self.dest, True)
97 def valid_bool(v: Any) -> bool:
99 If the string is a valid bool, return its value. Otherwise raise.
102 v: data passed to an argument expecting a bool on the cmdline.
105 The boolean value of v
108 ArgumentTypeError: parse error (e.g. not a valid bool string)
114 type=argparse_utils.valid_bool,
116 metavar='True|False',
117 help='Use your best judgement about --primary and --secondary',
123 >>> valid_bool("true")
126 >>> valid_bool("yes")
135 >>> valid_bool("off") # Note: expect False; invalid would raise.
138 >>> valid_bool(12345)
139 Traceback (most recent call last):
141 argparse.ArgumentTypeError: 12345
144 if isinstance(v, bool):
146 from pyutils.string_utils import to_bool
150 except Exception as e:
151 raise argparse.ArgumentTypeError(v) from e
154 def valid_ip(ip: str) -> str:
156 If the string is a valid IPv4 address, return it. Otherwise raise
157 an ArgumentTypeError.
160 ip: data passed to a commandline arg expecting an IP(v4) address.
163 The IP address, if valid.
166 ArgumentTypeError: parse error (e.g. not a valid IP address string)
173 metavar="TARGET_IP_ADDRESS",
174 help="Target IP Address",
175 type=argparse_utils.valid_ip,
178 >>> valid_ip("1.2.3.4")
181 >>> valid_ip("localhost")
182 Traceback (most recent call last):
184 argparse.ArgumentTypeError: localhost is an invalid IP address
187 from pyutils.string_utils import extract_ip_v4
189 s = extract_ip_v4(ip.strip())
192 msg = f"{ip} is an invalid IP address"
194 raise argparse.ArgumentTypeError(msg)
197 def valid_mac(mac: str) -> str:
199 If the string is a valid MAC address, return it. Otherwise raise
200 an ArgumentTypeError.
203 mac: a value passed to a commandline flag expecting a MAC address.
206 The MAC address passed
209 ArgumentTypeError: parse error (e.g. not a valid MAC address)
216 metavar="MAC_ADDRESS",
217 help="Target MAC Address",
218 type=argparse_utils.valid_mac,
221 >>> valid_mac('12:23:3A:4F:55:66')
224 >>> valid_mac('12-23-3A-4F-55-66')
228 Traceback (most recent call last):
230 argparse.ArgumentTypeError: big is an invalid MAC address
233 from pyutils.string_utils import extract_mac_address
235 s = extract_mac_address(mac)
238 msg = f"{mac} is an invalid MAC address"
240 raise argparse.ArgumentTypeError(msg)
243 def valid_percentage(num: str) -> float:
245 If the string is a valid (0 <= n <= 100) percentage, return it.
246 Otherwise raise an ArgumentTypeError.
249 num: data passed to a flag expecting a percentage with a value
250 between 0 and 100 inclusive.
253 The number if valid, otherwise raises ArgumentTypeError.
256 ArgumentTypeError: parse error (e.g. not a valid percentage)
262 type=argparse_utils.valid_percentage,
264 help='The percent change (0<=n<=100) of foobar',
267 >>> valid_percentage("15%")
270 >>> valid_percentage('40')
273 >>> valid_percentage('115')
274 Traceback (most recent call last):
276 argparse.ArgumentTypeError: 115 is an invalid percentage; expected 0 <= n <= 100.0
281 if 0.0 <= n <= 100.0:
283 msg = f"{num} is an invalid percentage; expected 0 <= n <= 100.0"
285 raise argparse.ArgumentTypeError(msg)
288 def valid_filename(filename: str) -> str:
290 If the string contains a valid filename that exists on the filesystem,
291 return it. Otherwise raise an ArgumentTypeError.
295 This method will accept directories that exist on the filesystem
296 in addition to files.
299 filename: data passed to a flag expecting a valid filename.
302 The filename if valid, otherwise raises ArgumentTypeError.
305 ArgumentTypeError: parse error (e.g. file doesn't exist)
310 '--network_mac_addresses_file',
311 default='/home/scott/bin/network_mac_addresses.txt',
313 help='Location of the network_mac_addresses file (must exist!).',
314 type=argparse_utils.valid_filename,
317 >>> valid_filename('/tmp')
320 >>> valid_filename('wfwefwefwefwefwefwefwefwef')
321 Traceback (most recent call last):
323 argparse.ArgumentTypeError: wfwefwefwefwefwefwefwefwef was not found and is therefore invalid.
327 if os.path.exists(s):
329 msg = f"{filename} was not found and is therefore invalid."
331 raise argparse.ArgumentTypeError(msg)
334 def valid_date(txt: str) -> datetime.date:
335 """If the string is a valid date, return it. Otherwise raise
336 an ArgumentTypeError.
339 txt: data passed to a commandline flag expecting a date.
342 the datetime.date described by txt
345 ArgumentTypeError: parse error (e.g. date not valid)
352 type=argparse_utils.valid_date,
353 metavar="DATE STRING",
357 >>> valid_date('6/5/2021')
358 datetime.date(2021, 6, 5)
361 dates like 'next wednesday' work fine, they are just
362 hard to doctest for without knowing when the testcase will be
363 executed... See :py:mod:`pyutils.datetimes.dateparse_utils`
364 for other examples of usable expressions.
366 >>> valid_date('next wednesday') # doctest: +ELLIPSIS
369 from pyutils.string_utils import to_date
374 msg = f"Cannot parse argument as a date: {txt}"
376 raise argparse.ArgumentTypeError(msg)
379 def valid_datetime(txt: str) -> datetime.datetime:
380 """If the string is a valid datetime, return it. Otherwise raise
381 an ArgumentTypeError.
384 txt: data passed to a commandline flag expecting a valid datetime.datetime.
387 The datetime.datetime described by txt
390 ArgumentTypeError: parse error (e.g. invalid datetime string)
395 "--override_timestamp",
397 type=argparse_utils.valid_datetime,
398 help="Don't use the current datetime, override to argument.",
399 metavar="DATE/TIME STRING",
403 >>> valid_datetime('6/5/2021 3:01:02')
404 datetime.datetime(2021, 6, 5, 3, 1, 2)
406 >>> valid_datetime('Sun Dec 11 11:50:00 PST 2022')
407 datetime.datetime(2022, 12, 11, 11, 50)
410 Because this code uses an English date-expression parsing grammar
411 internally, much more complex datetimes can be expressed in free form.
412 See :mod:`pyutils.datetimes.dateparse_utils` for details. These
413 are not included in here because they are hard to write valid doctests
416 >>> valid_datetime('next christmas at 4:15am') # doctest: +ELLIPSIS
419 from pyutils.string_utils import to_datetime
421 dt = to_datetime(txt)
425 # Don't choke on the default format of unix date.
427 return datetime.datetime.strptime(txt, "%a %b %d %H:%M:%S %Z %Y")
431 msg = f"Cannot parse argument as datetime: {txt}"
433 raise argparse.ArgumentTypeError(msg)
436 def valid_duration(txt: str) -> datetime.timedelta:
437 """If the string is a valid time duration, return a
438 datetime.timedelta representing the duration described.
439 This uses `datetime_utils.parse_duration` to parse durations
440 and expects data such as:
442 - 15 days, 3 hours, 15 minutes
443 - 15 days 3 hours 15 minutes
449 If the duration is not parsable, raise an ArgumentTypeError.
452 txt: data passed to a commandline arg expecting a duration.
455 The datetime.timedelta described by txt
459 ArgumentTypeError: parse error (e.g. invalid duration string)
464 '--ip_cache_max_staleness',
465 type=argparse_utils.valid_duration,
466 default=datetime.timedelta(seconds=60 * 60 * 4),
468 help='Max acceptable age of the IP address cache'
471 >>> valid_duration('15d3h5m')
472 datetime.timedelta(days=15, seconds=11100)
474 >>> valid_duration('15 days 3 hours 5 min')
475 datetime.timedelta(days=15, seconds=11100)
477 >>> valid_duration('3m')
478 datetime.timedelta(seconds=180)
480 >>> valid_duration('3 days, 2 hours')
481 datetime.timedelta(days=3, seconds=7200)
483 >>> valid_duration('a little while')
484 Traceback (most recent call last):
486 argparse.ArgumentTypeError: a little while is not a valid duration.
488 from pyutils.datetimes.datetime_utils import parse_duration
491 secs = parse_duration(txt, raise_on_error=True)
492 return datetime.timedelta(seconds=secs)
493 except Exception as e:
494 logger.exception("Exception while parsing a supposed duration: %s", txt)
495 raise argparse.ArgumentTypeError(e) from e
498 def valid_byte_count(txt: str) -> int:
499 """If the string is a valid number of bytes, return an integer
500 representing the requested byte count. This method uses
501 :meth:`string_utils.suffix_string_to_number` to parse and and
502 accepts / understands:
504 - plain numbers (123456)
505 - numbers with ISO suffixes (Mb, Gb, Pb, etc...)
508 txt: data passed to a commandline arg expecting a duration.
511 An integer number of bytes.
514 ArgumentTypeError: parse error (e.g. byte count not parsable)
520 type=argparse_utils.valid_byte_count,
521 default=(1024 * 1024),
523 help='The largest file we may create',
526 >>> valid_byte_count('1Mb')
529 >>> valid_byte_count("1234567")
532 >>> valid_byte_count("1M")
535 >>> valid_byte_count("1.2Gb")
538 >>> valid_byte_count('1.2') # <--- contains a decimal
539 Traceback (most recent call last):
541 argparse.ArgumentTypeError: Invalid byte count: 1.2
543 >>> valid_byte_count(1234567) # <--- not a string
544 Traceback (most recent call last):
546 argparse.ArgumentTypeError: Invalid byte count: 1234567
548 >>> valid_byte_count('On a dark and stormy night')
549 Traceback (most recent call last):
551 argparse.ArgumentTypeError: Invalid byte count: On a dark and stormy night
554 from pyutils.string_utils import suffix_string_to_number
557 num_bytes = suffix_string_to_number(txt)
560 raise argparse.ArgumentTypeError(f"Invalid byte count: {txt}")
561 except Exception as e:
562 logger.exception("Exception while parsing a supposed byte count: %s", txt)
563 raise argparse.ArgumentTypeError(e) from e
566 if __name__ == "__main__":
569 doctest.ELLIPSIS_MARKER = "-ANYTHING-"