steam-backlog-enforcer/steam_backlog_enforcer/_playtime.py
Krzysztof kuhy Rudnicki b5b4b35c07 Split total-block, playtime and MCP by responsibility
Each of these was one file doing several jobs, so they are split along
what they actually do rather than at arbitrary line offsets:

- _total_block: the lock file and the status derived from it stay in
  _total_block; making Steam *not installed* moves to _total_block_purge,
  making it *unreachable* to _total_block_hosts / _total_block_iptables.
  The generic pacman verbs (owner, installed, remove) become _pacman,
  since nothing about them is total-block-specific. Domain and process
  name tables become data modules.
- _playtime: budget state and persistence (_playtime_state), pure
  transitions (_playtime_budget), cmdline process detection
  (_playtime_procs), the warn/cutoff/kill ladder (_playtime_cutoff),
  desktop notifications (_playtime_notify), process-tree walking and
  killing (_playtime_kill), mountinfo parsing (_playtime_mountinfo) and
  the shared privileged-command helper (_playtime_run).
- _mcp: read-only tools (_mcp_query) split from mutating ones
  (_mcp_actions, _mcp_gaming_time), so "what can this server change?" is
  answerable from one file. _mcp keeps the server object and entry point.

No re-export shims: callers import from the module that defines the name.
That keeps a stale patch target loud (AttributeError) instead of letting
it pass while intercepting nothing.

`ruff check --select F821,F401` is run after every extraction -- an earlier
mechanical attempt moved functions without the module-level names they
referenced, which imported fine and only failed when called (349 errors),
so it was reverted and redone by hand.

The autouse fixtures in conftest are now imported by name and held by
__all__; ruff --unsafe-fixes had deleted them as unused, since pytest
resolves fixtures by name.

1324 tests pass at 100.00% coverage in the clean venv; `main status` is
byte-identical to a worktree at HEAD. 49 -> 39 files over the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
2026-08-23 20:52:08 +02:00

160 lines
5.3 KiB
Python

"""Daily gaming-time budget: accounting, day rollover and cutoff policy.
Tracks how long the user has spent gaming during the current *gaming day* and,
once the budget is exhausted, drives the cutoff sequence implemented in
:mod:`steam_backlog_enforcer._playtime_block`.
Three design points that are load-bearing:
**The gaming day starts at 06:00 local, not midnight.** A session that runs
past midnight belongs to the day it started in; a midnight boundary would hand
out a fresh 8-hour budget in the middle of a late session. Rollover is checked
lazily on every tick against a stored day key rather than scheduled, so it
survives suspend, reboot and daemon restarts with no timer of its own.
**Elapsed time is measured from wall-clock deltas, clamped.** Counting "one
tick = one interval" silently under-counts whenever the loop is slow and
over-counts nothing; measuring the raw delta over-counts wildly across a
suspend. Clamping each delta to a small multiple of the tick interval makes
suspends, missed ticks, daemon restarts and NTP steps all behave.
**Launchers are matched by cmdline, not by ``comm``.** ``/usr/bin/lutris`` is a
``#!/usr/bin/env python3`` script, so the kernel records its ``comm`` as
``python3`` and the ``comm``-based scan in :mod:`enforcer` never sees it. That
is why :func:`get_pids_by_cmdline_names` exists alongside
``enforcer.get_pids_by_process_names`` rather than replacing it.
"""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime, timezone
import logging
from typing import TYPE_CHECKING
from steam_backlog_enforcer._playtime_block import (
mounted_targets,
reconcile,
)
from steam_backlog_enforcer._playtime_budget import (
accumulate,
roll_over,
)
from steam_backlog_enforcer._playtime_cutoff import (
_begin_cutoff,
_sustain_block,
_warn,
)
from steam_backlog_enforcer._playtime_procs import qualifying_pids
from steam_backlog_enforcer._playtime_state import (
PlaytimeRules,
PlaytimeState,
gaming_day_key,
load_state,
rules_for,
save_state,
)
from steam_backlog_enforcer._total_block import is_total_block_active
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
_SECONDS_PER_MINUTE = 60
_MINUTES_PER_HOUR = 60
def playtime_tick(config: Config, *, interval: float, demo: bool = False) -> None:
"""Account for this tick's gaming time and enforce the daily budget.
Called first in every enforce-loop iteration, before any other guard, so
that time is still counted (and a 06:00 release still happens) in the
situations where the rest of the loop early-returns.
Args:
config: Loaded user configuration.
interval: Nominal seconds between enforce-loop ticks.
demo: Whether this is a short-budget demo run.
"""
now = datetime.now(timezone.utc).astimezone()
rules = rules_for(config, demo=demo)
state = roll_over(_state_or_recover(rules, now=now), day_key=gaming_day_key(now))
if is_total_block_active():
# The total block runs `pacman -R steam` every tick; our bind mounts
# would make that fail EBUSY. It is strictly stronger — get out of its
# way, and stop accruing against a budget nobody can spend.
reconcile(should_block=False)
save_state(state, demo=demo)
return
state = accumulate(
state,
now=now,
qualifying=qualifying_pids(rules),
interval=interval,
)
save_state(_policy(state, rules, now=now), demo=demo)
def _state_or_recover(rules: PlaytimeRules, *, now: datetime) -> PlaytimeState:
"""Load state, synthesising a fail-closed record if it is unusable.
If the state file is gone but the binaries are still masked, the only safe
reading is that the file was deleted to lift the block. Synthesising an
exhausted budget keeps it in force; the normal 06:00 rollover then lifts it
with no special case.
Args:
rules: Policy for this tick.
now: Timezone-aware local timestamp.
Returns:
Loaded or synthesised state.
"""
loaded = load_state(demo=rules.demo)
if loaded is not None:
return loaded
stamp = now.timestamp()
fresh = PlaytimeState(day_key=gaming_day_key(now), last_tick_at=stamp)
if not mounted_targets():
return fresh
logger.warning("Playtime state missing while blocked; failing closed.")
return replace(fresh, seconds=rules.budget_seconds, blocked_at=stamp)
def _policy(
state: PlaytimeState,
rules: PlaytimeRules,
*,
now: datetime,
) -> PlaytimeState:
"""Apply warnings, the cutoff, and block upkeep to *state*.
Args:
state: Accounting state after this tick's accumulation.
rules: Policy for this tick.
now: Timezone-aware local timestamp.
Returns:
The updated state.
"""
if not rules.enforcement:
# A kill switch must still release: leaving live mounts behind would
# make "disabled" mean "permanently blocked".
reconcile(should_block=False)
return state
if state.seconds < rules.budget_seconds:
reconcile(should_block=False)
return _warn(state, rules)
if not state.is_blocked():
return _begin_cutoff(state, rules, now=now)
return _sustain_block(state, rules, now=now)