Remove launcher

This commit is contained in:
Herklos
2019-01-02 10:13:29 +01:00
parent d9a82dca90
commit 9b86206929
10 changed files with 2 additions and 691 deletions

View File

@@ -10,4 +10,3 @@ omit =
tentacles/*
setup.py
start.py
launcher.py

View File

@@ -29,7 +29,7 @@ Octobot's main feature is **evolution** : you can [install](https://github.com/D
## Installation
OctoBot's installation is **very simple**... because **very documented** !
- Open the OctoBot [release page](https://github.com/Drakkar-Software/OctoBot/releases)
- Open the OctoBot-Launcher [release page](https://github.com/Drakkar-Software/OctoBot-Launcher/releases)
- Download launcher (*laucher_windows.exe* or *launcher_linux*)
- Start the launcher
- Click on "Update OctoBot"

View File

@@ -18,7 +18,6 @@ from logging import WARNING
from enum import Enum
PROJECT_NAME = "OctoBot"
PROJECT_LAUNCHER = "octobot-launcher"
SHORT_VERSION = "0.2.4"
MINOR_VERSION = "0"
VERSION_DEV_PHASE = "beta"
@@ -285,9 +284,6 @@ EVALUATOR_CONFIG_KEY = "evaluator_config"
TRADING_CONFIG_KEY = "trading_config"
COIN_MARKET_CAP_CURRENCIES_LIST_URL = "https://api.coinmarketcap.com/v2/listings/"
# launcher
LAUNCHER_PATH = "interfaces/gui/launcher"
class TentacleManagerActions(Enum):
INSTALL = 1

View File

@@ -1,32 +0,0 @@
# -*- mode: python -*-
block_cipher = None
a = Analysis(['../../launcher.py'],
pathex=['../../'],
binaries=[],
datas=[('../../interfaces/web', 'interfaces/web')],
hiddenimports=["glob", "subprocess", "json", "requests", "os", "logging",
"tkinter", "tkinter.ttk", "tkinter.dialog", "tkinter.dialog", "tkinter.messagebox",
"distutils", "distutils.version", "config", "logging"],
hookspath=[],
runtime_hooks=[],
excludes=["interfaces.gui.launcher"],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
name='launcher',
debug=False,
strip=False,
icon="../../interfaces/web/static/favicon.ico",
upx=True,
runtime_tmpdir=None,
console=True )

View File

@@ -3,16 +3,13 @@ $scripts_dir = 'pyinstaller.exe'
# specs path
$binary_path = """start.spec"""
$launcher_path = """launcher.spec"""
function DeliverOctobotForWindows($name, $python_dir)
{
$name
cd $python_dir
"Compiling launcher..."
& $scripts_dir $binary_path
"Compiling binary..."
& $scripts_dir $launcher_path
& $scripts_dir $binary_path
}

View File

@@ -1,15 +0,0 @@
# Drakkar-Software OctoBot
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.

View File

@@ -1,163 +0,0 @@
# Drakkar-Software OctoBot
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
import os
import subprocess
import sys
from threading import Thread
from time import sleep
from tkinter.dialog import Dialog, DIALOG_ICON
from tkinter.ttk import Progressbar, Label, Button
from config import PROJECT_NAME
from interfaces.gui.util.app_util import AbstractTkApp
from interfaces.gui.launcher import launcher_controller
from interfaces.gui.launcher.launcher_controller import Launcher
LAUNCHER_VERSION = "1.0.4"
class LauncherApp(AbstractTkApp):
PROGRESS_MIN = 0
PROGRESS_MAX = 100
def __init__(self):
self.window_title = f"{PROJECT_NAME} - Launcher"
self.progress = None
self.progress_label = None
self.start_bot_button = None
self.update_bot_button = None
self.bot_version_label = None
self.launcher_version_label = None
self.update_launcher_button = None
self.export_logs_button = None
Launcher.ensure_minimum_environment()
self.processing = False
super().__init__()
def create_components(self):
# bot update
self.update_bot_button = Button(self.top_frame, command=self.update_bot_handler,
text="Install/Update Octobot", style='Bot.TButton')
self.bot_version_label = Label(self.top_frame,
text="",
style='Bot.TLabel')
self.update_bot_button.grid(row=1, column=2, padx=200, pady=5)
self.bot_version_label.grid(row=1, column=1)
self.update_bot_version()
# launcher update
self.update_launcher_button = Button(self.top_frame, command=self.update_launcher_handler,
text="Update Launcher", style='Bot.TButton')
self.launcher_version_label = Label(self.top_frame,
text=f"Launcher version : {LAUNCHER_VERSION}",
style='Bot.TLabel')
self.update_launcher_button.grid(row=2, column=2, padx=200, pady=5)
self.launcher_version_label.grid(row=2, column=1, )
# buttons
self.start_bot_button = Button(self.top_frame, command=self.start_bot_handler,
text="Start Octobot", style='Bot.TButton')
self.start_bot_button.grid(row=3, column=1)
# bottom
self.progress = Progressbar(self.bottom_frame, orient="horizontal",
length=200, mode="determinate", style='Bot.Horizontal.TProgressbar')
self.progress.grid(row=1, column=1, padx=5, pady=5)
self.progress_label = Label(self.bottom_frame, text=f"{self.PROGRESS_MIN}%", style='Bot.TLabel')
self.progress_label.grid(row=1, column=2, padx=5)
self.progress["value"] = self.PROGRESS_MIN
self.progress["maximum"] = self.PROGRESS_MAX
def inc_progress(self, inc_size, to_min=False, to_max=False):
if to_max:
self.progress["value"] = self.PROGRESS_MAX
self.progress_label["text"] = f"{self.PROGRESS_MAX}%"
elif to_min:
self.progress["value"] = self.PROGRESS_MIN
self.progress_label["text"] = f"{self.PROGRESS_MIN}%"
else:
self.progress["value"] += inc_size
self.progress_label["text"] = f"{round(self.progress['value'], 1)}%"
def update_bot_handler(self):
if not self.processing:
thread = Thread(target=self.update_bot, args=(self,))
thread.start()
def update_launcher_handler(self):
if not self.processing:
launcher_process = subprocess.Popen([sys.executable, "--update_launcher"])
if launcher_process:
self.hide()
launcher_process.wait()
new_launcher_process = subprocess.Popen([sys.executable])
if new_launcher_process:
self.stop()
def start_bot_handler(self):
if not self.processing:
bot_process = Launcher.execute_command_on_detached_bot()
if bot_process:
self.hide()
bot_process.wait()
self.stop()
def update_bot_version(self):
current_server_version = launcher_controller.Launcher.get_current_server_version()
current_bot_version = launcher_controller.Launcher.get_current_bot_version()
self.bot_version_label["text"] = f"Bot version : " \
f"{current_bot_version if current_bot_version else 'Not found'}" \
f" (Latest : " \
f"{current_server_version if current_server_version else 'Not found'})"
@staticmethod
def update_bot(app=None):
if app:
app.processing = True
launcher_controller.Launcher(app)
if app:
app.processing = False
sleep(1)
app.update_bot_version()
def show_alert(self, text, strings=("OK",), title="Alert", bitmap=DIALOG_ICON, default=0):
return Dialog(self.window, text=text, title=title, bitmap=bitmap, default=default, strings=strings)
@staticmethod
def export_logs():
pass
@staticmethod
def close_callback():
os._exit(0)
def start_app(self):
self.window.mainloop()
def hide(self):
self.window.withdraw()
def stop(self):
self.window.quit()

View File

@@ -1,326 +0,0 @@
# Drakkar-Software OctoBot
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
import glob
import json
import logging
import os
import subprocess
import urllib.request
from distutils.version import LooseVersion
from subprocess import PIPE
from tkinter.messagebox import WARNING
import requests
from config import CONFIG_FILE, PROJECT_NAME, GITHUB_API_CONTENT_URL, GITHUB_REPOSITORY, GITHUB_RAW_CONTENT_URL, \
VERSION_DEV_PHASE, DEFAULT_CONFIG_FILE, LOGGING_CONFIG_FILE, DeliveryPlatformsName, TENTACLES_PATH, \
CONFIG_DEFAULT_EVALUATOR_FILE, CONFIG_DEFAULT_TRADING_FILE, CONFIG_INTERFACES, CONFIG_INTERFACES_WEB, \
OCTOBOT_BACKGROUND_IMAGE, OCTOBOT_ICON
FOLDERS_TO_CREATE = ["logs", "backtesting/collector/data"]
FILES_TO_DOWNLOAD = [
(
f"{GITHUB_RAW_CONTENT_URL}/cjhutto/vaderSentiment/master/vaderSentiment/emoji_utf8_lexicon.txt",
"vaderSentiment/emoji_utf8_lexicon.txt"),
(
f"{GITHUB_RAW_CONTENT_URL}/cjhutto/vaderSentiment/master/vaderSentiment/vader_lexicon.txt",
"vaderSentiment/vader_lexicon.txt"),
(
f"{GITHUB_RAW_CONTENT_URL}/{GITHUB_REPOSITORY}/{VERSION_DEV_PHASE}/{DEFAULT_CONFIG_FILE}",
CONFIG_FILE
),
(
f"{GITHUB_RAW_CONTENT_URL}/{GITHUB_REPOSITORY}/{VERSION_DEV_PHASE}/{CONFIG_DEFAULT_EVALUATOR_FILE}",
CONFIG_DEFAULT_EVALUATOR_FILE
),
(
f"{GITHUB_RAW_CONTENT_URL}/{GITHUB_REPOSITORY}/{VERSION_DEV_PHASE}/{CONFIG_DEFAULT_TRADING_FILE}",
CONFIG_DEFAULT_TRADING_FILE
)
]
IMAGES_TO_DOWNLOAD = [
(
f"{GITHUB_RAW_CONTENT_URL}/{GITHUB_REPOSITORY}/{VERSION_DEV_PHASE}/{LOGGING_CONFIG_FILE}",
LOGGING_CONFIG_FILE
),
(
f"{GITHUB_RAW_CONTENT_URL}/{GITHUB_REPOSITORY}/{VERSION_DEV_PHASE}/{CONFIG_INTERFACES}/{CONFIG_INTERFACES_WEB}"
f"/{OCTOBOT_BACKGROUND_IMAGE}",
f"{CONFIG_INTERFACES}/{CONFIG_INTERFACES_WEB}/{OCTOBOT_BACKGROUND_IMAGE}"
),
(
f"{GITHUB_RAW_CONTENT_URL}/{GITHUB_REPOSITORY}/{VERSION_DEV_PHASE}/{CONFIG_INTERFACES}/{CONFIG_INTERFACES_WEB}"
f"/{OCTOBOT_ICON}",
f"{CONFIG_INTERFACES}/{CONFIG_INTERFACES_WEB}/{OCTOBOT_ICON}"
)
]
GITHUB_LATEST_RELEASE_URL = f"{GITHUB_API_CONTENT_URL}/repos/{GITHUB_REPOSITORY}/releases/latest"
LIB_FILES_DOWNLOAD_PROGRESS_SIZE = 5
CREATE_FOLDERS_PROGRESS_SIZE = 5
BINARY_DOWNLOAD_PROGRESS_SIZE = 75
TENTACLES_UPDATE_INSTALL_PROGRESS_SIZE = 15
class Launcher:
def __init__(self, inst_app):
self.launcher_app = inst_app
self.create_environment()
binary_path = self.update_binary()
# give binary execution rights if necessary
if binary_path:
self.binary_execution_rights(binary_path)
# if update tentacles
if binary_path:
self.update_tentacles(binary_path)
else:
logging.error(f"No {PROJECT_NAME} found to update tentacles.")
@staticmethod
def _ensure_directory(file_path):
directory = os.path.dirname(file_path)
if not os.path.exists(directory) and directory:
os.makedirs(directory)
@staticmethod
def ensure_minimum_environment():
need_to_create_environment = False
try:
for file_to_dl in IMAGES_TO_DOWNLOAD:
Launcher._ensure_directory(file_to_dl[1])
file_name = file_to_dl[1]
if not os.path.isfile(file_name) and file_name:
if not need_to_create_environment:
print("Creating minimum launcher environment...")
need_to_create_environment = True
urllib.request.urlretrieve(file_to_dl[0], file_name)
for folder in FOLDERS_TO_CREATE:
if not os.path.exists(folder) and folder:
os.makedirs(folder)
except Exception as e:
print(f"Error when creating minimum launcher environment: {e} this should not prevent launcher "
f"from working.")
def create_environment(self):
self.launcher_app.inc_progress(0, to_min=True)
logging.info(f"{PROJECT_NAME} is checking your environment...")
# download files
for file_to_dl in FILES_TO_DOWNLOAD:
Launcher._ensure_directory(file_to_dl[1])
file_name = file_to_dl[1]
if not os.path.isfile(file_name) and file_name:
with open(file_name, "wb") as new_file_from_dl:
file_content = requests.get(file_to_dl[0]).text
new_file_from_dl.write(file_content.encode())
self.launcher_app.window.update()
if self.launcher_app:
self.launcher_app.inc_progress(LIB_FILES_DOWNLOAD_PROGRESS_SIZE)
if self.launcher_app:
self.launcher_app.inc_progress(CREATE_FOLDERS_PROGRESS_SIZE)
logging.info(f"Your {PROJECT_NAME} environment is ready !")
def update_binary(self):
# parse latest release
try:
logging.info(f"{PROJECT_NAME} is checking for updates...")
latest_release_data = self.get_latest_release_data()
# try to found in current folder binary
binary_path = self.get_local_bot_binary()
# if current octobot binary found
if binary_path:
logging.info(f"{PROJECT_NAME} installation found, analyzing...")
last_release_version = latest_release_data["tag_name"]
current_bot_version = self.get_current_bot_version(binary_path)
try:
check_new_version = LooseVersion(current_bot_version) < LooseVersion(last_release_version)
except AttributeError:
check_new_version = False
if check_new_version:
logging.info(f"Upgrading {PROJECT_NAME} : from {current_bot_version} to {last_release_version}...")
return self.download_binary(latest_release_data, replace=True)
else:
logging.info(f"Nothing to do : {PROJECT_NAME} is up to date")
if self.launcher_app:
self.launcher_app.inc_progress(BINARY_DOWNLOAD_PROGRESS_SIZE)
return binary_path
else:
return self.download_binary(latest_release_data)
except Exception as e:
logging.exception(f"Failed to download latest release data : {e}")
@staticmethod
def get_current_bot_version(binary_path=None):
if not binary_path:
binary_path = Launcher.get_local_bot_binary()
return Launcher.execute_command_on_current_bot(binary_path, ["--version"])
@staticmethod
def get_local_bot_binary():
binary = None
try:
# try to found in current folder binary
if os.name == 'posix':
binary = "./" + next(iter(glob.glob(f'{PROJECT_NAME}*')))
elif os.name == 'nt':
binary = next(iter(glob.glob(f'{PROJECT_NAME}*.exe')))
elif os.name == 'mac':
pass
except StopIteration:
binary = None
return binary
@staticmethod
def get_current_server_version(latest_release_data=None):
if not latest_release_data:
latest_release_data = Launcher.get_latest_release_data()
return latest_release_data["tag_name"]
@staticmethod
def get_latest_release_data():
return json.loads(requests.get(GITHUB_LATEST_RELEASE_URL).text)
@staticmethod
def execute_command_on_current_bot(binary_path, commands):
try:
cmd = [f"{binary_path}"] + commands
return subprocess.Popen(cmd, stdout=PIPE).stdout.read().rstrip().decode()
except PermissionError as e:
logging.error(f"Failed to run bot with command {commands} : {e}")
except FileNotFoundError as e:
logging.error(f"Can't find a valid binary")
@staticmethod
def execute_command_on_detached_bot(binary_path=None, commands=None):
try:
if not binary_path:
binary_path = Launcher.get_local_bot_binary()
cmd = [f"{binary_path}"] + (commands if commands else [])
return subprocess.Popen(cmd)
except Exception as e:
logging.error(f"Failed to run detached bot with command {commands} : {e}")
return None
@staticmethod
def get_asset_from_release_data(latest_release_data):
os_name = None
# windows
if os.name == 'nt':
os_name = DeliveryPlatformsName.WINDOWS
# linux
if os.name == 'posix':
os_name = DeliveryPlatformsName.LINUX
# mac
if os.name == 'mac':
os_name = DeliveryPlatformsName.MAC
# search for corresponding release
for asset in latest_release_data["assets"]:
asset_name, _ = os.path.splitext(asset["name"])
if f"{PROJECT_NAME}_{os_name.value}" in asset_name:
return asset
return None
def download_binary(self, latest_release_data, replace=False):
binary = self.get_asset_from_release_data(latest_release_data)
if binary:
final_size = binary["size"]
increment = (BINARY_DOWNLOAD_PROGRESS_SIZE / (final_size / 1024))
r = requests.get(binary["browser_download_url"], stream=True)
binary_name, binary_ext = os.path.splitext(binary["name"])
path = f"{PROJECT_NAME}{binary_ext}"
if r.status_code == 200:
if replace and os.path.isfile(path):
try:
os.remove(path)
except OSError as e:
logging.error(f"Can't remove old version binary : {e}")
with open(path, 'wb') as f:
for chunk in r.iter_content(1024):
f.write(chunk)
if self.launcher_app:
self.launcher_app.inc_progress(increment)
return path
else:
logging.error("Release not found on server")
return None
def update_tentacles(self, binary_path):
# if install required
if not os.path.exists(TENTACLES_PATH):
self.execute_command_on_current_bot(binary_path, ["-p", "install", "all"])
logging.info(f"Tentacles : all default tentacles have been installed.")
# update
else:
self.execute_command_on_current_bot(binary_path, ["-p", "update", "all"])
logging.info(f"Tentacles : all default tentacles have been updated.")
if self.launcher_app:
self.launcher_app.inc_progress(TENTACLES_UPDATE_INSTALL_PROGRESS_SIZE, to_max=True)
def binary_execution_rights(self, binary_path):
if os.name == 'posix':
try:
rights_process = subprocess.Popen(["chmod", "+x", binary_path])
except Exception as e:
logging.error(f"Failed to give execution rights to {binary_path} : {e}")
rights_process = None
if not rights_process:
# show message if user has to type the command
message = f"{PROJECT_NAME} binary need execution rights, " \
f"please type in a command line 'sudo chmod +x ./{PROJECT_NAME}'"
logging.warning(message)
if self.launcher_app:
self.launcher_app.show_alert(f"{message} and then press OK", bitmap=WARNING)

View File

@@ -1,89 +0,0 @@
# Drakkar-Software OctoBot
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
import argparse
import importlib
import logging
import os
import sys
import requests
from config import GITHUB_RAW_CONTENT_URL, GITHUB_REPOSITORY, LAUNCHER_PATH
# should have VERSION_DEV_PHASE
LAUNCHER_URL = f"{GITHUB_RAW_CONTENT_URL}/{GITHUB_REPOSITORY}/dev/{LAUNCHER_PATH}"
LAUNCHER_FILES = ["__init__.py", "launcher_app.py", "launcher_controller.py", "../util/app_util.py"]
sys.path.append(os.path.dirname(sys.executable))
def update_launcher(force=False):
for file in LAUNCHER_FILES:
create_launcher_files(f"{LAUNCHER_URL}/{file}", f"{LAUNCHER_PATH}/{file}", force=force)
logging.info("Launcher updated")
def create_launcher_files(file_to_dl, result_file_path, force=False):
file_content = requests.get(file_to_dl).text
directory = os.path.dirname(result_file_path)
if not os.path.exists(directory) and directory:
os.makedirs(directory)
file_name = result_file_path
if (not os.path.isfile(file_name) and file_name) or force:
with open(file_name, "w") as new_file_from_dl:
new_file_from_dl.write(file_content)
def start_launcher(args):
if args.version:
print(LAUNCHER_VERSION)
else:
if args.update_launcher:
update_launcher(force=True)
elif args.update:
LauncherApp.update_bot()
elif args.export_logs:
LauncherApp.export_logs()
else:
LauncherApp()
logging.basicConfig(level=logging.INFO)
parser = argparse.ArgumentParser(description='OctoBot - Launcher')
parser.add_argument('-v', '--version', help='show OctoBot Launcher current version',
action='store_true')
parser.add_argument('-u', '--update', help='update OctoBot with the latest version available',
action='store_true')
parser.add_argument('-l', '--update_launcher', help='update OctoBot Launcher with the latest version available',
action='store_true')
parser.add_argument('-e', '--export_logs', help="export Octobot's last logs",
action='store_true')
args = parser.parse_args()
update_launcher()
try:
from interfaces.gui.launcher.launcher_app import *
except ImportError:
importlib.import_module("interfaces.gui.launcher.launcher_app")
start_launcher(args)

View File

@@ -1,56 +0,0 @@
# Drakkar-Software OctoBot
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
from setuptools import setup
from config import VERSION, PROJECT_LAUNCHER
DESCRIPTION = open('README.md').read() + '\n\n' + open('docs/CHANGELOG.md').read()
REQUIRED = open('pre_requirements.txt').read() + open('requirements.txt').read()
REQUIRED_DEV = open('dev_requirements.txt').read()
setup(
name=PROJECT_LAUNCHER,
version=VERSION,
url='https://github.com/Drakkar-Software/OctoBot',
license='LGPL-3.0',
author='Drakkar-Software',
author_email='drakkar.software@protonmail.com',
description='Cryptocurrencies alert / trading bot',
py_modules=['launcher'],
packages=['interfaces.gui.launcher', 'interfaces.gui.util', 'config'],
long_description=DESCRIPTION,
install_requires=REQUIRED,
tests_require=REQUIRED_DEV,
test_suite="tests",
zip_safe=False,
python_requires='>=3.7',
entry_points={
'console_scripts': [
PROJECT_LAUNCHER + ' = launcher:main'
]
},
classifiers=[
'Development Status :: 4 - Beta',
'Operating System :: OS Independent',
'Operating System :: MacOS :: MacOS X',
'Operating System :: Microsoft :: Windows',
'Operating System :: POSIX',
'Programming Language :: Python',
'Programming Language :: Python :: 3.7',
],
)