ssbu_lokrez/lokrez/__init__.py

993 lines
30 KiB
Python
Raw Normal View History

2020-07-06 19:54:42 -04:00
import argparse
import configparser
2021-07-03 14:23:48 -04:00
import copy
import datetime
import html
2021-07-03 14:23:48 -04:00
import io
import json
2020-07-06 19:54:42 -04:00
import logging
import pathlib
2021-07-03 14:23:48 -04:00
import pprint
import requests
2020-07-06 19:54:42 -04:00
import sys
import urllib
2020-07-06 19:54:42 -04:00
import appdirs
from . import export
from . import resources
from . import smashgg
from . import version
from .games import ssbu, pplus, melee
2020-07-20 17:54:32 -04:00
# =============================================================================
__version__ = version.__version__
__license__ = version.__license__
ROOTDIR = pathlib.Path(__file__).absolute().parent
2020-07-06 19:54:42 -04:00
APPDIRS = appdirs.AppDirs(version.NAME, version.ENTITY)
LOG_DUMMY = logging.getLogger("dummy")
LOG_DUMMY.addHandler(logging.NullHandler())
DEFAULT_DIR_TEMPLATES = ROOTDIR / "templates"
2021-07-03 14:23:48 -04:00
# =============================================================================
class StoreOptionKeyPair(argparse.Action):
def __init__(
self,
option_strings,
dest,
nargs=1,
const=None,
default=None,
type=None,
choices=None,
required=False,
help=None,
metavar="KEY=VALUE",
):
if nargs == 0:
raise ValueError(
'nargs for append actions must be > 0; if arg '
'strings are not supplying the value to append, '
'the append const action may be more appropriate'
)
if const is not None and nargs != '?':
raise ValueError('nargs must be %r to supply const' % '?')
super(StoreOptionKeyPair, self).__init__(
option_strings=option_strings,
dest=dest,
nargs=nargs,
const=const,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar,
)
def __call__(self, parser, namespace, values, option_string=None):
try:
options = getattr(namespace, self.dest)
except AttributeError:
options = {}
for kv in values:
try:
k,v = kv.split("=")
except ValueError:
k = kv
v = True
options[k] = v
setattr(namespace, self.dest, options)
# =============================================================================
def get_templates_list(
dir_templates = DEFAULT_DIR_TEMPLATES,
):
templates_list = []
dir_templates_path = pathlib.Path(dir_templates)
for potential_template in dir_templates_path.iterdir():
if (potential_template / "template.svg.j2").is_file():
templates_list.append(potential_template.name)
return templates_list
2021-07-03 14:23:48 -04:00
# =============================================================================
def get_infos_from_file(
lkrz_file_path,
options = {},
outform = "dict",
log = LOG_DUMMY,
):
if not lkrz_file_path.exists():
raise IOError( "lkrz file '{}' does not exist" \
.format(str(lkrz_file_path)) )
lkrz = configparser.ConfigParser()
lkrz.read(str(lkrz_file_path))
log.info("Loading data from '{}'".format(str(lkrz_file_path)))
2021-11-28 17:58:08 -05:00
top_players = {}
tournament = None
for s in lkrz:
2021-07-03 14:23:48 -04:00
section = lkrz[s]
if s == "Tournament":
tournament = smashgg.Tournament(
id = 0,
name = section["name"],
game = section["game"],
slug = section["slug"],
startAt = datetime.datetime.strptime(
section["date"],
"%Y-%m-%d %H:%M:%S",
),
numEntrants = int(section["numEntrants"]),
venueName = section["location"],
) \
.clean_name(
options.get("name_seo_delimiter", None)
)
elif s.startswith("player "):
chars = {}
for char in section["characters"].split(","):
c = char.strip()
charname = c.split("_")[0]
charskin = c.split("_")[1].split(" ")[0]
charscore = float(c.split("(")[1].split(")")[0])
chars[(charname,charskin)] = charscore
player = smashgg.Player(
id = 0,
prefix = section["team"],
gamerTag = section["tag"],
placement = section["placement"],
seeding = section["seeding"],
twitterHandle = section["twitter"],
chars = chars,
)
top_players[player.gamerTag] = player
# Re-sort top players by their placement
top_players = sorted(
top_players.values(),
key = lambda p: p.placement,
)
return format_infos(
outform,
tournament,
top_players,
)
# =============================================================================
def get_infos_from_url(
url,
token,
options = {},
outform = "dict",
top = 8,
proxy = None,
log = LOG_DUMMY,
):
url_parsed = urllib.parse.urlparse(url)
if url_parsed.netloc not in [ "smash.gg", "start.gg", "www.smash.gg", "www.start.gg" ]:
raise ValueError("Unsupported domain name")
if outform not in [ "dict", "lkrz" ]:
raise ValueError("Unsupported outform")
2021-07-03 14:23:48 -04:00
# -------------------------------------------------------------------------
tournament = None
event = None
# -------------------------------------------------------------------------
if url_parsed.netloc in [ "smash.gg", "start.gg", "www.smash.gg", "www.start.gg" ]:
if (url_parsed.path.split("/")[1] != "tournament"):
2021-07-03 14:23:48 -04:00
log.error("Incomplete URL '{}'".format(url))
raise Exception("No tournament found in url {}".format(url))
2021-07-03 14:23:48 -04:00
try:
tournament = url_parsed.path.split("/")[2]
except:
log.error("Incomplete URL '{}'".format(url))
raise Exception("No tournament slug found in url {}".format(url))
2021-07-03 14:23:48 -04:00
try:
if (url_parsed.path.split("/")[3] == "event"):
event = url_parsed.path.split("/")[4]
except:
log.info("No event slug found in url")
# -------------------------------------------------------------------------
return get_infos_from_id_or_slug(
id_or_slug = tournament,
event_slug = event,
token = token,
options = options,
outform = outform,
top = top,
proxy = proxy,
log = log,
)
# =============================================================================
def get_infos_from_id_or_slug(
id_or_slug,
event_slug = None,
token = "",
options = [],
outform = "dict",
top = 8,
proxy = None,
log = LOG_DUMMY,
):
# Get infos from smash.gg and write the config file
tournament, top_players = getTournamentTop(
id_or_slug = id_or_slug,
event_slug = event_slug,
import_options = options,
top = top,
token = token,
proxy = proxy,
log = log,
)
if tournament is None or top_players is None:
log.error("Could not load data from smash.gg")
raise Exception("Could not load data from smash.gg")
return format_infos(outform, tournament, top_players)
# =============================================================================
def format_infos(
outform,
tournament,
top_players,
):
# -------------------------------------------------------------------------
if outform == "dict":
return {
"tournament": tournament,
"players": top_players,
}
# -------------------------------------------------------------------------
if outform == "lkrz":
return "\n".join(
[ tournament.conf() ] \
+ list(map(
lambda p:p.conf(),
top_players,
))
)
# =============================================================================
def init_resources(
imgdir,
game,
source = None,
store_raw = False,
proxy = None,
log = LOG_DUMMY,
):
# Create imgdir
imgdir.mkdir(parents=True, exist_ok=True)
# Start resources download according to game
if game in ssbu.GAME.list_names():
2021-07-03 14:23:48 -04:00
game = ssbu
elif game in melee.GAME.list_names():
2021-07-03 14:23:48 -04:00
game = melee
elif game in pplus.GAME.list_names():
2021-07-03 14:23:48 -04:00
game = pplus
else:
log.error("Unknown game '{}'".format(game))
return 1
2021-07-03 14:23:48 -04:00
resources.download_res(
dstdir = imgdir,
game = game,
source = source,
store_raw = store_raw,
proxy = proxy,
log = log,
)
return 0
# =============================================================================
def generate_pic(
infos_or_lkrzfile = None,
template = None,
outform = "svg",
options = {},
dir_templates = DEFAULT_DIR_TEMPLATES,
dir_res = None,
dir_cache = None,
log = LOG_DUMMY,
):
2021-07-03 14:23:48 -04:00
if outform.startswith("."):
outform = outform[1:]
if outform not in ["svg", "png"]:
raise Exception("Unsupported outform")
if type(infos_or_lkrzfile) == str:
2021-07-03 14:23:48 -04:00
# load lkrz as dict infos
infos = get_infos_from_file(
lkrz_file_path = pathlib.Path(infos_or_lkrzfile),
options = options,
outform = "dict",
log = log,
)
else:
infos = infos_or_lkrzfile
# -------------------------------------------------------------------------
# Build the context which will be passed to the template
context = {
"tournament": infos["tournament"].clean_name(
options.get(
"name_seo_delimiter",
None
)
),
"players" : sorted(
infos["players"],
key = lambda p: p.placement,
),
"dir_res_ssbu": dir_res,
"dir_template": str(dir_templates/template),
2021-07-03 14:23:48 -04:00
"options": options,
}
pic = export.generate_pic(
dir_templates,
template,
context,
outform,
log = log,
cachedir = dir_cache,
2021-07-03 14:23:48 -04:00
options = options,
)
if pic is None:
raise Exception("Failed to generate pic")
return pic
2020-07-06 19:54:42 -04:00
# =============================================================================
def main():
# -------------------------------------------------------------------------
parser = argparse.ArgumentParser(
formatter_class = argparse.ArgumentDefaultsHelpFormatter,
)
2020-07-06 19:54:42 -04:00
subparsers = parser.add_subparsers(
dest = "command",
help = "commands",
)
parser.add_argument(
"--proxy", "-p",
default = None,
help = "the proxy to use",
)
2020-07-06 19:54:42 -04:00
# -------------------------------------------------------------------------
init_parser = subparsers.add_parser(
"init",
formatter_class = argparse.ArgumentDefaultsHelpFormatter,
)
init_parser.add_argument(
"game",
default = "ssbu",
help = "The game you want to initialize the resources for",
)
init_parser.add_argument(
"--source", "-s",
default = None,
choices = ["spriters", "smashlyon"],
help = "From where should the resources images be downloaded",
)
init_parser.add_argument(
"--imgdir", "-ID",
type = pathlib.Path,
default = pathlib.Path(APPDIRS.user_data_dir) / "res",
help = "The directory we should download the resources to",
)
init_parser.add_argument(
"--raw", "-r",
action = "store_true",
help = "Download the raw zipfiles instead of extracting them",
)
2020-07-06 19:54:42 -04:00
# -------------------------------------------------------------------------
top8_parser = subparsers.add_parser(
"top8",
formatter_class = argparse.ArgumentDefaultsHelpFormatter,
2020-07-06 19:54:42 -04:00
)
top8_parser.add_argument(
"tournament",
default = None,
help = "The tournament url, slug or id",
2020-07-06 19:54:42 -04:00
)
top8_parser.add_argument(
"--token", "-t",
default = None,
help = "the authentication token to use ; needed if you're " \
"generating the top8 from a smash.gg url",
)
top8_parser.add_argument(
"--playerskinsdb", "-P",
type = (lambda s: s if s.startswith("http") else pathlib.Path(s)),
default = ROOTDIR / "data" / "playerskinsdb.json",
help = "A JSON file path or url matching player tags, characters,"\
" sponsors, and preferred skins",
)
top8_parser.add_argument(
"--imgdir", "-ID",
type = pathlib.Path,
default = pathlib.Path(APPDIRS.user_data_dir) / "res",
help = "The directories containing images, be careful whether " \
"you specify an absolute path or a relative one.",
)
2020-08-28 06:49:55 -04:00
top8_parser.add_argument(
"--cachedir", "-CD",
type = pathlib.Path,
default = pathlib.Path(APPDIRS.user_cache_dir),
help = "A directory to use for temporary files",
)
top8_parser.add_argument(
"--templatesdir", "-TD",
type = pathlib.Path,
default = DEFAULT_DIR_TEMPLATES,
help = "The local result templates directory",
)
2020-07-06 19:54:42 -04:00
top8_parser.add_argument(
"--template", "-T",
default = "rebootlyon2020",
2020-07-06 19:54:42 -04:00
help = "The local result template to use",
)
top8_parser.add_argument(
"--template-options", "-TO",
2021-07-03 14:23:48 -04:00
nargs="+",
action = StoreOptionKeyPair,
default = {},
help = "Template-specific options (like 'covid' or 'animated')",
)
top8_parser.add_argument(
"--export-options", "-EO",
2021-07-03 14:23:48 -04:00
nargs="+",
action = StoreOptionKeyPair,
default = {},
help = "Export options (like 'svg_embed_png')",
)
2020-07-06 19:54:42 -04:00
top8_parser.add_argument(
"--import-options", "-IO",
2021-07-03 14:23:48 -04:00
nargs="+",
action = StoreOptionKeyPair,
default = {},
help = "Import options (like 'use_smashgg_prefixes')",
2020-07-19 16:26:34 -04:00
)
top8_parser.add_argument(
"--outfile", "-o",
type = pathlib.Path,
default = None,
help = "The SVG or PNG local result file to output to ; if it's " \
"not specified, it will default to SVG and use the " \
"tournament slug as name ; if you're generating a " \
"localresult from a url, a LKRZ file with the same name " \
"will also be generated along the image file (unless you " \
"use the --no-lkrz flag).",
2020-07-19 16:26:34 -04:00
)
parser.add_argument( "--no-lkrz", "-nl",
default = False,
action = "store_true",
help = "Do not output a LKRZ file" )
2020-07-06 19:54:42 -04:00
# -------------------------------------------------------------------------
parser.add_argument( "--verbose", "-v",
default = 0,
action = "count",
help = "increase verbosity" )
parser.add_argument( "--version", "-V",
default = False,
action = "store_true",
help = "show version number" )
# -------------------------------------------------------------------------
args = parser.parse_args()
# Set log level
# -------------------------------------------------------------------------
log = logging.getLogger(version.NAME)
2020-07-06 19:54:42 -04:00
log.setLevel(logging.DEBUG)
log_handler_console = logging.StreamHandler()
log_handler_console.setLevel(logging.WARNING)
if(args.verbose >= 2):
log_handler_console.setLevel(logging.DEBUG)
elif(args.verbose >=1):
log_handler_console.setLevel(logging.INFO)
else:
log_handler_console.setLevel(logging.WARNING)
log_formatter_console = logging.Formatter("%(name)s:%(levelname)s: %(message)s")
log_handler_console.setFormatter(log_formatter_console)
log.addHandler(log_handler_console)
2021-07-03 14:23:48 -04:00
# Print all arguments in debug
# -------------------------------------------------------------------------
log.debug( "Command arguments:\n{}".format( pprint.pformat(vars(args))) )
2020-07-06 19:54:42 -04:00
# Print version if required
# -------------------------------------------------------------------------
if args.version:
print(version.VERSION_NAME)
return 0
2020-07-06 19:54:42 -04:00
# Check if command is recognized
# -------------------------------------------------------------------------
if args.command not in [ "init", "top8" ]:
parser.print_help()
return 1
# -- init
# -------------------------------------------------------------------------
if args.command == "init":
rv = init_resources(
imgdir = args.imgdir,
game = args.game,
source = args.source,
store_raw = args.raw,
proxy = args.proxy,
log = log,
)
return rv
# -- top8
# -------------------------------------------------------------------------
if args.command == "top8":
# Initialize PLAYERSKINS db
log.debug("loading playerskins db from '{}'" \
.format(args.playerskinsdb))
try:
PLAYERSKINS = requests.get(args.playerskinsdb).json()
smashgg.GET_PLAYERDATA = (lambda tag: PLAYERSKINS[tag.lower()])
except:
with args.playerskinsdb.open("r", encoding="utf8") as f:
PLAYERSKINS = json.load(f)
smashgg.GET_PLAYERDATA = (lambda tag: PLAYERSKINS[tag.lower()])
#
tournament = None
top_players = {}
2021-07-03 14:23:48 -04:00
lkrz_file = None
2021-07-03 14:23:48 -04:00
all_options = {
**args.import_options,
**args.template_options,
**args.export_options,
}
2021-07-03 14:23:48 -04:00
# Determine the nature of the 'tournament' argument :
# - url
# - id or slug
# - lkrz file
# url
if ( args.tournament.startswith("http://")
or args.tournament.startswith("https://") ):
infos = get_infos_from_url(
url = args.tournament,
token = args.token,
options = args.import_options,
outform = "dict",
top = 8,
proxy = args.proxy,
log = log,
)
2021-07-03 14:23:48 -04:00
# lkrz file
elif pathlib.Path(args.tournament).exists():
infos = get_infos_from_file(
lkrz_file_path = pathlib.Path(args.tournament),
options = args.import_options,
outform = "dict",
log = log,
)
2021-07-03 14:23:48 -04:00
# id or slug
else:
infos = get_infos_from_id_or_slug(
id_or_slug = args.tournament,
token = args.token,
options = args.import_options,
outform = "dict",
top = 8,
proxy = args.proxy,
log = log,
)
2021-07-03 14:23:48 -04:00
tournament = infos["tournament"]
top_players = infos["players"]
2021-07-03 14:23:48 -04:00
if tournament is None or top_players is None:
log.error("Could not load data")
return 1
# Default outfile is 'tournament-slug.svg'
if args.outfile is None:
args.outfile = pathlib.Path(
"{}.svg".format(tournament.slug),
)
2021-07-03 14:23:48 -04:00
# Save a lkrz file
if not args.no_lkrz:
lkrz_data = format_infos("lkrz", tournament, top_players)
lkrz_file = args.outfile.with_suffix(".lkrz")
with lkrz_file.open("w", encoding="utf8") as f:
f.write(lkrz_data)
# If the outfile we were asked for was a .lkrz, we're done
if args.outfile.suffix == ".lkrz":
return 0
# Otherwise, let's generate the picture file
# First build the context which will be passed to the template
2020-07-22 14:28:42 -04:00
try:
2021-07-03 14:23:48 -04:00
dir_res = (args.imgdir / tournament.game.name).as_uri() # not absolute => error
2020-07-22 14:28:42 -04:00
except ValueError:
2021-07-03 14:23:48 -04:00
dir_res = (args.imgdir / tournament.game.name).as_posix()
pic = generate_pic(
infos_or_lkrzfile = infos,
template = args.template,
outform = args.outfile.suffix,
options = all_options,
dir_templates = args.templatesdir,
dir_res = dir_res,
dir_cache = args.cachedir,
log = log,
)
2021-07-03 14:23:48 -04:00
if pic is None:
return 1
2021-07-03 14:23:48 -04:00
log.info("Saving picture as '{}'".format(args.outfile))
if type(pic) == io.StringIO:
openmode = "w"
else:
openmode = "wb"
with args.outfile.open(openmode) as f:
f.write(pic.read())
return 0
# -----------------------------------------------------------------------------
def getTournamentTop(
id_or_slug,
2021-07-03 14:23:48 -04:00
event_slug = None,
import_options = [],
2021-01-05 08:14:25 -05:00
top = 8,
token = "",
proxy = None,
2021-07-03 14:23:48 -04:00
log=LOG_DUMMY,
):
"""Returns a tuple : the smashgg.Tournament object and a list of the top
smashgg.Player in that tournament."""
# TODO if url matches challonge
#
#data = challonge.get_participants(
# api_key = token,
# tournament = id_or_slug,
# )
#
#top_array = []*top
#for p in data:
# top_array[p["participant"]["final_rank"]] = ...
# -------------------------------------------------------------------------
# Select the right event (the one with the most entrants or the most sets)
def selectBiggestEvent(data, log=LOG_DUMMY):
try:
event = data["events"][0]
except:
log.error("No event found in data")
log.debug(data)
return None
try:
numEntrants = event["numEntrants"]
except KeyError:
numEntrants = event["standings"]["pageInfo"]["total"]
for e in data["events"]:
try:
ne = e["numEntrants"]
except KeyError:
ne = e["standings"]["pageInfo"]["total"]
if ne > numEntrants:
event = e
numEntrants = ne
log.info("Selected Event '{}' with {} entrants" \
.format(
event["name"],
numEntrants,
))
return event
2021-07-03 14:23:48 -04:00
# -------------------------------------------------------------------------
# Select the specified event
def selectEventBySlug(data, slug, log=LOG_DUMMY):
try:
event = data["events"][0]
except:
log.error("No event found in data")
log.debug(data)
return None
for e in data["events"]:
try:
slug_full = e["slug"]
except KeyError:
continue
if ( slug == slug_full
or slug == slug_full.split("/")[-1] ):
log.info("Selected Event '{}' by slug '{}'" \
.format(
e["name"],
slug,
))
return e
log.error("No Event matching slug '{}' found".format(slug))
return None
# -------------------------------------------------------------------------
data = None
try:
data = smashgg.run_query(
query_name = "getTournamentTopById",
variables = {
"id" : int(id_or_slug), # If this fails, it's a slug
"top": top,
},
query_dir = ROOTDIR / "queries",
token = token,
proxy = proxy,
log = log,
)
except ValueError:
data = smashgg.run_query(
query_name = "getTournamentTopBySlug",
variables = {
"slug" : id_or_slug,
"top": top,
},
query_dir = ROOTDIR / "queries",
token = token,
proxy = proxy,
log = log,
)
try:
tournament_data = data["tournament"]
except:
log.error("Failed to load Tournaments")
return None,None
if tournament_data is None:
log.error("Failed to load Tournament")
return None,None
2021-07-03 14:23:48 -04:00
event = None
if event_slug is None:
event = selectBiggestEvent(tournament_data, log)
else:
event = selectEventBySlug(tournament_data, event_slug, log)
if event is None :
return None,None
# Get the tournament
tournament = smashgg.Tournament(
id = tournament_data["id"],
slug = tournament_data["slug"],
2021-07-03 14:23:48 -04:00
game = event["videogame"],
name = tournament_data["name"],
startAt = \
datetime.datetime. \
fromtimestamp(tournament_data["startAt"]),
numEntrants = event["standings"]["pageInfo"]["total"],
venueAddress = tournament_data["venueAddress"],
venueName = tournament_data["venueName"],
city = tournament_data["city"],
countryCode = tournament_data["countryCode"],
hashtag = tournament_data["hashtag"],
2021-07-03 14:23:48 -04:00
) \
.clean_name(
import_options.get("name_seo_delimiter", None)
)
# Get the top players
top_players = {}
standings = event["standings"]["nodes"]
for standing in standings :
seeding = None
2021-07-26 03:51:28 -04:00
seeding32 = None
for seed in standing["entrant"]["seeds"]:
# Take the seeding from the phase with *all* Event entrants
if seed["phase"]["numSeeds"] == tournament.numEntrants:
seeding = seed["groupSeedNum"]
2021-07-26 03:51:28 -04:00
if seed["phase"]["numSeeds"] == 32:
seeding32 = seed["groupSeedNum"]
if seeding is None:
log.info("no global seeding found, using top 32 seeding")
seeding = seeding32
participant_data = standing["entrant"]["participants"][0]
try:
twitterHandle = participant_data \
["player"] \
["user"] \
["authorizations"] \
[0] \
["externalUsername"]
2021-07-03 14:23:48 -04:00
except:
twitterHandle = None
2021-07-03 14:23:48 -04:00
if "use_smashgg_prefixes" in import_options:
prefix = participant_data["prefix"]
else:
prefix = ""
player = smashgg.Player(
id = standing["entrant"]["id"],
prefix = prefix,
gamerTag = participant_data["gamerTag"],
placement = standing["placement"],
seeding = seeding,
twitterHandle = twitterHandle,
)
top_players[player.id] = player
# -------------------------------------------------------------------------
# Now, we need to find which characters those top players chose
data = None
data = smashgg.run_query(
query_name = "getCharsByTournamentIdAndEntrantIds",
variables = {
"tournamentId" : int(tournament.id),
"entrantIds": [ id for id in top_players.keys() ],
},
query_dir = ROOTDIR / "queries",
token = token,
proxy = proxy,
log = log,
)
try:
tournament_data = data["tournament"]
except:
log.error("Failed to load Tournament")
return None,None
if tournament_data is None:
log.error("Failed to load Tournament")
return None,None
2021-07-03 14:23:48 -04:00
event = None
if event_slug is None:
event = selectBiggestEvent(tournament_data, log)
else:
event = selectEventBySlug(tournament_data, event_slug, log)
if event is None :
return None,None
# TODO check that sets number is < to hardcoded 100 max value (cf query)
sets = event["sets"]["nodes"]
for s in sets:
try:
for g in s["games"]:
winnerId = g["winnerId"]
for slct in g["selections"]:
if slct["selectionType"] == "CHARACTER":
eid = slct["entrant"]["id"]
try:
top_players[eid].add_character_selection(
2021-07-03 14:23:48 -04:00
game = tournament.game,
character = slct["selectionValue"],
win = (winnerId == eid),
)
except KeyError:
pass
except TypeError:
# If some games or selections are null, this can happen
continue
# Sort top_players by rank instead of id:
top_players_sorted = sorted(
top_players.values(),
key = lambda p: p.placement,
)
# Return the data
return tournament, top_players_sorted
2020-07-06 19:54:42 -04:00
# =============================================================================
if __name__ == '__main__':
rv = main()
sys.exit(rv)