mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 21:53:59 +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`
|
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
|
(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).
|
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`).
|
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 logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from focus_project import _clock, _store
|
from focus_project import _clock, _store
|
||||||
from focus_project._errors import RegistryError
|
from focus_project._errors import RegistryError
|
||||||
@ -15,6 +16,7 @@ from focus_project._model import GATES, Project, normalize_gate
|
|||||||
|
|
||||||
_LOG = logging.getLogger(__name__)
|
_LOG = logging.getLogger(__name__)
|
||||||
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
_ID = re.compile(r"[a-z0-9][a-z0-9-]*")
|
||||||
|
MAX_WORK_URL = 2000
|
||||||
|
|
||||||
|
|
||||||
def list_projects() -> list[Project]:
|
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)
|
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:
|
def _validate(project: Project) -> None:
|
||||||
|
_validate_work_url(project.work_url)
|
||||||
if not _ID.fullmatch(project.id):
|
if not _ID.fullmatch(project.id):
|
||||||
msg = f"id {project.id!r} must be a lowercase slug"
|
msg = f"id {project.id!r} must be a lowercase slug"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
@ -79,6 +97,7 @@ def create_project( # noqa: PLR0913
|
|||||||
unlock_minutes: int,
|
unlock_minutes: int,
|
||||||
subjects: list[str] | None = None,
|
subjects: list[str] | None = None,
|
||||||
site_patterns: list[str] | None = None,
|
site_patterns: list[str] | None = None,
|
||||||
|
work_url: str = "",
|
||||||
) -> Project:
|
) -> Project:
|
||||||
"""Add a project; ValueError on bad input, a duplicate id or an overlap."""
|
"""Add a project; ValueError on bad input, a duplicate id or an overlap."""
|
||||||
project = Project(
|
project = Project(
|
||||||
@ -91,6 +110,7 @@ def create_project( # noqa: PLR0913
|
|||||||
unlock_minutes=unlock_minutes,
|
unlock_minutes=unlock_minutes,
|
||||||
subjects=tuple(subjects or ()),
|
subjects=tuple(subjects or ()),
|
||||||
site_patterns=tuple(site_patterns or ()),
|
site_patterns=tuple(site_patterns or ()),
|
||||||
|
work_url=work_url,
|
||||||
created_ms=time.time_ns() // 1_000_000,
|
created_ms=time.time_ns() // 1_000_000,
|
||||||
)
|
)
|
||||||
_validate(project)
|
_validate(project)
|
||||||
|
|||||||
@ -59,6 +59,8 @@ class Project:
|
|||||||
site_patterns: tuple[str, ...] = ()
|
site_patterns: tuple[str, ...] = ()
|
||||||
ended_on: date | None = None
|
ended_on: date | None = None
|
||||||
created_ms: int = 0
|
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.
|
# Earlier targets, oldest first: an edit applies from its day, never back.
|
||||||
history: tuple[Limits, ...] = ()
|
history: tuple[Limits, ...] = ()
|
||||||
|
|
||||||
@ -102,6 +104,7 @@ class Project:
|
|||||||
"site_patterns": list(self.site_patterns),
|
"site_patterns": list(self.site_patterns),
|
||||||
"ended_on": self.ended_on.isoformat() if self.ended_on else None,
|
"ended_on": self.ended_on.isoformat() if self.ended_on else None,
|
||||||
"created_ms": self.created_ms,
|
"created_ms": self.created_ms,
|
||||||
|
"work_url": self.work_url,
|
||||||
"history": [h.to_dict() for h in self.history],
|
"history": [h.to_dict() for h in self.history],
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,6 +125,7 @@ class Project:
|
|||||||
site_patterns=tuple(row.get("site_patterns", ())),
|
site_patterns=tuple(row.get("site_patterns", ())),
|
||||||
ended_on=date.fromisoformat(ended) if ended else None,
|
ended_on=date.fromisoformat(ended) if ended else None,
|
||||||
created_ms=int(row.get("created_ms", 0)),
|
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", ())),
|
history=tuple(Limits.from_dict(h) for h in row.get("history", ())),
|
||||||
)
|
)
|
||||||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||||
|
|||||||
@ -21,6 +21,7 @@ _EDITABLE = frozenset(
|
|||||||
"unlock_minutes",
|
"unlock_minutes",
|
||||||
"subjects",
|
"subjects",
|
||||||
"site_patterns",
|
"site_patterns",
|
||||||
|
"work_url",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_TUPLES = ("subjects", "site_patterns")
|
_TUPLES = ("subjects", "site_patterns")
|
||||||
@ -77,7 +78,7 @@ def update_project(
|
|||||||
"""Edit ``project_id``; ValueError on bad input, overlap or a past edit.
|
"""Edit ``project_id``; ValueError on bad input, overlap or a past edit.
|
||||||
|
|
||||||
Editable: name, start, end, replaces, daily_minutes, unlock_minutes,
|
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
|
minute targets apply from ``today`` (default: now) on, earlier days keep
|
||||||
theirs. KeyError for an unknown id.
|
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))
|
_make("two", start=D4 + timedelta(days=3), end=D4 + timedelta(days=4))
|
||||||
assert (data / "projects.json").read_text() == before
|
assert (data / "projects.json").read_text() == before
|
||||||
assert not list(data.glob("*.tmp"))
|
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)["daily_minutes"] == 120
|
||||||
assert fp.status(D1)["unlock_minutes"] == 30
|
assert fp.status(D1)["unlock_minutes"] == 30
|
||||||
assert fp.status(D1 + DAY)["daily_minutes"] == 60
|
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]
|
[project]
|
||||||
name = "focus_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"
|
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
# Stdlib only on purpose: guards import it from the system python.
|
# Stdlib only on purpose: guards import it from the system python.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user