Ugh. Fix a bug in the new refactor of the env var config stuff.
[python_utils.git] / 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         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         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         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 from kazoo.client import KazooClient
94 from kazoo.protocol.states import WatchedEvent
95
96 import scott_secrets
97
98 # This module is commonly used by others in here and should avoid
99 # taking any unnecessary dependencies back on them.
100
101 # Make a copy of the original program arguments immediately upon module load.
102 PROGRAM_NAME: str = os.path.basename(sys.argv[0])
103 ORIG_ARGV: List[str] = sys.argv.copy()
104
105
106 class OptionalRawFormatter(argparse.HelpFormatter):
107     """This formatter has the same bahavior as the normal argparse text
108     formatter except when the help text of an argument begins with
109     "RAW|".  In that case, the line breaks are preserved and the text
110     is not wrapped.
111
112     Use this, for example, when you need the helptext of an argument
113     to have its spacing preserved exactly, e.g.::
114
115         args.add_argument(
116             '--mode',
117             type=str,
118             default='PLAY',
119             choices=['CHEAT', 'AUTOPLAY', 'SELFTEST', 'PRECOMPUTE', 'PLAY'],
120             metavar='MODE',
121             help='''RAW|Our mode of operation.  One of:
122
123                 PLAY = play wordle with me!  Pick a random solution or
124                        specify a solution with --template.
125
126                CHEAT = given a --template and, optionally, --letters_in_word
127                        and/or --letters_to_avoid, return the best guess word;
128
129             AUTOPLAY = given a complete word in --template, guess it step
130                        by step showing work;
131
132             SELFTEST = autoplay every possible solution keeping track of
133                        wins/losses and average number of guesses;
134
135           PRECOMPUTE = populate hash table with optimal guesses.
136             ''',
137         )
138     """
139
140     def _split_lines(self, text, width):
141         if text.startswith('RAW|'):
142             return text[4:].splitlines()
143         return argparse.HelpFormatter._split_lines(self, text, width)
144
145
146 # A global argparser that we will collect arguments in.  Each module (including
147 # us) will add arguments to a separate argument group.
148 ARGS = argparse.ArgumentParser(
149     description=None,
150     formatter_class=OptionalRawFormatter,
151     fromfile_prefix_chars="@",
152     epilog=f'{PROGRAM_NAME} uses config.py ({__file__}) for global, cross-module configuration setup and parsing.',
153     # I don't fully understand why but when loaded by sphinx sometimes
154     # the same module is loaded many times causing any arguments it
155     # registers via module-level code to be redefined.  Work around
156     # this iff the program is 'sphinx-build'
157     conflict_handler='resolve' if PROGRAM_NAME == 'sphinx-build' else 'error',
158 )
159
160 # Arguments specific to config.py.  Other users should get their own group by
161 # invoking config.add_commandline_args.
162 GROUP = ARGS.add_argument_group(
163     f'Global Config ({__file__})',
164     'Args that control the global config itself; how meta!',
165 )
166 GROUP.add_argument(
167     '--config_loadfile',
168     metavar='FILENAME',
169     default=None,
170     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.',
171 )
172 GROUP.add_argument(
173     '--config_dump',
174     default=False,
175     action='store_true',
176     help='Display the global configuration (possibly derived from multiple sources) on STDERR at program startup time.',
177 )
178 GROUP.add_argument(
179     '--config_savefile',
180     type=str,
181     metavar='FILENAME',
182     default=None,
183     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.',
184 )
185 GROUP.add_argument(
186     '--config_rejects_unrecognized_arguments',
187     default=False,
188     action='store_true',
189     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.',
190 )
191
192
193 class Config:
194     """
195     Everything in the config module used to be module-level functions and
196     variables but it made the code ugly and harder to maintain.  Now, this
197     class does the heavy lifting.  We still rely on some globals, though:
198
199         ARGS and GROUP to interface with argparse
200         PROGRAM_NAME stores argv[0] close to program invocation
201         ORIG_ARGV stores the original argv list close to program invocation
202         CONFIG and config: hold the (singleton) instance of this class.
203
204     """
205
206     def __init__(self):
207         # Has our parse() method been invoked yet?
208         self.config_parse_called = False
209
210         # A configuration dictionary that will contain parsed
211         # arguments.  This is the data that is most interesting to our
212         # callers as it will hold the configuration result.
213         self.config: Dict[str, Any] = {}
214
215         # Defer logging messages until later when logging has been
216         # initialized.
217         self.saved_messages: List[str] = []
218
219         # A zookeeper client that is lazily created so as to not incur
220         # the latency of connecting to zookeeper for programs that are
221         # not reading or writing their config data into zookeeper.
222         self.zk: Optional[KazooClient] = None
223
224         # Per known zk file, what is the max version we have seen?
225         self.max_version: Dict[str, int] = {}
226
227     def __getitem__(self, key: str) -> Optional[Any]:
228         """If someone uses []'s on us, pass it onto self.config."""
229         return self.config.get(key, None)
230
231     def __setitem__(self, key: str, value: Any) -> None:
232         self.config[key] = value
233
234     def __contains__(self, key: str) -> bool:
235         return key in self.config
236
237     @staticmethod
238     def add_commandline_args(title: str, description: str = "") -> argparse._ArgumentGroup:
239         """Create a new context for arguments and return a handle.
240
241         Args:
242             title: A title for your module's commandline arguments group.
243             description: A helpful description of your module.
244
245         Returns:
246             An argparse._ArgumentGroup to be populated by the caller.
247         """
248         return ARGS.add_argument_group(title, description)
249
250     @staticmethod
251     def overwrite_argparse_epilog(msg: str) -> None:
252         """Allows your code to override the default epilog created by
253         argparse.
254
255         Args:
256             msg: The epilog message to substitute for the default.
257         """
258         ARGS.epilog = msg
259
260     @staticmethod
261     def is_flag_already_in_argv(var: str) -> bool:
262         """Returns true if a particular flag is passed on the commandline
263         and false otherwise.
264
265         Args:
266             var: The flag to search for.
267         """
268         for _ in sys.argv:
269             if var in _:
270                 return True
271         return False
272
273     @staticmethod
274     def print_usage() -> None:
275         """Prints the normal help usage message out."""
276         ARGS.print_help()
277
278     @staticmethod
279     def usage() -> str:
280         """
281         Returns:
282             program usage help text as a string.
283         """
284         return ARGS.format_usage()
285
286     @staticmethod
287     def _reorder_arg_action_groups_before_help(entry_module: Optional[str]):
288         """Internal.  Used to reorder the arguments before dumping out a
289         generated help string such that the main program's arguments come
290         last.
291
292         """
293         reordered_action_groups = []
294         for grp in ARGS._action_groups:
295             if entry_module is not None and entry_module in grp.title:  # type: ignore
296                 reordered_action_groups.append(grp)
297             elif PROGRAM_NAME in GROUP.title:  # type: ignore
298                 reordered_action_groups.append(grp)
299             else:
300                 reordered_action_groups.insert(0, grp)
301         return reordered_action_groups
302
303     @staticmethod
304     def _parse_arg_into_env(arg: str) -> Optional[Tuple[str, str, List[str]]]:
305         """Internal helper to parse commandling args into environment vars."""
306         arg = arg.strip()
307         if not arg.startswith('['):
308             return None
309         arg = arg.strip('[')
310         if not arg.endswith(']'):
311             return None
312         arg = arg.strip(']')
313
314         chunks = arg.split()
315         if len(chunks) > 1:
316             var = chunks[0]
317         else:
318             var = arg
319
320         # Environment vars the same as flag names without
321         # the initial -'s and in UPPERCASE.
322         env = var.upper()
323         while env[0] == '-':
324             env = env[1:]
325         return var, env, chunks
326
327     def _augment_sys_argv_from_environment_variables(self):
328         """Internal.  Look at the system environment for variables that match
329         commandline arg names.  This is done via some munging such that:
330
331         :code:`--argument_to_match`
332
333         ...is matched by:
334
335         :code:`ARGUMENT_TO_MATCH`
336
337         This allows users to set args via shell environment variables
338         in lieu of passing them on the cmdline.
339
340         """
341         usage_message = Config.usage()
342         optional = False
343         arg = ''
344
345         # Foreach valid optional commandline option (chunk) generate
346         # its analogous environment variable.
347         for chunk in usage_message.split():
348             if chunk[0] == '[':
349                 optional = True
350             if optional:
351                 arg += f'{chunk} '
352                 if chunk[-1] == ']':
353                     optional = False
354                     _ = Config._parse_arg_into_env(arg)
355                     if _:
356                         var, env, chunks = _
357                         if env in os.environ:
358                             if not Config.is_flag_already_in_argv(var):
359                                 value = os.environ[env]
360                                 self.saved_messages.append(
361                                     f'Initialized from environment: {var} = {value}'
362                                 )
363                                 from string_utils import to_bool
364
365                                 if len(chunks) == 1 and to_bool(value):
366                                     sys.argv.append(var)
367                                 elif len(chunks) > 1:
368                                     sys.argv.append(var)
369                                     sys.argv.append(value)
370                     arg = ''
371
372     def _process_dynamic_args(self, event: WatchedEvent):
373         assert self.zk
374         logger = logging.getLogger(__name__)
375         contents, meta = self.zk.get(event.path, watch=self._process_dynamic_args)
376         logger.debug('Update for %s at version=%d.', event.path, meta.version)
377         logger.debug(
378             'Max known version for %s is %d.', event.path, self.max_version.get(event.path, 0)
379         )
380         if meta.version > self.max_version.get(event.path, 0):
381             self.max_version[event.path] = meta.version
382             contents = contents.decode()
383             temp_argv = []
384             for arg in contents.split():
385                 if 'dynamic' in arg:
386                     temp_argv.append(arg)
387                     logger.info("Updating %s from zookeeper async config change.", arg)
388             if len(temp_argv) > 0:
389                 old_argv = sys.argv
390                 sys.argv = temp_argv
391                 known, _ = ARGS.parse_known_args()
392                 sys.argv = old_argv
393                 self.config.update(vars(known))
394
395     def _augment_sys_argv_from_loadfile(self):
396         """Internal.  Augment with arguments persisted in a saved file."""
397
398         loadfile = None
399         saw_other_args = False
400         grab_next_arg = False
401         for arg in sys.argv[1:]:
402             if 'config_loadfile' in arg:
403                 pieces = arg.split('=')
404                 if len(pieces) > 1:
405                     loadfile = pieces[1]
406                 else:
407                     grab_next_arg = True
408             elif grab_next_arg:
409                 loadfile = arg
410             else:
411                 saw_other_args = True
412
413         if loadfile is not None:
414             zkpath = None
415             if loadfile[:3] == 'zk:':
416                 try:
417                     if self.zk is None:
418                         self.zk = KazooClient(
419                             hosts=scott_secrets.ZOOKEEPER_NODES,
420                             use_ssl=True,
421                             verify_certs=False,
422                             keyfile=scott_secrets.ZOOKEEPER_CLIENT_CERT,
423                             keyfile_password=scott_secrets.ZOOKEEPER_CLIENT_PASS,
424                             certfile=scott_secrets.ZOOKEEPER_CLIENT_CERT,
425                         )
426                         self.zk.start()
427                     zkpath = loadfile[3:]
428                     if not zkpath.startswith('/config/'):
429                         zkpath = '/config/' + zkpath
430                         zkpath = re.sub(r'//+', '/', zkpath)
431                     if not self.zk.exists(zkpath):
432                         raise Exception(
433                             f'ERROR: --config_loadfile argument must be a file, {loadfile} not found (in zookeeper)'
434                         )
435                 except Exception as e:
436                     raise Exception(
437                         f'ERROR: Error talking with zookeeper while looking for {loadfile}'
438                     ) from e
439             elif not os.path.exists(loadfile):
440                 raise Exception(
441                     f'ERROR: --config_loadfile argument must be a file, {loadfile} not found.'
442                 )
443
444             if saw_other_args:
445                 msg = f'Augmenting commandline arguments with those from {loadfile}.'
446             else:
447                 msg = f'Reading commandline arguments from {loadfile}.'
448             print(msg, file=sys.stderr)
449             self.saved_messages.append(msg)
450
451             newargs = []
452             if zkpath:
453                 try:
454                     assert self.zk
455                     contents, meta = self.zk.get(zkpath, watch=self._process_dynamic_args)
456                     contents = contents.decode()
457                     newargs = [
458                         arg.strip('\n')
459                         for arg in contents.split('\n')
460                         if 'config_savefile' not in arg
461                     ]
462                     self.saved_messages.append(f'Setting {zkpath}\'s max_version to {meta.version}')
463                     self.max_version[zkpath] = meta.version
464                 except Exception as e:
465                     raise Exception(f'Error reading {zkpath} from zookeeper.') from e
466                 self.saved_messages.append(f'Loaded config from zookeeper from {zkpath}')
467             else:
468                 with open(loadfile, 'r') as rf:
469                     newargs = rf.readlines()
470                 newargs = [arg.strip('\n') for arg in newargs if 'config_savefile' not in arg]
471             sys.argv += newargs
472
473     def dump_config(self):
474         """Print the current config to stdout."""
475         print("Global Configuration:", file=sys.stderr)
476         pprint.pprint(self.config, stream=sys.stderr)
477         print()
478
479     def parse(self, entry_module: Optional[str]) -> Dict[str, Any]:
480         """Main program should call this early in main().  Note that the
481         :code:`bootstrap.initialize` wrapper takes care of this automatically.
482         This should only be called once per program invocation.
483
484         """
485         if self.config_parse_called:
486             return self.config
487
488         # If we're about to do the usage message dump, put the main
489         # module's argument group last in the list (if possible) so that
490         # when the user passes -h or --help, it will be visible on the
491         # screen w/o scrolling.
492         for arg in sys.argv:
493             if arg in ('--help', '-h'):
494                 if entry_module is not None:
495                     entry_module = os.path.basename(entry_module)
496                 ARGS._action_groups = Config._reorder_arg_action_groups_before_help(entry_module)
497
498         # Examine the environment for variables that match known flags.
499         # For a flag called --example_flag the corresponding environment
500         # variable would be called EXAMPLE_FLAG.  If found, hackily add
501         # these into sys.argv to be parsed.
502         self._augment_sys_argv_from_environment_variables()
503
504         # Look for loadfile and read/parse it if present.  This also
505         # works by jamming these values onto sys.argv.
506         self._augment_sys_argv_from_loadfile()
507
508         # Parse (possibly augmented, possibly completely overwritten)
509         # commandline args with argparse normally and populate config.
510         known, unknown = ARGS.parse_known_args()
511         self.config.update(vars(known))
512
513         # Reconstruct the argv with unrecognized flags for the benefit of
514         # future argument parsers.  For example, unittest_main in python
515         # has some of its own flags.  If we didn't recognize it, maybe
516         # someone else will.
517         if len(unknown) > 0:
518             if config['config_rejects_unrecognized_arguments']:
519                 raise Exception(
520                     f'Encountered unrecognized config argument(s) {unknown} with --config_rejects_unrecognized_arguments enabled; halting.'
521                 )
522             self.saved_messages.append(
523                 f'Config encountered unrecognized commandline arguments: {unknown}'
524             )
525         sys.argv = sys.argv[:1] + unknown
526
527         # Check for savefile and populate it if requested.
528         savefile = config['config_savefile']
529         if savefile and len(savefile) > 0:
530             data = '\n'.join(ORIG_ARGV[1:])
531             if savefile[:3] == 'zk:':
532                 zkpath = savefile[3:]
533                 if not zkpath.startswith('/config/'):
534                     zkpath = '/config/' + zkpath
535                     zkpath = re.sub(r'//+', '/', zkpath)
536                 try:
537                     if not self.zk:
538                         self.zk = KazooClient(
539                             hosts=scott_secrets.ZOOKEEPER_NODES,
540                             use_ssl=True,
541                             verify_certs=False,
542                             keyfile=scott_secrets.ZOOKEEPER_CLIENT_CERT,
543                             keyfile_password=scott_secrets.ZOOKEEPER_CLIENT_PASS,
544                             certfile=scott_secrets.ZOOKEEPER_CLIENT_CERT,
545                         )
546                         self.zk.start()
547                     data = data.encode()
548                     if len(data) > 1024 * 1024:
549                         raise Exception(f'Saved args are too large!  ({len(data)} bytes)')
550                     if not self.zk.exists(zkpath):
551                         self.zk.create(zkpath, data)
552                         self.saved_messages.append(
553                             f'Just created {zkpath}; setting its max_version to 0'
554                         )
555                         self.max_version[zkpath] = 0
556                     else:
557                         meta = self.zk.set(zkpath, data)
558                         self.saved_messages.append(
559                             f'Setting {zkpath}\'s max_version to {meta.version}'
560                         )
561                         self.max_version[zkpath] = meta.version
562                 except Exception as e:
563                     raise Exception(f'Failed to create zookeeper path {zkpath}') from e
564                 self.saved_messages.append(f'Saved config to zookeeper in {zkpath}')
565             else:
566                 with open(savefile, 'w') as wf:
567                     wf.write(data)
568
569         # Also dump the config on stderr if requested.
570         if config['config_dump']:
571             self.dump_config()
572
573         self.config_parse_called = True
574         return self.config
575
576     def has_been_parsed(self) -> bool:
577         """Returns True iff the global config has already been parsed"""
578         return self.config_parse_called
579
580     def late_logging(self):
581         """Log messages saved earlier now that logging has been initialized."""
582         logger = logging.getLogger(__name__)
583         logger.debug('Original commandline was: %s', ORIG_ARGV)
584         for _ in self.saved_messages:
585             logger.debug(_)
586
587
588 # A global singleton instance of the Config class.
589 CONFIG = Config()
590
591 # A lot of client code uses config.config['whatever'] to lookup
592 # configuration so to preserve this we make this, config.config, with
593 # a __getitem__ method on it.
594 config = CONFIG
595
596 # Config didn't use to be a class; it was a mess of module-level
597 # functions and data.  The functions below preserve the old interface
598 # so that existing clients do not need to be changed.  As you can see,
599 # they mostly just thunk into the config class.
600
601
602 def add_commandline_args(title: str, description: str = "") -> argparse._ArgumentGroup:
603     """Create a new context for arguments and return a handle.  An alias
604     for config.config.add_commandline_args.
605
606     Args:
607         title: A title for your module's commandline arguments group.
608         description: A helpful description of your module.
609
610     Returns:
611         An argparse._ArgumentGroup to be populated by the caller.
612     """
613     return CONFIG.add_commandline_args(title, description)
614
615
616 def parse(entry_module: Optional[str]) -> Dict[str, Any]:
617     """Main program should call this early in main().  Note that the
618     :code:`bootstrap.initialize` wrapper takes care of this automatically.
619     This should only be called once per program invocation.  Subsequent
620     calls do not reparse the configuration settings but rather just
621     return the current state.
622     """
623     return CONFIG.parse(entry_module)
624
625
626 def has_been_parsed() -> bool:
627     """Returns True iff the global config has already been parsed"""
628     return CONFIG.has_been_parsed()
629
630
631 def late_logging() -> None:
632     """Log messages saved earlier now that logging has been initialized."""
633     CONFIG.late_logging()
634
635
636 def dump_config() -> None:
637     """Print the current config to stdout."""
638     CONFIG.dump_config()
639
640
641 def overwrite_argparse_epilog(msg: str) -> None:
642     """Allows your code to override the default epilog created by
643     argparse.
644
645     Args:
646         msg: The epilog message to substitute for the default.
647     """
648     Config.overwrite_argparse_epilog(msg)
649
650
651 def is_flag_already_in_argv(var: str) -> bool:
652     """Returns true if a particular flag is passed on the commandline
653     and false otherwise.
654
655     Args:
656         var: The flag to search for.
657     """
658     return Config.is_flag_already_in_argv(var)
659
660
661 def print_usage() -> None:
662     """Prints the normal help usage message out."""
663     Config.print_usage()
664
665
666 def usage() -> str:
667     """
668     Returns:
669         program usage help text as a string.
670     """
671     return Config.usage()