replace unlogger.py with c++ replay (#22430)
parent
94afd0ea0f
commit
e233d59e03
3 changed files with 23 additions and 612 deletions
@ -1,81 +0,0 @@ |
||||
#!/usr/bin/env python |
||||
# type: ignore |
||||
import sys |
||||
import matplotlib.pyplot as plt |
||||
import numpy as np |
||||
import cereal.messaging as messaging |
||||
import time |
||||
|
||||
# tool to plot one or more signals live. Call ex: |
||||
#./rqplot.py log.carState.vEgo log.carState.aEgo |
||||
|
||||
# TODO: can this tool consume 10x less cpu? |
||||
|
||||
def recursive_getattr(x, name): |
||||
l = name.split('.') |
||||
if len(l) == 1: |
||||
return getattr(x, name) |
||||
else: |
||||
return recursive_getattr(getattr(x, l[0]), ".".join(l[1:]) ) |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
poller = messaging.Poller() |
||||
|
||||
services = [] |
||||
fields = [] |
||||
subs = [] |
||||
values = [] |
||||
|
||||
plt.ion() |
||||
fig, ax = plt.subplots() |
||||
#fig = plt.figure(figsize=(10, 15)) |
||||
#ax = fig.add_subplot(111) |
||||
ax.grid(True) |
||||
fig.canvas.draw() |
||||
|
||||
subs_name = sys.argv[1:] |
||||
lines = [] |
||||
x, y = [], [] |
||||
LEN = 500 |
||||
|
||||
for i, sub in enumerate(subs_name): |
||||
sub_split = sub.split(".") |
||||
services.append(sub_split[0]) |
||||
fields.append(".".join(sub_split[1:])) |
||||
subs.append(messaging.sub_sock(sub_split[0], poller)) |
||||
|
||||
x.append(np.ones(LEN)*np.nan) |
||||
y.append(np.ones(LEN)*np.nan) |
||||
lines.append(ax.plot(x[i], y[i])[0]) |
||||
|
||||
for l in lines: |
||||
l.set_marker("*") |
||||
|
||||
cur_t = 0. |
||||
ax.legend(subs_name) |
||||
ax.set_xlabel('time [s]') |
||||
|
||||
while 1: |
||||
print(1./(time.time() - cur_t)) |
||||
cur_t = time.time() |
||||
for i, s in enumerate(subs): |
||||
msg = messaging.recv_sock(s) |
||||
#msg = messaging.recv_one_or_none(s) |
||||
if msg is not None: |
||||
x[i] = np.append(x[i], getattr(msg, 'logMonoTime') / 1e9) |
||||
x[i] = np.delete(x[i], 0) |
||||
y[i] = np.append(y[i], recursive_getattr(msg, subs_name[i])) |
||||
y[i] = np.delete(y[i], 0) |
||||
|
||||
lines[i].set_xdata(x[i]) |
||||
lines[i].set_ydata(y[i]) |
||||
|
||||
ax.relim() |
||||
ax.autoscale_view(True, scaley=True, scalex=True) |
||||
|
||||
fig.canvas.blit(ax.bbox) |
||||
fig.canvas.flush_events() |
||||
|
||||
# just a bit of wait to avoid 100% CPU usage |
||||
time.sleep(0.001) |
@ -1,500 +0,0 @@ |
||||
#!/usr/bin/env python3 |
||||
import argparse |
||||
import os |
||||
import sys |
||||
import zmq |
||||
import time |
||||
import signal |
||||
import multiprocessing |
||||
from uuid import uuid4 |
||||
from collections import namedtuple |
||||
from collections import deque |
||||
from datetime import datetime |
||||
|
||||
from cereal import log as capnp_log |
||||
from cereal.services import service_list |
||||
from cereal.messaging import pub_sock, MultiplePublishersError |
||||
from cereal.visionipc.visionipc_pyx import VisionIpcServer, VisionStreamType # pylint: disable=no-name-in-module, import-error |
||||
from common import realtime |
||||
from common.transformations.camera import eon_f_frame_size, tici_f_frame_size |
||||
|
||||
from tools.lib.kbhit import KBHit |
||||
from tools.lib.logreader import MultiLogIterator |
||||
from tools.lib.route import Route |
||||
from tools.lib.framereader import rgb24toyuv420 |
||||
from tools.lib.route_framereader import RouteFrameReader |
||||
|
||||
# Commands. |
||||
SetRoute = namedtuple("SetRoute", ("name", "start_time", "data_dir")) |
||||
SeekAbsoluteTime = namedtuple("SeekAbsoluteTime", ("secs",)) |
||||
SeekRelativeTime = namedtuple("SeekRelativeTime", ("secs",)) |
||||
TogglePause = namedtuple("TogglePause", ()) |
||||
StopAndQuit = namedtuple("StopAndQuit", ()) |
||||
VIPC_RGB = "rgb" |
||||
VIPC_YUV = "yuv" |
||||
|
||||
|
||||
class UnloggerWorker(object): |
||||
def __init__(self): |
||||
self._frame_reader = None |
||||
self._cookie = None |
||||
self._readahead = deque() |
||||
|
||||
def run(self, commands_address, data_address, pub_types): |
||||
zmq.Context._instance = None |
||||
commands_socket = zmq.Context.instance().socket(zmq.PULL) |
||||
commands_socket.connect(commands_address) |
||||
|
||||
data_socket = zmq.Context.instance().socket(zmq.PUSH) |
||||
data_socket.connect(data_address) |
||||
|
||||
poller = zmq.Poller() |
||||
poller.register(commands_socket, zmq.POLLIN) |
||||
|
||||
# We can't publish frames without roadEncodeIdx, so add when it's missing. |
||||
if "roadCameraState" in pub_types: |
||||
pub_types["roadEncodeIdx"] = None |
||||
|
||||
# gc.set_debug(gc.DEBUG_LEAK | gc.DEBUG_OBJECTS | gc.DEBUG_STATS | gc.DEBUG_SAVEALL | |
||||
# gc.DEBUG_UNCOLLECTABLE) |
||||
|
||||
# TODO: WARNING pycapnp leaks memory all over the place after unlogger runs for a while, gc |
||||
# pauses become huge because there are so many tracked objects solution will be to switch to new |
||||
# cython capnp |
||||
try: |
||||
route = None |
||||
while True: |
||||
while poller.poll(0.) or route is None: |
||||
cookie, cmd = commands_socket.recv_pyobj() |
||||
route = self._process_commands(cmd, route, pub_types) |
||||
|
||||
# **** get message **** |
||||
self._read_logs(cookie, pub_types) |
||||
self._send_logs(data_socket) |
||||
finally: |
||||
if self._frame_reader is not None: |
||||
self._frame_reader.close() |
||||
data_socket.close() |
||||
commands_socket.close() |
||||
|
||||
def _read_logs(self, cookie, pub_types): |
||||
fullHEVC = capnp_log.EncodeIndex.Type.fullHEVC |
||||
lr = self._lr |
||||
while len(self._readahead) < 1000: |
||||
route_time = lr.tell() |
||||
msg = next(lr) |
||||
typ = msg.which() |
||||
if typ not in pub_types: |
||||
continue |
||||
|
||||
# **** special case certain message types **** |
||||
if typ == "roadEncodeIdx" and msg.roadEncodeIdx.type == fullHEVC: |
||||
# this assumes the roadEncodeIdx always comes before the frame |
||||
self._frame_id_lookup[ |
||||
msg.roadEncodeIdx.frameId] = msg.roadEncodeIdx.segmentNum, msg.roadEncodeIdx.segmentId |
||||
#print "encode", msg.roadEncodeIdx.frameId, len(self._readahead), route_time |
||||
self._readahead.appendleft((typ, msg, route_time, cookie)) |
||||
|
||||
def _send_logs(self, data_socket): |
||||
while len(self._readahead) > 500: |
||||
typ, msg, route_time, cookie = self._readahead.pop() |
||||
smsg = msg.as_builder() |
||||
|
||||
if typ == "roadCameraState": |
||||
frame_id = msg.roadCameraState.frameId |
||||
|
||||
# Frame exists, make sure we have a framereader. |
||||
# load the frame readers as needed |
||||
s1 = time.time() |
||||
try: |
||||
img = self._frame_reader.get(frame_id, pix_fmt="rgb24") |
||||
except Exception: |
||||
img = None |
||||
|
||||
fr_time = time.time() - s1 |
||||
if fr_time > 0.05: |
||||
print("FRAME(%d) LAG -- %.2f ms" % (frame_id, fr_time*1000.0)) |
||||
|
||||
if img is not None: |
||||
|
||||
extra = (smsg.roadCameraState.frameId, smsg.roadCameraState.timestampSof, smsg.roadCameraState.timestampEof) |
||||
|
||||
# send YUV frame |
||||
if os.getenv("YUV") is not None: |
||||
img_yuv = rgb24toyuv420(img) |
||||
data_socket.send_pyobj((cookie, VIPC_YUV, msg.logMonoTime, route_time, extra), flags=zmq.SNDMORE) |
||||
data_socket.send(img_yuv.flatten().tobytes(), copy=False) |
||||
|
||||
img = img[:, :, ::-1] # Convert RGB to BGR, which is what the camera outputs |
||||
img = img.flatten() |
||||
bts = img.tobytes() |
||||
|
||||
smsg.roadCameraState.image = bts |
||||
|
||||
# send RGB frame |
||||
data_socket.send_pyobj((cookie, VIPC_RGB, msg.logMonoTime, route_time, extra), flags=zmq.SNDMORE) |
||||
data_socket.send(bts, copy=False) |
||||
|
||||
data_socket.send_pyobj((cookie, typ, msg.logMonoTime, route_time), flags=zmq.SNDMORE) |
||||
data_socket.send(smsg.to_bytes(), copy=False) |
||||
|
||||
def _process_commands(self, cmd, route, pub_types): |
||||
seek_to = None |
||||
if route is None or (isinstance(cmd, SetRoute) and route.name != cmd.name): |
||||
seek_to = cmd.start_time |
||||
route = Route(cmd.name, cmd.data_dir) |
||||
self._lr = MultiLogIterator(route.log_paths(), wraparound=True) |
||||
if self._frame_reader is not None: |
||||
self._frame_reader.close() |
||||
if "roadCameraState" in pub_types or "roadEncodeIdx" in pub_types: |
||||
# reset frames for a route |
||||
self._frame_id_lookup = {} |
||||
self._frame_reader = RouteFrameReader( |
||||
route.camera_paths(), None, self._frame_id_lookup, readahead=True) |
||||
|
||||
# always reset this on a seek |
||||
if isinstance(cmd, SeekRelativeTime): |
||||
seek_to = self._lr.tell() + cmd.secs |
||||
elif isinstance(cmd, SeekAbsoluteTime): |
||||
seek_to = cmd.secs |
||||
elif isinstance(cmd, StopAndQuit): |
||||
exit() |
||||
|
||||
if seek_to is not None: |
||||
print("seeking", seek_to) |
||||
if not self._lr.seek(seek_to): |
||||
print("Can't seek: time out of bounds") |
||||
else: |
||||
next(self._lr) # ignore one |
||||
return route |
||||
|
||||
def _get_address_send_func(address): |
||||
sock = pub_sock(address) |
||||
return sock.send |
||||
|
||||
def _get_vipc_server(length): |
||||
sizes = {3 * w * h: (w, h) for (w, h) in [tici_f_frame_size, eon_f_frame_size]} # RGB |
||||
sizes.update({(3 * w * h) / 2: (w, h) for (w, h) in [tici_f_frame_size, eon_f_frame_size]}) # YUV |
||||
|
||||
w, h = sizes[length] |
||||
|
||||
vipc_server = VisionIpcServer("camerad") |
||||
vipc_server.create_buffers(VisionStreamType.VISION_STREAM_RGB_BACK, 4, True, w, h) |
||||
vipc_server.create_buffers(VisionStreamType.VISION_STREAM_YUV_BACK, 40, False, w, h) |
||||
vipc_server.start_listener() |
||||
return vipc_server |
||||
|
||||
def unlogger_thread(command_address, forward_commands_address, data_address, run_realtime, |
||||
address_mapping, publish_time_length, bind_early, no_loop, no_visionipc): |
||||
# Clear context to avoid problems with multiprocessing. |
||||
zmq.Context._instance = None |
||||
context = zmq.Context.instance() |
||||
|
||||
command_sock = context.socket(zmq.PULL) |
||||
command_sock.bind(command_address) |
||||
|
||||
forward_commands_socket = context.socket(zmq.PUSH) |
||||
forward_commands_socket.bind(forward_commands_address) |
||||
|
||||
data_socket = context.socket(zmq.PULL) |
||||
data_socket.bind(data_address) |
||||
|
||||
# Set readahead to a reasonable number. |
||||
data_socket.setsockopt(zmq.RCVHWM, 10000) |
||||
|
||||
poller = zmq.Poller() |
||||
poller.register(command_sock, zmq.POLLIN) |
||||
poller.register(data_socket, zmq.POLLIN) |
||||
|
||||
if bind_early: |
||||
send_funcs = { |
||||
typ: _get_address_send_func(address) |
||||
for typ, address in address_mapping.items() |
||||
} |
||||
|
||||
# Give subscribers a chance to connect. |
||||
time.sleep(0.1) |
||||
else: |
||||
send_funcs = {} |
||||
|
||||
start_time = float("inf") |
||||
printed_at = 0 |
||||
generation = 0 |
||||
paused = False |
||||
reset_time = True |
||||
prev_msg_time = None |
||||
vipc_server = None |
||||
|
||||
while True: |
||||
evts = dict(poller.poll()) |
||||
if command_sock in evts: |
||||
cmd = command_sock.recv_pyobj() |
||||
if isinstance(cmd, TogglePause): |
||||
paused = not paused |
||||
if paused: |
||||
poller.modify(data_socket, 0) |
||||
else: |
||||
poller.modify(data_socket, zmq.POLLIN) |
||||
else: |
||||
# Forward the command the the log data thread. |
||||
# TODO: Remove everything on data_socket. |
||||
generation += 1 |
||||
forward_commands_socket.send_pyobj((generation, cmd)) |
||||
if isinstance(cmd, StopAndQuit): |
||||
return |
||||
|
||||
reset_time = True |
||||
elif data_socket in evts: |
||||
msg_generation, typ, msg_time, route_time, *extra = data_socket.recv_pyobj(flags=zmq.RCVMORE) |
||||
msg_bytes = data_socket.recv() |
||||
if msg_generation < generation: |
||||
# Skip packets. |
||||
continue |
||||
|
||||
if no_loop and prev_msg_time is not None and prev_msg_time > msg_time + 1e9: |
||||
generation += 1 |
||||
forward_commands_socket.send_pyobj((generation, StopAndQuit())) |
||||
return |
||||
prev_msg_time = msg_time |
||||
|
||||
msg_time_seconds = msg_time * 1e-9 |
||||
if reset_time: |
||||
msg_start_time = msg_time_seconds |
||||
real_start_time = realtime.sec_since_boot() |
||||
start_time = min(start_time, msg_start_time) |
||||
reset_time = False |
||||
|
||||
if publish_time_length and msg_time_seconds - start_time > publish_time_length: |
||||
generation += 1 |
||||
forward_commands_socket.send_pyobj((generation, StopAndQuit())) |
||||
return |
||||
|
||||
# Print time. |
||||
if abs(printed_at - route_time) > 5.: |
||||
print("at", route_time) |
||||
printed_at = route_time |
||||
|
||||
if typ not in send_funcs and typ not in [VIPC_RGB, VIPC_YUV]: |
||||
if typ in address_mapping: |
||||
# Remove so we don't keep printing warnings. |
||||
address = address_mapping.pop(typ) |
||||
try: |
||||
print("binding", typ) |
||||
send_funcs[typ] = _get_address_send_func(address) |
||||
except Exception as e: |
||||
print("couldn't replay {}: {}".format(typ, e)) |
||||
continue |
||||
else: |
||||
# Skip messages that we are not registered to publish. |
||||
continue |
||||
|
||||
# Sleep as needed for real time playback. |
||||
if run_realtime: |
||||
msg_time_offset = msg_time_seconds - msg_start_time |
||||
real_time_offset = realtime.sec_since_boot() - real_start_time |
||||
lag = msg_time_offset - real_time_offset |
||||
if lag > 0 and lag < 30: # a large jump is OK, likely due to an out of order segment |
||||
if lag > 1: |
||||
print("sleeping for", lag) |
||||
time.sleep(lag) |
||||
elif lag < -1: |
||||
# Relax the real time schedule when we slip far behind. |
||||
reset_time = True |
||||
|
||||
# Send message. |
||||
try: |
||||
if typ in [VIPC_RGB, VIPC_YUV]: |
||||
if not no_visionipc: |
||||
if vipc_server is None: |
||||
vipc_server = _get_vipc_server(len(msg_bytes)) |
||||
|
||||
i, sof, eof = extra[0] |
||||
stream = VisionStreamType.VISION_STREAM_RGB_BACK if typ == VIPC_RGB else VisionStreamType.VISION_STREAM_YUV_BACK |
||||
vipc_server.send(stream, msg_bytes, i, sof, eof) |
||||
else: |
||||
send_funcs[typ](msg_bytes) |
||||
except MultiplePublishersError: |
||||
del send_funcs[typ] |
||||
|
||||
def timestamp_to_s(tss): |
||||
return time.mktime(datetime.strptime(tss, '%Y-%m-%d--%H-%M-%S').timetuple()) |
||||
|
||||
def absolute_time_str(s, start_time): |
||||
try: |
||||
# first try if it's a float |
||||
return float(s) |
||||
except ValueError: |
||||
# now see if it's a timestamp |
||||
return timestamp_to_s(s) - start_time |
||||
|
||||
def _get_address_mapping(args): |
||||
if args.min is not None: |
||||
services_to_mock = [ |
||||
'deviceState', 'can', 'pandaState', 'sensorEvents', 'gpsNMEA', 'roadCameraState', 'roadEncodeIdx', |
||||
'modelV2', 'liveLocation', |
||||
] |
||||
elif args.enabled is not None: |
||||
services_to_mock = args.enabled |
||||
else: |
||||
services_to_mock = service_list.keys() |
||||
|
||||
address_mapping = {service_name: service_name for service_name in services_to_mock} |
||||
address_mapping.update(dict(args.address_mapping)) |
||||
|
||||
for k in args.disabled: |
||||
address_mapping.pop(k, None) |
||||
|
||||
non_services = set(address_mapping) - set(service_list) |
||||
if non_services: |
||||
print("WARNING: Unknown services {}".format(list(non_services))) |
||||
|
||||
return address_mapping |
||||
|
||||
def keyboard_controller_thread(q, route_start_time): |
||||
print("keyboard waiting for input") |
||||
kb = KBHit() |
||||
while 1: |
||||
c = kb.getch() |
||||
if c == 'm': # Move forward by 1m |
||||
q.send_pyobj(SeekRelativeTime(60)) |
||||
elif c == 'M': # Move backward by 1m |
||||
q.send_pyobj(SeekRelativeTime(-60)) |
||||
elif c == 's': # Move forward by 10s |
||||
q.send_pyobj(SeekRelativeTime(10)) |
||||
elif c == 'S': # Move backward by 10s |
||||
q.send_pyobj(SeekRelativeTime(-10)) |
||||
elif c == 'G': # Move backward by 10s |
||||
q.send_pyobj(SeekAbsoluteTime(0.)) |
||||
elif c == "\x20": # Space bar. |
||||
q.send_pyobj(TogglePause()) |
||||
elif c == "\n": |
||||
try: |
||||
seek_time_input = input('time: ') |
||||
seek_time = absolute_time_str(seek_time_input, route_start_time) |
||||
|
||||
# If less than 60, assume segment number |
||||
if seek_time < 60: |
||||
seek_time *= 60 |
||||
|
||||
q.send_pyobj(SeekAbsoluteTime(seek_time)) |
||||
except Exception as e: |
||||
print("Time not understood: {}".format(e)) |
||||
|
||||
def get_arg_parser(): |
||||
parser = argparse.ArgumentParser( |
||||
description="Mock openpilot components by publishing logged messages.", |
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter) |
||||
|
||||
parser.add_argument("route_name", type=(lambda x: x.replace("#", "|")), nargs="?", |
||||
help="The route whose messages will be published.") |
||||
parser.add_argument("data_dir", nargs='?', default=os.getenv('UNLOGGER_DATA_DIR'), |
||||
help="Path to directory in which log and camera files are located.") |
||||
|
||||
parser.add_argument("--no-loop", action="store_true", help="Stop at the end of the replay.") |
||||
|
||||
def key_value_pair(x): |
||||
return x.split("=") |
||||
|
||||
parser.add_argument("address_mapping", nargs="*", type=key_value_pair, |
||||
help="Pairs <service>=<zmq_addr> to publish <service> on <zmq_addr>.") |
||||
|
||||
def comma_list(x): |
||||
return x.split(",") |
||||
|
||||
to_mock_group = parser.add_mutually_exclusive_group() |
||||
to_mock_group.add_argument("--min", action="store_true", default=os.getenv("MIN")) |
||||
to_mock_group.add_argument("--enabled", default=os.getenv("ENABLED"), type=comma_list) |
||||
|
||||
parser.add_argument("--disabled", type=comma_list, default=os.getenv("DISABLED") or ()) |
||||
|
||||
parser.add_argument( |
||||
"--tl", dest="publish_time_length", type=float, default=None, |
||||
help="Length of interval in event time for which messages should be published.") |
||||
|
||||
parser.add_argument( |
||||
"--no-realtime", dest="realtime", action="store_false", default=True, |
||||
help="Publish messages as quickly as possible instead of realtime.") |
||||
|
||||
parser.add_argument( |
||||
"--no-interactive", dest="interactive", action="store_false", default=True, |
||||
help="Disable interactivity.") |
||||
|
||||
parser.add_argument( |
||||
"--bind-early", action="store_true", default=False, |
||||
help="Bind early to avoid dropping messages.") |
||||
|
||||
parser.add_argument( |
||||
"--no-visionipc", action="store_true", default=False, |
||||
help="Do not output video over visionipc") |
||||
|
||||
parser.add_argument( |
||||
"--start-time", type=float, default=0., |
||||
help="Seek to this absolute time (in seconds) upon starting playback.") |
||||
|
||||
return parser |
||||
|
||||
def main(argv): |
||||
args = get_arg_parser().parse_args(sys.argv[1:]) |
||||
|
||||
command_address = "ipc:///tmp/{}".format(uuid4()) |
||||
forward_commands_address = "ipc:///tmp/{}".format(uuid4()) |
||||
data_address = "ipc:///tmp/{}".format(uuid4()) |
||||
|
||||
address_mapping = _get_address_mapping(args) |
||||
|
||||
command_sock = zmq.Context.instance().socket(zmq.PUSH) |
||||
command_sock.connect(command_address) |
||||
|
||||
if args.route_name is not None: |
||||
route_name_split = args.route_name.split("|") |
||||
if len(route_name_split) > 1: |
||||
route_start_time = timestamp_to_s(route_name_split[1]) |
||||
else: |
||||
route_start_time = 0 |
||||
command_sock.send_pyobj( |
||||
SetRoute(args.route_name, args.start_time, args.data_dir)) |
||||
else: |
||||
print("waiting for external command...") |
||||
route_start_time = 0 |
||||
|
||||
subprocesses = {} |
||||
try: |
||||
subprocesses["data"] = multiprocessing.Process( |
||||
target=UnloggerWorker().run, |
||||
args=(forward_commands_address, data_address, address_mapping.copy())) |
||||
|
||||
subprocesses["control"] = multiprocessing.Process( |
||||
target=unlogger_thread, |
||||
args=(command_address, forward_commands_address, data_address, args.realtime, |
||||
_get_address_mapping(args), args.publish_time_length, args.bind_early, args.no_loop, args.no_visionipc)) |
||||
|
||||
subprocesses["data"].start() |
||||
subprocesses["control"].start() |
||||
|
||||
# Exit if any of the children die. |
||||
def exit_if_children_dead(*_): |
||||
for _, p in subprocesses.items(): |
||||
if not p.is_alive(): |
||||
[p.terminate() for p in subprocesses.values()] |
||||
exit() |
||||
signal.signal(signal.SIGCHLD, signal.SIG_IGN) |
||||
signal.signal(signal.SIGCHLD, exit_if_children_dead) |
||||
|
||||
if args.interactive: |
||||
keyboard_controller_thread(command_sock, route_start_time) |
||||
else: |
||||
# Wait forever for children. |
||||
while True: |
||||
time.sleep(10000.) |
||||
finally: |
||||
for p in subprocesses.values(): |
||||
if p.is_alive(): |
||||
try: |
||||
p.join(3.) |
||||
except multiprocessing.TimeoutError: |
||||
p.terminate() |
||||
continue |
||||
return 0 |
||||
|
||||
if __name__ == "__main__": |
||||
sys.exit(main(sys.argv[1:])) |
Loading…
Reference in new issue