diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9af2b10d..e729c315 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,3 +3,5 @@ Describe your changes here, and link to related issues if available. Reminder to run `python validate_po.py --extract` if you have added new translatable strings. + +Reminder to run `ruff check`, `ruff format`, and `mypy safeeyes` to ensure coding standards are followed and types are correct. diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 00000000..8627a712 --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,36 @@ +# Workflow to run mypy +# Adapted from the CPython mypy action +name: mypy + +on: [push, pull_request] + +permissions: + contents: read + +env: + UV_SYSTEM_PYTHON: 1 + PIP_DISABLE_PIP_VERSION_CHECK: 1 + FORCE_COLOR: 1 + TERM: xterm-256color # needed for FORCE_COLOR to work on mypy on Ubuntu, see https://github.com/python/mypy/issues/13817 + +jobs: + mypy: + name: mypy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@22695119d769bdb6f7032ad67b9bca0ef8c4a174 # v5.4.0 + with: + enable-cache: true + cache-dependency-glob: '' + cache-suffix: '3.13' + - name: Setup Python + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 + with: + python-version: '3.13' + - run: sudo apt-get install -y libwayland-dev libcairo2-dev libgirepository-2.0-dev + - run: uv pip install -r pyproject.toml + - run: uv pip install --group types + - run: mypy safeeyes diff --git a/README.md b/README.md index bf95c28c..ca6446fe 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,10 @@ To ensure the new strings are well-formed, you can use `python validate_po.py -- To ensure that the coding and formatting guidelines are followed, install [ruff](https://docs.astral.sh/ruff/) and run `ruff check` and `ruff format --check` to check for issues, as well as `ruff check --fix` and `ruff format` to autofix them. +To ensure that any types are correct, install [mypy](https://github.com/python/mypy) and run `mypy safeeyes`. + +The last three checks are also run in CI, so a PR must pass all the tests for it to be mmerged. + ## How to Release? 0. Run `update-po.sh` to generate new translation files (which will be eventually updated by translators). Commit and push the changes to the master branch. diff --git a/pyproject.toml b/pyproject.toml index 47c5f123..7ea24f3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,3 +46,35 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include=["safeeyes*"] + +[dependency-groups] +dev = [ + {include-group = "lint"}, + {include-group = "scripts"}, + {include-group = "types"} +] +lint = [ + "ruff==0.11.2" +] +scripts = [ + "polib==1.2.0" +] +types = [ + "mypy==1.15.0", + "PyGObject-stubs==2.13.0", + "types-croniter==5.0.1.20250322", + "types-psutil==7.0.0.20250218", + "types-python-xlib==0.33.0.20240407" +] + +[tool.mypy] +# catch typos in configuration file +warn_unused_configs = true +disallow_any_unimported = true +#check_untyped_defs = true +warn_unused_ignores = true +warn_unreachable = true +enable_error_code = [ + "ignore-without-code", + "possibly-undefined" +] diff --git a/safeeyes/plugins/donotdisturb/plugin.py b/safeeyes/plugins/donotdisturb/plugin.py index 9fb7be93..63d234bf 100644 --- a/safeeyes/plugins/donotdisturb/plugin.py +++ b/safeeyes/plugins/donotdisturb/plugin.py @@ -30,8 +30,8 @@ from safeeyes import utility context = None -skip_break_window_classes = [] -take_break_window_classes = [] +skip_break_window_classes: list[str] = [] +take_break_window_classes: list[str] = [] unfullscreen_allowed = True dnd_while_on_battery = False diff --git a/safeeyes/plugins/smartpause/ext_idle_notify.py b/safeeyes/plugins/smartpause/ext_idle_notify.py index 49ba29a1..1c4f02ff 100644 --- a/safeeyes/plugins/smartpause/ext_idle_notify.py +++ b/safeeyes/plugins/smartpause/ext_idle_notify.py @@ -20,6 +20,8 @@ import threading import datetime +import os +import select from pywayland.client import Display from pywayland.protocol.wayland.wl_seat import WlSeat @@ -33,19 +35,39 @@ class ExtIdleNotify: _notifier_set = False _running = True _thread = None + _r_channel = None + _w_channel = None _idle_since = None def __init__(self): + # Note that this creates a new connection to the wayland compositor. + # This is not an issue per se, but does mean that the compositor sees this as + # a new, separate client, that just happens to run in the same process as + # the SafeEyes gtk application. + # (This is not a problem, currently. swayidle does the same, it even runs in a + # separate process.) + # If in the future, a compositor decides to lock down ext-idle-notify-v1 to + # clients somehow, and we need to share the connection to wl_display with gtk + # (which might require some hacks, or FFI code), we might run into other issues + # described in this mailing thread: + # https://lists.freedesktop.org/archives/wayland-devel/2019-March/040344.html + # The best thing would be, of course, for gtk to gain native support for + # ext-idle-notify-v1. self._display = Display() self._display.connect() + self._r_channel, self._w_channel = os.pipe() def stop(self): self._running = False + # write anything, just to wake up the channel + os.write(self._w_channel, b"!") self._notification.destroy() self._notification = None self._seat = None self._thread.join() + os.close(self._r_channel) + os.close(self._w_channel) def run(self): self._thread = threading.Thread( @@ -57,8 +79,26 @@ def _run(self): reg = self._display.get_registry() reg.dispatcher["global"] = self._global_handler + display_fd = self._display.get_fd() + while self._running: - self._display.dispatch(block=True) + self._display.flush() + + # this blocks until either there are new events in self._display + # (retrieved using dispatch()) + # or until there are events in self._r_channel - which means that stop() + # was called + # unfortunately, this seems like the best way to make sure that dispatch + # doesn't block potentially forever (up to multiple seconds in my usage) + read, _w, _x = select.select((display_fd, self._r_channel), (), ()) + + if self._r_channel in read: + # the channel was written to, which means stop() was called + # at this point, self._running should be false as well + break + + if display_fd in read: + self._display.dispatch(block=True) self._display.disconnect() diff --git a/safeeyes/plugins/trayicon/plugin.py b/safeeyes/plugins/trayicon/plugin.py index 290b324f..21ced707 100644 --- a/safeeyes/plugins/trayicon/plugin.py +++ b/safeeyes/plugins/trayicon/plugin.py @@ -26,6 +26,7 @@ from safeeyes import utility import threading import time +import typing """ Safe Eyes tray icon plugin @@ -186,8 +187,10 @@ class DBusMenuService(DBusService): revision = 0 - items = [] - idToItems = {} + # TODO: replace dict here with more exact typing for item + items: list[dict] = [] + # TODO: replace dict here with more exact typing for item + idToItems: dict[str, dict] = {} def __init__(self, session_bus, context, items): super().__init__( @@ -366,7 +369,7 @@ class StatusNotifierItemService(DBusService): Status = "Active" IconName = "io.github.slgobinath.SafeEyes-enabled" IconThemePath = "" - ToolTip = ("", [], "Safe Eyes", "") + ToolTip: tuple[str, list[typing.Any], str, str] = ("", [], "Safe Eyes", "") XAyatanaLabel = "" ItemIsMenu = True Menu = None diff --git a/safeeyes/ui/break_screen.py b/safeeyes/ui/break_screen.py index bae3dfda..5a3fc3c6 100644 --- a/safeeyes/ui/break_screen.py +++ b/safeeyes/ui/break_screen.py @@ -25,7 +25,7 @@ from safeeyes import utility import Xlib from Xlib.display import Display -from Xlib.display import X +from Xlib import X gi.require_version("Gtk", "4.0") from gi.repository import Gdk