diff --git a/benchmark.py b/benchmark.py new file mode 100644 index 0000000..6d26792 --- /dev/null +++ b/benchmark.py @@ -0,0 +1,39 @@ +from datetime import datetime +import time +import json +import statistics as stats +from pprint import pprint +import os +import _ed_lrr + +NUM_LOOPS=5 + +results={} + +def time_run(w,s,file="benchmark.json", loops=None): + global results,NUM_LOOPS + if loops is None: + loops=NUM_LOOPS + for _ in range(loops): + t_start = time.time() + ret = _ed_lrr.route(s,48,None,'bfs',True,False,False,False,0.0,None,r"D:\devel\rust\ED_LRR\stars.csv",w,lambda *args,**kwargs: None) + t_end = time.time() + results.setdefault(w,[]).append({'ret':len(ret),'time':t_end - t_start}) + with open(file, "w") as of: + json.dump(results, of,indent=2) + +t_start = datetime.today() + +for w in [1,2,4,7,8,0]: + time_run(w,['Ix','72']) + +print("Benchmark took:", datetime.today() - t_start) + + +for workers,results in results.items(): + t_total=sum([res['time'] for res in results])/len(results) + avg_len=sum([res['ret'] for res in results])/len(results) + times.append([int(workers),timedelta(seconds=t_total),avg_len]) + +for k,v,l in sorted(times,key=lambda rec:rec[1]): + print(k,v,l) \ No newline at end of file diff --git a/celery_rabbitmq_setup.ps1 b/celery_rabbitmq_setup.ps1 new file mode 100644 index 0000000..f0b48ad --- /dev/null +++ b/celery_rabbitmq_setup.ps1 @@ -0,0 +1,17 @@ +#RABBITMQ +rabbitmqctl stop_app +rabbitmqctl reset +rabbitmqctl start_app +rabbitmqctl add_user ed_lrr ed_lrr +rabbitmqctl add_vhost ed_lrr +rabbitmqctl set_user_tags ed_lrr ed_lrr +rabbitmqctl set_permissions -p ed_lrr ed_lrr ".*" ".*" ".*" +rabbitmqctl set_permissions guest ".*" ".*" ".*" +rabbitmqctl set_permissions -p ed_lrr guest ".*" ".*" ".*" +Write-Host RabbitMQ setup done +#Celery +$env:FORKED_BY_MULTIPROCESSING=1 +Write-Host starting Celery +celery -A celery_test worker -E -l info + +#celery -A celery_test flower --presistent --broker=pyamqp://ed_lrr:ed_lrr@localhost/ed_lrr --broker_api=http://ed_lrr:ed_lrr@localhost:15672/api/ diff --git a/celery_test.py b/celery_test.py new file mode 100644 index 0000000..b831ada --- /dev/null +++ b/celery_test.py @@ -0,0 +1,9 @@ +from celery import Celery +import _ed_lrr +app = Celery('ed_lrr',backend = 'db+sqlite:///ed_lrr_results.sqlite', broker='pyamqp://ed_lrr:ed_lrr@localhost/ed_lrr') + +@app.task(bind=True) +def route(self,hops,jmp_range): + def callback(state): + self.update_state(state="PROGRESS", meta=state) + return _ed_lrr.route(hops,jmp_range,None,'bfs',True,False,False,False,0.0,None,r"C:\Users\Earthnuker\AppData\Local\ED_LRR\data\stars.csv",0,callback) \ No newline at end of file diff --git a/celery_worker.py b/celery_worker.py new file mode 100644 index 0000000..c1ad95c --- /dev/null +++ b/celery_worker.py @@ -0,0 +1,18 @@ +import time +from celery_test import route + +jobs = [route.delay(["Ix", "Colonia"], 48), route.delay(["Colonia", "Sol"], 48)] +while True: + for job in jobs: + if job.ready(): + print([job, job.state, len(job.info)]) + else: + print([job, job.state, job.info]) + print("="*10) + time.sleep(1) + +# 02c77491-9abd-4a88-ab2c-acdf2981086b +# d56b0ca8-067d-45a6-be9b-bb9e74f196cd + +# celery -A celery_test flower --presistent --broker=pyamqp://ed_lrr:ed_lrr@localhost/ed_lrr --broker_api=http://ed_lrr:ed_lrr@localhost:15672/api/ + diff --git a/ed_lrr_gui/__main__.py b/ed_lrr_gui/__main__.py index 3512700..67b6740 100644 --- a/ed_lrr_gui/__main__.py +++ b/ed_lrr_gui/__main__.py @@ -39,7 +39,7 @@ for file in cfg["history.bodies_path"][::-1]: @click.pass_context @click.version_option() def main(ctx): - "Elite: Dangerous long range router, command line interface" + "Elite: Dangerous long range router, command line interface." MP.freeze_support() if ctx.invoked_subcommand != "config": os.makedirs(cfg["folders.data_dir"], exist_ok=True) @@ -49,12 +49,31 @@ def main(ctx): return +@main.command() +@click.option("--port", "-p", help="Port to bind to", type=int, default=3777) +@click.option("--host", "-h", help="Address to bind to", type=str, default="0.0.0.0") +@click.option("--debug", "-d", is_flag=True, help="Run using debug server") +def web(host, port, debug): + "Run web interface." + from gevent.pywsgi import WSGIServer + from ed_lrr_gui.web import app + + with app.test_client() as c: + c.get("/") # Force before_first_request hook to run + if debug: + app.debug=True + app.run(host=host, port=port, debug=True) + return + print("Listening on {}:{}".format(host, port)) + server = WSGIServer((host, port), app) + server.serve_forever() + + @main.command() @click.argument("option", default=None, required=False) @click.argument("value", default=None, required=False) def config(option, value): - """Change configuration - + """Change configuration. If "key" and "value" are both omitted the current configuration is printed """ @@ -98,14 +117,14 @@ def config(option, value): @main.command() def explore(): - "Open file manager in data folder" + "Open file manager in data folder." click.launch(cfg["folders.data_dir"], locate=True) @main.command() -@click.option("--debug", help="Debug print", is_flag=True) +@click.option("--debug", help="Enable debug output", is_flag=True) def gui(debug): - "Run the ED LRR GUI (default)" + "Run the ED LRR GUI (default)." import ed_lrr_gui.gui as ED_LRR_GUI if (not debug) and os.name == "nt": @@ -133,7 +152,7 @@ def gui(debug): show_default=True, ) def download(url, folder): - "Download EDSM dumps" + "Download EDSM dumps." os.makedirs(folder, exist_ok=True) for file_name in ["systemsWithCoordinates.json", "bodies.json"]: download_url = urljoin(url, file_name) @@ -156,6 +175,7 @@ def download(url, folder): unit_divisor=1024, unit_scale=True, ascii=True, + smoothing=0 ) as pbar: with open(download_path, "wb") as of: resp = RQ.get( @@ -173,7 +193,7 @@ def download(url, folder): "-s", default=systems_path, metavar="", - help="Path to stars.csv", + help="Path to systemsWithCoordinates.json", type=click.Path(exists=True, dir_okay=False), show_default=True, ) @@ -196,7 +216,7 @@ def download(url, folder): show_default=True, ) def preprocess(systems, bodies, output): - "Preprocess EDSM dumps" + "Preprocess EDSM dumps." with click.progressbar( length=100, label="", show_percent=True, item_show_func=lambda v: v, width=50 ) as pbar: @@ -289,12 +309,20 @@ def preprocess(systems, bodies, output): "-m", default=cfg["route.mode"], help="Search mode", - type=click.Choice(["bfs", "a-star", "greedy"]), + type=click.Choice(["bfs","bfs_old", "a-star", "greedy"]), + show_default=True, +) +@click.option( + "--workers", + "-w", + metavar="", + default=1, + help="Number of worker threads (more is not always better)", show_default=True, ) @click.argument("systems", nargs=-1) def route(**kwargs): - "Compute a route" + "Compute a route." if len(kwargs["systems"]) < 2: exit("Need at least two systems to plot a route") if kwargs["prune"] == (0, 0): @@ -302,7 +330,7 @@ def route(**kwargs): def to_string(state): if state: - return "{prc_done:.2f}% [N:{depth} Q:{queue_size} D:{d_rem:.2f} Ly] {system}".format( + return "{prc_done:.2f}% [N:{depth} | Q:{queue_size} | D:{d_rem:.2f} Ly | S: {n_seen} ({prc_seen:.2f}%)] {system}".format( **state ) @@ -330,6 +358,7 @@ def route(**kwargs): kwargs["factor"], None, kwargs["path"], + kwargs["workers"] ] with click.progressbar( length=100, @@ -359,7 +388,7 @@ def route(**kwargs): pbar.update(0) for n, jump in enumerate(state.get("return", []), 1): jump["n"] = n - if jump["body"].index(jump["system"]) == -1: + if jump["body"].find(jump["system"]) == -1: jump["where"] = "[{body}] in [{system}]".format(**jump) else: jump["where"] = "[{body}]".format(**jump) @@ -397,6 +426,7 @@ def route(**kwargs): def precompute(*args, **kwargs): "Precompute routing graph" print("PreComp:", args, kwargs) + raise NotImplementedError if __name__ == "__main__": diff --git a/ed_lrr_gui/config.py b/ed_lrr_gui/config.py index cc8af19..f54dfea 100644 --- a/ed_lrr_gui/config.py +++ b/ed_lrr_gui/config.py @@ -55,5 +55,8 @@ cfg.init("folders.data_dir", os.path.join(config_dir, "data"), comment="Data dir cfg.init("GUI.theme", "dark", comment="GUI theme to use") +cfg.init("web.port",3777,comment="Port to bind to") +cfg.init("web.host","0.0.0.0",comment="Address to bind to") +cfg.init("web.debug",False,comment="Run using debug server") cfg.sync() diff --git a/ed_lrr_gui/gui/ed_lrr.py b/ed_lrr_gui/gui/ed_lrr.py index 56dd13a..81b99b5 100644 --- a/ed_lrr_gui/gui/ed_lrr.py +++ b/ed_lrr_gui/gui/ed_lrr.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'D:\devel\rust\ed_lrr_gui\ed_lrr_gui\gui\ed_lrr.ui' # -# Created by: PyQt5 UI code generator 5.13.1 +# Created by: PyQt5 UI code generator 5.14.1 # # WARNING! All changes made in this file will be lost! @@ -15,9 +15,7 @@ class Ui_ED_LRR(object): ED_LRR.setObjectName("ED_LRR") ED_LRR.setEnabled(True) ED_LRR.resize(577, 500) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(ED_LRR.sizePolicy().hasHeightForWidth()) @@ -28,14 +26,10 @@ class Ui_ED_LRR(object): ED_LRR.setDocumentMode(False) ED_LRR.setTabShape(QtWidgets.QTabWidget.Rounded) self.centralwidget = QtWidgets.QWidget(ED_LRR) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.centralwidget.sizePolicy().hasHeightForWidth() - ) + sizePolicy.setHeightForWidth(self.centralwidget.sizePolicy().hasHeightForWidth()) self.centralwidget.setSizePolicy(sizePolicy) self.centralwidget.setObjectName("centralwidget") self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) @@ -55,39 +49,27 @@ class Ui_ED_LRR(object): self.formLayout.setObjectName("formLayout") self.lbl_bodies_dl = QtWidgets.QLabel(self.tab_download) self.lbl_bodies_dl.setObjectName("lbl_bodies_dl") - self.formLayout.setWidget( - 1, QtWidgets.QFormLayout.LabelRole, self.lbl_bodies_dl - ) + self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.lbl_bodies_dl) self.lbl_systems_dl = QtWidgets.QLabel(self.tab_download) self.lbl_systems_dl.setObjectName("lbl_systems_dl") - self.formLayout.setWidget( - 3, QtWidgets.QFormLayout.LabelRole, self.lbl_systems_dl - ) + self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.lbl_systems_dl) self.inp_bodies_dl = QtWidgets.QComboBox(self.tab_download) self.inp_bodies_dl.setEditable(True) self.inp_bodies_dl.setInsertPolicy(QtWidgets.QComboBox.InsertAtTop) self.inp_bodies_dl.setObjectName("inp_bodies_dl") - self.formLayout.setWidget( - 1, QtWidgets.QFormLayout.FieldRole, self.inp_bodies_dl - ) + self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.inp_bodies_dl) self.inp_systems_dl = QtWidgets.QComboBox(self.tab_download) self.inp_systems_dl.setEditable(True) self.inp_systems_dl.setInsertPolicy(QtWidgets.QComboBox.InsertAtTop) self.inp_systems_dl.setObjectName("inp_systems_dl") - self.formLayout.setWidget( - 3, QtWidgets.QFormLayout.FieldRole, self.inp_systems_dl - ) + self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.inp_systems_dl) self.gridLayout = QtWidgets.QGridLayout() self.gridLayout.setObjectName("gridLayout") self.inp_bodies_dest_dl = QtWidgets.QComboBox(self.tab_download) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.inp_bodies_dest_dl.sizePolicy().hasHeightForWidth() - ) + sizePolicy.setHeightForWidth(self.inp_bodies_dest_dl.sizePolicy().hasHeightForWidth()) self.inp_bodies_dest_dl.setSizePolicy(sizePolicy) self.inp_bodies_dest_dl.setEditable(False) self.inp_bodies_dest_dl.setInsertPolicy(QtWidgets.QComboBox.InsertAtTop) @@ -103,14 +85,10 @@ class Ui_ED_LRR(object): self.btn_systems_dest_browse_dl.setObjectName("btn_systems_dest_browse_dl") self.gridLayout_2.addWidget(self.btn_systems_dest_browse_dl, 0, 1, 1, 1) self.inp_systems_dest_dl = QtWidgets.QComboBox(self.tab_download) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.inp_systems_dest_dl.sizePolicy().hasHeightForWidth() - ) + sizePolicy.setHeightForWidth(self.inp_systems_dest_dl.sizePolicy().hasHeightForWidth()) self.inp_systems_dest_dl.setSizePolicy(sizePolicy) self.inp_systems_dest_dl.setEditable(False) self.inp_systems_dest_dl.setInsertPolicy(QtWidgets.QComboBox.InsertAtTop) @@ -133,79 +111,57 @@ class Ui_ED_LRR(object): self.formLayout_3.setObjectName("formLayout_3") self.lbl_bodies_pp = QtWidgets.QLabel(self.tab_preprocess) self.lbl_bodies_pp.setObjectName("lbl_bodies_pp") - self.formLayout_3.setWidget( - 0, QtWidgets.QFormLayout.LabelRole, self.lbl_bodies_pp - ) + self.formLayout_3.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.lbl_bodies_pp) self.gr_bodies_pp = QtWidgets.QGridLayout() self.gr_bodies_pp.setObjectName("gr_bodies_pp") self.btn_bodies_browse_pp = QtWidgets.QPushButton(self.tab_preprocess) self.btn_bodies_browse_pp.setObjectName("btn_bodies_browse_pp") self.gr_bodies_pp.addWidget(self.btn_bodies_browse_pp, 0, 1, 1, 1) self.inp_bodies_pp = QtWidgets.QComboBox(self.tab_preprocess) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.inp_bodies_pp.sizePolicy().hasHeightForWidth() - ) + sizePolicy.setHeightForWidth(self.inp_bodies_pp.sizePolicy().hasHeightForWidth()) self.inp_bodies_pp.setSizePolicy(sizePolicy) self.inp_bodies_pp.setEditable(False) self.inp_bodies_pp.setInsertPolicy(QtWidgets.QComboBox.InsertAtTop) self.inp_bodies_pp.setObjectName("inp_bodies_pp") self.gr_bodies_pp.addWidget(self.inp_bodies_pp, 0, 0, 1, 1) - self.formLayout_3.setLayout( - 0, QtWidgets.QFormLayout.FieldRole, self.gr_bodies_pp - ) + self.formLayout_3.setLayout(0, QtWidgets.QFormLayout.FieldRole, self.gr_bodies_pp) self.lbl_systems_pp = QtWidgets.QLabel(self.tab_preprocess) self.lbl_systems_pp.setObjectName("lbl_systems_pp") - self.formLayout_3.setWidget( - 1, QtWidgets.QFormLayout.LabelRole, self.lbl_systems_pp - ) + self.formLayout_3.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.lbl_systems_pp) self.gr_systems_pp = QtWidgets.QGridLayout() self.gr_systems_pp.setObjectName("gr_systems_pp") self.btn_systems_browse_pp = QtWidgets.QPushButton(self.tab_preprocess) self.btn_systems_browse_pp.setObjectName("btn_systems_browse_pp") self.gr_systems_pp.addWidget(self.btn_systems_browse_pp, 0, 1, 1, 1) self.inp_systems_pp = QtWidgets.QComboBox(self.tab_preprocess) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.inp_systems_pp.sizePolicy().hasHeightForWidth() - ) + sizePolicy.setHeightForWidth(self.inp_systems_pp.sizePolicy().hasHeightForWidth()) self.inp_systems_pp.setSizePolicy(sizePolicy) self.inp_systems_pp.setEditable(False) self.inp_systems_pp.setInsertPolicy(QtWidgets.QComboBox.InsertAtTop) self.inp_systems_pp.setObjectName("inp_systems_pp") self.gr_systems_pp.addWidget(self.inp_systems_pp, 0, 0, 1, 1) - self.formLayout_3.setLayout( - 1, QtWidgets.QFormLayout.FieldRole, self.gr_systems_pp - ) + self.formLayout_3.setLayout(1, QtWidgets.QFormLayout.FieldRole, self.gr_systems_pp) self.lbl_out_pp = QtWidgets.QLabel(self.tab_preprocess) self.lbl_out_pp.setObjectName("lbl_out_pp") self.formLayout_3.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.lbl_out_pp) self.gr_out_grid_pp = QtWidgets.QGridLayout() self.gr_out_grid_pp.setObjectName("gr_out_grid_pp") self.btn_out_browse_pp = QtWidgets.QPushButton(self.tab_preprocess) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.btn_out_browse_pp.sizePolicy().hasHeightForWidth() - ) + sizePolicy.setHeightForWidth(self.btn_out_browse_pp.sizePolicy().hasHeightForWidth()) self.btn_out_browse_pp.setSizePolicy(sizePolicy) self.btn_out_browse_pp.setObjectName("btn_out_browse_pp") self.gr_out_grid_pp.addWidget(self.btn_out_browse_pp, 0, 1, 1, 1) self.inp_out_pp = QtWidgets.QComboBox(self.tab_preprocess) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.inp_out_pp.sizePolicy().hasHeightForWidth()) @@ -214,14 +170,10 @@ class Ui_ED_LRR(object): self.inp_out_pp.setInsertPolicy(QtWidgets.QComboBox.InsertAtTop) self.inp_out_pp.setObjectName("inp_out_pp") self.gr_out_grid_pp.addWidget(self.inp_out_pp, 0, 0, 1, 1) - self.formLayout_3.setLayout( - 2, QtWidgets.QFormLayout.FieldRole, self.gr_out_grid_pp - ) + self.formLayout_3.setLayout(2, QtWidgets.QFormLayout.FieldRole, self.gr_out_grid_pp) self.btn_preprocess = QtWidgets.QPushButton(self.tab_preprocess) self.btn_preprocess.setObjectName("btn_preprocess") - self.formLayout_3.setWidget( - 3, QtWidgets.QFormLayout.LabelRole, self.btn_preprocess - ) + self.formLayout_3.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.btn_preprocess) self.tabs.addTab(self.tab_preprocess, "") self.tab_route = QtWidgets.QWidget() self.tab_route.setObjectName("tab_route") @@ -229,18 +181,14 @@ class Ui_ED_LRR(object): self.formLayout_2.setObjectName("formLayout_2") self.lbl_sys_lst = QtWidgets.QLabel(self.tab_route) self.lbl_sys_lst.setObjectName("lbl_sys_lst") - self.formLayout_2.setWidget( - 0, QtWidgets.QFormLayout.LabelRole, self.lbl_sys_lst - ) + self.formLayout_2.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.lbl_sys_lst) self.gr_sys = QtWidgets.QGridLayout() self.gr_sys.setObjectName("gr_sys") self.btn_sys_lst_browse = QtWidgets.QPushButton(self.tab_route) self.btn_sys_lst_browse.setObjectName("btn_sys_lst_browse") self.gr_sys.addWidget(self.btn_sys_lst_browse, 0, 1, 1, 1) self.inp_sys_lst = QtWidgets.QComboBox(self.tab_route) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.inp_sys_lst.sizePolicy().hasHeightForWidth()) @@ -273,44 +221,32 @@ class Ui_ED_LRR(object): self.formLayout_2.setLayout(3, QtWidgets.QFormLayout.FieldRole, self.gr_mode) self.chk_permute = QtWidgets.QCheckBox(self.tab_route) self.chk_permute.setObjectName("chk_permute") - self.formLayout_2.setWidget( - 4, QtWidgets.QFormLayout.LabelRole, self.chk_permute - ) + self.formLayout_2.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.chk_permute) self.gridLayout_4 = QtWidgets.QGridLayout() self.gridLayout_4.setObjectName("gridLayout_4") self.chk_permute_keep_last = QtWidgets.QCheckBox(self.tab_route) self.chk_permute_keep_last.setObjectName("chk_permute_keep_last") self.gridLayout_4.addWidget(self.chk_permute_keep_last, 0, 3, 1, 1) self.chk_permute_keep_first = QtWidgets.QCheckBox(self.tab_route) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth( - self.chk_permute_keep_first.sizePolicy().hasHeightForWidth() - ) + sizePolicy.setHeightForWidth(self.chk_permute_keep_first.sizePolicy().hasHeightForWidth()) self.chk_permute_keep_first.setSizePolicy(sizePolicy) self.chk_permute_keep_first.setTristate(False) self.chk_permute_keep_first.setObjectName("chk_permute_keep_first") self.gridLayout_4.addWidget(self.chk_permute_keep_first, 0, 2, 1, 1) self.lbl_keep = QtWidgets.QLabel(self.tab_route) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.lbl_keep.sizePolicy().hasHeightForWidth()) self.lbl_keep.setSizePolicy(sizePolicy) self.lbl_keep.setObjectName("lbl_keep") self.gridLayout_4.addWidget(self.lbl_keep, 0, 1, 1, 1) - self.formLayout_2.setLayout( - 4, QtWidgets.QFormLayout.FieldRole, self.gridLayout_4 - ) + self.formLayout_2.setLayout(4, QtWidgets.QFormLayout.FieldRole, self.gridLayout_4) self.lst_sys = QtWidgets.QTreeWidget(self.tab_route) - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding - ) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.lst_sys.sizePolicy().hasHeightForWidth()) @@ -417,27 +353,17 @@ class Ui_ED_LRR(object): def retranslateUi(self, ED_LRR): _translate = QtCore.QCoreApplication.translate - ED_LRR.setWindowTitle( - _translate("ED_LRR", "Elite: Dangerous Long Range Route Plotter") - ) + ED_LRR.setWindowTitle(_translate("ED_LRR", "Elite: Dangerous Long Range Route Plotter")) self.lbl_bodies_dl.setText(_translate("ED_LRR", "bodies.json")) self.lbl_systems_dl.setText(_translate("ED_LRR", "systemsWithCoordinates.json")) - self.inp_bodies_dl.setCurrentText( - _translate("ED_LRR", "https://www.edsm.net/dump/bodies.json") - ) - self.inp_systems_dl.setCurrentText( - _translate( - "ED_LRR", "https://www.edsm.net/dump/systemsWithCoordinates.json" - ) - ) + self.inp_bodies_dl.setCurrentText(_translate("ED_LRR", "https://www.edsm.net/dump/bodies.json")) + self.inp_systems_dl.setCurrentText(_translate("ED_LRR", "https://www.edsm.net/dump/systemsWithCoordinates.json")) self.btn_bodies_dest_browse_dl.setText(_translate("ED_LRR", "...")) self.btn_systems_dest_browse_dl.setText(_translate("ED_LRR", "...")) self.btn_download.setText(_translate("ED_LRR", "Download")) self.label.setText(_translate("ED_LRR", "Download path")) self.label_2.setText(_translate("ED_LRR", "Download path")) - self.tabs.setTabText( - self.tabs.indexOf(self.tab_download), _translate("ED_LRR", "Download") - ) + self.tabs.setTabText(self.tabs.indexOf(self.tab_download), _translate("ED_LRR", "Download")) self.lbl_bodies_pp.setText(_translate("ED_LRR", "bodies.json")) self.btn_bodies_browse_pp.setText(_translate("ED_LRR", "...")) self.lbl_systems_pp.setText(_translate("ED_LRR", "systemsWithCoordinates.json")) @@ -445,9 +371,7 @@ class Ui_ED_LRR(object): self.lbl_out_pp.setText(_translate("ED_LRR", "Output")) self.btn_out_browse_pp.setText(_translate("ED_LRR", "...")) self.btn_preprocess.setText(_translate("ED_LRR", "Preprocess")) - self.tabs.setTabText( - self.tabs.indexOf(self.tab_preprocess), _translate("ED_LRR", "Preprocess") - ) + self.tabs.setTabText(self.tabs.indexOf(self.tab_preprocess), _translate("ED_LRR", "Preprocess")) self.lbl_sys_lst.setText(_translate("ED_LRR", "System List")) self.btn_sys_lst_browse.setText(_translate("ED_LRR", "...")) self.btn_add.setText(_translate("ED_LRR", "Add")) @@ -469,12 +393,8 @@ class Ui_ED_LRR(object): self.chk_primary.setText(_translate("ED_LRR", "Primary Stars Only")) self.lbl_mode.setText(_translate("ED_LRR", "Mode")) self.btn_go.setText(_translate("ED_LRR", "GO!")) - self.tabs.setTabText( - self.tabs.indexOf(self.tab_route), _translate("ED_LRR", "Route") - ) - self.tabs.setTabText( - self.tabs.indexOf(self.tab_log), _translate("ED_LRR", "Log") - ) + self.tabs.setTabText(self.tabs.indexOf(self.tab_route), _translate("ED_LRR", "Route")) + self.tabs.setTabText(self.tabs.indexOf(self.tab_log), _translate("ED_LRR", "Log")) self.menu_file.setTitle(_translate("ED_LRR", "File")) self.menuWindow.setTitle(_translate("ED_LRR", "Window")) self.menuStyle.setTitle(_translate("ED_LRR", "Style")) diff --git a/ed_lrr_gui/gui/main.py b/ed_lrr_gui/gui/main.py index 6901fa1..daae56d 100644 --- a/ed_lrr_gui/gui/main.py +++ b/ed_lrr_gui/gui/main.py @@ -8,7 +8,6 @@ import sys from sys import exit from datetime import datetime, timedelta from urllib.request import Request, urlopen - import _ed_lrr import ed_lrr_gui import requests as RQ @@ -436,7 +435,7 @@ class ED_LRR(Ui_ED_LRR): return self.bar_status.clearMessage() print(self.systems) - systems = [str(s["id"]) for s in self.systems] + systems = [s["id"] for s in self.systems] jump_range = self.sb_range.value() if jump_range == 0: self.error( @@ -466,13 +465,16 @@ class ED_LRR(Ui_ED_LRR): print( systems, jump_range, + None, mode, primary, permute, - (keep_first, keep_last), + keep_first, + keep_last, greedyness, - path, precomp, + path, + os.cpu_count()-1 ) if not self.current_job: self.bar_status.showMessage("Computing Route...") @@ -490,6 +492,7 @@ class ED_LRR(Ui_ED_LRR): greedyness, precomp, path, + os.cpu_count()-1 ) else: self.error("there is already a job running!") diff --git a/ed_lrr_gui/gui/widget_route.py b/ed_lrr_gui/gui/widget_route.py index 634b1ad..200d278 100644 --- a/ed_lrr_gui/gui/widget_route.py +++ b/ed_lrr_gui/gui/widget_route.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'D:\devel\rust\ed_lrr_gui\ed_lrr_gui\gui\widget_route.ui' # -# Created by: PyQt5 UI code generator 5.13.1 +# Created by: PyQt5 UI code generator 5.14.1 # # WARNING! All changes made in this file will be lost! @@ -25,9 +25,7 @@ class Ui_diag_route(object): self.lst_route.setAlternatingRowColors(True) self.lst_route.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) self.lst_route.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerItem) - self.lst_route.setHorizontalScrollMode( - QtWidgets.QAbstractItemView.ScrollPerPixel - ) + self.lst_route.setHorizontalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel) self.lst_route.setItemsExpandable(False) self.lst_route.setAllColumnsShowFocus(False) self.lst_route.setObjectName("lst_route") @@ -55,11 +53,7 @@ class Ui_diag_route(object): self.lst_route.headerItem().setText(0, _translate("diag_route", "Num")) self.lst_route.headerItem().setText(1, _translate("diag_route", "System")) self.lst_route.headerItem().setText(2, _translate("diag_route", "Body")) - self.lst_route.headerItem().setText( - 3, _translate("diag_route", "Distance (Ls)") - ) - self.chk_copy.setText( - _translate("diag_route", "Auto-copy next hop to clipboard") - ) + self.lst_route.headerItem().setText(3, _translate("diag_route", "Distance (Ls)")) + self.chk_copy.setText(_translate("diag_route", "Auto-copy next hop to clipboard")) self.btn_close.setText(_translate("diag_route", "Close")) self.btn_export.setText(_translate("diag_route", "Export")) diff --git a/ed_lrr_gui/html_export.py b/ed_lrr_gui/html_export.py index e790e6f..1b29db5 100644 --- a/ed_lrr_gui/html_export.py +++ b/ed_lrr_gui/html_export.py @@ -10,7 +10,6 @@ def dist(p1, p2): s += (c1 - c2) ** 2 return s ** 0.5 - colors = { "O": "#0000FF", "B": "#140AF0", diff --git a/ed_lrr_gui/router.py b/ed_lrr_gui/router.py index 4eb5212..be17c00 100644 --- a/ed_lrr_gui/router.py +++ b/ed_lrr_gui/router.py @@ -20,6 +20,7 @@ class Router(Process): self.queue.put({"status": state}) def run(self): + print("Route(): ",self.args,self.kwargs) route = _ed_lrr.route(*self.args, **self.kwargs) self.queue.put({"return": route}) diff --git a/ed_lrr_gui/web/__init__.py b/ed_lrr_gui/web/__init__.py index e69de29..5ea9afe 100644 --- a/ed_lrr_gui/web/__init__.py +++ b/ed_lrr_gui/web/__init__.py @@ -0,0 +1 @@ +from .app import app, templates, db diff --git a/ed_lrr_gui/web/__main__.py b/ed_lrr_gui/web/__main__.py deleted file mode 100644 index 01e5af5..0000000 --- a/ed_lrr_gui/web/__main__.py +++ /dev/null @@ -1,100 +0,0 @@ -from flask import Flask, jsonify -import uuid -import json -from webargs import fields, validate -from webargs.flaskparser import use_args -from flask_sqlalchemy import SQLAlchemy -from sqlalchemy_utils import Timestamp, generic_repr -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import scoped_session, sessionmaker, relationship, backref -from sqlalchemy.types import Float, String, Boolean - -app = Flask(__name__) -app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///jobs.db" -app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False - -db = SQLAlchemy(app) - - -@generic_repr -class Job(db.Model, Timestamp): - id = db.Column(db.String, default=lambda: str(uuid.uuid4()), primary_key=True) - jump_range = db.Column(db.Float, nullable=False) - mode = db.Column(db.String, default="bfs") - systems = db.Column(db.String) - permute = db.Column(db.String, default=None, nullable=True) - primary = db.Column(db.Boolean, default=False) - factor = db.Column(db.Float, default=0.5) - done = db.Column(db.DateTime, nullable=True, default=None) - started = db.Column(db.DateTime, nullable=True, default=None) - progress = db.Column(db.Float, default=0.0) - - # ============================================================ - - @classmethod - def new(cls, **kwargs): - obj = cls(**kwargs) - db.session.add(obj) - db.session.commit() - print(obj) - return obj - - @property - def dict(self): - ret = {} - for col in self.__table__.columns: - ret[col.name] = getattr(self, col.name) - ret["systems"] = json.loads(ret["systems"]) - return ret - - @dict.setter - def set_dict(self, *args, **kwargs): - raise NotImplementedError - - -db.create_all() -db.session.commit() - - -@app.errorhandler(422) -@app.errorhandler(400) -def handle_error(err): - headers = err.data.get("headers", None) - messages = err.data.get("messages", ["Invalid request."]) - if headers: - return jsonify({"errors": messages}), err.code, headers - else: - return jsonify({"errors": messages}), err.code - - -@app.route("/route", methods=["GET", "POST"]) -@use_args( - { - "jump_range": fields.Float(required=True), - "mode": fields.String( - missing="bfs", validate=validate.OneOf(["bfs", "greedy", "a-star"]) - ), - "systems": fields.DelimitedList(fields.String, required=True), - "permute": fields.String( - missing=None, - validate=validate.OneOf(["all", "keep_first", "keep_last", "keep_both"]), - ), - "primary": fields.Boolean(missing=False), - "factor": fields.Float(missing=0.5), - } -) -def route(args): - args["systems"] = json.dumps(args["systems"]) - for k, v in args.items(): - print(k, v) - return jsonify({"id": Job.new(**args).id}) - - -@app.route("/status/") -def status(job_id): - job = db.session.query(Job).get_or_404(str(job_id)) - return jsonify(job.dict) - - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=3777, debug=True) diff --git a/ed_lrr_gui/web/app.py b/ed_lrr_gui/web/app.py new file mode 100644 index 0000000..8fccda8 --- /dev/null +++ b/ed_lrr_gui/web/app.py @@ -0,0 +1,665 @@ +from flask import ( + Flask, + jsonify, + session, + render_template, + redirect, + url_for, + send_from_directory, + request, + flash, + current_app +) +from flask.json.tag import JSONTag +import uuid +import pickle +import os +import time +import random +import base64 +import gevent +from functools import wraps +from concurrent.futures.process import BrokenProcessPool +from datetime import datetime, timedelta +from decimal import Decimal +from multiprocessing import Queue +from webargs import fields, validate +from webargs.flaskparser import use_kwargs + +from flask_executor import Executor +from flask_sqlalchemy import SQLAlchemy +from flask_bootstrap import Bootstrap + +from flask_nav import Nav, register_renderer +from flask_nav.elements import Navbar, View + +from flask_admin import Admin +from flask_admin.contrib.sqla import ModelView + +from flask_wtf.csrf import CSRFProtect + +from flask_login import ( + LoginManager, + current_user, + logout_user, + UserMixin, + AnonymousUserMixin, + login_user, + login_required, +) + +from flask_debugtoolbar import DebugToolbarExtension + +from werkzeug.http import HTTP_STATUS_CODES +from sqlalchemy_utils import generic_repr, JSONType, PasswordType, UUIDType +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import scoped_session, sessionmaker, relationship, backref +from sqlalchemy.types import Float, String, DateTime +from jinja2.exceptions import TemplateNotFound +from .forms import RouteForm, LoginForm, RegisterForm, ChangePasswordForm +from .utils import prepare_route, BootsrapRenderer, is_safe_url + +import _ed_lrr as ed_lrr + +templates = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") +app = Flask(__name__, template_folder=templates) +app.config.from_pyfile("config.py") + +executor = Executor(app) +db = SQLAlchemy(app) +bootstrap = Bootstrap(app) +csrf = CSRFProtect(app) +nav = Nav(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.session_protection = "strong" +admin = Admin(app, name="ED_LRR", template_mode="bootstrap3") +app.debug=True +toolbar = DebugToolbarExtension(app) + + +def wants_json_response(): + return request.accept_mimetypes['application/json'] >= \ + request.accept_mimetypes['text/html'] + + +@app.errorhandler(422) +@app.errorhandler(400) +@app.errorhandler(500) +@app.errorhandler(404) +def handle_error(err): + if wants_json_response(): + return jsonify(error=str(err),code=err.code), err.code + templates=["error/{}.html".format(err.code),"error/default.html"] + try: + print(dir(err)) + return render_template(templates,error=err),err.code + except TemplateNotFound: + return err.get_response() + +def role_required(*roles): + def wrapper(fn): + @wraps(fn) + def decorated_view(*args, **kwargs): + if not current_user.is_authenticated(): + return current_app.login_manager.unauthorized() + has_role=False + user=current_app.login_manager.reload_user() + for role in roles: + has_role|=user.has_role(role) + if not has_role: + return current_app.login_manager.unauthorized() + return fn(*args, **kwargs) + return decorated_view + return wrapper + +@login_manager.user_loader +def load_user(user_name): + return User.query.get(user_name) + + +@login_manager.request_loader +def load_user_from_header(header_val): + for api_key in [request.args.get('api_key'),request.headers.get('X-API-Key')]: + if api_key: + user = User.query.filter_by(api_key=api_key).one_or_none() + if user: + return user + return None + return None + + +def left_nav(): + links=[View("Home", "index"),View("Route", "route"),View("Jobs", "status",job_id=None)] + if current_user.has_role('admin') or current_user.has_role('worker_host'): + links.insert(2,View("Workers","worker")) + return Navbar( + "E:D LRR", + *links + ) + + +def right_nav(): + links = [View("Login", "login"), View("Register", "register")] + if current_user.is_authenticated: + links = [View("Change Password", "change_password"), View("Logout", "logout")] + if current_user.has_role('admin'): + links = [View("Admin", "admin.index")] + links + return Navbar("", *links) + + +register_renderer(app, "bootstrap4", BootsrapRenderer) +nav.register_element("left_nav", left_nav) +nav.register_element("right_nav", right_nav) + + +def compute_route(args, kwargs): + return ed_lrr.route(*args, **kwargs) + + +class AnonymousUser(AnonymousUserMixin): + + def has_role(self,role): + return False + + @property + def roles(self): + return [] + + @roles.setter + def __set_roles(self,value): + raise NotImplementedError + + +login_manager.anonymous_user = AnonymousUser + + +@generic_repr +class Worker(db.Model): + id = db.Column( + UUIDType(binary=False, native=False), primary_key=True, default=uuid.uuid4 + ) + name = db.Column(db.String, unique=True) + current_job=db.Column(UUIDType(binary=False, native=False),db.ForeignKey("job.id"), nullable=True,default=None) + job = relationship('Job',backref="workers") + last_active = db.Column(DateTime, nullable=True,default=None) + owner_name = db.Column( + db.String, db.ForeignKey("user.name"), nullable=True,index=True + ) + owner = relationship("User",backref="workers") + +user_roles = db.Table('user_roles', + db.Column('user_name', db.String, db.ForeignKey('user.name'),primary_key=True), + db.Column('role_name', db.String, db.ForeignKey('role.name'),primary_key=True) +) + +class Role(db.Model): + name = db.Column(db.String, unique=True,index=True,primary_key=True) + + def __init__(self,name): + self.name=name + + def __repr__(self): + return self.name + +class User(db.Model, UserMixin): + name = db.Column(db.String, unique=True,index=True,primary_key=True) + is_active = db.Column(db.Boolean, default=False) + api_key = db.Column( + UUIDType(binary=False, native=False), nullable=True, default=uuid.uuid4,index=True + ) + password = db.Column(PasswordType(schemes=["pbkdf2_sha512"], max_length=256)) + created = db.Column(DateTime, default=datetime.today) + + roles = db.relationship("Role",secondary="user_roles") + + def add_roles(self,roles): + for role_name in roles: + role=Role.query.filter_by(name=role_name).one() + if not role in self.roles: + self.roles.append(role) + db.session.commit() + + def has_role(self,role_name): + return Role.query.join(User.roles).filter(User.name==self.name,Role.name==role_name).count()>0 + return ret + + def reset_api_key(self): + self.api_key=uuid,uuid4() + db.session.add(self) + db.session.comiit() + + def get_id(self): + return self.name + + def __repr__(self): + return self.name + + +class Job(db.Model): + id = db.Column( + UUIDType(binary=False, native=False), primary_key=True, default=uuid.uuid4 + ) + user_name = db.Column( + db.String, db.ForeignKey("user.name"), nullable=True,index=True + ) + func = db.Column(db.String) + args = db.Column(JSONType) + kwargs = db.Column(JSONType) + state = db.Column(JSONType, default={}) + priority = db.Column(db.Integer, default=0,nullable=True) + created = db.Column(DateTime, default=datetime.today) + finished = db.Column(DateTime, nullable=True, default=None) + started = db.Column(DateTime, nullable=True, default=None) + last_update = db.Column(DateTime, nullable=True, default=None) + user = relationship("User",backref="jobs") + # ============================================================ + + def __repr__(self): + return str(self.id) + + + @property + def future(self): + fut = executor.futures._futures.get(self.id) + return fut + + @property + def sort_key(self): + state_priorities={"Queued":0,"Starting":1,"Error":1,"Stalled":1,"Running":1} + status_key=state_priorities.get(self.status[1],-1)+1 + user=1-int(self.user is not None) + return (user,-status_key,self.priority,self.created) + + @property + def age(self): + dt=datetime.today()-self.created + return dt - dt % timedelta(seconds=1) + + @classmethod + def next(cls): + for job in sorted(cls.query.all(),key=lambda v:v.sort_key): + if job.status[1] in ['Done']: + continue + return job + return None + # return cls.query. + + @property + def status(self): + states=[ + ("primary", "Done"), + ("danger", "Error"), + ("info", "Stalled"), + ("success", "Running"), + ("secondary", "Starting"), + ("warning", "Queued") + ] + #return states[self.id.int%len(states)] + if self.state.get("result"): + return ("primary", "Done") + if self.state.get("error"): + return ("danger", "Error") + if self.state.get("progress"): + if (datetime.today() - self.last_update).total_seconds() > (60 * 10): + return ("info", "Stalled") + return ("success", "Running") + if self.started is not None: + return ("secondary", "Starting") + return ("warning", "Queued") + + @status.setter + def __set_status(self): + raise NotImplementedError + + @property + def dict(self): + return { + "id": self.id, + "args": self.args, + "kwargs": self.kwargs, + "state": self.state, + "finished": self.finished, + "created": self.created, + "started": self.started, + } + + @dict.setter + def __set_dict(self, value): + raise NotImplementedError + + @property + def route(self): + try: + return prepare_route(self.state["result"]) + except KeyError: + return None + + @property + def t_rem(self): + if self.started is None: + return None + runtime = datetime.today() - self.started + try: + prc_done = self.state["progress"]["prc_done"] + if prc_done != 0: + t_rem = (runtime / prc_done) * (100 - prc_done) + return timedelta(seconds=round(t_rem.total_seconds(), 0)) + return None + except KeyError: + return None + + @t_rem.setter + def __set_t_rem(self, value): + raise NotImplementedError + + @classmethod + def new(cls, func, args=None, kwargs=None): + args = args or () + kwargs = kwargs or {} + job = cls(args=args, kwargs=kwargs, func=func.__qualname__) + job.__last_upd = 0.0 + if current_user.is_authenticated: + job.user = current_user + db.session.add(job) + db.session.commit() + return job + + def start(self): + global executor + self.state = {} + self.started = None + db.session.add(self) + db.session.commit() + args = self.args + [self.callback] + try: + future = executor.submit_stored(self.id, compute_route, args, self.kwargs) + except (BrokenProcessPool, RuntimeError) as e: + print("Error:", e) + print("Restarting Executor!") + executor = Executor(app) + future = executor.submit_stored(self.id, compute_route, args, self.kwargs) + future.add_done_callback(self.done) + + def callback(self, cb_state): + try: + if self.started is None: + self.started = datetime.today() + if self.last_update is not None: + time_since_last_upd = ( + datetime.today() - self.last_update + ).total_seconds() + if time_since_last_upd < 5.0: + return + state = dict() + state.update(self.state) + state.update({"progress": cb_state}) + self.state = state + self.last_update = datetime.today() + db.session.add(self) + db.session.commit() + except Exception as e: + print(e) + + def done(self, future): + print(self.id, "DONE") + state = dict() + state.update(self.state) + executor.futures.pop(self.id) + exc = future.exception() + if exc: + state.update( + {"error": {"type": type(exc).__name__, "args": list(exc.args)}} + ) + else: + state.update({"result": future.result()}) + self.state = state + self.finished = datetime.now() + db.session.add(self) + db.session.commit() + + +db.create_all() +for role in ['admin','user','worker_host']: + if Role.query.filter_by(name=role).one_or_none() is None: + db.session.add(Role(role)) + +def create_user(name,password,roles,active=False): + user=User.query.filter_by(name=name).one_or_none() + if user: + db.session.delete(user) + user=User(name=name,password=password,is_active=active) + user.add_roles(roles) + db.session.add(user) + db.session.commit() + return user + +create_user('admin','admin',['admin','user'],True) +create_user('user','user',['user'],True) +create_user('host','host',['user','worker_host'],True) + + + +class SQLAView(ModelView): + column_exclude_list = ["password"] + column_editable_list = [] + create_modal = True + edit_modal = True + can_view_details = True + column_display_pk = True + + def is_accessible(self): + return current_user.is_authenticated and current_user.has_role('admin') + + def inaccessible_callback(self, name, **kwargs): + return redirect(url_for("login")) + + +class UserView(SQLAView): + from wtforms import PasswordField + + column_list = ("name", "active", "password", "api_key","roles") + column_formatters = { + "password": lambda view, context, model, name: "", + "api_key": lambda view, context, model, name: model.api_key or "", + } + form_extra_fields = {"password": PasswordField("Password")} + + +class JobView(SQLAView): + # Job.id,Job.user,Job.func,Job.args,Job.kwargs,Job.state,Job.created,Job.finished,Job.started,Job.last_update + column_list = ("id", "status", "user", "created", "started", "finished") + column_formatters = { + "status": lambda view, context, model, name: model.status[1], + } + + +class WorkerView(SQLAView): + pass + # # Job.id,Job.user,Job.func,Job.args,Job.kwargs,Job.state,Job.created,Job.finished,Job.started,Job.last_update + # column_list = ("id", "status", "user", "created", "started", "finished") + # column_formatters = { + # "user": lambda view, context, model, name: model.user.name + # if model.user + # else "", + # "status": lambda view, context, model, name: model.status[1], + # } + +admin.add_view(JobView(Job, db.session)) +admin.add_view(UserView(User, db.session)) +admin.add_view(SQLAView(Worker, db.session)) +admin.add_view(SQLAView(Role, db.session)) + + +def submit_job(func, *args, **kwargs): + job = Job.new(func, args, kwargs) + job.start() + return job.id + + +@app.route("/api/route", methods=["GET", "POST"]) +@use_kwargs( + { + "jump_range": fields.Float(required=True), + "mode": fields.String( + missing="bfs", validate=validate.OneOf(["bfs", "greedy", "a-star"]) + ), + "systems": fields.DelimitedList(fields.String, required=True), + "permute": fields.String( + missing=None, + validate=validate.OneOf( + ["off", "all", "keep_first", "keep_last", "keep_both"] + ), + ), + "primary": fields.Boolean(missing=False), + "factor": fields.Float(missing=0.5), + } +) +def api_route(_=None, **args): + if args["permute"] == "off": + args["permute"] = None + args["systems"] = [s.strip() for s in args["systems"]] + args = ( + args["systems"], + args["jump_range"], + None, + args["mode"], + args["primary"], + args["permute"] is not None, + args["permute"] in ["keep_first", "keep_both"], + args["permute"] in ["keep_last", "keep_both"], + args["factor"], + None, + r"D:\devel\rust\ED_LRR\stars.csv", + app.config['ROUTE_WORKERS'] + ) + return jsonify({"id": submit_job(ed_lrr.route, *args)}) + + +@app.route("/api/status") +def api_status(): + info = {"queued_jobs": len(executor.futures._futures)} + return jsonify(info) + + +@app.route("/api/whoami") +def api_whoami(): + return jsonify({'name':current_user.name}) + + +@app.route("/api/status/") +def api_job_status(job_id): + job = Job.query.get_or_404(str(job_id)) + return jsonify(job.dict) + + +@app.route("/static/") +def send_static(path): + return send_from_directory("static", path) + + +@app.route("/route", methods=["GET", "POST"]) +@login_required +def route(): + form = RouteForm() + if form.validate_on_submit(): + data = dict(form.data) + if data["permute"] == "off": + data["permute"] = None + del data["csrf_token"] + del data["submit"] + job = api_route(data) + return redirect(url_for("status", job_id=job.json["id"])) + return render_template("form.html", form=form, title="Plot Route") + + +@app.route("/status/",defaults={'job_id':None}) +@app.route("/status/") +@login_required +def status(job_id=None): + if job_id is not None: + job=Job.query.get_or_404(str(job_id)) + return render_template("job.html", job=job) + return render_template( + "status.html", Job=Job, state=request.args.get("state") + ) + +@app.route("/") +def index(): + return render_template("index.html") + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if current_user.is_authenticated: + return redirect(url_for("index")) + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(name=form.data["username"]).one_or_none() + if (user is None) or (user.password != form.data["password"]): + flash("Invalid credentials!", "danger") + return redirect(url_for("login")) + if not user.is_active: + flash("Account is deactivated!", "warning") + return redirect(url_for("login")) + login_user(user, remember=form.data["remember"]) + next = request.args.get('next') + if not is_safe_url(next): + next=None + return redirect(next or url_for("status")) + return render_template("form.html", form=form, title="Login") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + form = RegisterForm() + if form.validate_on_submit(): + if User.query.filter_by(name=form.data["username"]).one_or_none() is not None: + flash('Username already exists','danger') + return render_template("form.html", form=form, title="Register") + user = User() + user.name = form.data["username"] + user.password = form.data["password"] + db.session.add(user) + db.session.commit() + login_user(user) + return redirect(url_for("status")) + return render_template("form.html", form=form, title="Register") + + +@app.route("/change_password", methods=["GET", "POST"]) +def change_password(): + if current_user.is_anonymous: + return redirect(url_for("index")) + form = ChangePasswordForm() + if form.validate_on_submit(): + if form.data["old_password"] == current_user.password: + current_user.password = form.data["password"] + flash("Password changed!", "success") + else: + flash("Wrong password!", "danger") + return render_template("form.html", form=form, title="Register") + return redirect(url_for("status")) + return render_template("form.html", form=form, title="Register") + +@app.route("/workers/") +@login_required +def worker(): + return render_template("workers.html") + +@app.route("/logout") +def logout(): + logout_user() + return redirect(url_for("login")) + + +@app.before_first_request +def resume_jobs(): + print(Job.next()) + with app.test_request_context(): + for job in Job.query.all(): + if job.status[1] != "Done": + print("Restarting {} with state {}".format(job.id, job.status[1])) + job.start() + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=3777, debug=True) diff --git a/ed_lrr_gui/web/config.py b/ed_lrr_gui/web/config.py new file mode 100644 index 0000000..4811887 --- /dev/null +++ b/ed_lrr_gui/web/config.py @@ -0,0 +1,18 @@ +import os + +SECRET_KEY = "ED_LRR_WEBAPP" + +SQLALCHEMY_DATABASE_URI = "sqlite:///ed_lrr_web_ui.db" +SQLALCHEMY_TRACK_MODIFICATIONS = False + +ROUTE_WORKERS = 0 + +EXECUTOR_TYPE = "process" +EXECUTOR_MAX_WORKERS = os.cpu_count()-1 +EXECUTOR_FUTURES_MAX_LENGTH = 500 + +FLASK_ADMIN_SWATCH = "Darkly" + +DEBUG_TB_TEMPLATE_EDITOR_ENABLED = True + +MAIL_DEFAULT_SENDER = '"ED_LRR Admin" ' \ No newline at end of file diff --git a/ed_lrr_gui/web/forms.py b/ed_lrr_gui/web/forms.py new file mode 100644 index 0000000..785d8dc --- /dev/null +++ b/ed_lrr_gui/web/forms.py @@ -0,0 +1,104 @@ +from flask_wtf import FlaskForm +from wtforms import ( + StringField, + PasswordField, + FieldList, + FloatField, + BooleanField, + SelectField, + SubmitField, + validators, + Field, +) +from wtforms.widgets.html5 import NumberInput +from wtforms.widgets import TextInput +from wtforms.validators import ValidationError + +class StringListField(Field): + widget = TextInput() + + def _value(self): + if self.data: + return u",".join(self.data) + else: + return u"" + + def process_formdata(self, valuelist): + if valuelist: + self.data = [x.strip() for x in valuelist[0].split(",")] + else: + self.data = [] + + +class RouteForm(FlaskForm): + systems = StringListField("Systems", [validators.DataRequired()]) + jump_range = FloatField( + "Jump Range (Ly)", + [validators.DataRequired(), validators.NumberRange(0, None)], + widget=NumberInput(min=0, step=0.1), + ) + mode = SelectField( + "Routing Mode", + choices=[ + ("bfs", "Breadth-First Search"), + ("greedy", "Greedy Search"), + ("a-star", "A*-Search"), + ], + ) + permute = SelectField( + "Permutation Mode", + choices=[ + ("off", "Off"), + ("keep_first", "Keep starting system"), + ("keep_last", "Keep destination system"), + ("keep_both", "Keep both endpoints"), + ], + ) + primary = BooleanField("Only route through primary stars") + factor = FloatField( + "Greedyness for A*-Search (%)", + [validators.NumberRange(0, 100)], + default=50, + widget=NumberInput(min=0, max=100, step=1), + ) + + priority = FloatField( + "Priority (0=max, 100=min)", + [validators.NumberRange(0, 100)], + default=0, + widget=NumberInput(min=0, max=100, step=1), + ) + submit = SubmitField("GO!") + + +class LoginForm(FlaskForm): + username = StringField("Username", [validators.Required()]) + password = PasswordField("Password", [validators.Required()]) + remember = BooleanField("Remember me") + submit = SubmitField("Login") + + +class RegisterForm(FlaskForm): + username = StringField("Username", [validators.Required()]) + password = PasswordField( + "Password", + [ + validators.Required(), + validators.EqualTo("confirm", message="Passwords must match"), + ], + ) + confirm = PasswordField("Verify password", [validators.Required()]) + submit = SubmitField("Login") + + +class ChangePasswordForm(FlaskForm): + old_password = PasswordField("Current Password", [validators.Required()]) + password = PasswordField( + "Password", + [ + validators.Required(), + validators.EqualTo("confirm", message="Passwords must match"), + ], + ) + confirm = PasswordField("Verify password", [validators.Required()]) + submit = SubmitField("Change") diff --git a/ed_lrr_gui/web/static/theme.css b/ed_lrr_gui/web/static/theme.css new file mode 100644 index 0000000..cb647bb --- /dev/null +++ b/ed_lrr_gui/web/static/theme.css @@ -0,0 +1,23 @@ +body,input,select,pre { + background-color: #222 !important; + color: #eee; +} +table { + line-height: 1; +} +.progress { + background-color: #444; +} +.progress-bar { + background-color: #f70; +} + +.form-control { + color: #eee !important; +} + +#graph { + border: 1px solid #eee; + width: 512px; + height: 512px; +} \ No newline at end of file diff --git a/ed_lrr_gui/web/templates/admin/index.html b/ed_lrr_gui/web/templates/admin/index.html new file mode 100644 index 0000000..fb7f8ef --- /dev/null +++ b/ed_lrr_gui/web/templates/admin/index.html @@ -0,0 +1,5 @@ +{% extends 'admin/master.html' %} + +{% block body %} +

