From fd013abf6775e6b5e324a7c2b5a84c9a1bfa9a8a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 11:09:53 +0100 Subject: [PATCH] focus_project 0.5.0: work_url and open launcher python3 -m focus_project.open opens the tutor focus screen (start=1 when a project is active) and the project's work URL. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWXdzckyBH7fEbPywpr --- focus_project/README.md | 1 + focus_project/focus_project/_api.py | 20 ++ focus_project/focus_project/_model.py | 4 + focus_project/focus_project/_update.py | 3 +- focus_project/focus_project/open.py | 112 +++++++++++ focus_project/focus_project/tests/test_api.py | 45 +++++ .../focus_project/tests/test_open.py | 180 ++++++++++++++++++ .../focus_project/tests/test_update.py | 14 ++ focus_project/pyproject.toml | 2 +- 9 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 focus_project/focus_project/open.py create mode 100644 focus_project/focus_project/tests/test_open.py diff --git a/focus_project/README.md b/focus_project/README.md index 3bed107..2368aef 100644 --- a/focus_project/README.md +++ b/focus_project/README.md @@ -19,4 +19,5 @@ the clock earns only once the clock reaches it); `record_activity` rejects a `ts more than 120 s ahead (ValueError). `end_project(id, day)` rejects a past `day` (ValueError); today or later is fine. The harvester pays every project day of the last 31 days, so late-synced events for older days are credited (idempotent). +Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`). Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`). diff --git a/focus_project/focus_project/_api.py b/focus_project/focus_project/_api.py index f131d55..23cf9de 100644 --- a/focus_project/focus_project/_api.py +++ b/focus_project/focus_project/_api.py @@ -8,6 +8,7 @@ from datetime import date # noqa: TC003 import logging import re import time +from urllib.parse import urlsplit from focus_project import _clock, _store from focus_project._errors import RegistryError @@ -15,6 +16,7 @@ from focus_project._model import GATES, Project, normalize_gate _LOG = logging.getLogger(__name__) _ID = re.compile(r"[a-z0-9][a-z0-9-]*") +MAX_WORK_URL = 2000 def list_projects() -> list[Project]: @@ -47,7 +49,23 @@ def stands_down(gate: str, day: date) -> bool: return project is not None and project.replaces_gate(gate) +def _validate_work_url(url: str) -> None: + """Empty, or a plain http(s) URL with a host: it goes to ``xdg-open``.""" + if not url: + return + parts = urlsplit(url) if url.isprintable() and " " not in url else None + if ( + parts is None + or len(url) > MAX_WORK_URL + or parts.scheme not in {"http", "https"} + or not parts.netloc + ): + msg = "work_url must be an http(s) URL without spaces (or empty)" + raise ValueError(msg) + + def _validate(project: Project) -> None: + _validate_work_url(project.work_url) if not _ID.fullmatch(project.id): msg = f"id {project.id!r} must be a lowercase slug" raise ValueError(msg) @@ -79,6 +97,7 @@ def create_project( # noqa: PLR0913 unlock_minutes: int, subjects: list[str] | None = None, site_patterns: list[str] | None = None, + work_url: str = "", ) -> Project: """Add a project; ValueError on bad input, a duplicate id or an overlap.""" project = Project( @@ -91,6 +110,7 @@ def create_project( # noqa: PLR0913 unlock_minutes=unlock_minutes, subjects=tuple(subjects or ()), site_patterns=tuple(site_patterns or ()), + work_url=work_url, created_ms=time.time_ns() // 1_000_000, ) _validate(project) diff --git a/focus_project/focus_project/_model.py b/focus_project/focus_project/_model.py index cc4ed83..8d3a931 100644 --- a/focus_project/focus_project/_model.py +++ b/focus_project/focus_project/_model.py @@ -59,6 +59,8 @@ class Project: site_patterns: tuple[str, ...] = () ended_on: date | None = None created_ms: int = 0 + # Where the work happens (http/https); empty = the first site pattern. + work_url: str = "" # Earlier targets, oldest first: an edit applies from its day, never back. history: tuple[Limits, ...] = () @@ -102,6 +104,7 @@ class Project: "site_patterns": list(self.site_patterns), "ended_on": self.ended_on.isoformat() if self.ended_on else None, "created_ms": self.created_ms, + "work_url": self.work_url, "history": [h.to_dict() for h in self.history], } @@ -122,6 +125,7 @@ class Project: site_patterns=tuple(row.get("site_patterns", ())), ended_on=date.fromisoformat(ended) if ended else None, created_ms=int(row.get("created_ms", 0)), + work_url=str(row.get("work_url") or ""), history=tuple(Limits.from_dict(h) for h in row.get("history", ())), ) except (KeyError, TypeError, ValueError, AttributeError) as exc: diff --git a/focus_project/focus_project/_update.py b/focus_project/focus_project/_update.py index b8d2dc4..ff0d29d 100644 --- a/focus_project/focus_project/_update.py +++ b/focus_project/focus_project/_update.py @@ -21,6 +21,7 @@ _EDITABLE = frozenset( "unlock_minutes", "subjects", "site_patterns", + "work_url", } ) _TUPLES = ("subjects", "site_patterns") @@ -77,7 +78,7 @@ def update_project( """Edit ``project_id``; ValueError on bad input, overlap or a past edit. Editable: name, start, end, replaces, daily_minutes, unlock_minutes, - subjects, site_patterns. A started project's start cannot move; changed + subjects, site_patterns, work_url. A started project's start cannot move; changed minute targets apply from ``today`` (default: now) on, earlier days keep theirs. KeyError for an unknown id. """ diff --git a/focus_project/focus_project/open.py b/focus_project/focus_project/open.py new file mode 100644 index 0000000..df3ac8f --- /dev/null +++ b/focus_project/focus_project/open.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""``python3 -m focus_project.open [--no-site]``: go where the work is done. + +Opens the tutor app's focus screen (``run.sh --route``, which starts the tutor +server if it is down and shows the app in its Chrome ``--app`` window) and, +unless ``--no-site``, the project's work URL in the default browser. The link +carries ``start=1`` only while a project is active: the app starts the timer +if it is not already running. The site opens last so it ends up in front. + +One place for every caller (the i3blocks block, the daily-limits popup). No +active project, or an unreadable registry, still opens the focus screen. +""" + +from __future__ import annotations + +import argparse +import logging +import os +from pathlib import Path +import shutil +import subprocess +import sys +from typing import TYPE_CHECKING + +from focus_project import _api, _clock +from focus_project._errors import RegistryError + +if TYPE_CHECKING: + from collections.abc import Sequence + + from focus_project._model import Project + +_LOG = logging.getLogger(__name__) +TUTOR_RUN_ENV = "FOCUS_TUTOR_RUN" +_TUTOR_WAIT_SECONDS = 60 # run.sh may have to start the server first +_ROUTE = "/focus" +_ROUTE_START = "/focus?start=1" + + +def work_url(project: Project) -> str | None: + """The project's work URL, else ``https://``.""" + if project.work_url: + return project.work_url + for pattern in project.site_patterns: + host = pattern.removeprefix("*.") + if host and "*" not in host: + return f"https://{host}" + return None + + +def _active() -> Project | None: + try: + return _api.active(_clock.today()) + except (RegistryError, OSError) as exc: + _LOG.warning("focus registry unreadable, opening the tutor only: %s", exc) + return None + + +def _open_tutor(route: str) -> bool: + run = Path(os.environ.get(TUTOR_RUN_ENV) or "~/src/tutor/run.sh").expanduser() + try: + subprocess.run( + [str(run), "--route", route], + check=True, + stdin=subprocess.DEVNULL, + timeout=_TUTOR_WAIT_SECONDS, + start_new_session=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + _LOG.error("could not open the tutor via %s: %s", run, exc) + return False + return True + + +def _open_site(url: str) -> bool: + opener = shutil.which("xdg-open") + if opener is None: + _LOG.error("xdg-open not found, cannot open %s", url) + return False + try: + # Not a `with`: the browser must outlive this process. + subprocess.Popen( + [opener, url], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except OSError as exc: + _LOG.error("could not run xdg-open: %s", exc) + return False + return True + + +def main(argv: Sequence[str] | None = None) -> int: + """Open the focus screen and the work URL; 1 only if nothing opened.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--no-site", action="store_true", help="only the tutor focus screen" + ) + args = parser.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="focus_project.open: %(message)s") + project = _active() + opened = _open_tutor(_ROUTE if project is None else _ROUTE_START) + url = None if project is None or args.no_site else work_url(project) + if url is not None: + opened = _open_site(url) or opened + return 0 if opened else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/focus_project/focus_project/tests/test_api.py b/focus_project/focus_project/tests/test_api.py index adc799a..0cdf065 100644 --- a/focus_project/focus_project/tests/test_api.py +++ b/focus_project/focus_project/tests/test_api.py @@ -191,3 +191,48 @@ def test_failed_write_keeps_old_file(data: Path) -> None: _make("two", start=D4 + timedelta(days=3), end=D4 + timedelta(days=4)) assert (data / "projects.json").read_text() == before assert not list(data.glob("*.tmp")) + + +@pytest.mark.parametrize( + "bad", + [ + "javascript:alert(1)", + "file:///etc/passwd", + "ftp://example.com", + "https://", + "https://exa mple.com", + "https://example.com/\n", + "https://example.com/" + "a" * 2100, + "www.hackerrank.com", + ], +) +def test_work_url_must_be_a_plain_http_url(bad: str) -> None: + with pytest.raises(ValueError, match="work_url"): + fp.create_project( + "w", + name="W", + start=date(2026, 11, 2), + end=date(2026, 11, 5), + replaces=["leetcode"], + daily_minutes=60, + unlock_minutes=0, + work_url=bad, + ) + + +def test_work_url_round_trips_and_old_rows_read_empty(data: Path) -> None: + made = fp.create_project( + "w", + name="W", + start=date(2026, 11, 2), + end=date(2026, 11, 5), + replaces=["leetcode"], + daily_minutes=60, + unlock_minutes=0, + work_url="https://www.hackerrank.com/dashboard", + ) + assert fp.list_projects() == [made] + assert made.to_dict()["work_url"] == "https://www.hackerrank.com/dashboard" + row = made.to_dict() + del row["work_url"] + assert fp.Project.from_dict(row).work_url == "" diff --git a/focus_project/focus_project/tests/test_open.py b/focus_project/focus_project/tests/test_open.py new file mode 100644 index 0000000..a078fbe --- /dev/null +++ b/focus_project/focus_project/tests/test_open.py @@ -0,0 +1,180 @@ +# Copyright (c) 2026 Krzysztof Rudnicki +"""``python -m focus_project.open``: the tutor focus screen plus the work URL.""" + +from __future__ import annotations + +from datetime import date +import os +import stat +import subprocess +import sys +import time +from typing import TYPE_CHECKING + +import pytest + +import focus_project as fp +from focus_project import open as launcher + +if TYPE_CHECKING: + from pathlib import Path + +START = date(2029, 12, 1) +END = date(2030, 2, 1) + + +def _script(path: Path, log: Path) -> None: + """An executable stub that appends its argv (one line) to ``log``.""" + path.write_text(f'#!/bin/sh\necho "$@" >> "{log}"\n', encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +@pytest.fixture +def stubs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> dict[str, Path]: + """Stub run.sh and xdg-open; each logs its arguments.""" + bindir = tmp_path / "bin" + bindir.mkdir() + logs = {"tutor": tmp_path / "tutor.log", "site": tmp_path / "site.log"} + _script(tmp_path / "run.sh", logs["tutor"]) + _script(bindir / "xdg-open", logs["site"]) + monkeypatch.setenv("FOCUS_TUTOR_RUN", str(tmp_path / "run.sh")) + monkeypatch.setenv("PATH", str(bindir) + os.pathsep + "/usr/bin:/bin") + return logs + + +def _project(**fields: object) -> None: + fp.create_project( + "hr", + name="HackerRank prep", + start=START, + end=END, + replaces=["leetcode"], + daily_minutes=120, + unlock_minutes=30, + site_patterns=["leetcode.com", "hackerrank.com"], + **fields, # type: ignore[arg-type] + ) + + +def _lines(log: Path, *, wait: bool = True) -> list[str]: + """The log's lines; xdg-open is detached, so give a late line a moment.""" + if not wait: + time.sleep(0.3) # long enough for a wrongly started stub to log + deadline = time.monotonic() + (3 if wait else 0) + while not log.exists() and time.monotonic() < deadline: + time.sleep(0.02) + return log.read_text(encoding="utf-8").splitlines() if log.exists() else [] + + +def test_opens_tutor_focus_with_start_and_the_work_url(stubs: dict[str, Path]) -> None: + _project(work_url="https://www.hackerrank.com/dashboard") + assert launcher.main([]) == 0 + assert _lines(stubs["tutor"]) == ["--route /focus?start=1"] + assert _lines(stubs["site"]) == ["https://www.hackerrank.com/dashboard"] + + +def test_work_url_falls_back_to_the_first_site_pattern(stubs: dict[str, Path]) -> None: + _project() + launcher.main([]) + assert _lines(stubs["site"]) == ["https://leetcode.com"] + + +@pytest.mark.parametrize( + ("patterns", "want"), + [ + (["*.example.com"], "https://example.com"), + (["*", "ok.com"], "https://ok.com"), + (["a*b.com"], None), + ([], None), + ], +) +def test_fallback_handles_wildcards( + patterns: list[str], want: str | None, stubs: dict[str, Path] +) -> None: + fp.create_project( + "p", + name="P", + start=START, + end=END, + replaces=["leetcode"], + daily_minutes=60, + unlock_minutes=0, + site_patterns=patterns, + ) + launcher.main([]) + assert _lines(stubs["site"], wait=bool(want)) == ([want] if want else []) + + +def test_no_site_flag_opens_only_the_tutor(stubs: dict[str, Path]) -> None: + _project(work_url="https://www.hackerrank.com/dashboard") + assert launcher.main(["--no-site"]) == 0 + assert _lines(stubs["tutor"]) == ["--route /focus?start=1"] + assert _lines(stubs["site"], wait=False) == [] + + +def test_no_active_project_opens_the_tutor_without_start( + stubs: dict[str, Path], +) -> None: + assert launcher.main([]) == 0 + assert _lines(stubs["tutor"]) == ["--route /focus"] + assert _lines(stubs["site"], wait=False) == [] + + +def test_corrupt_registry_is_treated_as_no_project( + stubs: dict[str, Path], data: Path +) -> None: + data.mkdir(parents=True) + (data / "projects.json").write_text("{nope", encoding="utf-8") + assert launcher.main([]) == 0 + assert _lines(stubs["tutor"]) == ["--route /focus"] + assert _lines(stubs["site"], wait=False) == [] + + +def test_missing_tutor_launcher_still_opens_the_site( + stubs: dict[str, Path], monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _project(work_url="https://www.hackerrank.com/dashboard") + monkeypatch.setenv("FOCUS_TUTOR_RUN", str(tmp_path / "gone.sh")) + assert launcher.main([]) == 0 + assert _lines(stubs["site"]) == ["https://www.hackerrank.com/dashboard"] + + +def test_nothing_launchable_is_an_error( + stubs: dict[str, Path], monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("FOCUS_TUTOR_RUN", str(tmp_path / "gone.sh")) + assert launcher.main([]) == 1 + + +def test_missing_xdg_open_is_reported_not_raised( + stubs: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + _project(work_url="https://www.hackerrank.com/dashboard") + monkeypatch.setenv("PATH", "/nonexistent") + assert launcher.main([]) == 0 + assert _lines(stubs["tutor"]) == ["--route /focus?start=1"] + + +def test_runs_as_a_module(stubs: dict[str, Path], tmp_path: Path) -> None: + done = subprocess.run( + [sys.executable, "-m", "focus_project.open", "--no-site"], + check=False, + capture_output=True, + timeout=30, + env=os.environ, + ) + assert done.returncode == 0, done.stderr + assert _lines(stubs["tutor"]) == ["--route /focus"] + + +def test_xdg_open_failing_to_start_is_reported_not_raised( + stubs: dict[str, Path], monkeypatch: pytest.MonkeyPatch +) -> None: + _project(work_url="https://www.hackerrank.com/dashboard") + + def boom(*_a: object, **_k: object) -> None: + raise OSError + + monkeypatch.setattr(launcher.subprocess, "Popen", boom) + monkeypatch.setattr(launcher, "_open_tutor", lambda _route: True) + assert launcher.main([]) == 0 diff --git a/focus_project/focus_project/tests/test_update.py b/focus_project/focus_project/tests/test_update.py index a89ca44..85ffb14 100644 --- a/focus_project/focus_project/tests/test_update.py +++ b/focus_project/focus_project/tests/test_update.py @@ -171,3 +171,17 @@ def test_status_and_minutes_use_the_day_values(tmp_path: Path) -> None: assert fp.status(D1)["daily_minutes"] == 120 assert fp.status(D1)["unlock_minutes"] == 30 assert fp.status(D1 + DAY)["daily_minutes"] == 60 + + +def test_work_url_is_editable_kept_and_clearable() -> None: + _make() + url = "https://www.hackerrank.com/dashboard" + assert fp.update_project("hr", today=D1, work_url=url).work_url == url + assert fp.update_project("hr", today=D1, name="Other").work_url == url + assert fp.update_project("hr", today=D1, work_url="").work_url == "" + + +def test_update_refuses_a_non_http_work_url() -> None: + _make() + with pytest.raises(ValueError, match="work_url"): + fp.update_project("hr", today=D1, work_url="javascript:alert(1)") diff --git a/focus_project/pyproject.toml b/focus_project/pyproject.toml index 30310c0..8c00bad 100644 --- a/focus_project/pyproject.toml +++ b/focus_project/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "focus_project" -version = "0.4.0" +version = "0.5.0" description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate" requires-python = ">=3.14" # Stdlib only on purpose: guards import it from the system python.