updating headless

This commit is contained in:
Ayush Saini 2024-05-19 18:31:39 +05:30
parent 1119710804
commit 7d21296d63
2 changed files with 194 additions and 119 deletions

View file

@ -1,23 +1,24 @@
#!/usr/bin/env -S python3.11 -O #!/usr/bin/env -S python3.12 -O
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
# pylint: disable=too-many-lines
"""BallisticaKit server manager.""" """BallisticaKit server manager."""
from __future__ import annotations from __future__ import annotations
import json
import os import os
import signal
import subprocess
import sys import sys
import platform
import time import time
import json
import signal
import tomllib
import subprocess
from pathlib import Path from pathlib import Path
from threading import Lock, Thread, current_thread from threading import Lock, Thread, current_thread
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
# We make use of the bacommon and efro packages as well as site-packages # We make use of the bacommon and efro packages as well as site-packages
# included with our bundled Ballistica dist, so we need to add those paths # included with our bundled Ballistica dist, so we need to add those
# before we import them. # paths before we import them.
sys.path += [ sys.path += [
str(Path(Path(__file__).parent, 'dist', 'ba_data', 'python')), str(Path(Path(__file__).parent, 'dist', 'ba_data', 'python')),
str(Path(Path(__file__).parent, 'dist', 'ba_data', 'python-site-packages')), str(Path(Path(__file__).parent, 'dist', 'ba_data', 'python-site-packages')),
@ -32,34 +33,68 @@ if TYPE_CHECKING:
from types import FrameType from types import FrameType
from bacommon.servermanager import ServerCommand from bacommon.servermanager import ServerCommand
VERSION_STR = '1.3.1' VERSION_STR = '1.3.2'
# Version history: # Version history:
#
# 1.3.2
#
# - Updated to use Python 3.12.
#
# - Server config file is now in toml format instead of yaml.
#
# - Server config can now be set to a .json file OR a .toml file.
# By default it will look for 'config.json' and then 'config.toml'
# in the same dir as this script.
#
# 1.3.1 # 1.3.1
# Windows binary is now named BallisticaKitHeadless.exe #
# - Windows binary is now named 'BallisticaKitHeadless.exe'.
#
# 1.3: # 1.3:
# Added show_tutorial config option #
# Added team_names config option # - Added show_tutorial config option.
# Added team_colors config option #
# Added playlist_inline config option # - Added team_names config option.
#
# - Added team_colors config option.
#
# - Added playlist_inline config option.
#
# 1.2: # 1.2:
# Added optional --help arg #
# Added --config arg for specifying config file and --root for ba_root path # - Added optional --help arg.
# Added noninteractive mode and --interactive/--noninteractive args to #
# explicitly enable/disable it (it is autodetected by default) # - Added --config arg for specifying config file and --root for
# Added explicit control for auto-restart: --no-auto-restart # ba_root path.
# Config file is now reloaded each time server binary is restarted; no more #
# need to bring down server wrapper to pick up changes # - Added noninteractive mode and --interactive/--noninteractive args
# Now automatically restarts server binary when config file is modified # to explicitly enable/disable it (it is autodetected by default).
# (use --no-config-auto-restart to disable that behavior) #
# - Added explicit control for auto-restart: --no-auto-restart.
#
# - Config file is now reloaded each time server binary is restarted;
# no more need to bring down server wrapper to pick up changes.
#
# - Now automatically restarts server binary when config file is
# modified (use --no-config-auto-restart to disable that behavior).
#
# 1.1.1: # 1.1.1:
# Switched config reading to use efro.dataclasses.dataclass_from_dict() #
# - Switched config reading to use
# efro.dataclasses.dataclass_from_dict().
#
# 1.1.0: # 1.1.0:
# Added shutdown command #
# Changed restart to default to immediate=True # - Added shutdown command.
# Added clean_exit_minutes, unclean_exit_minutes, and idle_exit_minutes #
# - Changed restart to default to immediate=True.
#
# - Added clean_exit_minutes, unclean_exit_minutes, and idle_exit_minutes.
#
# 1.0.0: # 1.0.0:
# Initial release #
# - Initial release.
class ServerManagerApp: class ServerManagerApp:
@ -74,8 +109,7 @@ class ServerManagerApp:
IMMEDIATE_SHUTDOWN_TIME_LIMIT = 5.0 IMMEDIATE_SHUTDOWN_TIME_LIMIT = 5.0
def __init__(self) -> None: def __init__(self) -> None:
self._config_path = 'config.yaml' self._user_provided_config_path: str | None = None
self._user_provided_config_path = False
self._config = ServerConfig() self._config = ServerConfig()
self._ba_root_path = os.path.abspath('dist/ba_root') self._ba_root_path = os.path.abspath('dist/ba_root')
self._interactive = sys.stdin.isatty() self._interactive = sys.stdin.isatty()
@ -98,12 +132,14 @@ class ServerManagerApp:
self._subprocess_sent_unclean_exit = False self._subprocess_sent_unclean_exit = False
self._subprocess_thread: Thread | None = None self._subprocess_thread: Thread | None = None
self._subprocess_exited_cleanly: bool | None = None self._subprocess_exited_cleanly: bool | None = None
self._did_multi_config_warning = False
# This may override the above defaults. # This may override the above defaults.
self._parse_command_line_args() self._parse_command_line_args()
# Do an initial config-load. If the config is invalid at this point # Do an initial config-load. If the config is invalid at this
# we can cleanly die (we're more lenient later on reloads). # point we can cleanly die; we're more resilient later on reload
# attempts.
self.load_config(strict=True, print_confirmation=False) self.load_config(strict=True, print_confirmation=False)
@property @property
@ -133,8 +169,9 @@ class ServerManagerApp:
# Python will handle SIGINT for us (as KeyboardInterrupt) but we # Python will handle SIGINT for us (as KeyboardInterrupt) but we
# need to register a SIGTERM handler so we have a chance to clean # need to register a SIGTERM handler so we have a chance to clean
# up our subprocess when someone tells us to die. (and avoid # need to register a SIGTERM handler so we have a chance to
# zombie processes) # clean up our subprocess when someone tells us to die. (and
# avoid zombie processes)
signal.signal(signal.SIGTERM, self._handle_term_signal) signal.signal(signal.SIGTERM, self._handle_term_signal)
# During a run, we make the assumption that cwd is the dir # During a run, we make the assumption that cwd is the dir
@ -156,7 +193,8 @@ class ServerManagerApp:
f'{Clr.CYN}Waiting for subprocess exit...{Clr.RST}', flush=True f'{Clr.CYN}Waiting for subprocess exit...{Clr.RST}', flush=True
) )
# Mark ourselves as shutting down and wait for the process to wrap up. # Mark ourselves as shutting down and wait for the process to
# wrap up.
self._done = True self._done = True
self._subprocess_thread.join() self._subprocess_thread.join()
@ -182,9 +220,10 @@ class ServerManagerApp:
# Gracefully bow out if we kill ourself via keyboard. # Gracefully bow out if we kill ourself via keyboard.
pass pass
except SystemExit: except SystemExit:
# We get this from the builtin quit(), our signal handler, etc. # We get this from the builtin quit(), our signal handler,
# Need to catch this so we can clean up, otherwise we'll be # etc. Need to catch this so we can clean up, otherwise
# left in limbo with our process thread still running. # we'll be left in limbo with our process thread still
# running.
pass pass
self._postrun() self._postrun()
@ -208,14 +247,17 @@ class ServerManagerApp:
self._enable_tab_completion(context) self._enable_tab_completion(context)
# Now just sit in an interpreter. # Now just sit in an interpreter.
# TODO: make it possible to use IPython if the user has it available. #
# TODO: make it possible to use IPython if the user has it
# available.
try: try:
self._interpreter_start_time = time.time() self._interpreter_start_time = time.time()
code.interact(local=context, banner='', exitmsg='') code.interact(local=context, banner='', exitmsg='')
except SystemExit: except SystemExit:
# We get this from the builtin quit(), our signal handler, etc. # We get this from the builtin quit(), our signal handler,
# Need to catch this so we can clean up, otherwise we'll be # etc. Need to catch this so we can clean up, otherwise
# left in limbo with our process thread still running. # we'll be left in limbo with our process thread still
# running.
pass pass
except BaseException as exc: except BaseException as exc:
print( print(
@ -239,19 +281,21 @@ class ServerManagerApp:
self._block_for_command_completion() self._block_for_command_completion()
def _block_for_command_completion(self) -> None: def _block_for_command_completion(self) -> None:
# Ideally we'd block here until the command was run so our prompt would # Ideally we'd block here until the command was run so our
# print after it's results. We currently don't get any response from # prompt would print after it's results. We currently don't get
# the app so the best we can do is block until our bg thread has sent # any response from the app so the best we can do is block until
# it. In the future we can perhaps add a proper 'command port' # our bg thread has sent it. In the future we can perhaps add a
# interface for proper blocking two way communication. # proper 'command port' interface for proper blocking two way
# communication.
while True: while True:
with self._subprocess_commands_lock: with self._subprocess_commands_lock:
if not self._subprocess_commands: if not self._subprocess_commands:
break break
time.sleep(0.1) time.sleep(0.1)
# One last short delay so if we come out *just* as the command is sent # One last short delay so if we come out *just* as the command
# we'll hopefully still give it enough time to process/print. # is sent we'll hopefully still give it enough time to
# process/print.
time.sleep(0.1) time.sleep(0.1)
def screenmessage( def screenmessage(
@ -321,8 +365,8 @@ class ServerManagerApp:
) )
) )
# If we're asking for an immediate restart but don't get one within # If we're asking for an immediate restart but don't get one
# the grace period, bring down the hammer. # within the grace period, bring down the hammer.
if immediate: if immediate:
self._subprocess_force_kill_time = ( self._subprocess_force_kill_time = (
time.time() + self.IMMEDIATE_SHUTDOWN_TIME_LIMIT time.time() + self.IMMEDIATE_SHUTDOWN_TIME_LIMIT
@ -341,12 +385,12 @@ class ServerManagerApp:
ShutdownCommand(reason=ShutdownReason.NONE, immediate=immediate) ShutdownCommand(reason=ShutdownReason.NONE, immediate=immediate)
) )
# An explicit shutdown means we know to bail completely once this # An explicit shutdown means we know to bail completely once
# subprocess completes. # this subprocess completes.
self._wrapper_shutdown_desired = True self._wrapper_shutdown_desired = True
# If we're asking for an immediate shutdown but don't get one within # If we're asking for an immediate shutdown but don't get one
# the grace period, bring down the hammer. # within the grace period, bring down the hammer.
if immediate: if immediate:
self._subprocess_force_kill_time = ( self._subprocess_force_kill_time = (
time.time() + self.IMMEDIATE_SHUTDOWN_TIME_LIMIT time.time() + self.IMMEDIATE_SHUTDOWN_TIME_LIMIT
@ -372,16 +416,16 @@ class ServerManagerApp:
raise CleanError(f"Supplied path does not exist: '{path}'.") raise CleanError(f"Supplied path does not exist: '{path}'.")
# We need an abs path because we may be in a different # We need an abs path because we may be in a different
# cwd currently than we will be during the run. # cwd currently than we will be during the run.
self._config_path = os.path.abspath(path) self._user_provided_config_path = os.path.abspath(path)
self._user_provided_config_path = True
i += 2 i += 2
elif arg == '--root': elif arg == '--root':
if i + 1 >= argc: if i + 1 >= argc:
raise CleanError('Expected a path as next arg.') raise CleanError('Expected a path as next arg.')
path = sys.argv[i + 1] path = sys.argv[i + 1]
# Unlike config_path, this one doesn't have to exist now. # Unlike config_path, this one doesn't have to exist
# We do however need an abs path because we may be in a # now. We do however need an abs path because we may be
# different cwd currently than we will be during the run. # in a different cwd currently than we will be during
# the run.
self._ba_root_path = os.path.abspath(path) self._ba_root_path = os.path.abspath(path)
i += 2 i += 2
elif arg == '--interactive': elif arg == '--interactive':
@ -440,11 +484,9 @@ class ServerManagerApp:
+ cls._par( + cls._par(
'Set the config file read by the server script. The config' 'Set the config file read by the server script. The config'
' file contains most options for what kind of game to host.' ' file contains most options for what kind of game to host.'
' It should be in yaml format. Note that yaml is backwards' ' It should be in toml or json format. If not specified,'
' compatible with json so you can just write json if you' ' the script will look for a file named \'config.toml\' or'
' want to. If not specified, the script will look for a' ' \'config.json\' in the same directory as the script.'
' file named \'config.yaml\' in the same directory as the'
' script.'
) )
+ '\n' + '\n'
f'{Clr.BLD}--root [path]{Clr.RST}\n' f'{Clr.BLD}--root [path]{Clr.RST}\n'
@ -513,11 +555,6 @@ class ServerManagerApp:
f'{Clr.RED}Error loading config file:\n{exc}.{Clr.RST}', f'{Clr.RED}Error loading config file:\n{exc}.{Clr.RST}',
flush=True, flush=True,
) )
with open(self._ba_root_path + "/mods/defaults/config.yaml", "r") as infile:
default_file = infile.read()
with open(self._config_path, "w") as outfile:
outfile.write(default_file)
print("config reset done")
if trynum == maxtries - 1: if trynum == maxtries - 1:
print( print(
f'{Clr.RED}Max-tries reached; giving up.' f'{Clr.RED}Max-tries reached; giving up.'
@ -539,20 +576,51 @@ class ServerManagerApp:
return return
time.sleep(1) time.sleep(1)
def _get_config_path(self) -> str:
if self._user_provided_config_path is not None:
return self._user_provided_config_path
# Otherwise look for config.toml or config.json in the same dir
# as our script. Need to work in abs paths since we may chdir when
# we start running.
toml_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), 'config.toml')
)
toml_exists = os.path.exists(toml_path)
json_path = os.path.abspath(
os.path.join(os.path.dirname(__file__), 'config.json')
)
json_exists = os.path.exists(json_path)
# Warn if both configs are present.
if toml_exists and json_exists and not self._did_multi_config_warning:
self._did_multi_config_warning = True
print(
f'{Clr.YLW}Both config.toml and config.json'
f' found; will use json.{Clr.RST}',
flush=True,
)
if json_exists:
return json_path
return toml_path
def _load_config_from_file(self, print_confirmation: bool) -> ServerConfig: def _load_config_from_file(self, print_confirmation: bool) -> ServerConfig:
out: ServerConfig | None = None out: ServerConfig | None = None
if not os.path.exists(self._config_path): config_path = self._get_config_path()
if not os.path.exists(config_path):
# Special case: # Special case:
# If the user didn't specify a particular config file, allow #
# gracefully falling back to defaults if the default one is # If the user didn't provide a config path AND the default
# missing. # config path does not exist, fall back to defaults.
if not self._user_provided_config_path: if not self._user_provided_config_path:
if print_confirmation: if print_confirmation:
print( print(
f'{Clr.YLW}Default config file not found' f'{Clr.YLW}Default config file not found'
f' (\'{self._config_path}\'); using default' f' (\'{config_path}\'); using default'
f' settings.{Clr.RST}', f' config.{Clr.RST}',
flush=True, flush=True,
) )
self._config_mtime = None self._config_mtime = None
@ -560,25 +628,25 @@ class ServerManagerApp:
return ServerConfig() return ServerConfig()
# Don't be so lenient if the user pointed us at one though. # Don't be so lenient if the user pointed us at one though.
raise RuntimeError(f"Config file not found: '{self._config_path}'.") raise RuntimeError(f"Config file not found: '{config_path}'.")
import yaml with open(config_path, encoding='utf-8') as infile:
if config_path.endswith('.toml'):
user_config_raw = tomllib.loads(infile.read())
elif config_path.endswith('.json'):
user_config_raw = json.loads(infile.read())
else:
raise CleanError(
f"Invalid config file path '{config_path}';"
f" path must end with '.toml' or '.json'."
)
with open(self._config_path, encoding='utf-8') as infile: out = dataclass_from_dict(ServerConfig, user_config_raw)
user_config_raw = yaml.safe_load(infile.read())
# An empty config file will yield None, and that's ok.
if user_config_raw is not None:
out = dataclass_from_dict(ServerConfig, user_config_raw)
# Update our known mod-time since we know it exists. # Update our known mod-time since we know it exists.
self._config_mtime = Path(self._config_path).stat().st_mtime self._config_mtime = Path(config_path).stat().st_mtime
self._last_config_mtime_check_time = time.time() self._last_config_mtime_check_time = time.time()
# Go with defaults if we weren't able to load anything.
if out is None:
out = ServerConfig()
if print_confirmation: if print_confirmation:
print( print(
f'{Clr.CYN}Valid server config file loaded.{Clr.RST}', f'{Clr.CYN}Valid server config file loaded.{Clr.RST}',
@ -612,24 +680,26 @@ class ServerManagerApp:
"""Spin up the server subprocess and run it until exit.""" """Spin up the server subprocess and run it until exit."""
# pylint: disable=consider-using-with # pylint: disable=consider-using-with
# Reload our config, and update our overall behavior based on it. # Reload our config, and update our overall behavior based on
# We do non-strict this time to give the user repeated attempts if # it. We do non-strict this time to give the user repeated
# if they mess up while modifying the config on the fly. # attempts if if they mess up while modifying the config on the
# fly.
self.load_config(strict=False, print_confirmation=True) self.load_config(strict=False, print_confirmation=True)
self._prep_subprocess_environment() self._prep_subprocess_environment()
# Launch the binary and grab its stdin; # Launch the binary and grab its stdin; we'll use this to feed
# we'll use this to feed it commands. # it commands.
self._subprocess_launch_time = time.time() self._subprocess_launch_time = time.time()
# Set an environment var so the server process knows its being # Set an environment var so the server process knows its being
# run under us. This causes it to ignore ctrl-c presses and other # run under us. This causes it to ignore ctrl-c presses and
# slight behavior tweaks. Hmm; should this be an argument instead? # other slight behavior tweaks. Hmm; should this be an argument
# instead?
os.environ['BA_SERVER_WRAPPER_MANAGED'] = '1' os.environ['BA_SERVER_WRAPPER_MANAGED'] = '1'
# Set an environment var to change the device name. # Set an environment var to change the device name. Device name
# Device name is used while making connection with master server, # is used while making connection with master server,
# cloud-console recognize us with this name. # cloud-console recognize us with this name.
os.environ['BA_DEVICE_NAME'] = self._config.party_name os.environ['BA_DEVICE_NAME'] = self._config.party_name
@ -671,9 +741,10 @@ class ServerManagerApp:
assert self._subprocess_exited_cleanly is not None assert self._subprocess_exited_cleanly is not None
# EW: it seems that if we die before the main thread has fully started # EW: it seems that if we die before the main thread has fully
# up the interpreter, its possible that it will not break out of its # started up the interpreter, its possible that it will not
# loop via the usual SystemExit that gets sent when we die. # break out of its loop via the usual SystemExit that gets sent
# when we die.
if self._interactive: if self._interactive:
while ( while (
self._interpreter_start_time is None self._interpreter_start_time is None
@ -702,8 +773,8 @@ class ServerManagerApp:
# tell the main thread to die. # tell the main thread to die.
if self._wrapper_shutdown_desired: if self._wrapper_shutdown_desired:
# Only do this if the main thread is not already waiting for # Only do this if the main thread is not already waiting for
# us to die; otherwise it can lead to deadlock. # us to die; otherwise it can lead to deadlock. (we hang in
# (we hang in os.kill while main thread is blocked in Thread.join) # os.kill while main thread is blocked in Thread.join)
if not self._done: if not self._done:
self._done = True self._done = True
@ -729,6 +800,8 @@ class ServerManagerApp:
bincfg['Auto Balance Teams'] = self._config.auto_balance_teams bincfg['Auto Balance Teams'] = self._config.auto_balance_teams
bincfg['Show Tutorial'] = self._config.show_tutorial bincfg['Show Tutorial'] = self._config.show_tutorial
if self._config.protocol_version is not None:
bincfg['SceneV1 Host Protocol'] = self._config.protocol_version
if self._config.team_names is not None: if self._config.team_names is not None:
bincfg['Custom Team Names'] = self._config.team_names bincfg['Custom Team Names'] = self._config.team_names
elif 'Custom Team Names' in bincfg: elif 'Custom Team Names' in bincfg:
@ -777,8 +850,8 @@ class ServerManagerApp:
assert current_thread() is self._subprocess_thread assert current_thread() is self._subprocess_thread
assert self._subprocess.stdin is not None assert self._subprocess.stdin is not None
# Send the initial server config which should kick things off. # Send the initial server config which should kick things off
# (but make sure its values are still valid first) # (but make sure its values are still valid first).
dataclass_validate(self._config) dataclass_validate(self._config)
self._send_server_command(StartServerModeCommand(self._config)) self._send_server_command(StartServerModeCommand(self._config))
@ -790,8 +863,8 @@ class ServerManagerApp:
# Pass along any commands to our process. # Pass along any commands to our process.
with self._subprocess_commands_lock: with self._subprocess_commands_lock:
for incmd in self._subprocess_commands: for incmd in self._subprocess_commands:
# If we're passing a raw string to exec, no need to wrap it # If we're passing a raw string to exec, no need to
# in any proper structure. # wrap it in any proper structure.
if isinstance(incmd, str): if isinstance(incmd, str):
self._subprocess.stdin.write((incmd + '\n').encode()) self._subprocess.stdin.write((incmd + '\n').encode())
self._subprocess.stdin.flush() self._subprocess.stdin.flush()
@ -802,9 +875,9 @@ class ServerManagerApp:
# Request restarts/shut-downs for various reasons. # Request restarts/shut-downs for various reasons.
self._request_shutdowns_or_restarts() self._request_shutdowns_or_restarts()
# If they want to force-kill our subprocess, simply exit this # If they want to force-kill our subprocess, simply exit
# loop; the cleanup code will kill the process if its still # this loop; the cleanup code will kill the process if its
# alive. # still alive.
if ( if (
self._subprocess_force_kill_time is not None self._subprocess_force_kill_time is not None
and time.time() > self._subprocess_force_kill_time and time.time() > self._subprocess_force_kill_time
@ -850,8 +923,9 @@ class ServerManagerApp:
): ):
self._last_config_mtime_check_time = now self._last_config_mtime_check_time = now
mtime: float | None mtime: float | None
if os.path.isfile(self._config_path): config_path = self._get_config_path()
mtime = Path(self._config_path).stat().st_mtime if os.path.isfile(config_path):
mtime = Path(config_path).stat().st_mtime
else: else:
mtime = None mtime = None
if mtime != self._config_mtime: if mtime != self._config_mtime:
@ -863,8 +937,8 @@ class ServerManagerApp:
self.restart(immediate=True) self.restart(immediate=True)
self._subprocess_sent_config_auto_restart = True self._subprocess_sent_config_auto_restart = True
# Attempt clean exit if our clean-exit-time passes. # Attempt clean exit if our clean-exit-time passes (and enforce
# (and enforce a 6 hour max if not provided) # a 6 hour max if not provided).
clean_exit_minutes = 360.0 clean_exit_minutes = 360.0
if self._config.clean_exit_minutes is not None: if self._config.clean_exit_minutes is not None:
clean_exit_minutes = min( clean_exit_minutes = min(
@ -889,8 +963,8 @@ class ServerManagerApp:
self.shutdown(immediate=False) self.shutdown(immediate=False)
self._subprocess_sent_clean_exit = True self._subprocess_sent_clean_exit = True
# Attempt unclean exit if our unclean-exit-time passes. # Attempt unclean exit if our unclean-exit-time passes (and
# (and enforce a 7 hour max if not provided) # enforce a 7 hour max if not provided).
unclean_exit_minutes = 420.0 unclean_exit_minutes = 420.0
if self._config.unclean_exit_minutes is not None: if self._config.unclean_exit_minutes is not None:
unclean_exit_minutes = min( unclean_exit_minutes = min(
@ -932,8 +1006,8 @@ class ServerManagerApp:
print(f'{Clr.CYN}Stopping subprocess...{Clr.RST}', flush=True) print(f'{Clr.CYN}Stopping subprocess...{Clr.RST}', flush=True)
# First, ask it nicely to die and give it a moment. # First, ask it nicely to die and give it a moment. If that
# If that doesn't work, bring down the hammer. # doesn't work, bring down the hammer.
self._subprocess.terminate() self._subprocess.terminate()
try: try:
self._subprocess.wait(timeout=10) self._subprocess.wait(timeout=10)
@ -949,8 +1023,9 @@ def main() -> None:
try: try:
ServerManagerApp().run() ServerManagerApp().run()
except CleanError as exc: except CleanError as exc:
# For clean errors, do a simple print and fail; no tracebacks/etc. # For clean errors, do a simple print and fail; no
# Any others will bubble up and give us the usual mess. # tracebacks/etc. Any others will bubble up and give us the
# usual mess.
exc.pretty_print() exc.pretty_print()
sys.exit(1) sys.exit(1)

Binary file not shown.