diff --git a/backend/app/models.py b/backend/app/models.py index 149165d..68865ec 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -8,7 +8,7 @@ day so a re-send shows the same numbers. from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime from typing import Any from sqlalchemy import ( @@ -30,7 +30,7 @@ from app.db import Base def utcnow() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) class SettingsRow(Base): @@ -80,7 +80,7 @@ class PomodoroSession(Base): note: Mapped[str | None] = mapped_column(Text) abandon_reason: Mapped[str | None] = mapped_column(Text) - block_events: Mapped[list["BlockEvent"]] = relationship(back_populates="session") + block_events: Mapped[list[BlockEvent]] = relationship(back_populates="session") @property def is_work(self) -> bool: diff --git a/backend/app/services/achievements.py b/backend/app/services/achievements.py index c6398f3..2789372 100644 --- a/backend/app/services/achievements.py +++ b/backend/app/services/achievements.py @@ -8,9 +8,10 @@ the Telegram message at once. from __future__ import annotations import logging +from collections.abc import Callable from dataclasses import dataclass from datetime import date -from typing import Any, Callable +from typing import Any Metrics = dict[str, Any] Context = dict[str, Any] diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 1105db3..c46d0e0 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -17,7 +17,7 @@ import hashlib import hmac import secrets import time -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy import delete @@ -84,7 +84,7 @@ def create_session(session: Session, method: str, session_days: int) -> AuthSess row = AuthSession( token=secrets.token_urlsafe(32), method=method, - expires_at=datetime.now(timezone.utc) + timedelta(days=session_days), + expires_at=datetime.now(UTC) + timedelta(days=session_days), ) session.add(row) session.commit() @@ -93,7 +93,7 @@ def create_session(session: Session, method: str, session_days: int) -> AuthSess def _aware(dt: datetime) -> datetime: """SQLite drops the tzinfo it was stored with; Postgres does not.""" - return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + return dt if dt.tzinfo else dt.replace(tzinfo=UTC) def valid_session(session: Session, token: str | None) -> AuthSession | None: @@ -102,7 +102,7 @@ def valid_session(session: Session, token: str | None) -> AuthSession | None: row = session.get(AuthSession, token) if row is None: return None - if _aware(row.expires_at) < datetime.now(timezone.utc): + if _aware(row.expires_at) < datetime.now(UTC): session.delete(row) session.commit() return None @@ -117,7 +117,7 @@ def delete_session(session: Session, token: str | None) -> None: def _prune_expired(session: Session) -> None: - session.execute(delete(AuthSession).where(AuthSession.expires_at < datetime.now(timezone.utc))) + session.execute(delete(AuthSession).where(AuthSession.expires_at < datetime.now(UTC))) # -------------------------------------------------------------------------- diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py index 26a193e..a0e82a0 100644 --- a/backend/app/services/settings_service.py +++ b/backend/app/services/settings_service.py @@ -13,7 +13,8 @@ Three jobs: from __future__ import annotations import threading -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from sqlalchemy.orm import Session diff --git a/backend/app/services/tasks_service.py b/backend/app/services/tasks_service.py index d8b036e..e81069b 100644 --- a/backend/app/services/tasks_service.py +++ b/backend/app/services/tasks_service.py @@ -14,8 +14,9 @@ is recorded as closed today. from __future__ import annotations import logging +from collections.abc import Iterable from datetime import date -from typing import Any, Iterable +from typing import Any from uuid import uuid4 from sqlalchemy import select diff --git a/backend/app/services/telegram_bot.py b/backend/app/services/telegram_bot.py index 01e4422..e3412ef 100644 --- a/backend/app/services/telegram_bot.py +++ b/backend/app/services/telegram_bot.py @@ -10,7 +10,8 @@ from __future__ import annotations import logging import threading import time as time_module -from typing import Any, Callable +from collections.abc import Callable +from typing import Any import httpx diff --git a/backend/app/sources/base.py b/backend/app/sources/base.py index 91490d9..86fada7 100644 --- a/backend/app/sources/base.py +++ b/backend/app/sources/base.py @@ -51,7 +51,7 @@ class SourceResult: error: str | None = None @classmethod - def failed(cls, source: str, error: str) -> "SourceResult": + def failed(cls, source: str, error: str) -> SourceResult: return cls(source=source, items=[], ok=False, error=error) diff --git a/backend/app/sources/google_tasks.py b/backend/app/sources/google_tasks.py index 8d11824..7aebe34 100644 --- a/backend/app/sources/google_tasks.py +++ b/backend/app/sources/google_tasks.py @@ -11,8 +11,9 @@ still things you can spend a pomodoro on. from __future__ import annotations +from collections.abc import Iterator from datetime import date -from typing import Any, Iterator +from typing import Any import httpx diff --git a/backend/app/sources/jira.py b/backend/app/sources/jira.py index 5077c78..c075324 100644 --- a/backend/app/sources/jira.py +++ b/backend/app/sources/jira.py @@ -13,8 +13,9 @@ notices it has left the "open issues" query. One owner per task. from __future__ import annotations import re +from collections.abc import Iterator from datetime import date -from typing import Any, Iterator +from typing import Any import httpx diff --git a/backend/app/sources/registry.py b/backend/app/sources/registry.py index 75b08f5..86945f0 100644 --- a/backend/app/sources/registry.py +++ b/backend/app/sources/registry.py @@ -8,8 +8,9 @@ empty task list. from __future__ import annotations import logging +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor -from typing import Any, Callable +from typing import Any from app.settings_schema import AppSettings from app.sources.base import SourceError, SourceResult, TodoItem diff --git a/backend/app/sources/todoist.py b/backend/app/sources/todoist.py index 0bb024f..e646b7d 100644 --- a/backend/app/sources/todoist.py +++ b/backend/app/sources/todoist.py @@ -9,8 +9,9 @@ for people who want the narrower list. from __future__ import annotations +from collections.abc import Iterator from datetime import date -from typing import Any, Iterator +from typing import Any import httpx diff --git a/backend/app/timeutil.py b/backend/app/timeutil.py index 77c5121..b96d4b8 100644 --- a/backend/app/timeutil.py +++ b/backend/app/timeutil.py @@ -7,7 +7,7 @@ for" — here. from __future__ import annotations -from datetime import date, datetime, time, timedelta, timezone +from datetime import UTC, date, datetime, time, timedelta from zoneinfo import ZoneInfo @@ -19,7 +19,7 @@ def tz(name: str) -> ZoneInfo: def now_utc() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def ensure_utc(moment: datetime | None) -> datetime | None: @@ -31,12 +31,12 @@ def ensure_utc(moment: datetime | None) -> datetime | None: """ if moment is None: return None - return moment if moment.tzinfo is not None else moment.replace(tzinfo=timezone.utc) + return moment if moment.tzinfo is not None else moment.replace(tzinfo=UTC) def to_local(moment: datetime, tz_name: str) -> datetime: if moment.tzinfo is None: - moment = moment.replace(tzinfo=timezone.utc) + moment = moment.replace(tzinfo=UTC) return moment.astimezone(tz(tz_name)) @@ -54,7 +54,7 @@ def day_bounds_utc(day: date, tz_name: str) -> tuple[datetime, datetime]: zone = tz(tz_name) start = datetime.combine(day, time.min, tzinfo=zone) end = start + timedelta(days=1) - return start.astimezone(timezone.utc), end.astimezone(timezone.utc) + return start.astimezone(UTC), end.astimezone(UTC) def in_window(moment: time, start: time, end: time) -> bool: diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0507629..04558dd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -35,6 +35,23 @@ packages = ["app"] line-length = 90 target-version = "py312" +# Named rather than left to whatever the installed ruff defaults to, because CI +# runs this and a silent default change should not turn a push red. `format` is +# deliberately not enforced: the layout here is hand-set in places (comment +# tables, the DNS wire-format structs) and a formatter would flatten it. +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "SIM", "B", "RUF"] +ignore = [ + # FastAPI's whole dependency idiom is a call in a default argument. + "B008", + # A line-length cap on comments and docstrings is for a formatter to decide. + "E501", + # The reports and the bot speak Russian as well as English, and the typography + # is deliberate (× for counts, – in the laundry source's labels). "Ambiguous + # character" is the wrong complaint about a string the user is meant to read. + "RUF001", +] + [tool.pytest.ini_options] pythonpath = ["."] testpaths = ["tests"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 42cbc36..6e221b4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -61,9 +61,8 @@ def settings(session) -> AppSettings: @pytest.fixture() def client(): - from fastapi.testclient import TestClient - from app.main import app + from fastapi.testclient import TestClient Base.metadata.drop_all(bind=engine) init_db() diff --git a/backend/tests/test_db_migration.py b/backend/tests/test_db_migration.py index be2ac66..d1d57ba 100644 --- a/backend/tests/test_db_migration.py +++ b/backend/tests/test_db_migration.py @@ -13,10 +13,9 @@ known-dead columns after `create_all`; this pins that behaviour down. from __future__ import annotations -from sqlalchemy import text - from app.db import SessionLocal, engine, init_db from app.models import TaskRecord +from sqlalchemy import text def _simulate_pre_refactor_database() -> None: diff --git a/backend/tests/test_pomodoro.py b/backend/tests/test_pomodoro.py index fc0c3fd..be94d92 100644 --- a/backend/tests/test_pomodoro.py +++ b/backend/tests/test_pomodoro.py @@ -3,7 +3,6 @@ from __future__ import annotations import pytest - from app.models import PomodoroSession from app.services import pomodoro, settings_service diff --git a/backend/tests/test_reports.py b/backend/tests/test_reports.py index 7c8776a..6687614 100644 --- a/backend/tests/test_reports.py +++ b/backend/tests/test_reports.py @@ -5,8 +5,8 @@ from __future__ import annotations from datetime import timedelta from app.models import utcnow -from app.timeutil import local_date from app.services import pomodoro, report, settings_service, stats, tasks_service +from app.timeutil import local_date def _close(session, settings, task) -> None: diff --git a/desktop/kadence_desktop/__main__.py b/desktop/kadence_desktop/__main__.py index ed88551..6c5bc13 100644 --- a/desktop/kadence_desktop/__main__.py +++ b/desktop/kadence_desktop/__main__.py @@ -38,7 +38,7 @@ def _remote(verb: str) -> int: from PyQt6.QtCore import QCoreApplication from PyQt6.QtDBus import QDBusConnection, QDBusInterface - _app = QCoreApplication(sys.argv) # noqa: F841 - needed for the D-Bus loop + _app = QCoreApplication(sys.argv) interface = QDBusInterface( "org.kadence.Desktop", "/Timer", "org.kadence.Timer", QDBusConnection.sessionBus() ) diff --git a/desktop/kadence_desktop/blocking/hosts.py b/desktop/kadence_desktop/blocking/hosts.py index ea2526e..dff5d82 100644 --- a/desktop/kadence_desktop/blocking/hosts.py +++ b/desktop/kadence_desktop/blocking/hosts.py @@ -12,6 +12,7 @@ hostname, so the worst a compromised client can do is block a site. from __future__ import annotations +import contextlib import logging import os import re @@ -35,7 +36,7 @@ def valid_hostname(name: str) -> bool: def current_block() -> list[str]: """The hostnames currently inside our managed region of `/etc/hosts`.""" try: - with open(HOSTS_PATH, "r", encoding="utf-8") as handle: + with open(HOSTS_PATH, encoding="utf-8") as handle: lines = handle.read().splitlines() except OSError: return [] @@ -124,12 +125,10 @@ class HostsBlocker: list_path = handle.name os.chmod(list_path, 0o644) # the helper reads it as root from /tmp try: - return self._run(runner + ["apply", "--ip", redirect_ip, "--file", list_path]) + return self._run([*runner, "apply", "--ip", redirect_ip, "--file", list_path]) finally: - try: + with contextlib.suppress(OSError): os.unlink(list_path) - except OSError: - pass def clear(self) -> bool: """Remove the managed region. Safe to call when it is not there.""" @@ -138,7 +137,7 @@ class HostsBlocker: runner = self._runner() if runner is None: return False - return self._run(runner + ["clear"]) + return self._run([*runner, "clear"]) def _run(self, command: list[str]) -> bool: try: diff --git a/desktop/kadence_desktop/blocking/manager.py b/desktop/kadence_desktop/blocking/manager.py index fdfdb3b..15a78b0 100644 --- a/desktop/kadence_desktop/blocking/manager.py +++ b/desktop/kadence_desktop/blocking/manager.py @@ -26,8 +26,8 @@ from __future__ import annotations import logging import time +from collections.abc import Callable from types import ModuleType -from typing import Callable from kadence_desktop.blocking import processes from kadence_desktop.blocking.hosts import HostsBlocker @@ -262,7 +262,7 @@ class BlockerManager: self._asked[identity] = now self._announce(app, "killed") - def _announce(self, app: "processes.RunningApp", action: str) -> None: + def _announce(self, app: processes.RunningApp, action: str) -> None: """Report and notify, at most once per process per cooldown.""" if not self._claim_report(app.identity): return diff --git a/desktop/kadence_desktop/blocking/processes.py b/desktop/kadence_desktop/blocking/processes.py index 14e2d42..e3d3fba 100644 --- a/desktop/kadence_desktop/blocking/processes.py +++ b/desktop/kadence_desktop/blocking/processes.py @@ -23,8 +23,8 @@ from __future__ import annotations import logging import os import signal -from fnmatch import fnmatch from dataclasses import dataclass +from fnmatch import fnmatch from pathlib import Path log = logging.getLogger(__name__) diff --git a/desktop/kadence_desktop/blocking/resolver.py b/desktop/kadence_desktop/blocking/resolver.py index 5c02b5c..11f15a0 100644 --- a/desktop/kadence_desktop/blocking/resolver.py +++ b/desktop/kadence_desktop/blocking/resolver.py @@ -31,6 +31,7 @@ startup whenever a drop-in is found lying around. from __future__ import annotations import concurrent.futures +import contextlib import fnmatch import ipaddress import logging @@ -363,10 +364,8 @@ class StubResolver: for sock in (self._udp, self._tcp): if sock is None: continue - try: + with contextlib.suppress(OSError): sock.close() # the blocking recv/accept raises, ending the loop - except OSError: - pass for thread in self._threads: thread.join(timeout=1) for pool in (self._handlers, self._forwards): @@ -407,7 +406,7 @@ class StubResolver: while self._running and self._udp is not None: try: data, address = self._udp.recvfrom(4096) - except socket.timeout: + except TimeoutError: continue except OSError: break @@ -431,7 +430,7 @@ class StubResolver: while self._running and self._tcp is not None: try: connection, _ = self._tcp.accept() - except socket.timeout: + except TimeoutError: continue except OSError: break @@ -632,7 +631,7 @@ def _parse_nameserver(token: str) -> tuple[str, int] | None: def _parse_resolv_conf(path: str) -> list[tuple[str, int]]: servers: list[tuple[str, int]] = [] try: - with open(path, "r", encoding="utf-8", errors="replace") as handle: + with open(path, encoding="utf-8", errors="replace") as handle: lines = handle.read().splitlines() except OSError: return servers @@ -704,7 +703,7 @@ class ResolvedRoute: def _read_dropin(self) -> tuple[tuple[str, ...], int] | None: """What the drop-in currently says, if it is one of ours.""" try: - with open(self.dropin_path, "r", encoding="utf-8") as handle: + with open(self.dropin_path, encoding="utf-8") as handle: text = handle.read() except OSError: return None @@ -739,7 +738,7 @@ class ResolvedRoute: runner = self._runner() if runner is None: return False - command = runner + ["resolved-on", "--port", str(port), "--domains", ",".join(wanted)] + command = [*runner, "resolved-on", "--port", str(port), "--domains", ",".join(wanted)] if not self._run(command, f"route {len(wanted)} zone(s) through the local stub"): return False self._applied = (wanted, port) @@ -753,7 +752,7 @@ class ResolvedRoute: runner = self._runner() if runner is None: return False - if not self._run(runner + ["resolved-off"], "stop routing DNS through the local stub"): + if not self._run([*runner, "resolved-off"], "stop routing DNS through the local stub"): return False self._applied = None return True diff --git a/desktop/kadence_desktop/config.py b/desktop/kadence_desktop/config.py index a08d6a5..90744ef 100644 --- a/desktop/kadence_desktop/config.py +++ b/desktop/kadence_desktop/config.py @@ -107,7 +107,7 @@ class DesktopConfig: window: dict = field(default_factory=lambda: {"width": 1100, "height": 760}) @classmethod - def load(cls) -> "DesktopConfig": + def load(cls) -> DesktopConfig: if not CONFIG_PATH.exists(): config = cls() config.save() diff --git a/desktop/kadence_desktop/integrations/dbus_service.py b/desktop/kadence_desktop/integrations/dbus_service.py index 7a36d55..0ebeb17 100644 --- a/desktop/kadence_desktop/integrations/dbus_service.py +++ b/desktop/kadence_desktop/integrations/dbus_service.py @@ -19,7 +19,7 @@ every documented command then hits. from __future__ import annotations import logging -from typing import Callable +from collections.abc import Callable from PyQt6.QtCore import QObject, pyqtClassInfo, pyqtSlot from PyQt6.QtDBus import QDBusAbstractAdaptor, QDBusConnection @@ -92,60 +92,60 @@ class TimerAdaptor(QDBusAbstractAdaptor): # D-Bus methods are PascalCase by convention. @pyqtSlot(result=str) - def StartWork(self) -> str: # noqa: N802 + def StartWork(self) -> str: """Start a focus interval.""" return self._service.run("start_work") @pyqtSlot(result=str) - def StartBreak(self) -> str: # noqa: N802 + def StartBreak(self) -> str: return self._service.run("start_break") @pyqtSlot(result=str) - def Toggle(self) -> str: # noqa: N802 + def Toggle(self) -> str: """Start if idle, pause if running, resume if paused — one hotkey.""" return self._service.run("toggle") @pyqtSlot(result=str) - def Pause(self) -> str: # noqa: N802 + def Pause(self) -> str: return self._service.run("pause") @pyqtSlot(result=str) - def Resume(self) -> str: # noqa: N802 + def Resume(self) -> str: return self._service.run("resume") @pyqtSlot(result=str) - def Complete(self) -> str: # noqa: N802 + def Complete(self) -> str: return self._service.run("complete") @pyqtSlot(result=str) - def Stop(self) -> str: # noqa: N802 + def Stop(self) -> str: return self._service.run("stop") @pyqtSlot(result=str) - def Extend(self) -> str: # noqa: N802 + def Extend(self) -> str: return self._service.run("extend") @pyqtSlot(result=str) - def Interrupt(self) -> str: # noqa: N802 + def Interrupt(self) -> str: """Count a distraction without stopping the clock.""" return self._service.run("interrupt") @pyqtSlot(result=str) - def Status(self) -> str: # noqa: N802 + def Status(self) -> str: """One line: what is running and how much is left.""" return self._service.run("status") @pyqtSlot(result=str) - def SendReport(self) -> str: # noqa: N802 + def SendReport(self) -> str: return self._service.run("send_report") @pyqtSlot(result=str) - def ToggleMini(self) -> str: # noqa: N802 + def ToggleMini(self) -> str: """Show or hide the floating mini timer.""" return self._service.run("toggle_mini") @pyqtSlot(result=str) - def Raise(self) -> str: # noqa: N802 + def Raise(self) -> str: """Show the main window (used by a second instance).""" return self._service.run("raise_window") diff --git a/desktop/kadence_desktop/integrations/keep_above.py b/desktop/kadence_desktop/integrations/keep_above.py index ca9cb58..003700d 100644 --- a/desktop/kadence_desktop/integrations/keep_above.py +++ b/desktop/kadence_desktop/integrations/keep_above.py @@ -155,9 +155,7 @@ def up_to_date(position: tuple[int, int] | None = None) -> bool: if any(parser.get(section, key, fallback=None) != value for key, value in wanted.items()): return False # A position left over from a previous corner must not linger. - if position is None and parser.has_option(section, "position"): - return False - return True + return not (position is None and parser.has_option(section, "position")) except (configparser.Error, OSError): return False diff --git a/desktop/kadence_desktop/integrations/notifications.py b/desktop/kadence_desktop/integrations/notifications.py index b15a265..cedc7e2 100644 --- a/desktop/kadence_desktop/integrations/notifications.py +++ b/desktop/kadence_desktop/integrations/notifications.py @@ -10,7 +10,7 @@ so a running timer never stacks up a column of toasts. from __future__ import annotations import logging -from typing import Callable +from collections.abc import Callable from PyQt6.QtCore import QMetaType, QObject, QVariant, pyqtSlot from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage diff --git a/desktop/tests/test_placement.py b/desktop/tests/test_placement.py index b1bccd4..0ab888c 100644 --- a/desktop/tests/test_placement.py +++ b/desktop/tests/test_placement.py @@ -7,7 +7,6 @@ that silently puts a window half off the screen on somebody else's monitor. from __future__ import annotations import pytest - from kadence_desktop.integrations.placement import CORNER_LABELS, CORNERS, Rect, point_for SCREEN = Rect(0, 0, 1536, 960) diff --git a/desktop/tests/test_resolver.py b/desktop/tests/test_resolver.py index f8fcc7e..396cc52 100644 --- a/desktop/tests/test_resolver.py +++ b/desktop/tests/test_resolver.py @@ -18,7 +18,6 @@ import threading from pathlib import Path import pytest - from kadence_desktop.blocking.resolver import ( ResolvedRoute, StubResolver,