Hello world

+{% endblock %} \ No newline at end of file diff --git a/ed_lrr_gui/web/templates/base.html b/ed_lrr_gui/web/templates/base.html new file mode 100644 index 0000000..1bfa00a --- /dev/null +++ b/ed_lrr_gui/web/templates/base.html @@ -0,0 +1,48 @@ +{% extends "bootstrap/base.html" %} +{% import "bootstrap/utils.html" as utils %} +{% block title %}Elite: Dangerous Long Range Router{% endblock %} + +{% block scrips %} + +{% endblock %} + +{% block styles %} +{{super()}} + +{% endblock %} + +{% block navbar %} + + +{% endblock %} + +{% block content %} +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category,message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {# application content needs to be provided in the app_content block #} + {% block app_content %}{% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/ed_lrr_gui/web/templates/error/404.html b/ed_lrr_gui/web/templates/error/404.html new file mode 100644 index 0000000..ff69b53 --- /dev/null +++ b/ed_lrr_gui/web/templates/error/404.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + +{% block app_content %} +

404 Not Found

+

+{% endblock %} \ No newline at end of file diff --git a/ed_lrr_gui/web/templates/form.html b/ed_lrr_gui/web/templates/form.html new file mode 100644 index 0000000..f8ed079 --- /dev/null +++ b/ed_lrr_gui/web/templates/form.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} + +{% block app_content %} +

