The perfect script structure... Where do you write the main() function?
Best practice for Python main function definition and program start/exit - Software Engineering Stack Exchange
What does def main mean in Python?
What are some of your python scripts that might be useful to other too?
I made a python program that says “Hello friend” after you type in ‘ping’. I get lonely sometimes. 😄🤖
More on reddit.comHow do I execute the main function in Python?
How do I define the main function in Python?
Why should I use a main function in Python?
Well, I'm trying to figure out what is the best way to write one-file Python programs in terms of structure.
For the purpose of this post I will focus only in the use of the main() function.
Most people out there will write their one-file program this way:
# ...imports, globals, constats...
def first_thing_to_do():
do stuff
def second_thing_to_do()
do more stuff
def main():
first_thing_to_do()
second_thing_to_do()
if __name__ == "__main__":
main()But I think the more logical and readable order for a human is:
# ...imports, globals, constats...
def main():
first_thing_to_do()
second_thing_to_do()
def first_thing_to_do():
do stuff
def second_thing_to_do()
do more stuff
if __name__ == "__main__":
main()In the second way, a programmer that reads the Python program for the first time (or after some months) can see what the program does at first, and then how it does it, not the other way.
In other words, I used to write the problem definition as the main function on top (right next to imports, globals and constants), and the implementation at the bottom, as it is the most abstract (and less likely to change) part of the program.
I know Python can read it in both ways, but all the code I see on the internet use the first structure and I want to know if am I missing something or if it is just a matter of preference.
How do you write your own one-file scripts?
Your doing it in the past way is probably fine for quick& dirty scripts or for mini tools used by other developers. There’s often no need to be fancy. On the contrary, Python’s default traceback output can be an appropriate or even desired form of error reporting. A script falling off its end exits with code 0, an unhandled exception exits the script with a non-zero code, so you’re covered there, too:
def main() -> None:
# here would be code raising exceptions on error
# note the `None` return type
if __name__ == '__main__':
main()
When you need something more fancy working with sys.exit() explicitly is a good idea. That’s basically your future approach. But don’t only think of scripts called directly, also think of installed packages. Setuptools has a cross-platform mechanism to define functions as entry points for scripts. If you have this in your setup.py:
setuptools.setup(
...
entry_points={
'console_scripts': ['your_script=your_package.your_module:main'],
},
)
and install that package you can run your_script from the command line.
A console_scripts function must be callable without any arguments. So, as long as all parameters have default values you’re fine. For longer running scripts consider explicitly handling KeyboardInterrupt to print a nice message when the user aborts with Ctrl+C. In the end the relevant part of your your_package/your_module.py might look something like this:
def main(cli_args: List[str] = None) -> int:
"""
`cli_args` makes it possible to call this function command-line-style
from other Python code without touching sys.argv.
"""
try:
# Parsing with `argparse` and additional processing
# is usually lenghty enough to extract into separate functions.
raw_config = _parse_cli(
sys.argv[1:] if cli_args is None else cli_args)
config = _validate_and_sanitize(raw_config)
# Same exception raising idea as the simple approach
do_real_work(config.foo, config.bar)
except KeyboardInterrupt:
print('Aborted manually.', file=sys.stderr)
return 1
except Exception as err:
# (in real code the `except` would probably be less broad)
# Turn exceptions into appropriate logs and/or console output.
# non-zero return code to signal error
# Can of course be more fine grained than this general
# "something went wrong" code.
return 1
return 0 # success
# __main__ support is still here to make this file executable without
# installing the package first.
if __name__ == '__main__':
sys.exit(main())
I have worked on this template and use-case a bit more and here's the more refined structure for files containing a main function that I currently use:
#!/usr/bin/env python3
import sys
from argparse import ArgumentParser, Namespace
from typing import Dict, List
import yaml # just used as an example here for loading more configs, optional
def parse_arguments(cli_args: List[str] = None) -> Namespace:
parser = ArgumentParser()
# parser.add_argument()
# ...
return parser.parse_args(args=cli_args) # None defaults to sys.argv[1:]
def load_configs(args: Namespace) -> Dict:
try:
with open(args.config_path, 'r') as file_pointer:
config = yaml.safe_load(file_pointer)
# arrange and check configs here
return config
except Exception as err:
# log errors
print(err)
if err == "Really Bad":
raise err
# potentionally return some sane fallback defaults if desired/reasonable
sane_defaults = []
return sane_defaults
def main(args: Namespace = parse_arguments()) -> int:
try:
# maybe load some additional config files here or in a function called here
# e.g. args contains a path to a config folder; or use sane defaults
# if the config files are missing(if that is your desired behavior)
config = load_configs(args)
do_real_work(args, config)
except KeyboardInterrupt:
print("Aborted manually.", file=sys.stderr)
return 1
except Exception as err:
# (in real code the `except` would probably be less broad)
# Turn exceptions into appropriate logs and/or console output.
# log err
print("An unhandled exception crashed the application!", err)
# non-zero return code to signal error
# Can of course be more fine grained than this general
# "something went wrong" code.
return 1
return 0 # success
# __main__ support is still here to make this file executable without
# installing the package first.
if __name__ == "__main__":
sys.exit(main(parse_arguments()))
Having the parse_arguments function makes integration tests much more readable, as you then can just call that function to generate the desired namespace object for you, using the same string you'd use on the cli. Then as suggested in the accepted answer handle errors to give the output you'd want and pass the arguments to the function(s) doing the work. I also load and arrange configs in this context, as necessary.