diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 60de4d1..b5059ba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5d4b79b..df200c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index 9935e24..a710971 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/screen_locker/_compliance_predicates.py b/screen_locker/_compliance_predicates.py index 6f20f3d..deeb1c0 100644 --- a/screen_locker/_compliance_predicates.py +++ b/screen_locker/_compliance_predicates.py @@ -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: diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py index aa114b2..6d010b9 100644 --- a/screen_locker/_compliance_state.py +++ b/screen_locker/_compliance_state.py @@ -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 diff --git a/screen_locker/_log_mixin.py b/screen_locker/_log_mixin.py index 4b8a398..febe5fb 100644 --- a/screen_locker/_log_mixin.py +++ b/screen_locker/_log_mixin.py @@ -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. diff --git a/screen_locker/_mcp.py b/screen_locker/_mcp.py index cdf6480..6156e57 100644 --- a/screen_locker/_mcp.py +++ b/screen_locker/_mcp.py @@ -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), } diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py index 9901ccb..9519c1c 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -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, diff --git a/screen_locker/tests/_isolated_state.py b/screen_locker/tests/_isolated_state.py index 0b30bed..6e3aae2 100644 --- a/screen_locker/tests/_isolated_state.py +++ b/screen_locker/tests/_isolated_state.py @@ -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/. diff --git a/screen_locker/tests/_real_state_fixtures.py b/screen_locker/tests/_real_state_fixtures.py new file mode 100644 index 0000000..53bd7bd --- /dev/null +++ b/screen_locker/tests/_real_state_fixtures.py @@ -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 diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 6e09840..5ad4f2c 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -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. diff --git a/screen_locker/tests/test_compliance_state.py b/screen_locker/tests/test_compliance_state.py index 1d105ee..f619aa7 100644 --- a/screen_locker/tests/test_compliance_state.py +++ b/screen_locker/tests/test_compliance_state.py @@ -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: diff --git a/screen_locker/tests/test_compliance_state_part2.py b/screen_locker/tests/test_compliance_state_part2.py index a5bdb6f..4e02b75 100644 --- a/screen_locker/tests/test_compliance_state_part2.py +++ b/screen_locker/tests/test_compliance_state_part2.py @@ -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(), diff --git a/screen_locker/tests/test_mcp.py b/screen_locker/tests/test_mcp.py index d910bf7..223a302 100644 --- a/screen_locker/tests/test_mcp.py +++ b/screen_locker/tests/test_mcp.py @@ -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") diff --git a/screen_locker/tests/test_scheduled_skip.py b/screen_locker/tests/test_scheduled_skip.py index 62a2de8..049cebf 100644 --- a/screen_locker/tests/test_scheduled_skip.py +++ b/screen_locker/tests/test_scheduled_skip.py @@ -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, diff --git a/screen_locker/tests/test_status_data.py b/screen_locker/tests/test_status_data.py index 94f2e90..a0940a6 100644 --- a/screen_locker/tests/test_status_data.py +++ b/screen_locker/tests/test_status_data.py @@ -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", } diff --git a/screen_locker/tests/test_status_data_part3.py b/screen_locker/tests/test_status_data_part3.py index 88784ac..919778c 100644 --- a/screen_locker/tests/test_status_data_part3.py +++ b/screen_locker/tests/test_status_data_part3.py @@ -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 ): diff --git a/screen_locker/tests/test_sync_degradation.py b/screen_locker/tests/test_sync_degradation.py index 7b4b752..6498ef9 100644 --- a/screen_locker/tests/test_sync_degradation.py +++ b/screen_locker/tests/test_sync_degradation.py @@ -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", } diff --git a/scripts/add_scheduled_skip.py b/scripts/add_scheduled_skip.py index 155b91a..d64dc55 100755 --- a/scripts/add_scheduled_skip.py +++ b/scripts/add_scheduled_skip.py @@ -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 diff --git a/scripts/migrate_scheduled_skips.py b/scripts/migrate_scheduled_skips.py new file mode 100755 index 0000000..795385a --- /dev/null +++ b/scripts/migrate_scheduled_skips.py @@ -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())