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 def __init__(self, option_strings, dest, default=None, required=False, help=None):
52 msg = "You must provide a default with Yes/No action"
55 if len(option_strings) != 1:
56 msg = "Only single argument is allowed with NoYes action"
59 opt = option_strings[0]
60 if not opt.startswith("--"):
61 msg = "Yes/No arguments must be prefixed with --"
66 opts = ["--" + opt, "--no_" + opt]
78 def __call__(self, parser, namespace, values, option_strings=None):
79 if option_strings.startswith("--no-") or option_strings.startswith("--no_"):
80 setattr(namespace, self.dest, False)
82 setattr(namespace, self.dest, True)
85 def valid_bool(v: Any) -> bool:
87 If the string is a valid bool, return its value. Otherwise raise.
90 v: data passed to an argument expecting a bool on the cmdline.
93 The boolean value of v or raises an ArgumentTypeError on error.
99 type=argparse_utils.valid_bool,
101 metavar='True|False',
102 help='Use your best judgement about --primary and --secondary',
108 >>> valid_bool("true")
111 >>> valid_bool("yes")
120 >>> valid_bool("off") # Note: expect False; invalid would raise.
123 >>> valid_bool(12345)
124 Traceback (most recent call last):
126 argparse.ArgumentTypeError: 12345
129 if isinstance(v, bool):
131 from pyutils.string_utils import to_bool
135 except Exception as e:
136 raise argparse.ArgumentTypeError(v) from e
139 def valid_ip(ip: str) -> str:
141 If the string is a valid IPv4 address, return it. Otherwise raise
142 an ArgumentTypeError.
145 ip: data passed to a commandline arg expecting an IP(v4) address.
148 The IP address, if valid. Raises ArgumentTypeError otherwise.
155 metavar="TARGET_IP_ADDRESS",
156 help="Target IP Address",
157 type=argparse_utils.valid_ip,
160 >>> valid_ip("1.2.3.4")
163 >>> valid_ip("localhost")
164 Traceback (most recent call last):
166 argparse.ArgumentTypeError: localhost is an invalid IP address
169 from pyutils.string_utils import extract_ip_v4
171 s = extract_ip_v4(ip.strip())
174 msg = f"{ip} is an invalid IP address"
176 raise argparse.ArgumentTypeError(msg)
179 def valid_mac(mac: str) -> str:
181 If the string is a valid MAC address, return it. Otherwise raise
182 an ArgumentTypeError.
185 mac: a value passed to a commandline flag expecting a MAC address.
188 The MAC address passed or raises ArgumentTypeError on error.
195 metavar="MAC_ADDRESS",
196 help="Target MAC Address",
197 type=argparse_utils.valid_mac,
200 >>> valid_mac('12:23:3A:4F:55:66')
203 >>> valid_mac('12-23-3A-4F-55-66')
207 Traceback (most recent call last):
209 argparse.ArgumentTypeError: big is an invalid MAC address
212 from pyutils.string_utils import extract_mac_address
214 s = extract_mac_address(mac)
217 msg = f"{mac} is an invalid MAC address"
219 raise argparse.ArgumentTypeError(msg)
222 def valid_percentage(num: str) -> float:
224 If the string is a valid (0 <= n <= 100) percentage, return it.
225 Otherwise raise an ArgumentTypeError.
228 num: data passed to a flag expecting a percentage with a value
229 between 0 and 100 inclusive.
232 The number if valid, otherwise raises ArgumentTypeError.
238 type=argparse_utils.valid_percentage,
240 help='The percent change (0<=n<=100) of foobar',
243 >>> valid_percentage("15%")
246 >>> valid_percentage('40')
249 >>> valid_percentage('115')
250 Traceback (most recent call last):
252 argparse.ArgumentTypeError: 115 is an invalid percentage; expected 0 <= n <= 100.0
257 if 0.0 <= n <= 100.0:
259 msg = f"{num} is an invalid percentage; expected 0 <= n <= 100.0"
261 raise argparse.ArgumentTypeError(msg)
264 def valid_filename(filename: str) -> str:
266 If the string contains a valid filename that exists on the filesystem,
267 return it. Otherwise raise an ArgumentTypeError.
271 This method will accept directories that exist on the filesystem
272 in addition to files.
275 filename: data passed to a flag expecting a valid filename.
278 The filename if valid, otherwise raises ArgumentTypeError.
283 '--network_mac_addresses_file',
284 default='/home/scott/bin/network_mac_addresses.txt',
286 help='Location of the network_mac_addresses file (must exist!).',
287 type=argparse_utils.valid_filename,
290 >>> valid_filename('/tmp')
293 >>> valid_filename('wfwefwefwefwefwefwefwefwef')
294 Traceback (most recent call last):
296 argparse.ArgumentTypeError: wfwefwefwefwefwefwefwefwef was not found and is therefore invalid.
300 if os.path.exists(s):
302 msg = f"{filename} was not found and is therefore invalid."
304 raise argparse.ArgumentTypeError(msg)
307 def valid_date(txt: str) -> datetime.date:
308 """If the string is a valid date, return it. Otherwise raise
309 an ArgumentTypeError.
312 txt: data passed to a commandline flag expecting a date.
315 the datetime.date described by txt or raises ArgumentTypeError on error.
322 type=argparse_utils.valid_date,
323 metavar="DATE STRING",
327 >>> valid_date('6/5/2021')
328 datetime.date(2021, 6, 5)
331 dates like 'next wednesday' work fine, they are just
332 hard to doctest for without knowing when the testcase will be
333 executed... See :py:mod:`pyutils.datetimes.dateparse_utils`
334 for other examples of usable expressions.
336 >>> valid_date('next wednesday') # doctest: +ELLIPSIS
339 from pyutils.string_utils import to_date
344 msg = f"Cannot parse argument as a date: {txt}"
346 raise argparse.ArgumentTypeError(msg)
349 def valid_datetime(txt: str) -> datetime.datetime:
350 """If the string is a valid datetime, return it. Otherwise raise
351 an ArgumentTypeError.
354 txt: data passed to a commandline flag expecting a valid datetime.datetime.
357 The datetime.datetime described by txt or raises ArgumentTypeError on error.
362 "--override_timestamp",
364 type=argparse_utils.valid_datetime,
365 help="Don't use the current datetime, override to argument.",
366 metavar="DATE/TIME STRING",
370 >>> valid_datetime('6/5/2021 3:01:02')
371 datetime.datetime(2021, 6, 5, 3, 1, 2)
373 >>> valid_datetime('Sun Dec 11 11:50:00 PST 2022')
374 datetime.datetime(2022, 12, 11, 11, 50)
377 Because this code uses an English date-expression parsing grammar
378 internally, much more complex datetimes can be expressed in free form.
379 See :mod:`pyutils.datetimes.dateparse_utils` for details. These
380 are not included in here because they are hard to write valid doctests
383 >>> valid_datetime('next christmas at 4:15am') # doctest: +ELLIPSIS
386 from pyutils.string_utils import to_datetime
388 dt = to_datetime(txt)
392 # Don't choke on the default format of unix date.
394 return datetime.datetime.strptime(txt, "%a %b %d %H:%M:%S %Z %Y")
398 msg = f"Cannot parse argument as datetime: {txt}"
400 raise argparse.ArgumentTypeError(msg)
403 def valid_duration(txt: str) -> datetime.timedelta:
404 """If the string is a valid time duration, return a
405 datetime.timedelta representing the duration described.
406 This uses `datetime_utils.parse_duration` to parse durations
407 and expects data such as:
409 - 15 days, 3 hours, 15 minutes
410 - 15 days 3 hours 15 minutes
416 If the duration is not parsable, raise an ArgumentTypeError.
419 txt: data passed to a commandline arg expecting a duration.
422 The datetime.timedelta described by txt or raises ArgumentTypeError
428 '--ip_cache_max_staleness',
429 type=argparse_utils.valid_duration,
430 default=datetime.timedelta(seconds=60 * 60 * 4),
432 help='Max acceptable age of the IP address cache'
435 >>> valid_duration('15d3h5m')
436 datetime.timedelta(days=15, seconds=11100)
438 >>> valid_duration('15 days 3 hours 5 min')
439 datetime.timedelta(days=15, seconds=11100)
441 >>> valid_duration('3m')
442 datetime.timedelta(seconds=180)
444 >>> valid_duration('3 days, 2 hours')
445 datetime.timedelta(days=3, seconds=7200)
447 >>> valid_duration('a little while')
448 Traceback (most recent call last):
450 argparse.ArgumentTypeError: a little while is not a valid duration.
452 from pyutils.datetimes.datetime_utils import parse_duration
455 secs = parse_duration(txt, raise_on_error=True)
456 return datetime.timedelta(seconds=secs)
457 except Exception as e:
458 logger.exception("Exception while parsing a supposed duration: %s", txt)
459 raise argparse.ArgumentTypeError(e) from e
462 if __name__ == "__main__":
465 doctest.ELLIPSIS_MARKER = "-ANYTHING-"