mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
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
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:
parent
0f332c9057
commit
fd013abf67
@ -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`).
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
112
focus_project/focus_project/open.py
Normal file
112
focus_project/focus_project/open.py
Normal 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())
|
||||
@ -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 == ""
|
||||
|
||||
180
focus_project/focus_project/tests/test_open.py
Normal file
180
focus_project/focus_project/tests/test_open.py
Normal 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
|
||||
@ -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)")
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user