steam-backlog-enforcer/steam_backlog_enforcer/_cmd_playtime.py
Krzysztof kuhy Rudnicki 356530a01c
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041),
deprecated `typing` imports (UP035), PEP 695 `type` aliases (UP040), and ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That last one makes the source 3.14-only at the syntax level, which is what
`requires-python = ">=3.14"` already promised; kuhy chose it deliberately over
holding ruff a release back (2026-08-29).

ruff, ruff-format, mypy --strict and pylint all clean at the new target. The
two modified web/ TypeScript files are another session's in-flight work and
are deliberately left unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:44:25 +02:00

151 lines
4.6 KiB
Python

"""CLI handlers for the daily gaming budget.
Every ``_echo`` / ``input`` / ``sys.exit`` for this feature lives here rather
than in :mod:`steam_backlog_enforcer._playtime` or
:mod:`steam_backlog_enforcer._playtime_block`, so the MCP server can import
those modules' leaf helpers without any risk of writing to stdout — which is
the JSON-RPC channel.
"""
from __future__ import annotations
from datetime import UTC, datetime
import os
from typing import TYPE_CHECKING
from steam_backlog_enforcer._enforce_loop import do_enforce
from steam_backlog_enforcer._playtime_block import (
block_targets,
mounted_targets,
release_block,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
gaming_day_key,
load_state,
rules_for,
save_state,
state_path,
)
from steam_backlog_enforcer.game_install import _echo
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
_DEMO_FLAG = "--demo"
def cmd_enforce(config: Config, state: State, args: list[str]) -> int:
"""Run the enforcement loop.
Args:
config: Enforcer configuration.
state: Current enforcer state.
args: Arguments after the command name.
Returns:
Process exit code.
"""
unknown = [arg for arg in args if arg != _DEMO_FLAG]
if unknown:
# Never ignore an unrecognised flag here: a mistyped "--Demo" would
# silently run the real 8-hour budget during what was meant to be a
# 60-second demo, and the mistake only shows up hours later.
_echo(f"Unknown argument(s): {' '.join(unknown)}")
_echo(f"Usage: enforce [{_DEMO_FLAG}]")
return 2
demo = _DEMO_FLAG in args
if demo:
_echo("DEMO MODE: gaming budget is 60 seconds, using a separate state file.")
do_enforce(config, state, demo=demo)
return 0
def cmd_gaming_status(config: Config, _state: State) -> None:
"""Print today's gaming-budget usage and block state.
Args:
config: Enforcer configuration.
_state: Unused; required by the command dispatch signature.
"""
for demo in (False, True):
rules = rules_for(config, demo=demo)
stored = load_state(demo=demo)
if stored is None and demo:
continue
_echo(f"{'Demo' if demo else 'Gaming'} budget:")
_echo(f" state file: {state_path(demo=demo)}")
_echo(f" budget_seconds: {rules.budget_seconds:.0f}")
if stored is None:
_echo(" (no state recorded yet)")
else:
remaining = max(0.0, rules.budget_seconds - stored.seconds)
_echo(f" gaming day: {stored.day_key} (starts 06:00 local)")
_echo(f" used: {stored.seconds:.0f}s")
_echo(f" remaining: {remaining:.0f}s")
_echo(f" blocked: {stored.is_blocked()}")
masked = mounted_targets()
_echo(f"Masked launchers: {len(masked)}/{len(block_targets())}")
for target in sorted(masked):
_echo(f" {target}")
def cmd_gaming_reset(config: Config, _state: State) -> int:
"""Zero today's gaming counter and lift the block.
Args:
config: Enforcer configuration.
_state: Unused; required by the command dispatch signature.
Returns:
Process exit code.
"""
if os.geteuid() != 0:
_echo("gaming-reset requires root (it releases bind mounts).")
return 1
_echo("This resets today's gaming counter and lifts the block.")
if input("Type YES to confirm: ").strip() != "YES":
_echo("Aborted.")
return 1
released = release_block()
now = datetime.now(UTC).astimezone()
save_state(
PlaytimeState(day_key=gaming_day_key(now), last_tick_at=now.timestamp()),
demo=rules_for(config, demo=False).demo,
)
_echo(f"Gaming counter reset. Released {len(released)} mount(s).")
return 0
def cmd_gaming_unblock(args: list[str]) -> int:
"""Release every playtime mount unconditionally.
The recovery hatch: it consults no state at all, so it works when the state
file is unreadable, the daemon is down, or a block was left behind by a
previous run.
Args:
args: Arguments after the command name.
Returns:
Process exit code.
"""
if args:
_echo(f"Unknown argument(s): {' '.join(args)}")
return 2
if os.geteuid() != 0:
_echo("gaming-unblock requires root (it releases bind mounts).")
return 1
released = release_block()
if not released:
_echo("No playtime mounts were active.")
return 0
for target in released:
_echo(f"Released {target}")
return 0