Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: major changes in app and unit tests #482

Merged
merged 6 commits into from
Jul 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions normcap/__main__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Use this as entry point by briefcase."""

from normcap.app import main
from normcap.app import run

if __name__ == "__main__":
main()
run()
71 changes: 57 additions & 14 deletions normcap/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import signal
import sys
from argparse import Namespace
from typing import NoReturn

from PySide6 import QtCore, QtWidgets

Expand All @@ -12,20 +13,40 @@


def _get_args() -> Namespace:
"""Parse command line arguments.

Exit if NormCap was started with --version flag.

Auto-enable tray for "background mode", which starts NormCap in tray without
immediately opening the select-region window.
"""
args = utils.create_argparser().parse_args()
if args.background_mode:
args.tray = True

if args.version:
print(f"NormCap {__version__}") # noqa: T201
sys.exit(0)

if args.background_mode:
args.tray = True

return args


def _prepare_logging(args: Namespace) -> None:
if args.verbosity.upper() != "DEBUG":
def _prepare_logging(log_level: str) -> None:
"""Initialize the logger with the given log level.

This function wraps the QT logger to control the output in the Python logger.
For all log levels except DEBUG, an exception hook is used to improve the stack
trace output for bug reporting on Github.

Args:
log_level: Valid Python log level (debug, warning, error)
"""
log_level = log_level.upper()
if log_level != "DEBUG":
sys.excepthook = utils.hook_exceptions

utils.init_logger(level=args.verbosity.upper())
utils.init_logger(log_level=log_level)
logger = logging.getLogger("normcap")
logger.info("Start NormCap v%s", __version__)

Expand All @@ -34,7 +55,10 @@ def _prepare_logging(args: Namespace) -> None:


def _prepare_envs() -> None:
"""Prepare environment variables depending on setup and system."""
"""Prepare environment variables depending on setup and system.

Enable exiting via CTRL+C in Terminal.
"""
# Allow closing QT app with CTRL+C in terminal
signal.signal(signal.SIGINT, signal.SIG_DFL)

Expand All @@ -44,21 +68,40 @@ def _prepare_envs() -> None:
utils.set_environ_for_flatpak()


def main() -> None:
def _get_application() -> QtWidgets.QApplication:
"""Get a QApplication instance that doesn't exit on window close."""
app = QtWidgets.QApplication([])
app.setQuitOnLastWindowClosed(False)
return app


def _prepare() -> tuple[QtWidgets.QApplication, SystemTray]:
"""Prepares the application and system tray without executing.

Returns:
A tuple containing the QApplication ready for execution
and the not yet visible SystemTray.
"""
args = _get_args()
_prepare_logging(args)

_prepare_logging(log_level=str(getattr(args, "verbosity", "ERROR")))
_prepare_envs()
if system_info.is_prebuilt_package():
utils.copy_traineddata_files(target_path=system_info.get_tessdata_path())

app = QtWidgets.QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False)
if system_info.is_prebuilt_package():
utils.copy_traineddata_files(target_dir=system_info.get_tessdata_path())

app = _get_application()
tray = SystemTray(app, vars(args))
tray.show()

return app, tray


def run() -> NoReturn:
"""Run the main application."""
app, tray = _prepare()
tray.show()
sys.exit(app.exec())


if __name__ == "__main__":
main()
run()
1 change: 0 additions & 1 deletion normcap/gui/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ def scaled(self, scale_factor: float): # noqa: ANN201
class Screen:
"""About an attached display."""

