diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3376b8d..84ede9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,15 +24,16 @@ jobs: pip install build wheel - name: Get Current Version - run: | - project_version=$(python3 setup.py --version) - echo "project_version=$project_version" >> $GITHUB_OUTPUT + uses: SebRollen/toml-action@v1.2.0 + with: + file: "pyproject.toml" + field: project.version id: get_current_version - name: Create Tag uses: mathieudutour/github-tag-action@v6.1 with: - custom_tag: "${{steps.get_current_version.outputs.project_version}}" + custom_tag: "${{steps.get_current_version.outputs.value}}" github_token: ${{ secrets.GH_API_SECRET }} - name: Build Changelog @@ -44,7 +45,7 @@ jobs: - name: Create Release uses: softprops/action-gh-release@v1 with: - tag_name: 'v${{steps.get_current_version.outputs.project_version}}' + tag_name: 'v${{steps.get_current_version.outputs.value}}' body: ${{steps.build_changelog.outputs.changelog}} token: ${{ secrets.GH_API_SECRET }} diff --git a/MANIFEST.in b/MANIFEST.in index 28a03a4..b0ff714 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,8 @@ include LICENSES/GPL-3.0-or-later.txt include README.md + +graft safeeyes + +prune safeeyes/tests + +global-exclude *.py[cod] diff --git a/README.md b/README.md index 6f8ca15..470a1c9 100644 --- a/README.md +++ b/README.md @@ -200,9 +200,8 @@ To ensure the new strings are well-formed, you can use `python validate_po.py -- 1. Checkout the latest commits from the `master` branch 2. Run `python3 -m safeeyes` to make sure nothing is broken 3. Update the Safe Eyes version in the following places (Open the project in VSCode and search for the current version): - - [setup.py](https://github.com/slgobinath/SafeEyes/blob/master/setup.py#L83) - - [setup.py](https://github.com/slgobinath/SafeEyes/blob/master/setup.py#L90) - - [safeeyes.py](https://github.com/slgobinath/SafeEyes/blob/master/safeeyes/safeeyes.py#L42) + - [pyproject.toml](https://github.com/slgobinath/SafeEyes/blob/master/pyproject.toml#L4) + - [pyproject.toml](https://github.com/slgobinath/SafeEyes/blob/master/pyproject.toml#L35) - [io.github.slgobinath.SafeEyes.metainfo.xml](https://github.com/slgobinath/SafeEyes/blob/master/safeeyes/platform/io.github.slgobinath.SafeEyes.metainfo.xml#L56) - [about_dialog.glade](https://github.com/slgobinath/SafeEyes/blob/master/safeeyes/glade/about_dialog.glade#L74) 4. Update the [changelog](https://github.com/slgobinath/SafeEyes/blob/master/debian/changelog) (for Ubuntu PPA release) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..93de0e6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,54 @@ + +[project] +name = "safeeyes" +version = "2.2.2" +description = "Protect your eyes from eye strain using this continuous breaks reminder." +keywords = ["linux utility health eye-strain safe-eyes"] +readme = "README.md" +authors = [ + {name = "Gobinath Loganathan", email = "slgobinath@gmail.com"}, +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: X11 Applications :: GTK", + "Environment :: Other Environment", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Utilities", +] +dependencies = [ + "PyGObject", + "babel", + "psutil", + "packaging", + "python-xlib", +] +requires-python = ">=3.10" + +[project.urls] +Homepage = "https://github.com/slgobinath/SafeEyes" +Downloads = "https://github.com/slgobinath/SafeEyes/archive/v2.2.2.tar.gz" + +[project.scripts] +safeeyes = "safeeyes.__main__:main" + +[project.optional-dependencies] +healthstats = ["croniter"] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include=["safeeyes*"] +exclude=["safeeyes.tests*"] + +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", +] diff --git a/safeeyes/safeeyes.py b/safeeyes/safeeyes.py index 0fb9a9b..e0bfefc 100644 --- a/safeeyes/safeeyes.py +++ b/safeeyes/safeeyes.py @@ -24,6 +24,7 @@ import atexit import logging import os from threading import Timer +from importlib import metadata import gi from safeeyes import utility @@ -36,10 +37,11 @@ from safeeyes.plugin_manager import PluginManager from safeeyes.core import SafeEyesCore from safeeyes.ui.settings_dialog import SettingsDialog + gi.require_version('Gtk', '3.0') from gi.repository import Gtk, Gio, GLib -SAFE_EYES_VERSION = "2.2.2" +SAFE_EYES_VERSION = metadata.version("safeeyes") class SafeEyes(Gtk.Application): diff --git a/safeeyes/tests/__init__.py b/safeeyes/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/safeeyes/tests/test_core.py b/safeeyes/tests/test_core.py new file mode 100644 index 0000000..e607325 --- /dev/null +++ b/safeeyes/tests/test_core.py @@ -0,0 +1,584 @@ +from collections import deque +import datetime +import logging +import pytest + +from safeeyes import core +from safeeyes import model + +import threading +from time import sleep + +from unittest import mock + +class TestSafeEyesCore: + @pytest.fixture(autouse=True) + def set_time(self, time_machine): + time_machine.move_to(datetime.datetime.fromisoformat("2024-08-25T13:00:00+00:00"), tick=False) + + @pytest.fixture(autouse=True) + def monkeypatch_translations(self, monkeypatch): + monkeypatch.setattr(core, "_", lambda message: "translated!: " + message, raising=False) + monkeypatch.setattr(model, "_", lambda message: "translated!: " + message, raising=False) + + @pytest.fixture + def sequential_threading(self, monkeypatch, time_machine): + """This fixture allows stopping threads at any point. + + It is hard-coded for SafeEyesCore, the handle class returned by the fixture must be initialized + with a SafeEyesCore instance to be patched. + With this, all sleeping/blocking/thread starting calls inside SafeEyesCore are intercepted, and paused. + Additionally, all threads inside SafeEyesCore run sequentially. + The test code can use the next() method to unpause the thread, + which will run until the next sleeping/blocking/thread starting call, + after which it needs to be woken up using next() again. + The next() method blocks the test code while the thread is running. + """ + + # executes instantly, on the same thread + # no need to switch threads, as we don't use any gtk things + monkeypatch.setattr( + core.utility, + "execute_main_thread", + lambda target_function, *args, **kwargs: target_function(*args, **kwargs) + ) + + handle = None + + def utility_start_thread(target_function, **kwargs): + if not handle: + raise Exception("handle must be initialized before first thread") + handle.utility_start_thread(target_function, **kwargs) + + def time_sleep(time): + if not handle: + raise Exception("handle must be initialized before first sleep call") + handle.sleep(time) + + monkeypatch.setattr( + core.utility, + "start_thread", + utility_start_thread + ) + + monkeypatch.setattr( + core.time, + "sleep", + time_sleep + ) + + class PatchedCondition(threading.Condition): + def __init__(self, handle): + super().__init__() + self.handle = handle + + def wait(self, timeout): + self.handle.wait_condvar(timeout) + + + class Handle: + thread = None + task_queue = deque() + running = True + condvar_in = threading.Condition() + condvar_out = threading.Condition() + + def __init__(self, safe_eyes_core): + nonlocal handle + nonlocal time_machine + if handle: + raise Exception("only one handle is allowed per test call") + handle = self + self.time_machine = time_machine + self.safe_eyes_core = safe_eyes_core + self.safe_eyes_core.waiting_condition = PatchedCondition(self) + + + def background_thread(self): + while True: + with self.condvar_in: + success = self.condvar_in.wait(1) + if not success: + raise Exception("thread timed out") + + if not self.running: + break + + if self.task_queue: + (target_function, kwargs) = self.task_queue.popleft() + logging.debug(f"thread started {target_function}") + target_function(**kwargs) + logging.debug(f"thread finished {target_function}") + + with self.condvar_out: + self.condvar_out.notify() + + def sleep(self, time): + if self.thread is threading.current_thread(): + with self.condvar_out: + self.condvar_out.notify() + self.time_machine.shift(delta=datetime.timedelta(seconds=time)) + with self.condvar_in: + success = self.condvar_in.wait(1) + if not success: + raise Exception("thread timed out") + + def wait_condvar(self, time): + if self.thread is not threading.current_thread(): + raise Exception("waiting on condition may only happen in thread") + + with self.condvar_out: + self.condvar_out.notify() + self.time_machine.shift(delta=datetime.timedelta(seconds=time)) + with self.condvar_in: + success = self.condvar_in.wait(1) + if not success: + raise Exception("thread timed out") + + def utility_start_thread(self, target_function, **kwargs): + self.task_queue.append((target_function, kwargs)) + + if self.thread is None: + self.thread = threading.Thread(target=self.background_thread, name="SequentialThreadingRunner", daemon=False, kwargs=kwargs) + self.thread.start() + + def next(self): + assert self.thread + + with self.condvar_in: + self.condvar_in.notify() + + # wait until done: + with self.condvar_out: + success = self.condvar_out.wait(1) + if not success: + raise Exception("thread timed out") + + def stop(self): + self.running = False + with self.condvar_in: + self.condvar_in.notify() + + if self.thread: + self.thread.join(1) + + yield Handle + + if handle: + handle.stop() + + + def run_next_break( + self, + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + break_duration, + break_name_translated + ): + """Run one entire cycle of safe_eyes_core. + + It must be waiting for __scheduler_job to run. (This is the equivalent of State.WAITING). + That means it must either be just started, or have finished the previous cycle. + """ + + on_update_next_break = mock.Mock() + on_pre_break = mock.Mock(return_value=True) + on_start_break = mock.Mock(return_value=True) + start_break = mock.Mock() + on_count_down = mock.Mock() + + safe_eyes_core.on_update_next_break += on_update_next_break + safe_eyes_core.on_pre_break += on_pre_break + safe_eyes_core.on_start_break += on_start_break + safe_eyes_core.start_break += start_break + safe_eyes_core.on_count_down += on_count_down + + # start __scheduler_job + sequential_threading_handle.next() + # wait until it reaches the condvar + + + assert context['state'] == model.State.WAITING + + on_update_next_break.assert_called_once() + assert isinstance(on_update_next_break.call_args[0][0], model.Break) + assert on_update_next_break.call_args[0][0].name == break_name_translated + on_update_next_break.reset_mock() + + # continue after condvar + sequential_threading_handle.next() + # end of __scheduler_job + + assert context['state'] == model.State.PRE_BREAK + + on_pre_break.assert_called_once() + assert isinstance(on_pre_break.call_args[0][0], model.Break) + assert on_pre_break.call_args[0][0].name == break_name_translated + on_pre_break.reset_mock() + + # start __wait_until_prepare + sequential_threading_handle.next() + + # wait until it reaches the condvar + # continue after condvar + sequential_threading_handle.next() + # end of __wait_until_prepare + + # start __start_break + sequential_threading_handle.next() + + # first sleep in __start_break + sequential_threading_handle.next() + + on_start_break.assert_called_once() + assert isinstance(on_start_break.call_args[0][0], model.Break) + assert on_start_break.call_args[0][0].name == break_name_translated + on_start_break.reset_mock() + + start_break.assert_called_once() + assert isinstance(start_break.call_args[0][0], model.Break) + assert start_break.call_args[0][0].name == break_name_translated + start_break.reset_mock() + + assert context['state'] == model.State.BREAK + + # continue sleep in __start_break + for i in range(break_duration - 2): + sequential_threading_handle.next() + + sequential_threading_handle.next() + # end of __start_break + + on_count_down.assert_called() + assert on_count_down.call_count == break_duration + on_count_down.reset_mock() + + assert context['state'] == model.State.BREAK + + + def assert_datetime(self, string): + if not string.endswith("+00:00"): + string += "+00:00" + assert datetime.datetime.now(datetime.timezone.utc) == datetime.datetime.fromisoformat(string) + + + def test_create_empty(self): + context = {} + config = { + "short_breaks": [], + "long_breaks": [], + "short_break_interval": 15, + "long_break_interval": 75, + "long_break_duration": 60, + "short_break_duration": 15, + "random_order": False, + "postpone_duration": 5, + } + safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core.initialize(config) + + + def test_start_empty(self, sequential_threading): + context = {} + config = { + "short_breaks": [], + "long_breaks": [], + "short_break_interval": 15, + "long_break_interval": 75, + "long_break_duration": 60, + "short_break_duration": 15, + "random_order": False, + "postpone_duration": 5, + } + on_update_next_break = mock.Mock() + safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core.on_update_next_break += mock + + safe_eyes_core.initialize(config) + + safe_eyes_core.start() + safe_eyes_core.stop() + + on_update_next_break.assert_not_called() + + + def test_start(self, sequential_threading, time_machine): + context = { + "session": {}, + } + config = { + "short_breaks": [ + {"name": "break 1"}, + {"name": "break 2"}, + {"name": "break 3"}, + {"name": "break 4"}, + ], + "long_breaks": [ + {"name": "long break 1"}, + {"name": "long break 2"}, + {"name": "long break 3"}, + ], + "short_break_interval": 15, + "long_break_interval": 75, + "long_break_duration": 60, + "short_break_duration": 15, + "random_order": False, + "postpone_duration": 5, + } + on_update_next_break = mock.Mock() + safe_eyes_core = core.SafeEyesCore(context) + safe_eyes_core.on_update_next_break += on_update_next_break + + safe_eyes_core.initialize(config) + + sequential_threading_handle = sequential_threading(safe_eyes_core) + + safe_eyes_core.start() + + # start __scheduler_job + sequential_threading_handle.next() + + assert context['state'] == model.State.WAITING + + on_update_next_break.assert_called_once() + assert isinstance(on_update_next_break.call_args[0][0], model.Break) + assert on_update_next_break.call_args[0][0].name == "translated!: break 1" + on_update_next_break.reset_mock() + + # wait for end of __scheduler_job - we cannot stop while waiting on the condvar + # this just moves us into waiting for __wait_until_prepare to start + sequential_threading_handle.next() + + safe_eyes_core.stop() + assert context['state'] == model.State.STOPPED + + + def test_full_run_with_defaults(self, sequential_threading, time_machine): + context = { + "session": {}, + } + short_break_duration = 15 # seconds + short_break_interval = 15 # minutes + pre_break_warning_time = 10 # seconds + long_break_duration = 60 # seconds + long_break_interval = 75 # minutes + config = { + "short_breaks": [ + {"name": "break 1"}, + {"name": "break 2"}, + {"name": "break 3"}, + {"name": "break 4"}, + ], + "long_breaks": [ + {"name": "long break 1"}, + {"name": "long break 2"}, + {"name": "long break 3"}, + ], + "short_break_interval": short_break_interval, + "long_break_interval": long_break_interval, + "long_break_duration": long_break_duration, + "short_break_duration": short_break_duration, + "pre_break_warning_time": pre_break_warning_time, + "random_order": False, + "postpone_duration": 5, + } + + self.assert_datetime("2024-08-25T13:00:00") + + safe_eyes_core = core.SafeEyesCore(context) + + sequential_threading_handle = sequential_threading(safe_eyes_core) + + safe_eyes_core.initialize(config) + + safe_eyes_core.start() + + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 1" + ) + + # Time passed: 15min 25s + # 15min short_break_interval, 10 seconds pre_break_warning_time, 15 seconds short_break_duration + self.assert_datetime("2024-08-25T13:15:25") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 2" + ) + + self.assert_datetime("2024-08-25T13:30:50") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 3" + ) + + self.assert_datetime("2024-08-25T13:46:15") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 4" + ) + + self.assert_datetime("2024-08-25T14:01:40") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + long_break_duration, + "translated!: long break 1" + ) + + # Time passed: 16min 10s + # 15min short_break_interval (from previous, as long_break_interval must be multiple) + # 10 seconds pre_break_warning_time, 1 minute long_break_duration + self.assert_datetime("2024-08-25T14:17:50") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 1" + ) + + # Time passed: 15min 25s + # 15min short_break_interval, 10 seconds pre_break_warning_time, 15 seconds short_break_duration + self.assert_datetime("2024-08-25T14:33:15") + + safe_eyes_core.stop() + + assert context['state'] == model.State.STOPPED + + + def test_long_duration_is_bigger_than_short_interval(self, sequential_threading, time_machine): + """Example taken from https://github.com/slgobinath/SafeEyes/issues/640""" + + context = { + "session": {}, + } + short_break_duration = 300 # seconds = 5min + short_break_interval = 25 # minutes + pre_break_warning_time = 10 # seconds + long_break_duration = 1800 # seconds = 30min + long_break_interval = 100 # minutes + config = { + "short_breaks": [ + {"name": "break 1"}, + {"name": "break 2"}, + {"name": "break 3"}, + {"name": "break 4"}, + ], + "long_breaks": [ + {"name": "long break 1"}, + {"name": "long break 2"}, + {"name": "long break 3"}, + ], + "short_break_interval": short_break_interval, + "long_break_interval": long_break_interval, + "long_break_duration": long_break_duration, + "short_break_duration": short_break_duration, + "pre_break_warning_time": pre_break_warning_time, + "random_order": False, + "postpone_duration": 5, + } + + self.assert_datetime("2024-08-25T13:00:00") + + safe_eyes_core = core.SafeEyesCore(context) + + sequential_threading_handle = sequential_threading(safe_eyes_core) + + safe_eyes_core.initialize(config) + + safe_eyes_core.start() + + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 1" + ) + + # Time passed: 30m 10s + # 25min short_break_interval, 10 seconds pre_break_warning_time, 5 minutes short_break_duration + self.assert_datetime("2024-08-25T13:30:10") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 2" + ) + + self.assert_datetime("2024-08-25T14:00:20") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 3" + ) + + self.assert_datetime("2024-08-25T14:30:30") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + long_break_duration, + "translated!: long break 1" + ) + + # Time passed: 55min 10s + # 25min short_break_interval (from previous, as long_break_interval must be multiple) + # 10 seconds pre_break_warning_time, 30 minute long_break_duration + self.assert_datetime("2024-08-25T15:25:40") + + self.run_next_break( + sequential_threading_handle, + time_machine, + safe_eyes_core, + context, + short_break_duration, + "translated!: break 4" + ) + + # Time passed: 30m 10s + # 15min short_break_interval, 10 seconds pre_break_warning_time, 15 seconds short_break_duration + self.assert_datetime("2024-08-25T15:55:50") + + safe_eyes_core.stop() + + assert context['state'] == model.State.STOPPED diff --git a/safeeyes/tests/test_model.py b/safeeyes/tests/test_model.py new file mode 100644 index 0000000..4d60a78 --- /dev/null +++ b/safeeyes/tests/test_model.py @@ -0,0 +1,354 @@ +# Safe Eyes is a utility to remind you to take break frequently +# to protect your eyes from eye strain. + +# Copyright (C) 2017 Gobinath + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program 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 General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import random +from safeeyes import model + +class TestBreak: + def test_break_short(self): + b = model.Break( + model.BreakType.SHORT_BREAK, + "test break", + 15, + 15, + None, + None + ) + + assert b.is_short_break() + assert not b.is_long_break() + + def test_break_long(self): + b = model.Break( + model.BreakType.LONG_BREAK, + "long break", + 75, + 60, + None, + None + ) + + assert not b.is_short_break() + assert b.is_long_break() + + +class TestBreakQueue: + def test_create_empty(self): + config = { + "short_breaks": [], + "long_breaks": [], + "short_break_interval": 15, + "long_break_interval": 75, + "long_break_duration": 60, + "short_break_duration": 15, + "random_order": False, + } + + context = {} + + bq = model.BreakQueue( + config, + context + ) + + assert bq.is_empty() + assert bq.is_empty(model.BreakType.LONG_BREAK) + assert bq.is_empty(model.BreakType.SHORT_BREAK) + assert bq.next() is None + assert bq.get_break() is None + + + def get_bq_only_short(self, monkeypatch, random_seed=None): + if random_seed is not None: + random.seed(random_seed) + + monkeypatch.setattr(model, "_", lambda message: "translated!: " + message, raising=False) + + config = { + "short_breaks": [ + {"name": "break 1"}, + {"name": "break 2"}, + {"name": "break 3"}, + ], + "long_breaks": [], + "short_break_interval": 15, + "long_break_interval": 75, + "long_break_duration": 60, + "short_break_duration": 15, + "random_order": random_seed is not None, + } + + context = { + "session": {}, + } + + return model.BreakQueue( + config, + context + ) + + + def get_bq_only_long(self, monkeypatch, random_seed=None): + if random_seed is not None: + random.seed(random_seed) + + monkeypatch.setattr(model, "_", lambda message: "translated!: " + message, raising=False) + + config = { + "short_breaks": [], + "long_breaks": [ + {"name": "long break 1"}, + {"name": "long break 2"}, + {"name": "long break 3"}, + ], + "short_break_interval": 15, + "long_break_interval": 75, + "long_break_duration": 60, + "short_break_duration": 15, + "random_order": random_seed is not None, + } + + context = { + "session": {}, + } + + return model.BreakQueue( + config, + context + ) + + + def get_bq_full(self, monkeypatch, random_seed=None): + if random_seed is not None: + random.seed(random_seed) + + monkeypatch.setattr(model, "_", lambda message: "translated!: " + message, raising=False) + + config = { + "short_breaks": [ + {"name": "break 1"}, + {"name": "break 2"}, + {"name": "break 3"}, + {"name": "break 4"}, + ], + "long_breaks": [ + {"name": "long break 1"}, + {"name": "long break 2"}, + {"name": "long break 3"}, + ], + "short_break_interval": 15, + "long_break_interval": 75, + "long_break_duration": 60, + "short_break_duration": 15, + "random_order": random_seed is not None, + } + + context = { + "session": {}, + } + + return model.BreakQueue( + config, + context + ) + + + def test_create_only_short(self, monkeypatch): + bq = self.get_bq_only_short(monkeypatch) + + assert not bq.is_empty() + assert not bq.is_empty(model.BreakType.SHORT_BREAK) + assert bq.is_empty(model.BreakType.LONG_BREAK) + + + def test_only_short_repeat_get_break_no_change(self, monkeypatch): + bq = self.get_bq_only_short(monkeypatch) + + next = bq.get_break() + assert next.name == "translated!: break 1" + + next = bq.get_break() + assert next.name == "translated!: break 1" + + assert not bq.is_long_break() + + + def test_only_short_next_break(self, monkeypatch): + bq = self.get_bq_only_short(monkeypatch) + + next = bq.get_break() + assert next.name == "translated!: break 1" + + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + + + def test_only_short_next_break_random(self, monkeypatch): + random_seed = 5 + bq = self.get_bq_only_short(monkeypatch, random_seed) + + next = bq.get_break() + assert next.name == "translated!: break 3" + + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 1" + + + def test_create_only_long(self, monkeypatch): + bq = self.get_bq_only_long(monkeypatch) + + assert not bq.is_empty() + assert not bq.is_empty(model.BreakType.LONG_BREAK) + assert bq.is_empty(model.BreakType.SHORT_BREAK) + + + def test_only_long_repeat_get_break_no_change(self, monkeypatch): + bq = self.get_bq_only_long(monkeypatch) + + next = bq.get_break() + assert next.name == "translated!: long break 1" + + next = bq.get_break() + assert next.name == "translated!: long break 1" + + assert bq.is_long_break() + + + def test_only_long_next_break(self, monkeypatch): + bq = self.get_bq_only_long(monkeypatch) + + next = bq.get_break() + assert next.name == "translated!: long break 1" + + assert bq.next().name == "translated!: long break 2" + assert bq.next().name == "translated!: long break 3" + assert bq.next().name == "translated!: long break 1" + assert bq.next().name == "translated!: long break 2" + assert bq.next().name == "translated!: long break 3" + assert bq.next().name == "translated!: long break 1" + assert bq.next().name == "translated!: long break 2" + assert bq.next().name == "translated!: long break 3" + + + def test_only_long_next_break_random(self, monkeypatch): + random_seed = 5 + bq = self.get_bq_only_long(monkeypatch, random_seed) + + next = bq.get_break() + assert next.name == "translated!: long break 3" + + assert bq.next().name == "translated!: long break 2" + assert bq.next().name == "translated!: long break 1" + assert bq.next().name == "translated!: long break 3" + assert bq.next().name == "translated!: long break 1" + + + def test_create_full(self, monkeypatch): + bq = self.get_bq_full(monkeypatch) + + assert not bq.is_empty() + assert not bq.is_empty(model.BreakType.LONG_BREAK) + assert not bq.is_empty(model.BreakType.SHORT_BREAK) + + + def test_full_repeat_get_break_no_change(self, monkeypatch): + bq = self.get_bq_full(monkeypatch) + + next = bq.get_break() + assert next.name == "translated!: break 1" + + next = bq.get_break() + assert next.name == "translated!: break 1" + + assert not bq.is_long_break() + + + def test_full_next_break(self, monkeypatch): + bq = self.get_bq_full(monkeypatch) + + next = bq.get_break() + assert next.name == "translated!: break 1" + assert not bq.is_long_break() + + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 1" + assert bq.is_long_break() + assert bq.next().name == "translated!: break 1" + assert not bq.is_long_break() + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 2" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 3" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 1" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 2" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 3" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: long break 1" + + + def test_full_next_break_random(self, monkeypatch): + random_seed = 5 + bq = self.get_bq_full(monkeypatch, random_seed) + + next = bq.get_break() + assert next.name == "translated!: break 1" + + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: long break 3" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: long break 2" + assert bq.next().name == "translated!: break 2" + assert bq.next().name == "translated!: break 4" + assert bq.next().name == "translated!: break 1" + assert bq.next().name == "translated!: break 3" + assert bq.next().name == "translated!: long break 1" diff --git a/setup.py b/setup.py index 480c3ab..d333e82 100644 --- a/setup.py +++ b/setup.py @@ -1,103 +1,73 @@ -import os, sys, site -import subprocess -import setuptools +#!/usr/bin/python3 + +import os + +from pathlib import Path +from setuptools import Command, setup +from setuptools.command.build import build as OriginalBuildCommand + +class BuildCommand(OriginalBuildCommand): + sub_commands = [('build_mo', None), *OriginalBuildCommand.sub_commands] -requires = [ - 'babel', - 'psutil', - 'croniter', - 'PyGObject', - 'packaging', - 'python-xlib' -] +class BuildMoSubCommand(Command): + description = 'Compile .po files into .mo files' -_ROOT = os.path.abspath(os.path.dirname(__file__)) + files = None -with open(os.path.join(_ROOT, 'README.md')) as f: - long_description = f.read() + def initialize_options(self): + self.files = None + self.editable_mode = False + self.build_lib = None + + def finalize_options(self): + self.set_undefined_options("build_py", ("build_lib", "build_lib")) + + def run(self): + files = self._get_files() + + for build_file, source_file in files.items(): + if not self.editable_mode: + # Parent directory required for msgfmt to work correctly + Path(build_file).parent.mkdir(parents=True, exist_ok=True) + self.spawn(['msgfmt', source_file, '-o', build_file]) + + def _get_files(self): + if self.files is not None: + return self.files + + files = {} + + localedir = Path('safeeyes/config/locale') + po_dirs = [l.joinpath('LC_MESSAGES') for l in localedir.iterdir() if l.is_dir()] + for po_dir in po_dirs: + po_files = [f + for f in po_dir.iterdir() + if f.is_file() and f.suffix == '.po'] + for po_file in po_files: + mo_file = po_file.with_suffix(".mo") + + source_file = po_file + build_file = mo_file + + if not self.editable_mode: + build_file = Path(self.build_lib).joinpath(build_file) + + files[str(build_file)] = str(source_file) + + self.files = files + return files + + def get_output_mapping(self): + return self._get_files() + + def get_outputs(self): + return self._get_files().keys() + + def get_source_files(self): + return self._get_files().values() -def __compile_po_files(): - """ - Compile the *.po trainslation files. - """ - localedir = 'safeeyes/config/locale' - po_dirs = [localedir + '/' + l + '/LC_MESSAGES/' - for l in next(os.walk(localedir))[1]] - for po_dir in po_dirs: - po_files = [f - for f in next(os.walk(po_dir))[2] - if os.path.splitext(f)[1] == '.po'] - for po_file in po_files: - filename, _ = os.path.splitext(po_file) - mo_file = filename + '.mo' - msgfmt_cmd = 'msgfmt {} -o {}'.format( - po_dir + po_file, po_dir + mo_file) - subprocess.call(msgfmt_cmd, shell=True) - - -def __data_files(): - """ - Collect the data files. - """ - root_dir = sys.prefix - return [(os.path.join(root_dir, "share/applications"), ["safeeyes/platform/io.github.slgobinath.SafeEyes.desktop"]), - (os.path.join(root_dir, "share/icons/hicolor/24x24/status"), ["safeeyes/platform/icons/hicolor/24x24/status/io.github.slgobinath.SafeEyes-disabled.png", "safeeyes/platform/icons/hicolor/24x24/status/io.github.slgobinath.SafeEyes-enabled.png", "safeeyes/platform/icons/hicolor/24x24/status/io.github.slgobinath.SafeEyes-timer.png"]), - (os.path.join(root_dir, "share/icons/hicolor/24x24/apps"), ["safeeyes/platform/icons/hicolor/24x24/apps/io.github.slgobinath.SafeEyes.png"]), - (os.path.join(root_dir, "share/icons/hicolor/16x16/status"), ["safeeyes/platform/icons/hicolor/16x16/status/io.github.slgobinath.SafeEyes-disabled.png", "safeeyes/platform/icons/hicolor/16x16/status/io.github.slgobinath.SafeEyes-enabled.png", "safeeyes/platform/icons/hicolor/16x16/status/io.github.slgobinath.SafeEyes-timer.png"]), - (os.path.join(root_dir, "share/icons/hicolor/16x16/apps"), ["safeeyes/platform/icons/hicolor/16x16/apps/io.github.slgobinath.SafeEyes.png"]), - (os.path.join(root_dir, "share/icons/hicolor/32x32/status"), ["safeeyes/platform/icons/hicolor/32x32/status/io.github.slgobinath.SafeEyes-disabled.png", "safeeyes/platform/icons/hicolor/32x32/status/io.github.slgobinath.SafeEyes-enabled.png"]), - (os.path.join(root_dir, "share/icons/hicolor/32x32/apps"), ["safeeyes/platform/icons/hicolor/32x32/apps/io.github.slgobinath.SafeEyes.png"]), - (os.path.join(root_dir, "share/icons/hicolor/64x64/apps"), ["safeeyes/platform/icons/hicolor/64x64/apps/io.github.slgobinath.SafeEyes.png"]), - (os.path.join(root_dir, "share/icons/hicolor/128x128/apps"), ["safeeyes/platform/icons/hicolor/128x128/apps/io.github.slgobinath.SafeEyes.png"]), - (os.path.join(root_dir, "share/icons/hicolor/48x48/status"), ["safeeyes/platform/icons/hicolor/48x48/status/io.github.slgobinath.SafeEyes-disabled.png", "safeeyes/platform/icons/hicolor/48x48/status/io.github.slgobinath.SafeEyes-enabled.png"]), - (os.path.join(root_dir, "share/icons/hicolor/48x48/apps"), ["safeeyes/platform/icons/hicolor/48x48/apps/io.github.slgobinath.SafeEyes.png"]),] - - -def __package_files(directory): - """ - Collect the package files. - """ - paths = [] - for (path, _, filenames) in os.walk(directory): - for filename in filenames: - paths.append(os.path.join('..', path, filename)) - return paths - - -def __package_data(): - """ - Return a list of package data. - """ - __compile_po_files() - data = ['glade/*.glade', 'resource/*'] - data.extend(__package_files('safeeyes/config')) - data.extend(__package_files('safeeyes/plugins')) - data.extend(__package_files('safeeyes/platform')) - return data - -setuptools.setup( - name="safeeyes", - version="2.2.2", - description="Protect your eyes from eye strain using this continuous breaks reminder.", - long_description=long_description, - long_description_content_type="text/markdown", - author="Gobinath Loganathan", - author_email="slgobinath@gmail.com", - url="https://github.com/slgobinath/SafeEyes", - download_url="https://github.com/slgobinath/SafeEyes/archive/v2.2.2.tar.gz", - packages=setuptools.find_packages(), - package_data={'safeeyes': __package_data()}, - data_files=__data_files(), - install_requires=requires, - entry_points={'console_scripts': ['safeeyes = safeeyes.__main__:main']}, - keywords='linux utility health eye-strain safe-eyes', - classifiers=[ - "Operating System :: POSIX :: Linux", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Development Status :: 5 - Production/Stable", - "Environment :: X11 Applications :: GTK", - "Intended Audience :: End Users/Desktop", - "Topic :: Utilities"] + [('Programming Language :: Python :: %s' % x) for x in '3 3.5 3.6 3.7 3.8 3.9'.split()] +setup( + cmdclass={'build': BuildCommand, 'build_mo': BuildMoSubCommand} )