{{title}}

+ {% for field in form %} + {% for error in field.errors %} +
{{error}}
+ {% endfor %} + {% endfor %} +
+
+ {{ wtf.quick_form(form) }} +
+
+{% endblock %} \ No newline at end of file diff --git a/ed_lrr_gui/web/templates/index.html b/ed_lrr_gui/web/templates/index.html new file mode 100644 index 0000000..28898b2 --- /dev/null +++ b/ed_lrr_gui/web/templates/index.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block app_content %} +

E:D LRR

+
+
+ Number of Jobs: {{current_user.jobs|count}} +
+
+{% endblock %} \ No newline at end of file diff --git a/ed_lrr_gui/web/templates/job.html b/ed_lrr_gui/web/templates/job.html new file mode 100644 index 0000000..224af3e --- /dev/null +++ b/ed_lrr_gui/web/templates/job.html @@ -0,0 +1,131 @@ +{% extends "base.html" %} +{% block app_content %} +

Job Status {{ job.status[1] }}

+
+
+ {% if job.state.error %} +
    + {% for err in job.state.error.args %} +
  • {{err}}
  • + {% endfor %} +
+ {% endif %} + + {% if job.state.progress %} +

Routing from {{ job.state.progress.from }} to {{ job.state.progress.to }} using + {{ job.state.progress.mode }}

