Change settings in flake8 and black.
[python_utils.git] / config.py
1 #!/usr/bin/env python3
2
3 """Global configuration driven by commandline arguments, environment variables
4 and saved configuration files.  This works across several modules.
5
6 Usage:
7
8     module.py:
9     ----------
10     import config
11
12     parser = config.add_commandline_args(
13         "Module",
14         "Args related to module doing the thing.",
15     )
16     parser.add_argument(
17         "--module_do_the_thing",
18         type=bool,
19         default=True,
20         help="Should the module do the thing?"
21     )
22
23     main.py:
24     --------
25     import config
26
27     def main() -> None:
28         parser = config.add_commandline_args(
29             "Main",
30             "A program that does the thing.",
31         )
32         parser.add_argument(
33             "--dry_run",
34             type=bool,
35             default=False,
36             help="Should we really do the thing?"
37         )
38         config.parse()   # Very important, this must be invoked!
39
40     If you set this up and remember to invoke config.parse(), all commandline
41     arguments will play nicely together.  This is done automatically for you
42     if you're using the bootstrap module's initialize wrapper.
43
44     % main.py -h
45     usage: main.py [-h]
46                    [--module_do_the_thing MODULE_DO_THE_THING]
47                    [--dry_run DRY_RUN]
48
49     Module:
50       Args related to module doing the thing.
51
52       --module_do_the_thing MODULE_DO_THE_THING
53                    Should the module do the thing?
54
55     Main:
56       A program that does the thing
57
58       --dry_run
59                    Should we really do the thing?
60
61     Arguments themselves should be accessed via
62     config.config['arg_name'].  e.g.
63
64     if not config.config['dry_run']:
65         module.do_the_thing()
66
67 """
68
69 import argparse
70 import logging
71 import os
72 import pprint
73 import sys
74 from typing import Any, Dict, List, Optional
75
76 # This module is commonly used by others in here and should avoid
77 # taking any unnecessary dependencies back on them.
78
79 # Defer logging messages until later when logging has been initialized.
80 saved_messages: List[str] = []
81
82 # Make a copy of the original program arguments.
83 program_name: str = os.path.basename(sys.argv[0])
84 original_argv: List[str] = [arg for arg in sys.argv]
85
86
87 # A global parser that we will collect arguments into.
88 args = argparse.ArgumentParser(
89     description=None,
90     formatter_class=argparse.ArgumentDefaultsHelpFormatter,
91     fromfile_prefix_chars="@",
92     epilog=f'{program_name} uses config.py ({__file__}) for global, cross-module configuration setup and parsing.',
93 )
94
95 # Keep track of if we've been called and prevent being called more
96 # than once.
97 config_parse_called = False
98
99
100 # A global configuration dictionary that will contain parsed arguments.
101 # It is also this variable that modules use to access parsed arguments.
102 # This is the data that is most interesting to our callers; it will hold
103 # the configuration result.
104 config: Dict[str, Any] = {}
105 # It would be really nice if this shit worked from interactive python
106
107
108 def add_commandline_args(title: str, description: str = ""):
109     """Create a new context for arguments and return a handle."""
110     return args.add_argument_group(title, description)
111
112
113 group = add_commandline_args(
114     f'Global Config ({__file__})',
115     'Args that control the global config itself; how meta!',
116 )
117 group.add_argument(
118     '--config_loadfile',
119     metavar='FILENAME',
120     default=None,
121     help='Config file (populated via --config_savefile) from which to read args in lieu or in addition to commandline.',
122 )
123 group.add_argument(
124     '--config_dump',
125     default=False,
126     action='store_true',
127     help='Display the global configuration (possibly derived from multiple sources) on STDERR at program startup.',
128 )
129 group.add_argument(
130     '--config_savefile',
131     type=str,
132     metavar='FILENAME',
133     default=None,
134     help='Populate config file compatible with --config_loadfile to save global config for later use.',
135 )
136 group.add_argument(
137     '--config_rejects_unrecognized_arguments',
138     default=False,
139     action='store_true',
140     help=(
141         'If present, config will raise an exception if it doesn\'t recognize an argument.  The '
142         + 'default behavior is to ignore this so as to allow interoperability with programs that '
143         + 'want to use their own argparse calls to parse their own, separate commandline args.'
144     ),
145 )
146
147
148 def is_flag_already_in_argv(var: str):
149     """Is a particular flag passed on the commandline?"""
150     for _ in sys.argv:
151         if var in _:
152             return True
153     return False
154
155
156 def reorder_arg_action_groups(entry_module: Optional[str]):
157     global program_name, args
158     reordered_action_groups = []
159     for group in args._action_groups:
160         if entry_module is not None and entry_module in group.title:  # type: ignore
161             reordered_action_groups.append(group)
162         elif program_name in group.title:  # type: ignore
163             reordered_action_groups.append(group)
164         else:
165             reordered_action_groups.insert(0, group)
166     return reordered_action_groups
167
168
169 def augment_sys_argv_from_environment_variables():
170     global saved_messages
171     usage_message = args.format_usage()
172     optional = False
173     var = ''
174     for x in usage_message.split():
175         if x[0] == '[':
176             optional = True
177         if optional:
178             var += f'{x} '
179             if x[-1] == ']':
180                 optional = False
181                 var = var.strip()
182                 var = var.strip('[')
183                 var = var.strip(']')
184                 chunks = var.split()
185                 if len(chunks) > 1:
186                     var = var.split()[0]
187
188                 # Environment vars the same as flag names without
189                 # the initial -'s and in UPPERCASE.
190                 env = var.strip('-').upper()
191                 if env in os.environ:
192                     if not is_flag_already_in_argv(var):
193                         value = os.environ[env]
194                         saved_messages.append(f'Initialized from environment: {var} = {value}')
195                         from string_utils import to_bool
196
197                         if len(chunks) == 1 and to_bool(value):
198                             sys.argv.append(var)
199                         elif len(chunks) > 1:
200                             sys.argv.append(var)
201                             sys.argv.append(value)
202                 var = ''
203                 env = ''
204
205
206 def augment_sys_argv_from_loadfile():
207     global saved_messages
208     loadfile = None
209     saw_other_args = False
210     grab_next_arg = False
211     for arg in sys.argv[1:]:
212         if 'config_loadfile' in arg:
213             pieces = arg.split('=')
214             if len(pieces) > 1:
215                 loadfile = pieces[1]
216             else:
217                 grab_next_arg = True
218         elif grab_next_arg:
219             loadfile = arg
220         else:
221             saw_other_args = True
222
223     if loadfile is not None:
224         if not os.path.exists(loadfile):
225             raise Exception(
226                 f'ERROR: --config_loadfile argument must be a file, {loadfile} not found.'
227             )
228         if saw_other_args:
229             msg = f'Augmenting commandline arguments with those from {loadfile}.'
230         else:
231             msg = f'Reading commandline arguments from {loadfile}.'
232         print(msg, file=sys.stderr)
233         saved_messages.append(msg)
234
235         with open(loadfile, 'r') as rf:
236             newargs = rf.readlines()
237         newargs = [arg.strip('\n') for arg in newargs if 'config_savefile' not in arg]
238         sys.argv += newargs
239
240
241 def parse(entry_module: Optional[str]) -> Dict[str, Any]:
242     """Main program should call this early in main().  Note that the
243     bootstrap.initialize wrapper takes care of this automatically.
244
245     """
246     global config_parse_called
247     if config_parse_called:
248         return config
249     global saved_messages
250
251     # If we're about to do the usage message dump, put the main
252     # module's argument group last in the list (if possible) so that
253     # when the user passes -h or --help, it will be visible on the
254     # screen w/o scrolling.
255     for arg in sys.argv:
256         if arg == '--help' or arg == '-h':
257             args._action_groups = reorder_arg_action_groups(entry_module)
258
259     # Examine the environment for variables that match known flags.
260     # For a flag called --example_flag the corresponding environment
261     # variable would be called EXAMPLE_FLAG.  If found, hackily add
262     # these into sys.argv to be parsed.
263     augment_sys_argv_from_environment_variables()
264
265     # Look for loadfile and read/parse it if present.  This also
266     # works by jamming these values onto sys.argv.
267     augment_sys_argv_from_loadfile()
268
269     # Parse (possibly augmented, possibly completely overwritten)
270     # commandline args with argparse normally and populate config.
271     known, unknown = args.parse_known_args()
272     config.update(vars(known))
273
274     # Reconstruct the argv with unrecognized flags for the benefit of
275     # future argument parsers.  For example, unittest_main in python
276     # has some of its own flags.  If we didn't recognize it, maybe
277     # someone else will.
278     if len(unknown) > 0:
279         if config['config_rejects_unrecognized_arguments']:
280             raise Exception(
281                 f'Encountered unrecognized config argument(s) {unknown} with --config_rejects_unrecognized_arguments enabled; halting.'
282             )
283         saved_messages.append(f'Config encountered unrecognized commandline arguments: {unknown}')
284     sys.argv = sys.argv[:1] + unknown
285
286     # Check for savefile and populate it if requested.
287     savefile = config['config_savefile']
288     if savefile and len(savefile) > 0:
289         with open(savefile, 'w') as wf:
290             wf.write("\n".join(original_argv[1:]))
291
292     # Also dump the config on stderr if requested.
293     if config['config_dump']:
294         dump_config()
295
296     config_parse_called = True
297     return config
298
299
300 def has_been_parsed() -> bool:
301     """Has the global config been parsed yet?"""
302     global config_parse_called
303     return config_parse_called
304
305
306 def dump_config():
307     """Print the current config to stdout."""
308     print("Global Configuration:", file=sys.stderr)
309     pprint.pprint(config, stream=sys.stderr)
310     print()
311
312
313 def late_logging():
314     """Log messages saved earlier now that logging has been initialized."""
315     logger = logging.getLogger(__name__)
316     global saved_messages
317     for _ in saved_messages:
318         logger.debug(_)