3 """Global configuration driven by commandline arguments (even across
4 different modules). Usage:
10 parser = config.add_commandline_args(
12 "Args related to module doing the thing.",
15 "--module_do_the_thing",
18 help="Should the module do the thing?"
26 parser = config.add_commandline_args(
28 "A program that does the thing.",
34 help="Should we really do the thing?"
36 config.parse() # Very important, this must be invoked!
38 If you set this up and remember to invoke config.parse(), all commandline
39 arguments will play nicely together:
43 [--module_do_the_thing MODULE_DO_THE_THING]
47 Args related to module doing the thing.
49 --module_do_the_thing MODULE_DO_THE_THING
50 Should the module do the thing?
53 A program that does the thing
56 Should we really do the thing?
58 Arguments themselves should be accessed via
59 config.config['arg_name']. e.g.
61 if not config.config['dry_run']:
71 from typing import Any, Dict, List, Optional
73 # This module is commonly used by others in here and should avoid
74 # taking any unnecessary dependencies back on them.
76 # Note: at this point in time, logging hasn't been configured and
77 # anything we log will come out the root logger.
78 logger = logging.getLogger(__name__)
80 # Defer logging messages until later when logging has been initialized.
81 saved_messages: List[str] = []
83 # Make a copy of the original program arguments.
84 program_name = os.path.basename(sys.argv[0])
85 original_argv = [arg for arg in sys.argv]
87 # A global parser that we will collect arguments into.
88 args = argparse.ArgumentParser(
90 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
91 fromfile_prefix_chars="@",
92 epilog=f'-----------------------------------------------------------------------------\n{program_name} uses config.py ({__file__}) for global, cross-module configuration setup and parsing.\n-----------------------------------------------------------------------------'
95 # Keep track of if we've been called and prevent being called more
97 config_parse_called = False
99 # A global configuration dictionary that will contain parsed arguments.
100 # It is also this variable that modules use to access parsed arguments.
101 # This is the data that is most interesting to our callers; it will hold
102 # the configuration result.
103 config: Dict[str, Any] = {}
106 def add_commandline_args(title: str, description: str = ""):
107 """Create a new context for arguments and return a handle."""
108 return args.add_argument_group(title, description)
111 group = add_commandline_args(
112 f'Global Config ({__file__})',
113 'Args that control the global config itself; how meta!',
119 help='Config file from which to read args in lieu or in addition to commandline.',
125 help='Display the global configuration on STDERR at program startup.',
132 help='Populate config file compatible --config_loadfile to save config for later use.',
136 def is_flag_already_in_argv(var: str):
137 """Is a particular flag passed on the commandline?"""
144 def parse(entry_module: Optional[str]) -> Dict[str, Any]:
145 """Main program should call this early in main()"""
146 global config_parse_called
147 if config_parse_called:
148 logger.warning('config.parse has already been called; ignoring spurious invocation')
151 global saved_messages
153 # If we're about to do the usage message dump, put the main module's
154 # argument group last in the list (if possible) so that when the user
155 # passes -h or --help, it will be visible on the screen w/o scrolling.
156 reordered_action_groups = []
159 if arg == '--help' or arg == '-h':
160 for group in args._action_groups:
161 if entry_module is not None and entry_module in group.title:
162 reordered_action_groups.append(group)
163 elif program_name in group.title:
164 reordered_action_groups.append(group)
166 reordered_action_groups.insert(0, group)
167 args._action_groups = reordered_action_groups
169 # Examine the environment variables that match known flags. For a
170 # flag called --example_flag the corresponding environment
171 # variable would be called EXAMPLE_FLAG.
172 usage_message = args.format_usage()
175 for x in usage_message.split():
189 # Environment vars the same as flag names without
190 # the initial -'s and in UPPERCASE.
191 env = var.strip('-').upper()
192 if env in os.environ:
193 if not is_flag_already_in_argv(var):
194 value = os.environ[env]
195 saved_messages.append(
196 f'Initialized from environment: {var} = {value}'
198 from string_utils import to_bool
199 if len(chunks) == 1 and to_bool(value):
201 elif len(chunks) > 1:
203 sys.argv.append(value)
209 # Look for loadfile and read/parse it if present.
211 saw_other_args = False
212 grab_next_arg = False
213 for arg in sys.argv[1:]:
214 if 'config_loadfile' in arg:
215 pieces = arg.split('=')
223 saw_other_args = True
225 if loadfile is not None:
227 msg = f'WARNING: Augmenting commandline arguments with those from {loadfile}.'
228 print(msg, file=sys.stderr)
229 saved_messages.append(msg)
230 if not os.path.exists(loadfile):
231 print(f'--config_loadfile argument must be a file, {loadfile} not found.',
234 with open(loadfile, 'r') as rf:
235 newargs = rf.readlines()
236 newargs = [arg.strip('\n') for arg in newargs if 'config_savefile' not in arg]
239 # Parse (possibly augmented, possibly completely overwritten)
240 # commandline args with argparse normally and populate config.
241 known, unknown = args.parse_known_args()
242 config.update(vars(known))
244 # Reconstruct the argv with unrecognized flags for the benefit of
245 # future argument parsers. For example, unittest_main in python
246 # has some of its own flags. If we didn't recognize it, maybe
248 sys.argv = sys.argv[:1] + unknown
250 # Check for savefile and populate it if requested.
251 savefile = config['config_savefile']
252 if savefile and len(savefile) > 0:
253 with open(savefile, 'w') as wf:
255 "\n".join(original_argv[1:])
258 # Also dump the config on stderr if requested.
259 if config['config_dump']:
262 config_parse_called = True
266 def has_been_parsed() -> bool:
267 """Has the global config been parsed yet?"""
268 global config_parse_called
269 return config_parse_called
273 """Print the current config to stdout."""
274 print("Global Configuration:", file=sys.stderr)
275 pprint.pprint(config, stream=sys.stderr)
279 """Log messages saved earlier now that logging has been initialized."""
280 logger = logging.getLogger(__name__)
281 global saved_messages
282 for _ in saved_messages: