focus_project 0.5.0: work_url and open launcher
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWXdzckyBH7fEbPywpr
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 11:09:53 +01:00
parent 0f332c9057
commit fd013abf67
9 changed files with 379 additions and 2 deletions

View File

@ -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`).

View File

@ -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)

View File

@ -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:

View File

@ -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.
"""

View File

@ -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://<first usable site pattern>``."""
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())

View File

@ -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 == ""

View File

@ -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

View File

@ -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)")

View File

@ -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.