mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
Replace scheduled skips with the shared free-day pool
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the one that already had this idea: scheduled_skips.json was a bare list of dates, checked first in the lock chain, that only this app could see. That is exactly what the shared pool generalises, so the chain now asks freedays and a day taken here also stands down diet-guard, wake-alarm, leetcode-guard and home-guard. Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d, 10/90d), the 120-character justification and the escalating countdown all exist to make a sick day *hard* to take. Free days are the opposite -- declared ahead, no reason, consecutive runs allowed. Merging the two policies would have had to ruin one of them, so only the storage of the frictionless half moved. This fixes a real bug in passing. is_scheduled_skip_today compared against a UTC day while add_scheduled_skip.py wrote a local one, so a skip added near midnight could name a date the chain never matched. freedays owns "what is today" for both sides now, in local time, and the disagreement cannot recur. scripts/migrate_scheduled_skips.py moves the existing dates across; it is idempotent and has a --dry-run. Already run here: the 8 real skips (2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and count against 2026, leaving 27 of 35. The original file is gitignored and was left on disk untouched. add_scheduled_skip.py becomes a thin wrapper over freedays rather than being deleted, because arm.sh calls it and the name is in muscle memory. Left as-is it would have kept writing to a file nothing reads. conftest.py went over the 250-line cap, so the two fixtures that keep the suite off real user state moved to _real_state_fixtures.py. They belong together and neither subsumes the other: _isolate_home covers paths resolved from Path.home() at call time, while the freedays redirect covers paths bound into module constants at import time, which redirecting home afterwards cannot reach. requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one distribution, so those stale pins would have made the hook environments unresolvable rather than merely old. 1256 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
d35097f461
commit
d6e8c0973f
@ -122,8 +122,12 @@ repos:
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.1#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.0#subdirectory=crdt-sync
|
||||
# Kept in step with pyproject.toml: pip refuses two different
|
||||
# direct URLs for one distribution, so a stale pin here becomes
|
||||
# an unresolvable hook env once freedays joins the list.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
- id: pylint
|
||||
@ -137,8 +141,12 @@ repos:
|
||||
- --disable=protected-access,unused-argument,duplicate-code
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.1#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.0#subdirectory=crdt-sync
|
||||
# Kept in step with pyproject.toml: pip refuses two different
|
||||
# direct URLs for one distribution, so a stale pin here becomes
|
||||
# an unresolvable hook env once freedays joins the list.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@ -4,6 +4,7 @@ bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.0
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
|
||||
mcp==2.1.1
|
||||
mypy==2.3.1
|
||||
@ -11,12 +12,12 @@ pre-commit==4.6.2
|
||||
pylint==4.0.8
|
||||
pytest==9.1.1
|
||||
pytest-cov==7.1.0
|
||||
pytest-randomly==4.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
# Direct dependency since the Firebase session recovery exchanges a refresh
|
||||
# token over the REST endpoint; crdt-sync already pulls requests in, but this
|
||||
# module imports it itself, so it is declared rather than assumed.
|
||||
requests==2.34.2
|
||||
ruff==0.16.5
|
||||
types-requests==2.33.0.20260712
|
||||
ruff==0.16.6
|
||||
types-requests==2.33.0.20260906
|
||||
|
||||
@ -19,6 +19,7 @@ import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import freedays
|
||||
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
|
||||
|
||||
from screen_locker._constants import (
|
||||
@ -31,6 +32,7 @@ from screen_locker._sick_tracker import is_sick_day as _is_sick_day
|
||||
from screen_locker._weekly_check import RELAXED_DAY_SKIP_TYPE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
@ -43,24 +45,22 @@ def _today_str() -> str:
|
||||
return datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def is_scheduled_skip_today(
|
||||
scheduled_skips_file: Path, *, today: str | None = None
|
||||
) -> bool:
|
||||
"""Return True if *today* is listed in *scheduled_skips_file*."""
|
||||
if not scheduled_skips_file.exists():
|
||||
return False
|
||||
try:
|
||||
with scheduled_skips_file.open() as f:
|
||||
skips = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_logger.warning(
|
||||
"Could not read scheduled skips from %s: %s — treating today as NOT "
|
||||
"a scheduled skip (the lock chain continues)",
|
||||
scheduled_skips_file,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
return (today or _today_str()) in skips
|
||||
def is_scheduled_skip_today(today: date | None = None) -> bool:
|
||||
"""Return True if *today* is in the shared free-day pool.
|
||||
|
||||
The source moved from this app's own ``scheduled_skips.json`` to the
|
||||
fleet-wide pool (``~/utils/freedays``), so one marked day now stands down
|
||||
every gate app at once instead of only this one.
|
||||
|
||||
The date is **local**, not UTC. The file-backed check this replaces
|
||||
compared against a UTC day while ``add_scheduled_skip.py`` wrote a local
|
||||
one, so a skip added near midnight could name a date the chain never
|
||||
matched. freedays owns "what is today" for all of them now.
|
||||
|
||||
Still fails closed: an unreadable pool reads as "not free", so the lock
|
||||
chain continues rather than the lock silently switching itself off.
|
||||
"""
|
||||
return freedays.is_free_day(today)
|
||||
|
||||
|
||||
def has_logged_today(log_file: Path, *, today: str | None = None) -> bool:
|
||||
|
||||
@ -63,7 +63,6 @@ __all__ = [
|
||||
def explain_lock_decision(
|
||||
*,
|
||||
log_file: Path,
|
||||
scheduled_skips_file: Path,
|
||||
early_bird_pending_file: Path,
|
||||
sick_history: SickHistory,
|
||||
extended_early_bird: bool,
|
||||
@ -84,7 +83,7 @@ def explain_lock_decision(
|
||||
local_dt = instant.astimezone()
|
||||
local_minutes = local_dt.hour * 60 + local_dt.minute
|
||||
|
||||
scheduled = is_scheduled_skip_today(scheduled_skips_file, today=today_str)
|
||||
scheduled = is_scheduled_skip_today(local_dt.date())
|
||||
pending = is_early_bird_pending(early_bird_pending_file, today=today_str)
|
||||
window_open = _early_bird_window_open(
|
||||
extended=extended_early_bird, local_minutes=local_minutes
|
||||
|
||||
@ -11,7 +11,6 @@ from typing import TYPE_CHECKING
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
|
||||
from screen_locker import _compliance_state
|
||||
from screen_locker._constants import SCHEDULED_SKIPS_FILE
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
|
||||
|
||||
@ -144,7 +143,7 @@ class LogMixin:
|
||||
|
||||
def _is_scheduled_skip_today(self) -> bool:
|
||||
"""Return True if today's date is listed in the scheduled skips file."""
|
||||
return _compliance_state.is_scheduled_skip_today(SCHEDULED_SKIPS_FILE)
|
||||
return _compliance_state.is_scheduled_skip_today()
|
||||
|
||||
def save_workout_log(self) -> RecordResult:
|
||||
"""Append today's workout to the log (HMAC-signed) and report the result.
|
||||
|
||||
@ -48,7 +48,6 @@ from screen_locker._compliance_state import (
|
||||
)
|
||||
from screen_locker._constants import (
|
||||
EARLY_BIRD_PENDING_FILE,
|
||||
SCHEDULED_SKIPS_FILE,
|
||||
)
|
||||
from screen_locker._sick_tracker import load_history
|
||||
from screen_locker._status_data import format_summary_line, gather_status
|
||||
@ -137,7 +136,7 @@ def get_flags() -> dict[str, bool]:
|
||||
history = load_history()
|
||||
return {
|
||||
"has_logged_today": has_logged_today(_LOG_FILE),
|
||||
"is_scheduled_skip_today": is_scheduled_skip_today(SCHEDULED_SKIPS_FILE),
|
||||
"is_scheduled_skip_today": is_scheduled_skip_today(),
|
||||
"is_early_bird_pending": is_early_bird_pending(EARLY_BIRD_PENDING_FILE),
|
||||
"is_sick_day_today": is_sick_day_today(history),
|
||||
}
|
||||
|
||||
@ -46,7 +46,6 @@ from screen_locker._constants import (
|
||||
EXTRA_BENEFITS_FILE,
|
||||
MANUAL_WORKOUT_BUDGET_PER_7_DAYS,
|
||||
MANUAL_WORKOUT_BUDGET_PER_30_DAYS,
|
||||
SCHEDULED_SKIPS_FILE,
|
||||
SHUTDOWN_BASE_FILE,
|
||||
SHUTDOWN_CONFIG_FILE,
|
||||
SICK_BUDGET_PER_7_DAYS,
|
||||
@ -174,7 +173,6 @@ def gather_status(
|
||||
extra_benefits_file: Path = EXTRA_BENEFITS_FILE,
|
||||
shutdown_base_file: Path = SHUTDOWN_BASE_FILE,
|
||||
shutdown_config_file: Path = SHUTDOWN_CONFIG_FILE,
|
||||
scheduled_skips_file: Path = SCHEDULED_SKIPS_FILE,
|
||||
early_bird_pending_file: Path = EARLY_BIRD_PENDING_FILE,
|
||||
now: datetime | None = None,
|
||||
) -> StatusSnapshot:
|
||||
@ -196,7 +194,6 @@ def gather_status(
|
||||
|
||||
lock_explanation = explain_lock_decision(
|
||||
log_file=log_file,
|
||||
scheduled_skips_file=scheduled_skips_file,
|
||||
early_bird_pending_file=early_bird_pending_file,
|
||||
sick_history=sick_history,
|
||||
extended_early_bird=early_bird_extended,
|
||||
|
||||
@ -40,7 +40,6 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"_shutdown_sick_state.SICK_DAY_STATE_FILE",
|
||||
),
|
||||
),
|
||||
("scheduled_skips.json", ("_log_mixin.SCHEDULED_SKIPS_FILE",)),
|
||||
# The durable lock-decision trail. Written on EVERY locker run, so without
|
||||
# this the suite would append test decisions to the user's real
|
||||
# enforcement history in ~/.local/share/screen_locker/.
|
||||
|
||||
72
screen_locker/tests/_real_state_fixtures.py
Normal file
72
screen_locker/tests/_real_state_fixtures.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Autouse fixtures whose one job is keeping tests off real user state.
|
||||
|
||||
Split out of ``conftest.py`` for the 250-line cap, and grouped because they
|
||||
answer the same question in two different ways: what stops this suite from
|
||||
reading or overwriting the developer's own files? Loaded through
|
||||
``pytest_plugins`` in ``conftest.py``, so both stay autouse for every test.
|
||||
|
||||
The two seams are genuinely different, which is why neither subsumes the
|
||||
other. ``_isolate_home`` covers paths resolved at *call* time from
|
||||
``Path.home()``; ``_no_free_days_by_default`` covers paths bound into module
|
||||
constants at *import* time, which redirecting home afterwards cannot reach.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_free_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never let the lock chain read the developer's real free-day pool.
|
||||
|
||||
``_isolate_home`` cannot cover this one. freedays binds its paths from
|
||||
``Path.home()`` at *import* time into module constants, so redirecting
|
||||
home afterwards leaves them pointing at the real
|
||||
``~/.local/share/freedays``. Every writer resolves through
|
||||
``resolve_paths``, so that is the seam.
|
||||
|
||||
Without it, a real free day on this machine would short-circuit the lock
|
||||
chain in every test here -- it is the very first check in the chain --
|
||||
and the failures would name early-bird and sick-day logic instead.
|
||||
"""
|
||||
import freedays._api
|
||||
|
||||
monkeypatch.setattr(
|
||||
freedays._api,
|
||||
"resolve_paths",
|
||||
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
"""Point ``$HOME`` and ``Path.home()`` at tmp_path for every test.
|
||||
|
||||
``ISOLATED_STATE`` cannot cover this. Everything under ``~/.config`` is
|
||||
resolved at *call* time by ``crdt_sync.credential_store_for``
|
||||
(``Path.home() / ".config" / app / "firebase_auth.json"``), not bound to a
|
||||
module-level constant, so there is no attribute to patch -- the only seam
|
||||
is home itself.
|
||||
|
||||
What this protects is a live credential, not a scratch file. A test that
|
||||
reaches ``credential_store_for("screen_locker")`` and saves would overwrite
|
||||
the machine's real Firebase session with fixture values, and the damage is
|
||||
invisible until the next sync fails with HTTP 401. The same redirect also
|
||||
covers the sync PAT and the shared ``~/.config/crdt-sync/`` password and
|
||||
OAuth secret, which are read the same way.
|
||||
|
||||
``Path.home()`` is patched as well as ``$HOME``: it consults ``os.environ``
|
||||
on POSIX, but only via ``expanduser``, and a test that patches the
|
||||
environment differently must not silently regain access to the real one.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
with patch.object(Path, "home", lambda: tmp_path):
|
||||
yield
|
||||
@ -68,6 +68,11 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
|
||||
# Autouse fixtures that keep this suite off real user state live here; see
|
||||
# that module for why the two seams are different.
|
||||
pytest_plugins = ("screen_locker.tests._real_state_fixtures",)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _block_real_tk_and_exit() -> Iterator[None]:
|
||||
"""Replace the whole ``tk`` module and ``sys.exit`` for every test.
|
||||
@ -130,32 +135,6 @@ def _block_real_network() -> Iterator[None]:
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
"""Point ``$HOME`` and ``Path.home()`` at tmp_path for every test.
|
||||
|
||||
``ISOLATED_STATE`` cannot cover this. Everything under ``~/.config`` is
|
||||
resolved at *call* time by ``crdt_sync.credential_store_for``
|
||||
(``Path.home() / ".config" / app / "firebase_auth.json"``), not bound to a
|
||||
module-level constant, so there is no attribute to patch -- the only seam
|
||||
is home itself.
|
||||
|
||||
What this protects is a live credential, not a scratch file. A test that
|
||||
reaches ``credential_store_for("screen_locker")`` and saves would overwrite
|
||||
the machine's real Firebase session with fixture values, and the damage is
|
||||
invisible until the next sync fails with HTTP 401. The same redirect also
|
||||
covers the sync PAT and the shared ``~/.config/crdt-sync/`` password and
|
||||
OAuth secret, which are read the same way.
|
||||
|
||||
``Path.home()`` is patched as well as ``$HOME``: it consults ``os.environ``
|
||||
on POSIX, but only via ``expanduser``, and a test that patches the
|
||||
environment differently must not silently regain access to the real one.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
with patch.object(Path, "home", lambda: tmp_path):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_degraded_sources() -> Iterator[None]:
|
||||
"""Reset the process-scoped degraded-backend registry around every test.
|
||||
|
||||
@ -2,11 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
|
||||
from screen_locker._compliance_state import (
|
||||
has_logged_today,
|
||||
is_early_bird_pending,
|
||||
@ -26,33 +28,39 @@ def _today() -> str:
|
||||
class TestIsScheduledSkipToday:
|
||||
"""Mirrors test_scheduled_skip.py's coverage, for the standalone function."""
|
||||
|
||||
def test_missing_file(self, tmp_path: Path) -> None:
|
||||
"""A missing skips file means today is not a scheduled skip."""
|
||||
assert is_scheduled_skip_today(tmp_path / "skips.json") is False
|
||||
def test_empty_pool(self, tmp_path: Path) -> None:
|
||||
"""An empty pool means today is not a free day."""
|
||||
assert is_scheduled_skip_today() is False
|
||||
|
||||
def test_today_listed(self, tmp_path: Path) -> None:
|
||||
"""Today's date in the skips list makes it a scheduled skip."""
|
||||
skip_file = tmp_path / "skips.json"
|
||||
skip_file.write_text(json.dumps([_today()]))
|
||||
assert is_scheduled_skip_today(skip_file) is True
|
||||
def test_today_marked(self, tmp_path: Path) -> None:
|
||||
"""Today in the shared pool makes it a free day."""
|
||||
freedays.mark(
|
||||
freedays.today(), paths=freedays.Paths.under(tmp_path / "freedays")
|
||||
)
|
||||
assert is_scheduled_skip_today() is True
|
||||
|
||||
def test_today_not_listed(self, tmp_path: Path) -> None:
|
||||
"""A list without today is not a scheduled skip."""
|
||||
skip_file = tmp_path / "skips.json"
|
||||
skip_file.write_text(json.dumps(["1999-01-01"]))
|
||||
assert is_scheduled_skip_today(skip_file) is False
|
||||
def test_today_not_marked(self, tmp_path: Path) -> None:
|
||||
"""A pool holding some other date is not today's free day."""
|
||||
freedays.mark(
|
||||
date(2026, 12, 24),
|
||||
paths=freedays.Paths.under(tmp_path / "freedays"),
|
||||
)
|
||||
assert is_scheduled_skip_today() is False
|
||||
|
||||
def test_corrupt_json(self, tmp_path: Path) -> None:
|
||||
"""Unparsable JSON fails closed: not a skip."""
|
||||
skip_file = tmp_path / "skips.json"
|
||||
skip_file.write_text("{bad}")
|
||||
assert is_scheduled_skip_today(skip_file) is False
|
||||
def test_corrupt_pool(self, tmp_path: Path) -> None:
|
||||
"""Unparsable JSON fails closed: not a free day, the chain continues."""
|
||||
pool = tmp_path / "freedays"
|
||||
pool.mkdir(exist_ok=True)
|
||||
(pool / "free_days.json").write_text("{bad}")
|
||||
assert is_scheduled_skip_today() is False
|
||||
|
||||
def test_explicit_today_override(self, tmp_path: Path) -> None:
|
||||
def test_explicit_day_override(self, tmp_path: Path) -> None:
|
||||
"""The `today` argument overrides the real date."""
|
||||
skip_file = tmp_path / "skips.json"
|
||||
skip_file.write_text(json.dumps(["2020-05-05"]))
|
||||
assert is_scheduled_skip_today(skip_file, today="2020-05-05") is True
|
||||
freedays.mark(
|
||||
date(2026, 12, 24),
|
||||
paths=freedays.Paths.under(tmp_path / "freedays"),
|
||||
)
|
||||
assert is_scheduled_skip_today(date(2026, 12, 24)) is True
|
||||
|
||||
|
||||
class TestHasLoggedToday:
|
||||
|
||||
@ -7,6 +7,8 @@ import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
|
||||
from screen_locker._compliance_state import explain_lock_decision
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
|
||||
@ -24,13 +26,15 @@ class TestExplainLockDecision:
|
||||
def _files(self, tmp_path: Path) -> dict[str, Path]:
|
||||
return {
|
||||
"log_file": tmp_path / "log.json",
|
||||
"scheduled_skips_file": tmp_path / "scheduled_skips.json",
|
||||
"early_bird_pending_file": tmp_path / "early_bird_pending.json",
|
||||
}
|
||||
|
||||
def test_scheduled_skip_short_circuits(self, tmp_path: Path) -> None:
|
||||
files = self._files(tmp_path)
|
||||
files["scheduled_skips_file"].write_text(json.dumps([_today()]))
|
||||
freedays.mark(
|
||||
freedays.today(),
|
||||
paths=freedays.Paths.under(tmp_path / "freedays"),
|
||||
)
|
||||
result = explain_lock_decision(
|
||||
**files,
|
||||
sick_history=SickHistory(),
|
||||
|
||||
@ -137,7 +137,7 @@ class TestGetFlags:
|
||||
}
|
||||
lh.assert_called_once_with()
|
||||
hlt.assert_called_once_with(_mcp._LOG_FILE)
|
||||
iss.assert_called_once_with(_mcp.SCHEDULED_SKIPS_FILE)
|
||||
iss.assert_called_once_with()
|
||||
iep.assert_called_once_with(_mcp.EARLY_BIRD_PENDING_FILE)
|
||||
isd.assert_called_once_with("HIST")
|
||||
|
||||
|
||||
@ -2,17 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import freedays
|
||||
import pytest
|
||||
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
@ -23,104 +23,69 @@ class TestIsScheduledSkipToday:
|
||||
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
|
||||
return create_locker(mock_tk, tmp_path)
|
||||
|
||||
def test_returns_false_when_file_absent(
|
||||
def test_returns_false_when_pool_empty(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Returns False when scheduled_skips.json does not exist."""
|
||||
"""Returns False when nothing has ever been marked."""
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
skip_file = tmp_path / "scheduled_skips.json"
|
||||
with patch(
|
||||
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
|
||||
skip_file,
|
||||
):
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
|
||||
def test_returns_true_when_today_listed(
|
||||
def test_returns_true_when_today_marked(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Returns True when today's date is in the skips list."""
|
||||
"""Returns True when today is in the shared free-day pool."""
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
skip_file = tmp_path / "scheduled_skips.json"
|
||||
skip_file.write_text(json.dumps([today]))
|
||||
with patch(
|
||||
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
|
||||
skip_file,
|
||||
):
|
||||
assert locker._is_scheduled_skip_today() is True
|
||||
freedays.mark(
|
||||
freedays.today(), paths=freedays.Paths.under(tmp_path / "freedays")
|
||||
)
|
||||
assert locker._is_scheduled_skip_today() is True
|
||||
|
||||
def test_returns_false_when_today_not_listed(
|
||||
def test_returns_false_when_only_another_day_marked(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Returns False when today's date is not in the skips list."""
|
||||
"""A pool holding some other date does not free today."""
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
skip_file = tmp_path / "scheduled_skips.json"
|
||||
skip_file.write_text(json.dumps(["1999-01-01", "2000-06-15"]))
|
||||
with patch(
|
||||
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
|
||||
skip_file,
|
||||
):
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
freedays.mark(
|
||||
date(2026, 12, 24),
|
||||
paths=freedays.Paths.under(tmp_path / "freedays"),
|
||||
)
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
|
||||
def test_returns_false_on_corrupt_json(
|
||||
def test_returns_false_on_corrupt_pool(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Returns False when the skips file contains invalid JSON."""
|
||||
"""Fail closed: an unparsable pool locks normally rather than opening."""
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
skip_file = tmp_path / "scheduled_skips.json"
|
||||
skip_file.write_text("{not valid json}")
|
||||
with patch(
|
||||
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
|
||||
skip_file,
|
||||
):
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
pool = tmp_path / "freedays"
|
||||
pool.mkdir(exist_ok=True)
|
||||
(pool / "free_days.json").write_text("{not valid json}")
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
|
||||
def test_returns_false_on_read_error(
|
||||
def test_returns_false_when_the_day_was_released(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Returns False when the skips file cannot be read (OSError)."""
|
||||
"""Giving a day back re-arms the lock for it."""
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
skip_file = tmp_path / "scheduled_skips.json"
|
||||
skip_file.write_text("[]")
|
||||
with (
|
||||
patch(
|
||||
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
|
||||
skip_file,
|
||||
),
|
||||
patch("builtins.open", side_effect=OSError("permission denied")),
|
||||
):
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
|
||||
def test_empty_list_returns_false(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Returns False for an empty skips list."""
|
||||
locker = self._make_locker(mock_tk, tmp_path)
|
||||
skip_file = tmp_path / "scheduled_skips.json"
|
||||
skip_file.write_text("[]")
|
||||
with patch(
|
||||
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
|
||||
skip_file,
|
||||
):
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
paths = freedays.Paths.under(tmp_path / "freedays")
|
||||
freedays.mark(freedays.today(), paths=paths)
|
||||
assert locker._is_scheduled_skip_today() is True
|
||||
freedays.release(freedays.today(), paths=paths)
|
||||
assert locker._is_scheduled_skip_today() is False
|
||||
|
||||
|
||||
class TestScheduledSkipEarlyExit:
|
||||
@ -128,9 +93,10 @@ class TestScheduledSkipEarlyExit:
|
||||
|
||||
@staticmethod
|
||||
def _write_today_skip(tmp_path: Path) -> None:
|
||||
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
|
||||
skip_file = tmp_path / "scheduled_skips.json"
|
||||
skip_file.write_text(json.dumps([today]))
|
||||
"""Mark today free in the shared pool the conftest redirected here."""
|
||||
freedays.mark(
|
||||
freedays.today(), paths=freedays.Paths.under(tmp_path / "freedays")
|
||||
)
|
||||
|
||||
def test_exits_on_scheduled_skip_day(
|
||||
self,
|
||||
|
||||
@ -22,6 +22,5 @@ def _files(tmp_path: Path) -> dict[str, Path]:
|
||||
"extra_benefits_file": tmp_path / "extra_benefits_state.json",
|
||||
"shutdown_base_file": tmp_path / "shutdown_base.json",
|
||||
"shutdown_config_file": tmp_path / "shutdown_config.conf",
|
||||
"scheduled_skips_file": tmp_path / "scheduled_skips.json",
|
||||
"early_bird_pending_file": tmp_path / "early_bird_pending.json",
|
||||
}
|
||||
|
||||
@ -7,11 +7,13 @@ weekly minimum) means no lock is needed.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
|
||||
from screen_locker._status_data import gather_status
|
||||
from screen_locker.tests.test_status_data import _files
|
||||
|
||||
@ -73,7 +75,11 @@ class TestGatherStatusLockSuppression:
|
||||
def test_scheduled_skip_stops_lock(self, tmp_path: Path) -> None:
|
||||
"""Scheduled skip stops lock."""
|
||||
files = _files(tmp_path)
|
||||
files["scheduled_skips_file"].write_text(json.dumps(["2024-01-05"]))
|
||||
freedays.mark(
|
||||
date(2024, 1, 5),
|
||||
paths=freedays.Paths.under(tmp_path / "freedays"),
|
||||
now=date(2024, 1, 5),
|
||||
)
|
||||
with patch(
|
||||
"screen_locker._status_data.has_workout_skip_today", return_value=False
|
||||
):
|
||||
|
||||
@ -44,7 +44,6 @@ class TestUnreadableSourceIsNotNoWorkout:
|
||||
def _files(self, tmp_path: Path) -> dict[str, Path]:
|
||||
return {
|
||||
"log_file": tmp_path / "log.json",
|
||||
"scheduled_skips_file": tmp_path / "scheduled_skips.json",
|
||||
"early_bird_pending_file": tmp_path / "early_bird_pending.json",
|
||||
}
|
||||
|
||||
|
||||
@ -1,111 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a date to ``scheduled_skips.json`` — the locker's first early exit.
|
||||
"""Mark a day free, for this app and every other gate app at once.
|
||||
|
||||
A scheduled skip is the supported way to exempt a specific day without
|
||||
weakening enforcement for any other day: the lock chain checks it first, and
|
||||
(since the 2026-08 logging work) records the exemption as a decision, so an
|
||||
unexpected quiet day is traceable to the date that caused it instead of looking
|
||||
like the enforcer silently died again.
|
||||
This used to append to ``screen_locker/scheduled_skips.json``, which only
|
||||
screen-locker read. That file is no longer consulted by anything: the lock
|
||||
chain now asks the shared pool (``~/utils/freedays``), so a day taken here
|
||||
also stands down diet-guard, wake-alarm, leetcode-guard and home-guard.
|
||||
|
||||
Kept out of ``arm.sh`` because the file it edits is the one the lock chain
|
||||
reads: a malformed write is read as "not a skip" and the machine locks anyway,
|
||||
so this belongs somewhere the repo's linters and tests actually apply.
|
||||
Kept as a thin wrapper rather than deleted because ``arm.sh`` calls it, and
|
||||
because this is the name already in muscle memory. ``freedays mark`` does
|
||||
exactly the same thing.
|
||||
|
||||
Usage:
|
||||
python3 scripts/add_scheduled_skip.py --date today
|
||||
python3 scripts/add_scheduled_skip.py --date 2026-08-20
|
||||
python3 scripts/add_scheduled_skip.py --list
|
||||
scripts/add_scheduled_skip.py --date today
|
||||
scripts/add_scheduled_skip.py --date 2026-12-24
|
||||
|
||||
The date is **local**. The old version computed a local "today" while the
|
||||
lock chain compared against a UTC one, so a skip added near midnight could
|
||||
name a date the chain never matched. freedays owns "what is today" for both
|
||||
sides now, so that disagreement cannot recur.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import sys
|
||||
|
||||
SKIPS_FILE = (
|
||||
Path(__file__).resolve().parent.parent / "screen_locker" / ("scheduled_skips.json")
|
||||
)
|
||||
import freedays
|
||||
|
||||
|
||||
def _today() -> str:
|
||||
"""Return today's local date as YYYY-MM-DD.
|
||||
|
||||
Local, not UTC: the skip must match the day the user is living in, and the
|
||||
lock chain's own ``_today_str`` is local for the same reason.
|
||||
"""
|
||||
return datetime.now().astimezone().strftime("%Y-%m-%d")
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
"""Write one line to stdout (matches the other scripts in this directory)."""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
|
||||
|
||||
def load_skips(path: Path) -> list[str]:
|
||||
"""Return the recorded skip dates, or [] when the file is absent."""
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
# Refuse rather than silently starting a fresh list: overwriting an
|
||||
# unreadable file would drop skips the user still expects to hold.
|
||||
msg = f"could not read {path}: {exc}"
|
||||
msg = f"ERROR: {msg}"
|
||||
raise SystemExit(msg) from exc
|
||||
if not isinstance(data, list):
|
||||
msg = f"ERROR: {path} does not contain a JSON list"
|
||||
raise SystemExit(msg)
|
||||
return [str(entry) for entry in data]
|
||||
|
||||
|
||||
def add_skip(path: Path, date: str) -> bool:
|
||||
"""Add ``date`` to the skips file. True if it was newly added."""
|
||||
skips = load_skips(path)
|
||||
if date in skips:
|
||||
return False
|
||||
skips.append(date)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(sorted(skips), indent=2) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Add the requested date, or list what is already recorded."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
"""Mark the requested day free in the shared pool.
|
||||
|
||||
Returns:
|
||||
``0`` when the day is free (including when it already was), ``1``
|
||||
when the pool refused, ``2`` when the date could not be parsed.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
help="date to skip: 'today' or YYYY-MM-DD",
|
||||
default="today",
|
||||
help="YYYY-MM-DD, or 'today'/'tomorrow' (default: today)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="print the recorded skip dates and exit",
|
||||
"--reason",
|
||||
default="",
|
||||
help="optional free text; never required",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.list:
|
||||
for entry in sorted(load_skips(SKIPS_FILE)):
|
||||
_report(entry)
|
||||
try:
|
||||
day = freedays.resolve(args.date)
|
||||
except ValueError as exc:
|
||||
_logger.warning("could not parse the requested date: %s", exc)
|
||||
_report(f"error: {exc}")
|
||||
return 2
|
||||
|
||||
iso = freedays.to_iso(day)
|
||||
if freedays.is_free_day(day):
|
||||
_report(f"{iso} is already a free day; nothing to do")
|
||||
return 0
|
||||
|
||||
if not args.date:
|
||||
parser.error("--date is required (or use --list)")
|
||||
|
||||
date = _today() if args.date == "today" else args.date
|
||||
try:
|
||||
datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
msg = f"ERROR: {date!r} is not a YYYY-MM-DD date"
|
||||
raise SystemExit(msg) from None
|
||||
freedays.mark(day, reason=args.reason)
|
||||
except freedays.FreeDayError as exc:
|
||||
_logger.warning("the pool refused %s: %s", iso, exc)
|
||||
_report(f"error: {exc}")
|
||||
return 1
|
||||
|
||||
if add_skip(SKIPS_FILE, date):
|
||||
_report(f"Added scheduled skip for {date} — the locker will not lock that day.")
|
||||
else:
|
||||
_report(f"{date} is already a scheduled skip; nothing to do.")
|
||||
left = freedays.status(year=day.year).left
|
||||
_report(f"{iso} is now a free day for every gate app ({left} left in {day.year})")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
132
scripts/migrate_scheduled_skips.py
Executable file
132
scripts/migrate_scheduled_skips.py
Executable file
@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Move ``scheduled_skips.json`` into the shared free-day pool, once.
|
||||
|
||||
screen-locker's scheduled skips were the first version of "a day when the
|
||||
gate stands down", and the only one any app had. freedays generalises them:
|
||||
the same idea, but one marked day now stands down every gate app at once.
|
||||
|
||||
This copies the dates across and leaves the original file untouched, so the
|
||||
migration can be inspected -- and rerun -- before anything is deleted.
|
||||
Rerunning is safe: a date already in the pool is reported and skipped, never
|
||||
duplicated or double-counted.
|
||||
|
||||
scripts/migrate_scheduled_skips.py --dry-run # show what would happen
|
||||
scripts/migrate_scheduled_skips.py # do it
|
||||
|
||||
Every imported date is in the past, and ``freedays.mark`` deliberately
|
||||
refuses to backdate -- that refusal is what stops a gate you already failed
|
||||
being erased afterwards. Passing ``now=day`` supplies the day itself as the
|
||||
reference instead of reaching for the system clock, which is the one honest
|
||||
way to say "this day was taken, and it was taken then". Each lands as spent
|
||||
(``consumed``), which is correct: these days happened.
|
||||
|
||||
Output goes through ``_report`` rather than ``print``. ruff's T201 fix is
|
||||
*unsafe* -- it deletes the whole call, message and all -- and it did exactly
|
||||
that to the first draft of this file, leaving a migration that moved data
|
||||
while appearing to do nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import date
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import freedays
|
||||
|
||||
DEFAULT_SKIPS_FILE = (
|
||||
Path(__file__).resolve().parent.parent / "screen_locker" / "scheduled_skips.json"
|
||||
)
|
||||
_REASON = "migrated from screen-locker scheduled_skips.json"
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
"""Write one line to stdout, un-deletable by an autofixer."""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
|
||||
|
||||
def read_skips(path: Path) -> list[date]:
|
||||
"""Return the dates listed in ``path``, ascending.
|
||||
|
||||
Raises:
|
||||
SystemExit: If the file is missing or is not a JSON list of dates.
|
||||
"""
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
msg = f"no scheduled-skips file at {path} -- nothing to migrate"
|
||||
raise SystemExit(msg) from exc
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
msg = f"could not read {path}: {exc}"
|
||||
raise SystemExit(msg) from exc
|
||||
if not isinstance(raw, list):
|
||||
sys.exit(f"{path} is not a JSON list of dates")
|
||||
try:
|
||||
return sorted(date.fromisoformat(str(entry)) for entry in raw)
|
||||
except ValueError as exc:
|
||||
msg = f"{path} holds something that is not a YYYY-MM-DD date: {exc}"
|
||||
raise SystemExit(msg) from exc
|
||||
|
||||
|
||||
def migrate(days: list[date], *, dry_run: bool) -> int:
|
||||
"""Mark each day in the shared pool. Returns how many were newly added."""
|
||||
added = 0
|
||||
for day in days:
|
||||
iso = freedays.to_iso(day)
|
||||
if freedays.is_free_day(day):
|
||||
_report(f" {iso} already in the pool, leaving it alone")
|
||||
continue
|
||||
if dry_run:
|
||||
_report(f" {iso} would be added")
|
||||
added += 1
|
||||
continue
|
||||
try:
|
||||
freedays.mark(day, reason=_REASON, now=day)
|
||||
except freedays.FreeDayError as exc:
|
||||
_logger.warning("the pool refused %s: %s", iso, exc)
|
||||
_report(f" {iso} REFUSED: {exc}")
|
||||
continue
|
||||
_report(f" {iso} added")
|
||||
added += 1
|
||||
return added
|
||||
|
||||
|
||||
def _report_budget(days: list[date]) -> None:
|
||||
"""Print each affected year's remaining budget."""
|
||||
years = sorted({day.year for day in days})
|
||||
for year in years:
|
||||
status = freedays.status(year=year)
|
||||
_report(
|
||||
f"{year}: {status.spent} of {status.budget} free days spent, "
|
||||
f"{status.left} left"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Read the skips file and copy its dates into the shared pool."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--skips-file", type=Path, default=DEFAULT_SKIPS_FILE)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="show what would change, write nothing"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
days = read_skips(args.skips_file)
|
||||
_report(f"{len(days)} scheduled skip(s) in {args.skips_file}:")
|
||||
added = migrate(days, dry_run=args.dry_run)
|
||||
_report_budget(days)
|
||||
|
||||
verb = "would be added" if args.dry_run else "added"
|
||||
_report(f"{added} {verb}.")
|
||||
if not args.dry_run and added:
|
||||
_report(f"{args.skips_file} left in place -- delete it once this looks right.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in New Issue
Block a user