Compare commits

..

10 Commits

16 changed files with 264 additions and 81 deletions

View File

@ -8,6 +8,7 @@ pyside6 = "~=6.3"
requests = "~=2.28"
requests-futures = "~=1.0"
packaging = "~=21.3"
loguru = "~=0.6"
[dev-packages]
pyinstaller = "~=5.1"

10
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "633aca7e0b43a295ce3429d3e12976a4c16dcf422cee53e37d266f8000ef5feb"
"sha256": "b04dfa0eda7fd2df5b6d27d5a1f7d71168c984c74ce363c45a888430bca7b7d8"
},
"pipfile-spec": 6,
"requires": {
@ -40,6 +40,14 @@
"markers": "python_version >= '3.5'",
"version": "==3.4"
},
"loguru": {
"hashes": [
"sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c",
"sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"
],
"index": "pypi",
"version": "==0.6.0"
},
"packaging": {
"hashes": [
"sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",

8
scripts/release.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
last_patch=$(git tag | sort -V | tail -n1 | cut -d "." -f3)
git tag "1.0.$((last_patch + 1))"
git push gitlab main -o ci.skip
git push gitlab --tags
git push origin main
git push origin --tags

View File

@ -22,6 +22,7 @@ install_requires =
requests
requests-futures
packaging
loguru
[options.packages.find]
where = src

View File

@ -1,5 +1,5 @@
try:
from fime._version import __version__, __version_tuple__
except ImportError:
__version__ = version = '1.0.0.dev0'
__version_tuple__ = version_tuple = (1, 0, 0, 'dev0')
__version__ = version = '0+devfallback'
__version_tuple__ = version_tuple = (0, 'devfallback')

View File

@ -1,22 +1,22 @@
import sys
import traceback
from copy import copy
from pathlib import Path
from textwrap import dedent
from threading import Lock
from typing import Optional
from loguru import logger
from packaging.version import Version
from requests_futures.sessions import FuturesSession
from fime.progressindicator import ProgressIndicator
from fime.util import get_icon
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
import fime
from fime.progressindicator import ProgressIndicator
from fime.util import get_icon
URL = "https://gitlab.com/faerbit/fime/-/releases/permalink/latest"
@ -51,8 +51,7 @@ class UpdateChecker:
else:
self.result = f"Newer fime version available: {latest_version}"
except Exception:
print("Could not get update info:\n", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
logger.exception("Could not get update info")
finally:
with self.lock:
self._done = True
@ -63,14 +62,18 @@ class About(QtWidgets.QDialog):
super().__init__(parent, *args, **kwargs)
self.setWindowTitle("About")
log_dir_path = Path(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.AppDataLocation)) / "logs"
text = dedent(f"""\
fime
Copyright (c) 2020 - 2022 Faerbit
<a href="https://gitlab.com/faerbit/fime/-/blob/main/LICENSE">License</a>
<a href="https://fime.faerb.it">Website</a> <a href="https://gitlab.com/faerbit/fime/-/blob/main/LICENSE">License</a>
fime Version {fime.__version__}
Qt Version {QtCore.__version__}
Python Version {sys.version}
Log directory: <a href="{log_dir_path}">{log_dir_path}</a>
""")
text = text.replace("\n", "<br/>")
version_label = QtWidgets.QLabel(self)

View File

@ -1,6 +1,9 @@
from configparser import ConfigParser
from io import StringIO
from pathlib import Path
from loguru import logger
try:
from PySide6 import QtCore
except ImportError:
@ -20,32 +23,63 @@ class Config:
def __init__(self):
self._configparser = ConfigParser()
config_dir_path = Path(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.AppConfigLocation))
config_path = config_dir_path / "fime.conf"
if config_path.exists():
print(f'Reading config file "{config_path}"')
with open(config_path) as f:
self.config_path = config_dir_path / "fime.conf"
if self.config_path.exists():
logger.info(f'reading config file "{self.config_path}"')
with self.config_path.open(encoding="utf-8") as f:
config_text = f.read()
config_text = "[DEFAULT]\n" + config_text
self._configparser.read_string(config_text)
# TODO change menu items
if (not self._configparser.has_option("DEFAULT", "jira_url") or
not self._configparser.has_option("DEFAULT", "jira_token")):
raise FimeException(f'Please add config file {config_path} '
f'with config keys "jira_url" and "jira_token" in INI style')
raise FimeException(f'Please add config file {self.config_path} '
f'with config keys "jira_url" and "jira_token" in INI style')
def save(self):
logger.info(f'writing config file "{self.config_path}"')
config_str = StringIO()
self._configparser.write(config_str)
# do not conform to configparser's stupid section requirement
config_str = "\n".join(config_str.getvalue().splitlines()[1:])
with self.config_path.open("w", encoding="utf-8") as f:
f.write(config_str)
@property
def jira_url(self):
return dequotify(self._configparser["DEFAULT"]["jira_url"])
@jira_url.setter
def jira_url(self, value):
self._configparser["DEFAULT"]["jira_url"] = f'"{value}"'
@property
def jira_token(self):
return dequotify(self._configparser["DEFAULT"]["jira_token"])
@jira_token.setter
def jira_token(self, value):
self._configparser["DEFAULT"]["jira_token"] = f'"{value}"'
@property
def tray_theme(self):
val = dequotify(self._configparser.get("DEFAULT", "tray_theme", fallback="dark")).lower()
return val if val in ["light", "dark"] else "dark"
@tray_theme.setter
def tray_theme(self, value):
value = value.lower()
if value not in ["light", "dark"]:
raise RuntimeError('config key "tray_theme" can only be set to "light" or "dark"')
self._configparser["DEFAULT"]["tray_theme"] = f'"{value}"'
@property
def flip_menu(self):
val = dequotify(self._configparser.get("DEFAULT", "flip_menu", fallback="no")).lower()
return val in ["yes", "true", "1"]
@flip_menu.setter
def flip_menu(self, value):
if type(value) is not bool:
raise RuntimeError('config key "flip_menu" must be a bool')
self._configparser["DEFAULT"]["flip_menu"] = f'"{value}"'

View File

@ -8,6 +8,8 @@ from datetime import datetime, date, timedelta, time
from threading import Thread, Event
from typing import List, Tuple, Dict, Union
from loguru import logger
try:
from PySide6 import QtCore
except ImportError:
@ -63,7 +65,7 @@ class Data(MutableMapping):
def _save(self):
for key in self._hot_keys:
print(f"... saving dict {key} ...")
logger.info(f"saving dict {key}")
to_write = self._cache[key] # apparently thread-safe
with open(self.data_path.format(key), "w+") as f:
f.write(json.dumps(to_write))

View File

@ -14,17 +14,19 @@ class ImportTask(QtWidgets.QDialog):
super().__init__(parent, *args, **kwargs)
self.setWindowTitle("New Tasks")
self.config = config
self.line_edit = QtWidgets.QLineEdit(self)
completer = TaskCompleter(config)
self.line_edit.setCompleter(completer)
self.line_edit.textChanged.connect(completer.update_picker)
self.completer = TaskCompleter(config)
self.line_edit.setCompleter(self.completer)
self.line_edit.textChanged.connect(self.completer.update_picker)
self.line_edit.setFocus()
self.indicator = ProgressIndicator(self)
self.indicator.setAnimationDelay(70)
self.indicator.setDisplayedWhenStopped(False)
completer.running.connect(self.spin)
completer.stopped.connect(self.no_spin)
self.completer.running.connect(self.spin)
self.completer.stopped.connect(self.no_spin)
ok_button = QtWidgets.QPushButton()
ok_button.setText("OK")
@ -77,6 +79,8 @@ class ImportTask(QtWidgets.QDialog):
return self.line_edit.text()
def showEvent(self, _):
# pick up config changes
self.completer.update_urls()
self.line_edit.setText("")
self.raise_()
self.activateWindow()

View File

@ -2,10 +2,10 @@
import signal
import sys
from functools import partial
from pathlib import Path
from typing import Optional
from fime.about import About
from fime.config import Config
from fime.worklog import WorklogDialog
from loguru import logger
try:
from PySide6 import QtCore, QtWidgets
@ -14,10 +14,14 @@ except ImportError:
from PySide2 import QtCore, QtWidgets
PYSIDE_6 = False
from fime.about import About
from fime.config import Config
from fime.worklog import WorklogDialog
from fime.data import Tasks, Log, Data, LogCommentsData, Worklog, Report
from fime.exceptions import FimeException
from fime.import_task import ImportTask
from fime.report import ReportDialog
from fime.settings import Settings
from fime.task_edit import TaskEdit
from fime.util import get_screen_height, get_icon
@ -33,37 +37,44 @@ class App:
self.log = Log(lcd)
self._active_task = self.log.last_log() or "Nothing"
config = Config()
if config.tray_theme == "light":
icon = get_icon("appointment-new-light")
else:
icon = get_icon("appointment-new")
self.menu_flipped = config.flip_menu
self.config = Config()
self.menu = QtWidgets.QMenu(None)
self.import_task = ImportTask(config, None)
self.import_task = ImportTask(self.config, None)
self.import_task.accepted.connect(self.new_task_imported)
self.taskEdit = TaskEdit(None)
self.taskEdit = TaskEdit(self.tasks, None)
self.taskEdit.accepted.connect(self.tasks_edited)
self.reportDialog = ReportDialog(self.tasks, Report(lcd), None)
self.reportDialog.accepted.connect(self.log_edited)
self.worklogDialog = WorklogDialog(config, Worklog(lcd), None)
self.worklogDialog = WorklogDialog(self.config, Worklog(lcd), None)
self.worklogDialog.accepted.connect(self.log_edited)
self.settings = Settings(self.config, None)
self.settings.accepted.connect(self.update_icon)
self.settings.accepted.connect(self.update_tray_menu)
self.about = About(None)
self.tray = QtWidgets.QSystemTrayIcon()
self.tray.setIcon(icon)
self.tray.setContextMenu(self.menu)
self.update_icon()
self.tray.show()
self.tray.setToolTip("fime")
self.update_tray_menu()
self.last_dialog: Optional[QtWidgets.QDialog] = None
def update_icon(self):
if self.config.tray_theme == "light":
icon = get_icon("appointment-new-light")
else:
icon = get_icon("appointment-new")
self.tray.setIcon(icon)
@QtCore.Slot()
def new_task_imported(self):
if self.import_task.task_text:
@ -89,11 +100,21 @@ class App:
self.tray.setToolTip(f"{task} - fime")
self.update_tray_menu()
def close_open_dialog(self):
if self.last_dialog and self.last_dialog.isVisible():
self.last_dialog.reject()
def change_task(self, task):
self.close_open_dialog()
self.active_task = task
self.log.log(task)
self.tasks.update_jira_task_usage(task)
def open_new_dialog(self, new_dialog: QtWidgets.QDialog):
self.close_open_dialog()
self.last_dialog = new_dialog
new_dialog.show()
def update_tray_menu(self):
menu_items = []
tmp_action = self.menu.addAction("tmp")
@ -107,7 +128,7 @@ class App:
add_tasks(self.tasks.tasks)
menu_items.append((1, None))
already_taken = (len(self.tasks.tasks) + 5) * action_height
already_taken = (len(self.tasks.tasks) + 6) * action_height
available_space = get_screen_height(self.menu) * 0.8 - already_taken
jira_entry_count = int(available_space // action_height)
add_tasks(self.tasks.jira_tasks[-jira_entry_count:])
@ -118,17 +139,21 @@ class App:
add_tasks(["Nothing"])
menu_items.append((1, None))
menu_items.append(("Import Jira task", self.import_task.show))
menu_items.append(("Edit tasks", self.edit_tasks))
menu_items.append(("Report", self.reportDialog.show))
menu_items.append(("Worklog", self.worklogDialog.show))
jira_integration = self.config.jira_url and self.config.jira_token
if jira_integration:
menu_items.append(("Import Jira task", partial(self.open_new_dialog, self.import_task)))
menu_items.append(("Edit tasks", partial(self.open_new_dialog, self.taskEdit)))
menu_items.append(("Report", partial(self.open_new_dialog, self.reportDialog)))
if jira_integration:
menu_items.append(("Worklog", partial(self.open_new_dialog, self.worklogDialog)))
menu_items.append((1, None))
menu_items.append(("About", self.about.show))
menu_items.append(("Settings", partial(self.open_new_dialog, self.settings)))
menu_items.append(("About", partial(self.open_new_dialog, self.about)))
menu_items.append(("Close", self.app.quit))
if self.menu_flipped:
if self.config.flip_menu:
menu_items.reverse()
seps = 0
@ -143,7 +168,7 @@ class App:
action.triggered.connect(item[1])
def sigterm_handler(self, signo, _frame):
print(f'handling signal "{signal.strsignal(signo)}"')
logger.debug(f'handling signal "{signal.strsignal(signo)}"')
self.app.quit()
def run(self):
@ -158,25 +183,28 @@ class App:
else:
self.app.exec_()
@QtCore.Slot()
def edit_tasks(self):
self.taskEdit.tasks = self.tasks.tasks
self.taskEdit.show()
def init_logging():
log_dir_path = Path(QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.AppDataLocation)) / "logs"
logger.add(log_dir_path / "fime_{time:YYYY-MM-DD}.log", rotation="1d", retention="30d", compression="zip", level="DEBUG")
def excepthook(original, e_type, e_value, tb_obj):
def excepthook(e_type, e_value, tb_obj):
if e_type is FimeException:
QtWidgets.QMessageBox.critical(None, "Error", str(e_value), QtWidgets.QMessageBox.Ok)
elif issubclass(e_type, KeyboardInterrupt):
sys.__excepthook__(e_type, e_value, tb_obj)
else:
original(e_type, e_value, tb_obj)
logger.critical("Unhandled exception", exc_info=(e_type, e_value, tb_obj))
sys.__excepthook__(e_type, e_value, tb_obj)
def main():
# important for QStandardPath to be correct
QtCore.QCoreApplication.setApplicationName("fime")
init_logging()
# also catches exceptions in other threads
original_excepthook = sys.excepthook
sys.excepthook = partial(excepthook, original_excepthook)
sys.excepthook = excepthook
app = App()
app.run()

93
src/fime/settings.py Normal file
View File

@ -0,0 +1,93 @@
from fime.util import get_icon
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
from fime.config import Config
class Settings(QtWidgets.QDialog):
def __init__(self, config: Config, parent, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self.setWindowTitle("Settings")
self.config = config
caption_label = QtWidgets.QLabel()
caption_label.setText("Settings")
caption_label.setAlignment(QtCore.Qt.AlignHCenter)
settings_layout = QtWidgets.QGridLayout()
jira_url_label = QtWidgets.QLabel()
jira_url_label.setText("Jira URL")
settings_layout.addWidget(jira_url_label, 0, 0)
self.jira_url_edit = QtWidgets.QLineEdit()
settings_layout.addWidget(self.jira_url_edit, 0, 1)
jira_token_label = QtWidgets.QLabel()
jira_token_label.setText("Jira Personal Access Token<br/> see <a href='https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html#UsingPersonalAccessTokens-CreatingPATsinapplication'>here</a> for how to get one")
jira_token_label.setTextFormat(QtCore.Qt.RichText)
jira_token_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
jira_token_label.setOpenExternalLinks(True)
settings_layout.addWidget(jira_token_label, 1, 0)
self.jira_token_edit = QtWidgets.QLineEdit()
settings_layout.addWidget(self.jira_token_edit, 1, 1)
tray_theme_label = QtWidgets.QLabel()
tray_theme_label.setText("Tray theme")
settings_layout.addWidget(tray_theme_label, 2, 0)
self.tray_theme_combo_box = QtWidgets.QComboBox()
self.tray_theme_combo_box.addItem("Light")
self.tray_theme_combo_box.addItem("Dark")
settings_layout.addWidget(self.tray_theme_combo_box, 2, 1, QtCore.Qt.AlignRight)
flip_menu_label = QtWidgets.QLabel()
flip_menu_label.setText("Flip menu")
settings_layout.addWidget(flip_menu_label, 3, 0)
self.flip_menu_check_box = QtWidgets.QCheckBox()
settings_layout.addWidget(self.flip_menu_check_box, 3, 1, QtCore.Qt.AlignRight)
self.ok_button = QtWidgets.QPushButton()
self.ok_button.setText("OK")
self.ok_button.setIcon(get_icon("dialog-ok"))
self.ok_button.pressed.connect(self.accept)
self.ok_button.setAutoDefault(True)
button_layout = QtWidgets.QHBoxLayout()
button_layout.addStretch(66)
button_layout.addWidget(self.ok_button, 33)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(caption_label)
layout.addLayout(settings_layout)
layout.addLayout(button_layout)
self.setLayout(layout)
self.resize(500, 0)
self.accepted.connect(self._accepted)
def showEvent(self, _):
self.jira_url_edit.setText(self.config.jira_url)
self.jira_token_edit.setText(self.config.jira_token)
self.tray_theme_combo_box.setCurrentText(self.config.tray_theme.capitalize())
self.flip_menu_check_box.setChecked(self.config.flip_menu)
def _accepted(self):
self.config.jira_url = self.jira_url_edit.text()
self.config.jira_token = self.jira_token_edit.text()
self.config.tray_theme = self.tray_theme_combo_box.currentText()
self.config.flip_menu = self.flip_menu_check_box.isChecked()
self.config.save()
# only for dev/debug
if __name__ == "__main__":
QtCore.QCoreApplication.setApplicationName("fime")
app = QtWidgets.QApplication()
cfg = Config()
settings = Settings(cfg, None)
settings.show()
app.exec()

View File

@ -1,11 +1,11 @@
import os
import sys
import threading
import traceback
from enum import Enum, auto
from functools import reduce, partial
from queue import Queue, Empty
from loguru import logger
try:
from PySide6 import QtCore, QtWidgets
except ImportError:
@ -29,8 +29,9 @@ class TaskCompleter(QtWidgets.QCompleter):
self.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
self.session = FuturesSession()
self.config = config
self.picker_url = os.path.join(self.config.jira_url, "rest/api/2/issue/picker")
self.search_url = os.path.join(self.config.jira_url, "rest/api/2/search")
self.picker_url = None
self.search_url = None
self.update_urls()
self.text = ""
self.response_text = ""
self.model_data = set()
@ -44,6 +45,10 @@ class TaskCompleter(QtWidgets.QCompleter):
self.rif_counter_lock = threading.Lock()
self.last_rif_state = TaskCompleter.RifState.STOPPED
def update_urls(self):
self.picker_url = os.path.join(self.config.jira_url, "rest/api/2/issue/picker")
self.search_url = os.path.join(self.config.jira_url, "rest/api/2/search")
@QtCore.Slot()
def process_response(self):
with self.rif_counter_lock:
@ -105,12 +110,11 @@ class TaskCompleter(QtWidgets.QCompleter):
})
else:
if not self.escalate:
print("No picker results. Escalating")
logger.debug("No picker results. Escalating")
self.escalate = True
self.update_search()
except Exception:
print("Ignoring exception, as it only breaks autocompletion:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
logger.exception("Ignoring exception, as it only breaks autocompletion")
return
def update_search(self):
@ -144,6 +148,5 @@ class TaskCompleter(QtWidgets.QCompleter):
"result": extracted,
})
except Exception:
print("Ignoring exception, as it only breaks autocompletion:", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
logger.exception("Ignoring exception, as it only breaks autocompletion")
return

View File

@ -3,12 +3,15 @@ try:
except ImportError:
from PySide2 import QtCore, QtGui, QtWidgets
from fime.data import Tasks
from fime.util import get_icon, EditStartedDetector
class TaskEdit(QtWidgets.QDialog):
def __init__(self, parent, *args, **kwargs):
def __init__(self, tasks: Tasks, parent, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
self._task_data = tasks
self.setWindowTitle("Edit Tasks")
self.list = QtCore.QStringListModel()
@ -82,10 +85,7 @@ class TaskEdit(QtWidgets.QDialog):
ret = self.list.stringList()
return list(filter(None, ret)) # filter empty strings
@tasks.setter
def tasks(self, tasks):
self.list.setStringList(tasks)
def showEvent(self, _):
self.list.setStringList(self._task_data.tasks)
self.raise_()
self.activateWindow()

View File

@ -1,5 +1,7 @@
import enum
from loguru import logger
try:
from PySide6 import QtCore, QtGui, QtWidgets
except ImportError:
@ -13,7 +15,7 @@ def get_screen_height(qobject):
if hasattr(qobject, "screen"):
return qobject.screen().size().height()
else:
print("unable to detect screen height falling back to default value of 1080")
logger.info("unable to detect screen height falling back to default value of 1080")
return 1080
@ -21,7 +23,7 @@ def get_screen_width(qobject):
if hasattr(qobject, "screen"):
return qobject.screen().size().width()
else:
print("unable to detect screen width falling back to default value of 1920")
logger.info("unable to detect screen width falling back to default value of 1920")
return 1920

View File

@ -99,7 +99,7 @@ class WorklogDialog(QtWidgets.QDialog):
super().__init__(parent, *args, **kwargs)
self.config = config
self.rest = WorklogRest(self.config)
self.rest = None
self._changing_items = False
self._worklog = worklog
@ -175,7 +175,7 @@ class WorklogDialog(QtWidgets.QDialog):
self.update_timer.timeout.connect(self.update_statuses)
def showEvent(self, _):
# reinitialize to purge caches
# reinitialize to purge caches and pick up config changes
self.rest = WorklogRest(self.config)
self._worklog.date = date.today()
self.update_all()

View File

@ -1,6 +1,4 @@
import os
import sys
import traceback
from concurrent.futures import Future
from datetime import date, datetime, timedelta, time
from functools import partial
@ -9,6 +7,7 @@ from threading import Lock
from typing import List, Dict, Tuple, Optional
import requests
from loguru import logger
from requests_futures.sessions import FuturesSession
from fime.config import Config
@ -42,12 +41,9 @@ class WorklogRest:
future.add_done_callback(self._resp_user)
return future
@logger.catch(message="Could not get user key")
def _resp_user(self, future):
try:
self._user = future.result().json()["key"]
except Exception:
print("Could not get user key:\n", file=sys.stderr)
print(traceback.format_exc(), file=sys.stderr)
self._user = future.result().json()["key"]
def get_issues_state(self, issue_keys: List[str], pdate: date) -> List[Tuple[Status, str, Optional[str]]]:
ret = []
@ -114,9 +110,9 @@ class WorklogRest:
worklog_found = True
break
if worklog_found:
print(f"Found existing worklog for issue {issue_key}")
logger.debug(f"Found existing worklog for issue {issue_key}")
else:
print(f"Did not find existing worklog for issue {issue_key}")
logger.debug(f"Did not find existing worklog for issue {issue_key}")
self._issue_state[issue_key] = (Status.OK, issue_title)
def _upload_sanity_check(self, issue_keys: List[str]):
@ -183,7 +179,7 @@ class WorklogRest:
with self._issues_lock:
if resp.status_code in (200, 201):
self._issue_state[issue_key] = (Status.OK, "Successfully uploaded")
print(f"Successfully uploaded issue {issue_key}")
logger.info(f"Successfully uploaded issue {issue_key}")
else:
msg = dedent(f"""\
Worklog upload failed:
@ -193,4 +189,4 @@ class WorklogRest:
Response: {resp.text}
""")
self._issue_state[issue_key] = (Status.ERROR, msg)
print(msg, file=sys.stderr)
logger.error(msg)