+

Current system: {{ job.state.progress.system }}

+

Search queue size: {{"{:,}".format(job.state.progress.queue_size) }}

+

Number of systems checked: {{"{:,}".format(job.state.progress.n_seen) }} + ({{job.state.progress.prc_seen|round(2)}} %)

+

Estimated time remaining: {{job.t_rem}}

+

Search Depth: {{job.state.progress.depth}}

+
+
+ {{job.state.progress.prc_done|round(2)}} % +
+
+ {% endif %} + + {% if job.state.result %} +

Result

+ +

Map

+
+
+ + +

Jumps

+ + + + + + + + + + + {% for sys in job.route %} + + + + + + + {% endfor %} + + + + +
NumBodyTypeDistance from arrivalJump distance
{{sys.num}} + {{sys.body}}{{sys.star_type}}{{"{:,}".format(sys.distance)}} Ls{{"{:,}".format(sys.jump_dist|round(2))}} Ly
Total Distance{{"{:,}".format(job.route|sum(attribute='jump_dist')|round(2))}} Ly
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/ed_lrr_gui/web/templates/status.html b/ed_lrr_gui/web/templates/status.html new file mode 100644 index 0000000..d0a9fe2 --- /dev/null +++ b/ed_lrr_gui/web/templates/status.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} + +{% block app_content %} + {% if current_user.has_role('admin') %} + {% set jobs = Job.query.all() %} + {% else %} + {% set jobs = current_user.jobs %} + {% endif %} +