is_primary: bool
device_pixel_ratio: float
rect: Rect
index: int
Expand Down
1 change: 0 additions & 1 deletion normcap/gui/system_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ def screens() -> list[Screen]:
"""Get information about available monitors."""
return [
Screen(
is_primary=screen == QtWidgets.QApplication.primaryScreen(),
device_pixel_ratio=QtGui.QScreen.devicePixelRatio(screen),
rect=Rect(
left=screen.geometry().left(),
Expand Down
39 changes: 18 additions & 21 deletions normcap/gui/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,12 @@

logger = logging.getLogger(__name__)

UPDATE_CHECK_INTERVAL_DAYS = 7

# TODO: Add tutorial screen


class Communicate(QtCore.QObject):
"""TrayMenus' communication bus."""

close_windows = QtCore.Signal()
exit_application = QtCore.Signal(bool)
on_copied_to_clipboard = QtCore.Signal()
on_image_cropped = QtCore.Signal()
on_region_selected = QtCore.Signal(Rect)
Expand All @@ -40,9 +37,12 @@ class Communicate(QtCore.QObject):
class SystemTray(QtWidgets.QSystemTrayIcon):
"""System tray icon with menu."""

# Only for (unit-)testing purposes:
_EXIT_DELAY_MILLISECONDS: int = 5_000
_UPDATE_CHECK_INTERVAL_DAYS: int = 7

# Only for testing purposes: forcefully enables language manager in settings menu
# (Normally language manager is only available in pre-build version)
_testing_language_manager = False
_testing_do_not_sys_exit_on_hide = False

# Used for singleton:
_socket_name = f"v{__version__}-normcap"
Expand Down Expand Up @@ -75,7 +75,6 @@ def __init__(self, parent: QtCore.QObject, args: dict[str, Any]) -> None:

self.screens: list[Screen] = system_info.screens()

# TODO: Fix menu get's created top level!
self.tray_menu = QtWidgets.QMenu(None)
self.tray_menu.aboutToShow.connect(self._populate_context_menu_entries)
self.setContextMenu(self.tray_menu)
Expand Down Expand Up @@ -107,7 +106,7 @@ def _ensure_single_instance(self) -> None:
logger.debug("Another instance is already running. Sending capture signal.")
self._socket_out.write(b"capture")
self._socket_out.waitForBytesWritten(1000)
self._exit_application(delayed=False)
self.com.exit_application.emit(False)
else:
self._create_socket_server()

Expand Down Expand Up @@ -297,7 +296,7 @@ def _print_to_stdout(self) -> None:
"""Print results to stdout ."""
logger.debug("Print text to stdout and exit.")
print(self.capture.ocr_text, file=sys.stdout) # noqa: T201
self._exit_application(delayed=False)
self.com.exit_application.emit(False)

@QtCore.Slot()
def _notify(self) -> None:
Expand All @@ -306,8 +305,8 @@ def _notify(self) -> None:

@QtCore.Slot()
def _notify_or_close(self) -> None:
if self.settings.value("notification", type=bool):
self.delayed_exit_timer.start(5000)
if not self.settings.value("notification", False, type=bool):
self.delayed_exit_timer.start(self._EXIT_DELAY_MILLISECONDS)

@QtCore.Slot()
def _close_windows(self) -> None:
Expand Down Expand Up @@ -378,7 +377,7 @@ def _ensure_screenshot_permission(self) -> None:
),
buttons=QtWidgets.QMessageBox.Ok,
)
self._exit_application(delayed=False)
self.com.exit_application.emit(False)

def _set_signals(self) -> None:
"""Set up signals to trigger program logic."""
Expand All @@ -393,12 +392,15 @@ def _set_signals(self) -> None:
self.com.on_copied_to_clipboard.connect(self._color_tray_icon)
self.com.on_languages_changed.connect(self._sanitize_language_setting)
self.com.on_languages_changed.connect(self._update_installed_languages)
self.com.exit_application.connect(self._exit_application)

def _add_update_checker(self) -> None:
if not self.settings.value("update", type=bool):
return

now_sub_interval_sec = time.time() - (60 * 60 * 24 * UPDATE_CHECK_INTERVAL_DAYS)
now_sub_interval_sec = time.time() - (
60 * 60 * 24 * self._UPDATE_CHECK_INTERVAL_DAYS
)
now_sub_interval = time.strftime("%Y-%m-%d", time.gmtime(now_sub_interval_sec))
if str(self.settings.value("last-update-check", type=str)) > now_sub_interval:
return
Expand Down Expand Up @@ -440,7 +442,7 @@ def _populate_context_menu_entries(self) -> None:

action = QtGui.QAction("Exit", self.tray_menu)
action.setObjectName("exit")
action.triggered.connect(lambda: self._exit_application(delayed=False))
action.triggered.connect(lambda: self.com.exit_application.emit(False))
self.tray_menu.addAction(action)

def _create_next_window(self) -> None:
Expand Down Expand Up @@ -498,7 +500,7 @@ def _minimize_or_exit_application(self, delayed: bool) -> None:
if self.settings.value("tray", type=bool):
return

self._exit_application(delayed=delayed)
self.com.exit_application.emit(delayed)

@QtCore.Slot(bool)
def _exit_application(self, delayed: bool) -> None:
Expand All @@ -508,7 +510,7 @@ def _exit_application(self, delayed: bool) -> None:
self._socket_server.removeServer(self._socket_name)

if delayed:
self.delayed_exit_timer.start(5000)
self.delayed_exit_timer.start(self._EXIT_DELAY_MILLISECONDS)
else:
self.hide()

Expand All @@ -526,11 +528,6 @@ def hide(self) -> NoReturn | None:
"Debug images saved in %s%snormcap", utils.tempfile.gettempdir(), os.sep
)

# Because monkeypatching sys.exit() seems tricky in pytest, we use a flag
# to prevent sys.exit() during integration tests.
if getattr(self, "_testing_do_not_sys_exit_on_hide", True):
return None

# The preferable QApplication.quit() doesn't work reliably on macOS. E.g. when
# right clicking on "close" in tray menu, NormCap process keeps running.
sys.exit(0)
8 changes: 4 additions & 4 deletions normcap/gui/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ def save_image_in_temp_folder(image: QImage, postfix: str = "") -> None:
"""For debugging it can be useful to store the cropped image."""
temp_dir = Path(tempfile.gettempdir()) / "normcap"
temp_dir.mkdir(exist_ok=True)
now = time.time()
now_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.gmtime(now))
now_str += f"{now % 1}"[:-3]
file_name = f"{now}{postfix}.png"

now_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.gmtime())
file_name = f"{now_str}{postfix}.png"

logger.debug("Save debug image as %s", temp_dir / file_name)
image.save(str(temp_dir / file_name))
12 changes: 10 additions & 2 deletions normcap/ocr/magics/email_magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@ class EmailMagic(BaseMagic):
@staticmethod
@functools.cache
def _extract_emails(text: str) -> list[str]:
reg_email = r"[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+"
return re.findall(reg_email, text)
reg_email = r"""
[a-zA-Z0-9._-]+ # Valid chars of an email name
@ # name to domain delimiter
[a-zA-Z0-9._-]+ # Domain name w/o TLD
(?:\s{0,1})\.(?:\s{0,1}) # Dot before TLD, potentially with whitespace
# around it, which happens sometimes in OCR.
# The whitespace is not captured.
[a-zA-Z0-9._-]{2,15} # TLD, mostly between 2-15 chars.
"""
return re.findall(reg_email, text, flags=re.X)

@staticmethod
def _remove_email_names_from_text(emails: list[str], text: str) -> str:
Expand Down
Loading