f142d7e4a4adeb42c7e6f2d82031fc99ed732232
[pyutils.git] / src / pyutils / argparse_utils.py
1 #!/usr/bin/python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
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.
10 """
11
12 import argparse
13 import datetime
14 import logging
15 import os
16 from typing import Any
17
18 from overrides import overrides
19
20 # This module is commonly used by others in here and should avoid
21 # taking any unnecessary dependencies back on them.
22
23 logger = logging.getLogger(__name__)
24
25
26 class ActionNoYes(argparse.Action):
27     """An argparse Action that allows for commandline arguments like this::
28
29         cfg.add_argument(
30             '--enable_the_thing',
31             action=ActionNoYes,
32             default=False,
33             help='Should we enable the thing?'
34         )
35
36     This creates the following cmdline arguments::
37
38         --enable_the_thing
39         --no_enable_the_thing
40
41     These arguments can be used to indicate the inclusion or exclusion of
42     binary exclusive behaviors.
43     """
44
45     def __init__(self, option_strings, dest, default=None, required=False, help=None):
46         if default is None:
47             msg = 'You must provide a default with Yes/No action'
48             logger.critical(msg)
49             raise ValueError(msg)
50         if len(option_strings) != 1:
51             msg = 'Only single argument is allowed with NoYes action'
52             logger.critical(msg)
53             raise ValueError(msg)
54         opt = option_strings[0]
55         if not opt.startswith('--'):
56             msg = 'Yes/No arguments must be prefixed with --'
57             logger.critical(msg)
58             raise ValueError(msg)
59
60         opt = opt[2:]
61         opts = ['--' + opt, '--no_' + opt]
62         super().__init__(
63             opts,
64             dest,
65             nargs=0,
66             const=None,
67             default=default,
68             required=required,
69             help=help,
70         )
71
72     @overrides
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)
76         else:
77             setattr(namespace, self.dest, True)
78
79
80 def valid_bool(v: Any) -> bool:
81     """
82     If the string is a valid bool, return its value.  Otherwise raise.
83
84     Args:
85         v: data passed to an argument expecting a bool on the cmdline.
86
87     Returns:
88         The boolean value of v or raises an ArgumentTypeError on error.
89
90     Sample usage::
91
92         args.add_argument(
93             '--auto',
94             type=argparse_utils.valid_bool,
95             default=None,
96             metavar='True|False',
97             help='Use your best judgement about --primary and --secondary',
98         )
99
100     >>> valid_bool(True)
101     True
102
103     >>> valid_bool("true")
104     True
105
106     >>> valid_bool("yes")
107     True
108
109     >>> valid_bool("on")
110     True
111
112     >>> valid_bool("1")
113     True
114
115     >>> valid_bool("off")   # Note: expect False; invalid would raise.
116     False
117
118     >>> valid_bool(12345)
119     Traceback (most recent call last):
120     ...
121     argparse.ArgumentTypeError: 12345
122
123     """
124     if isinstance(v, bool):
125         return v
126     from pyutils.string_utils import to_bool
127
128     try:
129         return to_bool(v)
130     except Exception as e:
131         raise argparse.ArgumentTypeError(v) from e
132
133
134 def valid_ip(ip: str) -> str:
135     """
136     If the string is a valid IPv4 address, return it.  Otherwise raise
137     an ArgumentTypeError.
138
139     Args:
140         ip: data passed to a commandline arg expecting an IP(v4) address.
141
142     Returns:
143         The IP address, if valid.  Raises ArgumentTypeError otherwise.
144
145     Sample usage::
146
147         args.add_argument(
148             "-i",
149             "--ip_address",
150             metavar="TARGET_IP_ADDRESS",
151             help="Target IP Address",
152             type=argparse_utils.valid_ip,
153         )
154
155     >>> valid_ip("1.2.3.4")
156     '1.2.3.4'
157
158     >>> valid_ip("localhost")
159     Traceback (most recent call last):
160     ...
161     argparse.ArgumentTypeError: localhost is an invalid IP address
162
163     """
164     from pyutils.string_utils import extract_ip_v4
165
166     s = extract_ip_v4(ip.strip())
167     if s is not None:
168         return s
169     msg = f"{ip} is an invalid IP address"
170     logger.error(msg)
171     raise argparse.ArgumentTypeError(msg)
172
173
174 def valid_mac(mac: str) -> str:
175     """
176     If the string is a valid MAC address, return it.  Otherwise raise
177     an ArgumentTypeError.
178
179     Args:
180         mac: a value passed to a commandline flag expecting a MAC address.
181
182     Returns:
183         The MAC address passed or raises ArgumentTypeError on error.
184
185     Sample usage::
186
187         group.add_argument(
188             "-m",
189             "--mac",
190             metavar="MAC_ADDRESS",
191             help="Target MAC Address",
192             type=argparse_utils.valid_mac,
193         )
194
195     >>> valid_mac('12:23:3A:4F:55:66')
196     '12:23:3A:4F:55:66'
197
198     >>> valid_mac('12-23-3A-4F-55-66')
199     '12-23-3A-4F-55-66'
200
201     >>> valid_mac('big')
202     Traceback (most recent call last):
203     ...
204     argparse.ArgumentTypeError: big is an invalid MAC address
205
206     """
207     from pyutils.string_utils import extract_mac_address
208
209     s = extract_mac_address(mac)
210     if s is not None:
211         return s
212     msg = f"{mac} is an invalid MAC address"
213     logger.error(msg)
214     raise argparse.ArgumentTypeError(msg)
215
216
217 def valid_percentage(num: str) -> float:
218     """
219     If the string is a valid (0 <= n <= 100) percentage, return it.
220     Otherwise raise an ArgumentTypeError.
221
222     Arg:
223         num: data passed to a flag expecting a percentage with a value
224              between 0 and 100 inclusive.
225
226     Returns:
227         The number if valid, otherwise raises ArgumentTypeError.
228
229     Sample usage::
230
231         args.add_argument(
232             '--percent_change',
233             type=argparse_utils.valid_percentage,
234             default=0,
235             help='The percent change (0<=n<=100) of foobar',
236         )
237
238     >>> valid_percentage("15%")
239     15.0
240
241     >>> valid_percentage('40')
242     40.0
243
244     >>> valid_percentage('115')
245     Traceback (most recent call last):
246     ...
247     argparse.ArgumentTypeError: 115 is an invalid percentage; expected 0 <= n <= 100.0
248
249     """
250     num = num.strip('%')
251     n = float(num)
252     if 0.0 <= n <= 100.0:
253         return n
254     msg = f"{num} is an invalid percentage; expected 0 <= n <= 100.0"
255     logger.error(msg)
256     raise argparse.ArgumentTypeError(msg)
257
258
259 def valid_filename(filename: str) -> str:
260     """
261     If the string contains a valid filename that exists on the filesystem,
262     return it.  Otherwise raise an ArgumentTypeError.
263
264     .. note::
265
266         This method will accept directories that exist on the filesystem
267         in addition to files.
268
269     Args:
270         filename: data passed to a flag expecting a valid filename.
271
272     Returns:
273         The filename if valid, otherwise raises ArgumentTypeError.
274
275     Sample usage::
276
277         args.add_argument(
278             '--network_mac_addresses_file',
279             default='/home/scott/bin/network_mac_addresses.txt',
280             metavar='FILENAME',
281             help='Location of the network_mac_addresses file (must exist!).',
282             type=argparse_utils.valid_filename,
283         )
284
285     >>> valid_filename('/tmp')
286     '/tmp'
287
288     >>> valid_filename('wfwefwefwefwefwefwefwefwef')
289     Traceback (most recent call last):
290     ...
291     argparse.ArgumentTypeError: wfwefwefwefwefwefwefwefwef was not found and is therefore invalid.
292
293     """
294     s = filename.strip()
295     if os.path.exists(s):
296         return s
297     msg = f"{filename} was not found and is therefore invalid."
298     logger.error(msg)
299     raise argparse.ArgumentTypeError(msg)
300
301
302 def valid_date(txt: str) -> datetime.date:
303     """If the string is a valid date, return it.  Otherwise raise
304     an ArgumentTypeError.
305
306     Args:
307         txt: data passed to a commandline flag expecting a date.
308
309     Returns:
310         the datetime.date described by txt or raises ArgumentTypeError on error.
311
312     Sample usage::
313
314         cfg.add_argument(
315             "--date",
316             nargs=1,
317             type=argparse_utils.valid_date,
318             metavar="DATE STRING",
319             default=None
320         )
321
322     >>> valid_date('6/5/2021')
323     datetime.date(2021, 6, 5)
324
325     .. note::
326         dates like 'next wednesday' work fine, they are just
327         hard to doctest for without knowing when the testcase will be
328         executed...
329
330     >>> valid_date('next wednesday') # doctest: +ELLIPSIS
331     -ANYTHING-
332     """
333     from pyutils.string_utils import to_date
334
335     date = to_date(txt)
336     if date is not None:
337         return date
338     msg = f'Cannot parse argument as a date: {txt}'
339     logger.error(msg)
340     raise argparse.ArgumentTypeError(msg)
341
342
343 def valid_datetime(txt: str) -> datetime.datetime:
344     """If the string is a valid datetime, return it.  Otherwise raise
345     an ArgumentTypeError.
346
347     Args:
348         txt: data passed to a commandline flag expecting a valid datetime.datetime.
349
350     Returns:
351         The datetime.datetime described by txt or raises ArgumentTypeError on error.
352
353     Sample usage::
354
355         cfg.add_argument(
356             "--override_timestamp",
357             nargs=1,
358             type=argparse_utils.valid_datetime,
359             help="Don't use the current datetime, override to argument.",
360             metavar="DATE/TIME STRING",
361             default=None,
362         )
363
364     >>> valid_datetime('6/5/2021 3:01:02')
365     datetime.datetime(2021, 6, 5, 3, 1, 2)
366
367     .. note::
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
372         for!
373
374     >>> valid_datetime('next christmas at 4:15am') # doctest: +ELLIPSIS
375     -ANYTHING-
376     """
377     from pyutils.string_utils import to_datetime
378
379     dt = to_datetime(txt)
380     if dt is not None:
381         return dt
382     msg = f'Cannot parse argument as datetime: {txt}'
383     logger.error(msg)
384     raise argparse.ArgumentTypeError(msg)
385
386
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:
392
393         - 15 days, 3 hours, 15 minutes
394         - 15 days 3 hours 15 minutes
395         - 15d 3h 15m
396         - 15d3h5m
397         - 3m 2s
398         - 1000s
399
400     If the duration is not parsable, raise an ArgumentTypeError.
401
402     Args:
403         txt: data passed to a commandline arg expecting a duration.
404
405     Returns:
406         The datetime.timedelta described by txt or raises ArgumentTypeError
407         on error.
408
409     Sample usage::
410
411         cfg.add_argument(
412             '--ip_cache_max_staleness',
413             type=argparse_utils.valid_duration,
414             default=datetime.timedelta(seconds=60 * 60 * 4),
415             metavar='DURATION',
416             help='Max acceptable age of the IP address cache'
417         )
418
419     >>> valid_duration('15d3h5m')
420     datetime.timedelta(days=15, seconds=11100)
421
422     >>> valid_duration('15 days 3 hours 5 min')
423     datetime.timedelta(days=15, seconds=11100)
424
425     >>> valid_duration('3m')
426     datetime.timedelta(seconds=180)
427
428     >>> valid_duration('3 days, 2 hours')
429     datetime.timedelta(days=3, seconds=7200)
430
431     >>> valid_duration('a little while')
432     Traceback (most recent call last):
433     ...
434     argparse.ArgumentTypeError: a little while is not a valid duration.
435     """
436     from pyutils.datetimez.datetime_utils import parse_duration
437
438     try:
439         secs = parse_duration(txt, raise_on_error=True)
440         return datetime.timedelta(seconds=secs)
441     except Exception as e:
442         logger.exception(e)
443         raise argparse.ArgumentTypeError(e) from e
444
445
446 if __name__ == '__main__':
447     import doctest
448
449     doctest.ELLIPSIS_MARKER = '-ANYTHING-'
450     doctest.testmod()