From 6288c78aeaaaac3ba9127e9874630fd159aeff3b Mon Sep 17 00:00:00 2001 From: deltragon Date: Sun, 14 Jul 2024 15:55:23 +0200 Subject: [PATCH 01/13] migrate from setup.py to pyproject.toml --- MANIFEST.in | 4 ++ README.md | 4 +- pyproject.toml | 49 +++++++++++++++ setup.py | 165 +++++++++++++++++++++---------------------------- 4 files changed, 124 insertions(+), 98 deletions(-) create mode 100644 pyproject.toml diff --git a/MANIFEST.in b/MANIFEST.in index 28a03a4..b0a6c49 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,6 @@ include LICENSES/GPL-3.0-or-later.txt include README.md + +graft safeeyes + +global-exclude *.py[cod] diff --git a/README.md b/README.md index 7964b73..e7cabd2 100644 --- a/README.md +++ b/README.md @@ -200,8 +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) + - [pyproject.toml](https://github.com/slgobinath/SafeEyes/blob/master/pyproject.toml#L4) + - [pyproject.toml](https://github.com/slgobinath/SafeEyes/blob/master/pyproject.toml#L36) - [safeeyes.py](https://github.com/slgobinath/SafeEyes/blob/master/safeeyes/safeeyes.py#L42) - [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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e2621b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ + +[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=["safeeyes"] +include-package-data = true diff --git a/setup.py b/setup.py index 480c3ab..cc6a87b 100644 --- a/setup.py +++ b/setup.py @@ -1,103 +1,76 @@ -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 orig_build + +class build(orig_build): + sub_commands = [('build_mo', None), *orig_build.sub_commands] -requires = [ - 'babel', - 'psutil', - 'croniter', - 'PyGObject', - 'packaging', - 'python-xlib' -] +class build_mo(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")) + pass + + 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 = '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' + + source_file = po_dir + po_file + + build_file = po_dir + mo_file + if not self.editable_mode: + build_file = os.path.join(self.build_lib, build_file) + + files[build_file] = 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': build, 'build_mo': build_mo} ) From bb11f7674e4b2e3cd5b6659d29d9ffbef046a634 Mon Sep 17 00:00:00 2001 From: deltragon Date: Sun, 14 Jul 2024 17:29:06 +0200 Subject: [PATCH 02/13] read version from pyproject.toml --- README.md | 1 - safeeyes/safeeyes.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e7cabd2..27bec1a 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,6 @@ To ensure the new strings are well-formed, you can use `python validate_po.py -- 3. Update the Safe Eyes version in the following places (Open the project in VSCode and search for the current version): - [pyproject.toml](https://github.com/slgobinath/SafeEyes/blob/master/pyproject.toml#L4) - [pyproject.toml](https://github.com/slgobinath/SafeEyes/blob/master/pyproject.toml#L36) - - [safeeyes.py](https://github.com/slgobinath/SafeEyes/blob/master/safeeyes/safeeyes.py#L42) - [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/safeeyes/safeeyes.py b/safeeyes/safeeyes.py index 3188ec0..c803b25 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 -SAFE_EYES_VERSION = "2.2.2" +SAFE_EYES_VERSION = metadata.version("safeeyes") class SafeEyes(Gtk.Application): From bf4a04a378fd94ed828cf10abcb0b8f1c1daa5ab Mon Sep 17 00:00:00 2001 From: deltragon Date: Sun, 14 Jul 2024 17:29:49 +0200 Subject: [PATCH 03/13] update release action to use python -m build --- .github/workflows/release.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 }} From 38b80b2ae9268df81dfdf5b5cdf37895cdbfdbcd Mon Sep 17 00:00:00 2001 From: deltragon Date: Mon, 26 Aug 2024 15:52:32 +0200 Subject: [PATCH 04/13] fix typos --- README.md | 2 +- setup.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 27bec1a..d1faff5 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ To ensure the new strings are well-formed, you can use `python validate_po.py -- 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): - [pyproject.toml](https://github.com/slgobinath/SafeEyes/blob/master/pyproject.toml#L4) - - [pyproject.toml](https://github.com/slgobinath/SafeEyes/blob/master/pyproject.toml#L36) + - [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/setup.py b/setup.py index cc6a87b..f4b56b2 100644 --- a/setup.py +++ b/setup.py @@ -4,13 +4,13 @@ import os from pathlib import Path from setuptools import Command, setup -from setuptools.command.build import build as orig_build +from setuptools.command.build import build as OriginalBuildCommand -class build(orig_build): - sub_commands = [('build_mo', None), *orig_build.sub_commands] +class BuildCommand(OriginalBuildCommand): + sub_commands = [('build_mo', None), *OriginalBuildCommand.sub_commands] -class build_mo(Command): +class BuildMoSubCommand(Command): description = 'Compile .po files into .mo files' files = None @@ -22,7 +22,6 @@ class build_mo(Command): def finalize_options(self): self.set_undefined_options("build_py", ("build_lib", "build_lib")) - pass def run(self): files = self._get_files() @@ -72,5 +71,5 @@ class build_mo(Command): setup( - cmdclass={'build': build, 'build_mo': build_mo} + cmdclass={'build': BuildCommand, 'build_mo': BuildMoSubCommand} ) From 300cad2b8fccec72df68371921de8e3aa309ea3e Mon Sep 17 00:00:00 2001 From: deltragon Date: Mon, 26 Aug 2024 16:25:32 +0200 Subject: [PATCH 05/13] setup.py: refactor mo building to use pathlib --- setup.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index f4b56b2..d333e82 100644 --- a/setup.py +++ b/setup.py @@ -38,24 +38,22 @@ class BuildMoSubCommand(Command): files = {} - localedir = 'safeeyes/config/locale' - po_dirs = [localedir + '/' + l + '/LC_MESSAGES/' - for l in next(os.walk(localedir))[1]] + 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 next(os.walk(po_dir))[2] - if os.path.splitext(f)[1] == '.po'] + for f in po_dir.iterdir() + if f.is_file() and f.suffix == '.po'] for po_file in po_files: - filename, _ = os.path.splitext(po_file) - mo_file = filename + '.mo' + mo_file = po_file.with_suffix(".mo") - source_file = po_dir + po_file + source_file = po_file + build_file = mo_file - build_file = po_dir + mo_file if not self.editable_mode: - build_file = os.path.join(self.build_lib, build_file) + build_file = Path(self.build_lib).joinpath(build_file) - files[build_file] = source_file + files[str(build_file)] = str(source_file) self.files = files return files From 4227212e89c1fa753dac75a7469fb430e12059e5 Mon Sep 17 00:00:00 2001 From: deltragon Date: Mon, 26 Aug 2024 16:51:39 +0200 Subject: [PATCH 06/13] fix deprecation notice from setuptools --- pyproject.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e2621b3..35e5a50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,5 @@ healthstats = ["croniter"] requires = ["setuptools"] build-backend = "setuptools.build_meta" -[tool.setuptools] -packages=["safeeyes"] -include-package-data = true +[tool.setuptools.packages.find] +include=["safeeyes*"] From 1d74ee2fbe3dbcba8bb02750d0169ec0fa3f4ca8 Mon Sep 17 00:00:00 2001 From: deltragon Date: Thu, 22 Aug 2024 21:39:21 +0200 Subject: [PATCH 07/13] pytest: test model.Break and model.BreakQueue --- pyproject.toml | 5 + safeeyes/tests/__init__.py | 0 safeeyes/tests/test_model.py | 354 +++++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+) create mode 100644 safeeyes/tests/__init__.py create mode 100644 safeeyes/tests/test_model.py diff --git a/pyproject.toml b/pyproject.toml index 35e5a50..485b061 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,3 +46,8 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include=["safeeyes*"] + +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", +] 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_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" From affd1af5e03abbf92cc15d486cb59f18bc34af01 Mon Sep 17 00:00:00 2001 From: deltragon Date: Sun, 25 Aug 2024 21:06:39 +0200 Subject: [PATCH 08/13] test SafeEyesCore: initial --- safeeyes/tests/test_core.py | 345 ++++++++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 safeeyes/tests/test_core.py diff --git a/safeeyes/tests/test_core.py b/safeeyes/tests/test_core.py new file mode 100644 index 0000000..c77e264 --- /dev/null +++ b/safeeyes/tests/test_core.py @@ -0,0 +1,345 @@ +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")) + + @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): + # executes instantly + # TODO: separate thread? + monkeypatch.setattr( + core.utility, + "execute_main_thread", + lambda target_function, *args, **kwargs: target_function(*args, **kwargs) + ) + + + class Handle: + thread = None + task_queue = deque() + running = True + condvar_in = threading.Condition() + condvar_out = threading.Condition() + + 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: + logging.debug(f"background task shutdown") + break + + logging.debug(f"background task woken up") + + 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() + 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="WorkThread", daemon=False, kwargs=kwargs) + self.thread.start() + + def next(self): + assert self.thread + + with self.condvar_in: + self.condvar_in.notify() + + def wait(self): + # 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) + + handle = Handle() + + monkeypatch.setattr( + core.utility, + "start_thread", + handle.utility_start_thread + ) + + monkeypatch.setattr( + core.time, + "sleep", + lambda time: handle.sleep(time) + ) + + yield handle + + handle.stop() + + 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) + + safe_eyes_core.start() + + # start __scheduler_job + sequential_threading.next() + # FIXME: sleep is needed so code reaches the Condition + sleep(0.1) + 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() + + with safe_eyes_core.lock: + time_machine.shift(delta=datetime.timedelta(minutes=15)) + + with safe_eyes_core.waiting_condition: + logging.debug("notify") + safe_eyes_core.waiting_condition.notify_all() + + logging.debug("wait for end of __scheduler_job") + sequential_threading.wait() + logging.debug("done waiting for end of __scheduler_job") + + + safe_eyes_core.stop() + assert context['state'] == model.State.STOPPED + + logging.debug("done") + + + def test_actual(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, + "pre_break_warning_time": 10, + "random_order": False, + "postpone_duration": 5, + } + 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 = core.SafeEyesCore(context) + 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 + + safe_eyes_core.initialize(config) + + safe_eyes_core.start() + + # start __scheduler_job + sequential_threading.next() + # FIXME: sleep is needed so code reaches the Condition + sleep(0.1) + 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() + + with safe_eyes_core.lock: + time_machine.shift(delta=datetime.timedelta(minutes=15)) + + with safe_eyes_core.waiting_condition: + logging.debug("notify") + safe_eyes_core.waiting_condition.notify_all() + + logging.debug("wait for end of __scheduler_job") + sequential_threading.wait() + logging.debug("done waiting for 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 == "translated!: break 1" + on_pre_break.reset_mock() + + # start __wait_until_prepare + sequential_threading.next() + + # FIXME: sleep is needed so code reaches the Condition + sleep(0.1) + with safe_eyes_core.lock: + time_machine.shift(delta=datetime.timedelta(seconds=10)) + + with safe_eyes_core.waiting_condition: + logging.debug("notify") + safe_eyes_core.waiting_condition.notify_all() + + logging.debug("wait for end of __wait_until_prepare") + sequential_threading.wait() + logging.debug("done waiting for end of __wait_until_prepare") + + # start __start_break + sequential_threading.next() + sequential_threading.wait() + + # first sleep in __start_break + sequential_threading.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 == "translated!: break 1" + 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 == "translated!: break 1" + start_break.reset_mock() + + assert context['state'] == model.State.BREAK + + # continue sleep in __start_break + for i in range(config["short_break_duration"] - 1): + sequential_threading.wait() + sequential_threading.next() + + logging.debug("wait for end of __start_break") + sequential_threading.wait() + logging.debug("done waiting for end of __start_break") + + on_count_down.assert_called() + assert on_count_down.call_count == 15 + on_count_down.reset_mock() + + assert context['state'] == model.State.BREAK + + safe_eyes_core.stop() + + on_update_next_break.assert_not_called() + on_pre_break.assert_not_called() + on_start_break.assert_not_called() + start_break.assert_not_called() + assert context['state'] == model.State.STOPPED From d43e8dc8bbd1e3a86d866dcaa76a5a12986feeb5 Mon Sep 17 00:00:00 2001 From: deltragon Date: Sun, 25 Aug 2024 21:07:04 +0200 Subject: [PATCH 09/13] test SafeEyesCore: refactor out run_next_break method --- safeeyes/tests/test_core.py | 202 +++++++++++++++++++++++++++--------- 1 file changed, 155 insertions(+), 47 deletions(-) diff --git a/safeeyes/tests/test_core.py b/safeeyes/tests/test_core.py index c77e264..8d9aa32 100644 --- a/safeeyes/tests/test_core.py +++ b/safeeyes/tests/test_core.py @@ -14,7 +14,7 @@ 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")) + 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): @@ -22,7 +22,7 @@ class TestSafeEyesCore: monkeypatch.setattr(model, "_", lambda message: "translated!: " + message, raising=False) @pytest.fixture - def sequential_threading(self, monkeypatch): + def sequential_threading(self, monkeypatch, time_machine): # executes instantly # TODO: separate thread? monkeypatch.setattr( @@ -39,6 +39,9 @@ class TestSafeEyesCore: condvar_in = threading.Condition() condvar_out = threading.Condition() + def __init__(self, time_machine): + self.time_machine = time_machine + def background_thread(self): while True: with self.condvar_in: @@ -65,6 +68,7 @@ class TestSafeEyesCore: 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: @@ -98,7 +102,7 @@ class TestSafeEyesCore: if self.thread: self.thread.join(1) - handle = Handle() + handle = Handle(time_machine=time_machine) monkeypatch.setattr( core.utility, @@ -190,7 +194,7 @@ class TestSafeEyesCore: # start __scheduler_job sequential_threading.next() - # FIXME: sleep is needed so code reaches the Condition + # FIXME: sleep is needed so code reaches the waiting_condition sleep(0.1) assert context['state'] == model.State.WAITING @@ -216,60 +220,48 @@ class TestSafeEyesCore: logging.debug("done") + def run_next_break( + self, + sequential_threading, + time_machine, + safe_eyes_core, + context, + break_duration, + break_interval, + pre_break_warning_time, + 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. + """ - def test_actual(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, - "pre_break_warning_time": 10, - "random_order": False, - "postpone_duration": 5, - } 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 = core.SafeEyesCore(context) + 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 - safe_eyes_core.initialize(config) - - safe_eyes_core.start() - # start __scheduler_job sequential_threading.next() - # FIXME: sleep is needed so code reaches the Condition + # FIXME: sleep is needed so code reaches the waiting_condition sleep(0.1) 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" + assert on_update_next_break.call_args[0][0].name == break_name_translated on_update_next_break.reset_mock() with safe_eyes_core.lock: - time_machine.shift(delta=datetime.timedelta(minutes=15)) + time_machine.shift(delta=datetime.timedelta(minutes=break_interval)) with safe_eyes_core.waiting_condition: logging.debug("notify") @@ -283,16 +275,16 @@ class TestSafeEyesCore: 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 == "translated!: break 1" + assert on_pre_break.call_args[0][0].name == break_name_translated on_pre_break.reset_mock() # start __wait_until_prepare sequential_threading.next() - # FIXME: sleep is needed so code reaches the Condition + # FIXME: sleep is needed so code reaches the waiting_condition sleep(0.1) with safe_eyes_core.lock: - time_machine.shift(delta=datetime.timedelta(seconds=10)) + time_machine.shift(delta=datetime.timedelta(seconds=pre_break_warning_time)) with safe_eyes_core.waiting_condition: logging.debug("notify") @@ -311,18 +303,18 @@ class TestSafeEyesCore: 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 == "translated!: break 1" + 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 == "translated!: break 1" + 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(config["short_break_duration"] - 1): + for i in range(break_duration - 1): sequential_threading.wait() sequential_threading.next() @@ -331,15 +323,131 @@ class TestSafeEyesCore: logging.debug("done waiting for end of __start_break") on_count_down.assert_called() - assert on_count_down.call_count == 15 + 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_actual(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) + + safe_eyes_core.initialize(config) + + safe_eyes_core.start() + + + self.run_next_break( + sequential_threading, + time_machine, + safe_eyes_core, + context, + short_break_duration, + short_break_interval, + pre_break_warning_time, + "translated!: break 1" + ) + + self.assert_datetime("2024-08-25T13:15:25") + + self.run_next_break( + sequential_threading, + time_machine, + safe_eyes_core, + context, + short_break_duration, + short_break_interval, + pre_break_warning_time, + "translated!: break 2" + ) + + self.assert_datetime("2024-08-25T13:30:50") + + self.run_next_break( + sequential_threading, + time_machine, + safe_eyes_core, + context, + short_break_duration, + short_break_interval, + pre_break_warning_time, + "translated!: break 3" + ) + + self.assert_datetime("2024-08-25T13:46:15") + + self.run_next_break( + sequential_threading, + time_machine, + safe_eyes_core, + context, + short_break_duration, + short_break_interval, + pre_break_warning_time, + "translated!: break 4" + ) + + self.assert_datetime("2024-08-25T14:01:40") + + self.run_next_break( + sequential_threading, + time_machine, + safe_eyes_core, + context, + long_break_duration, + long_break_interval, + pre_break_warning_time, + "translated!: long break 1" + ) + + #self.assert_datetime("2024-08-25T14:16:40") + + self.run_next_break( + sequential_threading, + time_machine, + safe_eyes_core, + context, + short_break_duration, + short_break_interval, + pre_break_warning_time, + "translated!: break 1" + ) + safe_eyes_core.stop() - on_update_next_break.assert_not_called() - on_pre_break.assert_not_called() - on_start_break.assert_not_called() - start_break.assert_not_called() assert context['state'] == model.State.STOPPED From a823848a91c36f16f08c4c5d88e3a2e507ceb3e4 Mon Sep 17 00:00:00 2001 From: deltragon Date: Sun, 25 Aug 2024 22:33:32 +0200 Subject: [PATCH 10/13] test SafeEyesCore: add handling for condvar, add explanation, check exact times --- safeeyes/tests/test_core.py | 213 ++++++++++++++++++++---------------- 1 file changed, 116 insertions(+), 97 deletions(-) diff --git a/safeeyes/tests/test_core.py b/safeeyes/tests/test_core.py index 8d9aa32..1d80a98 100644 --- a/safeeyes/tests/test_core.py +++ b/safeeyes/tests/test_core.py @@ -23,14 +23,58 @@ class TestSafeEyesCore: @pytest.fixture def sequential_threading(self, monkeypatch, time_machine): - # executes instantly - # TODO: separate thread? + """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 @@ -39,8 +83,16 @@ class TestSafeEyesCore: condvar_in = threading.Condition() condvar_out = threading.Condition() - def __init__(self, time_machine): + 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: @@ -50,11 +102,8 @@ class TestSafeEyesCore: raise Exception("thread timed out") if not self.running: - logging.debug(f"background task shutdown") break - logging.debug(f"background task woken up") - if self.task_queue: (target_function, kwargs) = self.task_queue.popleft() logging.debug(f"thread started {target_function}") @@ -74,11 +123,23 @@ class TestSafeEyesCore: 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="WorkThread", daemon=False, kwargs=kwargs) + self.thread = threading.Thread(target=self.background_thread, name="SequentialThreadingRunner", daemon=False, kwargs=kwargs) self.thread.start() def next(self): @@ -87,7 +148,6 @@ class TestSafeEyesCore: with self.condvar_in: self.condvar_in.notify() - def wait(self): # wait until done: with self.condvar_out: success = self.condvar_out.wait(1) @@ -102,23 +162,10 @@ class TestSafeEyesCore: if self.thread: self.thread.join(1) - handle = Handle(time_machine=time_machine) + yield Handle - monkeypatch.setattr( - core.utility, - "start_thread", - handle.utility_start_thread - ) - - monkeypatch.setattr( - core.time, - "sleep", - lambda time: handle.sleep(time) - ) - - yield handle - - handle.stop() + if handle: + handle.stop() def test_create_empty(self): context = {} @@ -190,12 +237,13 @@ class TestSafeEyesCore: safe_eyes_core.initialize(config) + sequential_threading_handle = sequential_threading(safe_eyes_core) + safe_eyes_core.start() # start __scheduler_job - sequential_threading.next() - # FIXME: sleep is needed so code reaches the waiting_condition - sleep(0.1) + sequential_threading_handle.next() + assert context['state'] == model.State.WAITING on_update_next_break.assert_called_once() @@ -203,32 +251,21 @@ class TestSafeEyesCore: assert on_update_next_break.call_args[0][0].name == "translated!: break 1" on_update_next_break.reset_mock() - with safe_eyes_core.lock: - time_machine.shift(delta=datetime.timedelta(minutes=15)) - - with safe_eyes_core.waiting_condition: - logging.debug("notify") - safe_eyes_core.waiting_condition.notify_all() - - logging.debug("wait for end of __scheduler_job") - sequential_threading.wait() - logging.debug("done waiting for end of __scheduler_job") - + # 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 - logging.debug("done") def run_next_break( self, - sequential_threading, + sequential_threading_handle, time_machine, safe_eyes_core, context, break_duration, - break_interval, - pre_break_warning_time, break_name_translated ): """Run one entire cycle of safe_eyes_core. @@ -250,9 +287,10 @@ class TestSafeEyesCore: safe_eyes_core.on_count_down += on_count_down # start __scheduler_job - sequential_threading.next() - # FIXME: sleep is needed so code reaches the waiting_condition - sleep(0.1) + sequential_threading_handle.next() + # wait until it reaches the condvar + + assert context['state'] == model.State.WAITING on_update_next_break.assert_called_once() @@ -260,16 +298,9 @@ class TestSafeEyesCore: assert on_update_next_break.call_args[0][0].name == break_name_translated on_update_next_break.reset_mock() - with safe_eyes_core.lock: - time_machine.shift(delta=datetime.timedelta(minutes=break_interval)) - - with safe_eyes_core.waiting_condition: - logging.debug("notify") - safe_eyes_core.waiting_condition.notify_all() - - logging.debug("wait for end of __scheduler_job") - sequential_threading.wait() - logging.debug("done waiting for end of __scheduler_job") + # continue after condvar + sequential_threading_handle.next() + # end of __scheduler_job assert context['state'] == model.State.PRE_BREAK @@ -279,27 +310,18 @@ class TestSafeEyesCore: on_pre_break.reset_mock() # start __wait_until_prepare - sequential_threading.next() + sequential_threading_handle.next() - # FIXME: sleep is needed so code reaches the waiting_condition - sleep(0.1) - with safe_eyes_core.lock: - time_machine.shift(delta=datetime.timedelta(seconds=pre_break_warning_time)) - - with safe_eyes_core.waiting_condition: - logging.debug("notify") - safe_eyes_core.waiting_condition.notify_all() - - logging.debug("wait for end of __wait_until_prepare") - sequential_threading.wait() - logging.debug("done waiting for end of __wait_until_prepare") + # wait until it reaches the condvar + # continue after condvar + sequential_threading_handle.next() + # end of __wait_until_prepare # start __start_break - sequential_threading.next() - sequential_threading.wait() + sequential_threading_handle.next() # first sleep in __start_break - sequential_threading.next() + sequential_threading_handle.next() on_start_break.assert_called_once() assert isinstance(on_start_break.call_args[0][0], model.Break) @@ -314,13 +336,11 @@ class TestSafeEyesCore: assert context['state'] == model.State.BREAK # continue sleep in __start_break - for i in range(break_duration - 1): - sequential_threading.wait() - sequential_threading.next() + for i in range(break_duration - 2): + sequential_threading_handle.next() - logging.debug("wait for end of __start_break") - sequential_threading.wait() - logging.debug("done waiting for end of __start_break") + sequential_threading_handle.next() + # end of __start_break on_count_down.assert_called() assert on_count_down.call_count == break_duration @@ -333,7 +353,7 @@ class TestSafeEyesCore: string += "+00:00" assert datetime.datetime.now(datetime.timezone.utc) == datetime.datetime.fromisoformat(string) - def test_actual(self, sequential_threading, time_machine): + def test_full_run_with_defaults(self, sequential_threading, time_machine): context = { "session": {}, } @@ -367,87 +387,86 @@ class TestSafeEyesCore: 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, + sequential_threading_handle, time_machine, safe_eyes_core, context, short_break_duration, - short_break_interval, - pre_break_warning_time, "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, + sequential_threading_handle, time_machine, safe_eyes_core, context, short_break_duration, - short_break_interval, - pre_break_warning_time, "translated!: break 2" ) self.assert_datetime("2024-08-25T13:30:50") self.run_next_break( - sequential_threading, + sequential_threading_handle, time_machine, safe_eyes_core, context, short_break_duration, - short_break_interval, - pre_break_warning_time, "translated!: break 3" ) self.assert_datetime("2024-08-25T13:46:15") self.run_next_break( - sequential_threading, + sequential_threading_handle, time_machine, safe_eyes_core, context, short_break_duration, - short_break_interval, - pre_break_warning_time, "translated!: break 4" ) self.assert_datetime("2024-08-25T14:01:40") self.run_next_break( - sequential_threading, + sequential_threading_handle, time_machine, safe_eyes_core, context, long_break_duration, - long_break_interval, - pre_break_warning_time, "translated!: long break 1" ) - #self.assert_datetime("2024-08-25T14:16:40") + # 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, + sequential_threading_handle, time_machine, safe_eyes_core, context, short_break_duration, - short_break_interval, - pre_break_warning_time, "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 From 60afefc35ca7ca39d83de6dba0d25ec3ab031778 Mon Sep 17 00:00:00 2001 From: deltragon Date: Sun, 25 Aug 2024 22:33:49 +0200 Subject: [PATCH 11/13] test SafeEyesCore: add test from https://github.com/slgobinath/SafeEyes/issues/640 --- safeeyes/tests/test_core.py | 110 ++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/safeeyes/tests/test_core.py b/safeeyes/tests/test_core.py index 1d80a98..ffa324a 100644 --- a/safeeyes/tests/test_core.py +++ b/safeeyes/tests/test_core.py @@ -470,3 +470,113 @@ class TestSafeEyesCore: 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 From 8953b2237f139ae362b76062aac92fbb00c93f5b Mon Sep 17 00:00:00 2001 From: deltragon Date: Sun, 25 Aug 2024 22:36:38 +0200 Subject: [PATCH 12/13] reorder methods --- safeeyes/tests/test_core.py | 184 ++++++++++++++++++------------------ 1 file changed, 93 insertions(+), 91 deletions(-) diff --git a/safeeyes/tests/test_core.py b/safeeyes/tests/test_core.py index ffa324a..e607325 100644 --- a/safeeyes/tests/test_core.py +++ b/safeeyes/tests/test_core.py @@ -167,97 +167,6 @@ class TestSafeEyesCore: if handle: handle.stop() - 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 run_next_break( self, @@ -348,11 +257,104 @@ class TestSafeEyesCore: 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": {}, From f9e39081d8c9b059bae814a8af25dc0484dc88ff Mon Sep 17 00:00:00 2001 From: deltragon Date: Mon, 26 Aug 2024 17:00:19 +0200 Subject: [PATCH 13/13] exclude tests from package --- MANIFEST.in | 2 ++ pyproject.toml | 1 + 2 files changed, 3 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index b0a6c49..b0ff714 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,6 @@ include README.md graft safeeyes +prune safeeyes/tests + global-exclude *.py[cod] diff --git a/pyproject.toml b/pyproject.toml index 485b061..93de0e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include=["safeeyes*"] +exclude=["safeeyes.tests*"] [tool.pytest.ini_options] addopts = [