System Status

+
+

Overview

+
+
+ + + + + + {% for group in (jobs|groupby('status')) %} + + + + + {% endfor %} + + + + +
StatusCount
+ + {{ group.grouper[1] }} + + + {{group.list|count}} +
+ + Total + + + {{jobs|count}} +
+
+
+

Jobs

+
+
+ + + + + + + + + + + + + + + {% for job in (jobs|sort(attribute='sort_key')) %} + {% if (state==None) or job.status[1]==state %} + + + + + + + {% if job.state.progress %} + + {% else %} + + {% endif %} + + + + {% endif %} + {% endfor %} +
IDSystemsStatusUserPriorityProgessETCCreated
{{job.id}}{{job.args[0]|join(', ')}}{{ job.status[1] }}{{job.user.name}}{{job.priority}} +
+
+ {{job.state.progress.prc_done|round(2)}} % +
+
+
Unknown{{job.t_rem}}{{job.age}} ago
+
+{% endblock %} \ No newline at end of file diff --git a/ed_lrr_gui/web/templates/workers.html b/ed_lrr_gui/web/templates/workers.html new file mode 100644 index 0000000..9e58735 --- /dev/null +++ b/ed_lrr_gui/web/templates/workers.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block app_content %} +

Workers

+
+
+ {% if current_user.is_authenticated %} + Hello {{current_user.name}}! + {% else %} + Nothing to see here! + {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/ed_lrr_gui/web/utils.py b/ed_lrr_gui/web/utils.py new file mode 100644 index 0000000..6f5fe35 --- /dev/null +++ b/ed_lrr_gui/web/utils.py @@ -0,0 +1,73 @@ +from flask_nav.renderers import Renderer +from dominate import tags +from urllib.parse import urlparse,urljoin +from flask import request + +def is_safe_url(target): + ref_url = urlparse(request.host_url) + test_url = urlparse(urljoin(request.host_url, target)) + return test_url.scheme in ('http', 'https') and \ + ref_url.netloc == test_url.netloc + +def dist(p1, p2): + s = 0 + for c1, c2 in zip(p1, p2): + s += (c1 - c2) ** 2 + return s ** 0.5 + + +class BootsrapRenderer(Renderer): + def visit_Navbar(self, node): + sub = [] + for item in node.items: + sub.append(self.visit(item)) + return "".join([v.render() for v in sub]) + + def visit_View(self, node): + classes = ["nav-link"] + if node.active: + classes.append("active") + return tags.li( + tags.a(node.text, href=node.get_url(), cls=" ".join(classes)), + cls="nav-item", + ) + + def visit_Subgroup(self, node): + # almost the same as visit_Navbar, but written a bit more concise + return tags.div(node.title, *[self.visit(item) for item in node.items]) + + +colors = { + "O": "#0000FF", + "B": "#140AF0", + "A": "#3C1EDC", + "F": "#EEEEEE", + "G": "#969646", + "K": "#B43C1E", + "M": "#FF280A", + "L": "#FF1E00", + "T": "#800000", + "Y": "#800000", + "White Dwarf": "#5D67EF", + "Neutron": "#99A0FF", +} + + +def prepare_route(route): + entries = [] + prev = route[0] + num = 1 + for hop in route[1:]: + prev["jump_dist"] = dist(hop["pos"], prev["pos"]) + prev["num"] = num + prev["color"] = colors.get(prev["star_type"].split()[0], "#eee") + prev["distance"] = prev["distance"] + entries.append(prev) + prev = hop + num += 1 + prev["jump_dist"] = 0 + prev["distance"] = prev["distance"] + prev["num"] = num + prev["color"] = colors.get(prev["star_type"].split()[0], "#eee") + entries.append(prev) + return entries diff --git a/ed_lrr_gui/web/worker.py b/ed_lrr_gui/web/worker.py new file mode 100644 index 0000000..7182efe --- /dev/null +++ b/ed_lrr_gui/web/worker.py @@ -0,0 +1,6 @@ +import requests as RQ +import _ed_lrr as ed_lrr + +funcs = { + func: getattr(ed_lrr, func) for func in dir(ed_lrr) if not func.startswith("_") +} diff --git a/rust/src/galaxy.rs b/rust/src/galaxy.rs new file mode 100644 index 0000000..9bd1fbb --- /dev/null +++ b/rust/src/galaxy.rs @@ -0,0 +1,68 @@ +use serde::Deserialize; +use serde_json::Result; +use serde_json; +use std::fs::File; +use std::io::Seek; +use std::io::{BufRead, BufReader, BufWriter, SeekFrom}; +use std::path::PathBuf; +use std::str; +use std::time::Instant; + +#[derive(Debug, Deserialize)] +struct Coords { + x: f32, + y: f32, + z: f32, +} + +#[derive(Debug, Deserialize)] +struct Body { + name: String, + #[serde(rename = "type")] + body_type: String, + subType: String, + #[serde(rename = "distanceToArrival")] + distance: f32, +} + +#[derive(Debug, Deserialize)] +struct System { + coords: Coords, + name: String, + bodies: Vec, +} + +fn main() -> std::io::Result<()> { + better_panic::install(); + let mut buffer = String::new(); + let mut bz2_reader = std::process::Command::new("bzip2").args( + &["-d","-c",r#"E:\EDSM\galaxy.json.bz2"#] + ).stdout(std::process::Stdio::piped()) + .spawn() + .expect("Failed to spawn execute bzip2!"); + let mut reader = BufReader::new(bz2_reader.stdout.as_mut().expect("Failed to open stdout of child process")); + let mut count = 0; + while let Ok(n) = reader.read_line(&mut buffer) { + if n==0 { + break; + } + buffer = buffer + .trim() + .trim_end_matches(|c| c == ',') + .trim() + .to_string(); + if let Ok(sys) = serde_json::from_str::(&buffer) { + for b in &sys.bodies { + if b.body_type == "Star" { + count += 1; + if (count % 100_000) == 0 { + println!("{}: {:?}", count, b); + } + } + } + } + buffer.clear(); + } + println!("Total: {}", count); + Ok(()) +} diff --git a/tests/test_ed_lrr.py b/tests/test_ed_lrr.py new file mode 100644 index 0000000..2f2e3f9 --- /dev/null +++ b/tests/test_ed_lrr.py @@ -0,0 +1,24 @@ +import pytest + +stars_csv = "D:\\devel\\rust\\ED_LRR\\stars.csv" + + +@pytest.mark.dependency() +def test_import(): + import _ed_lrr + + +@pytest.mark.dependency(depends=["test_import"]) +def test_search_works(): + import _ed_lrr + + system_names = ["Ix", "Sol", "Colonia", "Sagittarius A*"] + systems = _ed_lrr.find_sys(system_names, stars_csv) + print(systems) + + +@pytest.mark.dependency(depends=["test_import"]) +def test_zero_range_fails(): + import _ed_lrr + + # _ed_lrr.route() diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 0000000..6eb59d5 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,18 @@ +import pytest + + +@pytest.mark.dependency() +def test_import(): + import ed_lrr_gui + from ed_lrr_gui.main import main + import ed_lrr_gui.gui as ED_LRR_GUI + + +@pytest.mark.dependency(depends=["test_import"]) +def test_search_works(): + import ed_lrr_gui + + +@pytest.mark.dependency(depends=["test_import"]) +def test_zero_range_fails(): + import ed_lrr_gui