Migration from old pyutilz package name (which, in turn, came from
[pyutils.git] / src / pyutils / config.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """Global configuration driven by commandline arguments, environment variables,
6 saved configuration files, and zookeeper-based dynamic configurations.  This
7 works across several modules.
8
9 Example usage:
10
11     In your file.py::
12
13         from pyutils import config
14
15         parser = config.add_commandline_args(
16             "Module",
17             "Args related to module doing the thing.",
18         )
19         parser.add_argument(
20             "--module_do_the_thing",
21             type=bool,
22             default=True,
23             help="Should the module do the thing?"
24         )
25
26     In your main.py::
27
28         from pyutils import config
29
30         parser = config.add_commandline_args(
31             "Main",
32             "A program that does the thing.",
33         )
34         parser.add_argument(
35             "--dry_run",
36             type=bool,
37             default=False,
38             help="Should we really do the thing?"
39         )
40
41         def main() -> None:
42             config.parse()   # Very important, this must be invoked!
43
44     If you set this up and remember to invoke config.parse(), all commandline
45     arguments will play nicely together.  This is done automatically for you
46     if you're using the :meth:`bootstrap.initialize` decorator on
47     your program's entry point.  See :meth:`python_modules.bootstrap.initialize`
48     for more details.::
49
50         from pyutils import bootstrap
51
52         @bootstrap.initialize
53         def main():
54             whatever
55
56         if __name__ == '__main__':
57             main()
58
59     Either way, you'll get this behavior from the commandline::
60
61         % main.py -h
62         usage: main.py [-h]
63                        [--module_do_the_thing MODULE_DO_THE_THING]
64                        [--dry_run DRY_RUN]
65
66         Module:
67           Args related to module doing the thing.
68
69           --module_do_the_thing MODULE_DO_THE_THING
70                        Should the module do the thing?
71
72         Main:
73           A program that does the thing
74
75           --dry_run
76                        Should we really do the thing?
77
78     Arguments themselves should be accessed via
79     :code:`config.config['arg_name']`.  e.g.::
80
81         if not config.config['dry_run']:
82             module.do_the_thing()
83 """
84
85 import argparse
86 import logging
87 import os
88 import pprint
89 import re
90 import sys
91 from typing import Any, Dict, List, Optional, Tuple
92
93 # This module is commonly used by others in here and should avoid
94 # taking any unnecessary dependencies back on them.
95
96 # Make a copy of the original program arguments immediately upon module load.
97 PROGRAM_NAME: str = os.path.basename(sys.argv[0])
98 ORIG_ARGV: List[str] = sys.argv.copy()
99
100
101 class OptionalRawFormatter(argparse.HelpFormatter):
102     """This formatter has the same bahavior as the normal argparse text
103     formatter except when the help text of an argument begins with
104     "RAW|".  In that case, the line breaks are preserved and the text
105     is not wrapped.
106
107     Use this, for example, when you need the helptext of an argument
108     to have its spacing preserved exactly, e.g.::
109
110         args.add_argument(
111             '--mode',
112             type=str,
113             default='PLAY',
114             choices=['CHEAT', 'AUTOPLAY', 'SELFTEST', 'PRECOMPUTE', 'PLAY'],
115             metavar='MODE',
116             help='''RAW|Our mode of operation.  One of:
117
118                 PLAY = play wordle with me!  Pick a random solution or
119                        specify a solution with --template.
120
121                CHEAT = given a --template and, optionally, --letters_in_word
122                        and/or --letters_to_avoid, return the best guess word;
123
124             AUTOPLAY = given a complete word in --template, guess it step
125                        by step showing work;
126
127             SELFTEST = autoplay every possible solution keeping track of
128                        wins/losses and average number of guesses;
129
130           PRECOMPUTE = populate hash table with optimal guesses.
131             ''',
132         )
133     """
134
135     def _split_lines(self, text, width):
136         if text.startswith('RAW|'):
137             return text[4:].splitlines()
138         return argparse.HelpFormatter._split_lines(self, text, width)
139
140
141 # A global argparser that we will collect arguments in.  Each module (including
142 # us) will add arguments to a separate argument group.
143 ARGS = argparse.ArgumentParser(
144     description=None,
145     formatter_class=OptionalRawFormatter,
146     fromfile_prefix_chars="@",
147     epilog=f'{PROGRAM_NAME} uses config.py ({__file__}) for global, cross-module configuration setup and parsing.',
148     # I don't fully understand why but when loaded by sphinx sometimes
149     # the same module is loaded many times causing any arguments it
150     # registers via module-level code to be redefined.  Work around
151     # this iff the program is 'sphinx-build'
152     conflict_handler='resolve' if PROGRAM_NAME == 'sphinx-build' else 'error',
153 )
154
155 # Arguments specific to config.py.  Other users should get their own group by
156 # invoking config.add_commandline_args.
157 GROUP = ARGS.add_argument_group(
158     f'Global Config ({__file__})',
159     'Args that control the global config itself; how meta!',
160 )
161 GROUP.add_argument(
162     '--config_loadfile',
163     metavar='FILENAME',
164     default=None,
165     help='Config file (populated via --config_savefile) from which to read args in lieu or in addition to those passed via the commandline.  Note that if the given path begins with "zk:" then it is interpreted as a zookeeper path instead of as a filesystem path.  When loading config from zookeeper, any argument with the string "dynamic" in the name (e.g. --module_dynamic_url) may be modified at runtime by changes made to zookeeper (using --config_savefile=zk:path).  You should therefore either write your code to handle dynamic argument changes or avoid naming arguments "dynamic" if you use zookeeper configuration paths.',
166 )
167 GROUP.add_argument(
168     '--config_dump',
169     default=False,
170     action='store_true',
171     help='Display the global configuration (possibly derived from multiple sources) on STDERR at program startup time.',
172 )
173 GROUP.add_argument(
174     '--config_savefile',
175     type=str,
176     metavar='FILENAME',
177     default=None,
178     help='Populate a config file (compatible with --config_loadfile) with the given path for later use.  If the given path begins with "zk:" it is interpreted as a zookeeper path instead of a filesystem path.  When updating zookeeper-based configs, all running programs that read their configuration from zookeeper (via --config_loadfile=zk:path) at startup time will see their configuration dynamically updated; flags with "dynamic" in their names (e.g. --my_dynamic_flag) may have their values changed.  You should therefore either write your code to handle dynamic argument changes or avoid naming arguments "dynamic" if you use zookeeper configuration paths.',
179 )
180 GROUP.add_argument(
181     '--config_rejects_unrecognized_arguments',
182     default=False,
183     action='store_true',
184     help='If present, config will raise an exception if it doesn\'t recognize an argument.  The default behavior is to ignore unknown arguments so as to allow interoperability with programs that want to use their own argparse calls to parse their own, separate commandline args.',
185 )
186 GROUP.add_argument(
187     '--config_exit_after_parse',
188     default=False,
189     action='store_true',
190     help='If present, halt the program after parsing config.  Useful, for example, to write a --config_savefile and then terminate.',
191 )
192
193
194 class Config:
195     """
196     Everything in the config module used to be module-level functions and
197     variables but it made the code ugly and harder to maintain.  Now, this
198     class does the heavy lifting.  We still rely on some globals, though:
199
200         ARGS and GROUP to interface with argparse
201         PROGRAM_NAME stores argv[0] close to program invocation
202         ORIG_ARGV stores the original argv list close to program invocation
203         CONFIG and config: hold the (singleton) instance of this class.
204
205     """
206
207     def __init__(self):
208         # Has our parse() method been invoked yet?
209         self.config_parse_called = False
210
211         # A configuration dictionary that will contain parsed
212         # arguments.  This is the data that is most interesting to our
213         # callers as it will hold the configuration result.
214         self.config: Dict[str, Any] = {}
215
216         # Defer logging messages until later when logging has been
217         # initialized.
218         self.saved_messages: List[str] = []
219
220         # A zookeeper client that is lazily created so as to not incur
221         # the latency of connecting to zookeeper for programs that are
222         # not reading or writing their config data into zookeeper.
223         self.zk: Optional[Any] = None
224
225         # Per known zk file, what is the max version we have seen?
226         self.max_version: Dict[str, int] = {}
227
228     def __getitem__(self, key: str) -> Optional[Any]:
229         """If someone uses []'s on us, pass it onto self.config."""
230         return self.config.get(key, None)
231
232     def __setitem__(self, key: str, value: Any) -> None:
233         self.config[key] = value
234
235     def __contains__(self, key: str) -> bool:
236         return key in self.config
237
238     def get(self, key: str, default: Any = None) -> Optional[Any]:
239         return self.config.get(key, default)
240
241     @staticmethod
242     def add_commandline_args(
243         title: str, description: str = ""
244     ) -> argparse._ArgumentGroup:
245         """Create a new context for arguments and return a handle.
246
247         Args:
248             title: A title for your module's commandline arguments group.
249             description: A helpful description of your module.
250
251         Returns:
252             An argparse._ArgumentGroup to be populated by the caller.
253         """
254         return ARGS.add_argument_group(title, description)
255
256     @staticmethod
257     def overwrite_argparse_epilog(msg: str) -> None:
258         """Allows your code to override the default epilog created by
259         argparse.
260
261         Args:
262             msg: The epilog message to substitute for the default.
263         """
264         ARGS.epilog = msg
265
266     @staticmethod
267     def is_flag_already_in_argv(var: str) -> bool:
268         """Returns true if a particular flag is passed on the commandline
269         and false otherwise.
270
271         Args:
272             var: The flag to search for.
273         """
274         for _ in sys.argv:
275             if var in _:
276                 return True
277         return False
278
279     @staticmethod
280     def print_usage() -> None:
281         """Prints the normal help usage message out."""
282         ARGS.print_help()
283
284     @staticmethod
285     def usage() -> str:
286         """
287         Returns:
288             program usage help text as a string.
289         """
290         return ARGS.format_usage()
291
292     @staticmethod
293     def _reorder_arg_action_groups_before_help(entry_module: Optional[str]):
294         """Internal.  Used to reorder the arguments before dumping out a
295         generated help string such that the main program's arguments come
296         last.
297
298         """
299         reordered_action_groups = []
300         for grp in ARGS._action_groups:
301             if entry_module is not None and entry_module in grp.title:  # type: ignore
302                 reordered_action_groups.append(grp)
303             elif PROGRAM_NAME in GROUP.title:  # type: ignore
304                 reordered_action_groups.append(grp)
305             else:
306                 reordered_action_groups.insert(0, grp)
307         return reordered_action_groups
308
309     @staticmethod
310     def _parse_arg_into_env(arg: str) -> Optional[Tuple[str, str, List[str]]]:
311         """Internal helper to parse commandling args into environment vars."""
312         arg = arg.strip()
313         if not arg.startswith('['):
314             return None
315         arg = arg.strip('[')
316         if not arg.endswith(']'):
317             return None
318         arg = arg.strip(']')
319
320         chunks = arg.split()
321         if len(chunks) > 1:
322             var = chunks[0]
323         else:
324             var = arg
325
326         # Environment vars the same as flag names without
327         # the initial -'s and in UPPERCASE.
328         env = var.upper()
329         while env[0] == '-':
330             env = env[1:]
331         return var, env, chunks
332
333     @staticmethod
334     def _to_bool(in_str: str) -> bool:
335         """
336         Args:
337             in_str: the string to convert to boolean
338
339         Returns:
340             A boolean equivalent of the original string based on its contents.
341             All conversion is case insensitive.  A positive boolean (True) is
342             returned if the string value is any of the following:
343
344             * "true"
345             * "t"
346             * "1"
347             * "yes"
348             * "y"
349             * "on"
350
351             Otherwise False is returned.
352
353         >>> to_bool('True')
354         True
355
356         >>> to_bool('1')
357         True
358
359         >>> to_bool('yes')
360         True
361
362         >>> to_bool('no')
363         False
364
365         >>> to_bool('huh?')
366         False
367
368         >>> to_bool('on')
369         True
370         """
371         return in_str.lower() in ("true", "1", "yes", "y", "t", "on")
372
373     def _augment_sys_argv_from_environment_variables(self):
374         """Internal.  Look at the system environment for variables that match
375         commandline arg names.  This is done via some munging such that:
376
377         :code:`--argument_to_match`
378
379         ...is matched by:
380
381         :code:`ARGUMENT_TO_MATCH`
382
383         This allows users to set args via shell environment variables
384         in lieu of passing them on the cmdline.
385
386         """
387         usage_message = Config.usage()
388         optional = False
389         arg = ''
390
391         # Foreach valid optional commandline option (chunk) generate
392         # its analogous environment variable.
393         for chunk in usage_message.split():
394             if chunk[0] == '[':
395                 optional = True
396             if optional:
397                 arg += f'{chunk} '
398                 if chunk[-1] == ']':
399                     optional = False
400                     _ = Config._parse_arg_into_env(arg)
401                     if _:
402                         var, env, chunks = _
403                         if env in os.environ:
404                             if not Config.is_flag_already_in_argv(var):
405                                 value = os.environ[env]
406                                 self.saved_messages.append(
407                                     f'Initialized from environment: {var} = {value}'
408                                 )
409                                 if len(chunks) == 1 and Config._to_bool(value):
410                                     sys.argv.append(var)
411                                 elif len(chunks) > 1:
412                                     sys.argv.append(var)
413                                     sys.argv.append(value)
414                     arg = ''
415
416     def _process_dynamic_args(self, event):
417         """Invoked as a callback when a zk-based config changed."""
418
419         if not self.zk:
420             return
421         logger = logging.getLogger(__name__)
422         try:
423             contents, meta = self.zk.get(event.path, watch=self._process_dynamic_args)
424             logger.debug('Update for %s at version=%d.', event.path, meta.version)
425             logger.debug(
426                 'Max known version for %s is %d.',
427                 event.path,
428                 self.max_version.get(event.path, 0),
429             )
430         except Exception as e:
431             raise Exception('Error reading data from zookeeper') from e
432
433         # Make sure we process changes in order.
434         if meta.version > self.max_version.get(event.path, 0):
435             self.max_version[event.path] = meta.version
436             contents = contents.decode()
437             temp_argv = []
438             for arg in contents.split():
439
440                 # Our rule is that arguments must contain the word
441                 # 'dynamic' if we are going to allow them to change at
442                 # runtime as a signal that the programmer is expecting
443                 # this.
444                 if 'dynamic' in arg:
445                     temp_argv.append(arg)
446                     logger.info("Updating %s from zookeeper async config change.", arg)
447
448             if len(temp_argv) > 0:
449                 old_argv = sys.argv
450                 sys.argv = temp_argv
451                 known, _ = ARGS.parse_known_args()
452                 sys.argv = old_argv
453                 self.config.update(vars(known))
454
455     def _read_config_from_zookeeper(self, zkpath: str) -> Optional[str]:
456         from pyutils import zookeeper
457
458         if not zkpath.startswith('/config/'):
459             zkpath = '/config/' + zkpath
460             zkpath = re.sub(r'//+', '/', zkpath)
461
462         try:
463             if self.zk is None:
464                 self.zk = zookeeper.get_started_zk_client()
465             if not self.zk.exists(zkpath):
466                 return None
467
468             # Note: we're putting a watch on this config file.  Our
469             # _process_dynamic_args routine will be called to reparse
470             # args when/if they change.
471             contents, meta = self.zk.get(zkpath, watch=self._process_dynamic_args)
472             contents = contents.decode()
473             self.saved_messages.append(
474                 f'Setting {zkpath}\'s max_version to {meta.version}'
475             )
476             self.max_version[zkpath] = meta.version
477             self.saved_messages.append(f'Read config from zookeeper {zkpath}.')
478             return contents
479         except Exception as e:
480             self.saved_messages.append(
481                 f'Failed to read {zkpath} from zookeeper: exception {e}'
482             )
483             return None
484
485     def _read_config_from_disk(self, filepath: str) -> Optional[str]:
486         if not os.path.exists(filepath):
487             return None
488         with open(filepath, 'r') as rf:
489             self.saved_messages.append(f'Read config from disk file {filepath}')
490             return rf.read()
491
492     def _augment_sys_argv_from_loadfile(self):
493         """Internal.  Augment with arguments persisted in a saved file."""
494
495         # Check for --config_loadfile in the args manually; argparse isn't
496         # invoked yet and can't be yet.
497         loadfile = None
498         saw_other_args = False
499         grab_next_arg = False
500         for arg in sys.argv[1:]:
501             if 'config_loadfile' in arg:
502                 pieces = arg.split('=')
503                 if len(pieces) > 1:
504                     loadfile = pieces[1]
505                 else:
506                     grab_next_arg = True
507             elif grab_next_arg:
508                 loadfile = arg
509             else:
510                 saw_other_args = True
511
512         if not loadfile or len(loadfile) == 0:
513             return
514
515         # Get contents from wherever.
516         contents = None
517         if loadfile[:3] == 'zk:':
518             contents = self._read_config_from_zookeeper(loadfile[3:])
519         else:
520             contents = self._read_config_from_disk(loadfile)
521
522         if contents:
523             if saw_other_args:
524                 msg = f'Augmenting commandline arguments with those from {loadfile}.'
525             else:
526                 msg = f'Reading commandline arguments from {loadfile}.'
527             print(msg, file=sys.stderr)
528             self.saved_messages.append(msg)
529         else:
530             msg = f'Failed to read/parse contents from {loadfile}'
531             print(msg, file=sys.stderr)
532             self.saved_messages.append(msg)
533             return
534
535         # Augment args with new ones.
536         newargs = [
537             arg.strip('\n')
538             for arg in contents.split('\n')
539             if 'config_savefile' not in arg
540         ]
541         sys.argv += newargs
542
543     def dump_config(self):
544         """Print the current config to stdout."""
545         print("Global Configuration:", file=sys.stderr)
546         pprint.pprint(self.config, stream=sys.stderr)
547         print()
548
549     def _write_config_to_disk(self, data: str, filepath: str) -> None:
550         with open(filepath, 'w') as wf:
551             wf.write(data)
552
553     def _write_config_to_zookeeper(self, data: str, zkpath: str) -> None:
554         if not zkpath.startswith('/config/'):
555             zkpath = '/config/' + zkpath
556             zkpath = re.sub(r'//+', '/', zkpath)
557         try:
558             if not self.zk:
559                 from pyutils import zookeeper
560
561                 self.zk = zookeeper.get_started_zk_client()
562             encoded_data = data.encode()
563             if len(encoded_data) > 1024 * 1024:
564                 raise Exception(
565                     f'Saved args are too large ({len(encoded_data)} bytes exceeds zk limit)'
566                 )
567             if not self.zk.exists(zkpath):
568                 self.zk.create(zkpath, encoded_data)
569                 self.saved_messages.append(
570                     f'Just created {zkpath}; setting its max_version to 0'
571                 )
572                 self.max_version[zkpath] = 0
573             else:
574                 meta = self.zk.set(zkpath, encoded_data)
575                 self.saved_messages.append(
576                     f'Setting {zkpath}\'s max_version to {meta.version}'
577                 )
578                 self.max_version[zkpath] = meta.version
579         except Exception as e:
580             raise Exception(f'Failed to create zookeeper path {zkpath}') from e
581         self.saved_messages.append(f'Saved config to zookeeper in {zkpath}')
582
583     def parse(self, entry_module: Optional[str]) -> Dict[str, Any]:
584         """Main program should call this early in main().  Note that the
585         :code:`bootstrap.initialize` wrapper takes care of this automatically.
586         This should only be called once per program invocation.
587
588         """
589         if self.config_parse_called:
590             return self.config
591
592         # If we're about to do the usage message dump, put the main
593         # module's argument group last in the list (if possible) so that
594         # when the user passes -h or --help, it will be visible on the
595         # screen w/o scrolling.
596         for arg in sys.argv:
597             if arg in ('--help', '-h'):
598                 if entry_module is not None:
599                     entry_module = os.path.basename(entry_module)
600                 ARGS._action_groups = Config._reorder_arg_action_groups_before_help(
601                     entry_module
602                 )
603
604         # Examine the environment for variables that match known flags.
605         # For a flag called --example_flag the corresponding environment
606         # variable would be called EXAMPLE_FLAG.  If found, hackily add
607         # these into sys.argv to be parsed.
608         self._augment_sys_argv_from_environment_variables()
609
610         # Look for loadfile and read/parse it if present.  This also
611         # works by jamming these values onto sys.argv.
612         self._augment_sys_argv_from_loadfile()
613
614         # Parse (possibly augmented, possibly completely overwritten)
615         # commandline args with argparse normally and populate config.
616         known, unknown = ARGS.parse_known_args()
617         self.config.update(vars(known))
618
619         # Reconstruct the argv with unrecognized flags for the benefit of
620         # future argument parsers.  For example, unittest_main in python
621         # has some of its own flags.  If we didn't recognize it, maybe
622         # someone else will.
623         if len(unknown) > 0:
624             if config['config_rejects_unrecognized_arguments']:
625                 raise Exception(
626                     f'Encountered unrecognized config argument(s) {unknown} with --config_rejects_unrecognized_arguments enabled; halting.'
627                 )
628             self.saved_messages.append(
629                 f'Config encountered unrecognized commandline arguments: {unknown}'
630             )
631         sys.argv = sys.argv[:1] + unknown
632
633         # Check for savefile and populate it if requested.
634         savefile = config['config_savefile']
635         if savefile and len(savefile) > 0:
636             data = '\n'.join(ORIG_ARGV[1:])
637             if savefile[:3] == 'zk:':
638                 self._write_config_to_zookeeper(savefile[3:], data)
639             else:
640                 self._write_config_to_disk(savefile, data)
641
642         # Also dump the config on stderr if requested.
643         if config['config_dump']:
644             self.dump_config()
645
646         self.config_parse_called = True
647         if config['config_exit_after_parse']:
648             print("Exiting because of --config_exit_after_parse.")
649             if self.zk:
650                 self.zk.stop()
651             sys.exit(0)
652         return self.config
653
654     def has_been_parsed(self) -> bool:
655         """Returns True iff the global config has already been parsed"""
656         return self.config_parse_called
657
658     def late_logging(self):
659         """Log messages saved earlier now that logging has been initialized."""
660         logger = logging.getLogger(__name__)
661         logger.debug('Original commandline was: %s', ORIG_ARGV)
662         for _ in self.saved_messages:
663             logger.debug(_)
664
665
666 # A global singleton instance of the Config class.
667 CONFIG = Config()
668
669 # A lot of client code uses config.config['whatever'] to lookup
670 # configuration so to preserve this we make this, config.config, with
671 # a __getitem__ method on it.
672 config = CONFIG
673
674 # Config didn't use to be a class; it was a mess of module-level
675 # functions and data.  The functions below preserve the old interface
676 # so that existing clients do not need to be changed.  As you can see,
677 # they mostly just thunk into the config class.
678
679
680 def add_commandline_args(title: str, description: str = "") -> argparse._ArgumentGroup:
681     """Create a new context for arguments and return a handle.  An alias
682     for config.config.add_commandline_args.
683
684     Args:
685         title: A title for your module's commandline arguments group.
686         description: A helpful description of your module.
687
688     Returns:
689         An argparse._ArgumentGroup to be populated by the caller.
690     """
691     return CONFIG.add_commandline_args(title, description)
692
693
694 def parse(entry_module: Optional[str]) -> Dict[str, Any]:
695     """Main program should call this early in main().  Note that the
696     :code:`bootstrap.initialize` wrapper takes care of this automatically.
697     This should only be called once per program invocation.  Subsequent
698     calls do not reparse the configuration settings but rather just
699     return the current state.
700     """
701     return CONFIG.parse(entry_module)
702
703
704 def has_been_parsed() -> bool:
705     """Returns True iff the global config has already been parsed"""
706     return CONFIG.has_been_parsed()
707
708
709 def late_logging() -> None:
710     """Log messages saved earlier now that logging has been initialized."""
711     CONFIG.late_logging()
712
713
714 def dump_config() -> None:
715     """Print the current config to stdout."""
716     CONFIG.dump_config()
717
718
719 def overwrite_argparse_epilog(msg: str) -> None:
720     """Allows your code to override the default epilog created by
721     argparse.
722
723     Args:
724         msg: The epilog message to substitute for the default.
725     """
726     Config.overwrite_argparse_epilog(msg)
727
728
729 def is_flag_already_in_argv(var: str) -> bool:
730     """Returns true if a particular flag is passed on the commandline
731     and false otherwise.
732
733     Args:
734         var: The flag to search for.
735     """
736     return Config.is_flag_already_in_argv(var)
737
738
739 def print_usage() -> None:
740     """Prints the normal help usage message out."""
741     Config.print_usage()
742
743
744 def usage() -> str:
745     """
746     Returns:
747         program usage help text as a string.
748     """
749     return Config.usage()