steam-backlog-enforcer/steam_backlog_enforcer/main/_shared.py
Krzysztof kuhy Rudnicki 45ff4eb7c2 Split main.py (1076 lines) into a main/ package
main.py became main/ with __main__.py, so the invocation string baked into
run.sh and the systemd unit (`python -m steam_backlog_enforcer.main`) stays
byte-identical and nothing needs redeploying. Commands are grouped into
sibling modules (status, install, picks, abandon, misc), with _shared.py as
a leaf holding the lock helpers and constants both need, and _registry.py
holding the command tables. main/__init__.py keeps every public and private
name importable, so `from steam_backlog_enforcer.main import X` is unchanged.

The subtle part is patch targets. The test suite builds them as
f"{PKG}.{name}", so a grep for the literal module path found nothing -- but
once a command moves to a submodule it resolves its dependencies from that
module's globals, and patching the main package's re-export silently stops
biting. Measured which of the 36 patched symbols moved: 8 could have passed
vacuously. Rather than repair PKG per class, the five test_main*.py files
(all cap violators anyway) were regrouped to mirror the production modules,
so each file has exactly one correct PKG. Verified by mutation: breaking a
line in status/misc/picks/_shared each makes the corresponding tests fail.

test_web_server.py patched main_mod.serve, which no longer intercepts
cmd_serve; repointed at main.misc and confirmed it fails when the call is
removed. Added tests for the __main__ shim, which coverage would otherwise
report at 0% under fail_under = 100.

Shared test builders live in tests/_main_helpers.py, reachable because the
Phase 0 name-tests-test exclude allows tests/_*.py.

1290 tests pass; every main/ module reports 100% coverage; `main`, `status`,
`bogus` and `--status` produce byte-identical output and exit codes to
pre-refactor. 55 -> 49 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 19:56:34 +02:00

188 lines
6.9 KiB
Python

"""Constants and lock helpers shared by the CLI command modules.
Leaf module: it imports from the wider package but never from a sibling in
``main``, so the command modules can all depend on it without creating an
import cycle.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import sys
from typing import TYPE_CHECKING
from steam_backlog_enforcer._actions import (
MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS,
)
from steam_backlog_enforcer._actions import (
active_manual_picks,
manual_pick_grace_remaining,
)
from steam_backlog_enforcer._actions import (
is_manual_pick_locked as _is_manual_pick_locked,
)
from steam_backlog_enforcer._allowed_games import (
MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS,
)
from steam_backlog_enforcer._total_block import (
TotalBlockStatus,
get_total_block_status,
is_total_block_active,
)
from steam_backlog_enforcer.game_install import _echo
if TYPE_CHECKING:
from steam_backlog_enforcer.config import State
_LIST_DISPLAY_LIMIT = 50
_MIN_CLI_ARGS = 2
# Commands that remain usable while the manual pick lock is active.
# Principle: only what is needed to release the lock (done/check) or
# that cannot change the game assignment (status, enforce, setup, serve).
_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset(
{
"done",
"check",
"status",
"enforce",
"setup",
"serve",
"abandon-pick",
# Allowed so a second game can be locked in alongside the first; the
# cap inside cmd_pick_manual is what stops this being a way out.
"pick-manual",
# The daily gaming budget is orthogonal to which game is assigned, and
# gaming-unblock is a recovery hatch for a stuck bind mount - locking
# it behind a manual pick would leave Steam masked with no way back.
"gaming-status",
"gaming-unblock",
"gaming-reset",
}
)
# Commands that remain usable while a total gaming block is active. Far
# stricter than _MANUAL_LOCK_EXEMPT_COMMANDS: no done/pick/reset/
# add-exception - there is no in-app way to shorten a total block.
#
# gaming-unblock is included because a playtime bind mount makes the total
# block's own `pacman -R steam` fail EBUSY - it must stay reachable exactly
# when the two collide. gaming-reset is NOT included: it shortens enforcement.
_TOTAL_BLOCK_EXEMPT_COMMANDS = frozenset(
{"status", "enforce", "gaming-status", "gaming-unblock"}
)
# ──────────────────────────────────────────────────────────────
# Total gaming block lock helpers
# ──────────────────────────────────────────────────────────────
def _show_total_block_lock_message(status: TotalBlockStatus) -> None:
"""Print the total-gaming-block-active message to stdout."""
_echo("\n" + "=" * 60)
_echo(" *** TOTAL GAMING BLOCK ACTIVE ***")
_echo("=" * 60)
if status.until is not None:
_echo(f"\nBlocked until: {status.until.strftime('%Y-%m-%d %H:%M UTC')}")
_echo(f"Days remaining: {status.days_remaining:.1f}")
_echo(
"\nSteam has been uninstalled, all known game/launcher processes are"
"\nbeing killed on sight, and Steam + game-website domains are blocked."
"\nThere is NO in-app command to lift this early."
f"\n\nAllowed commands: {', '.join(sorted(_TOTAL_BLOCK_EXEMPT_COMMANDS))}"
)
_echo("=" * 60 + "\n")
def _enforce_total_block_lock(command: str) -> None:
"""Exit with a lock message if command is blocked by an active total block."""
if not is_total_block_active():
return
if command in _TOTAL_BLOCK_EXEMPT_COMMANDS:
return
_show_total_block_lock_message(get_total_block_status())
sys.exit(1)
# ──────────────────────────────────────────────────────────────
# Manual pick lock helpers
# (the predicate itself lives in _actions so the MCP server can reuse it)
# ──────────────────────────────────────────────────────────────
def _describe_pick(state: State, pick: dict[str, object]) -> bool:
"""Print one manual pick's deadline and grace status; return if abandonable.
Args:
state: Current enforcer state.
pick: One active entry from ``state.manual_picks``.
Returns:
Whether this pick is still inside its grace window.
"""
app_id = pick["app_id"]
_echo(f"\n {pick['game_name']} (AppID={app_id})")
started_at = str(pick.get("started_at") or "")
if started_at:
try:
started = datetime.fromisoformat(started_at)
deadline = started + timedelta(days=_MANUAL_LOCK_DAYS)
days_left = (deadline - datetime.now(timezone.utc)).days
_echo(f" Locked since: {started.strftime('%Y-%m-%d')}")
_echo(
f" Deadline: {deadline.strftime('%Y-%m-%d')}"
f" ({max(0, days_left)} day(s) remaining)"
)
except ValueError:
pass
grace_left = manual_pick_grace_remaining(state, int(str(app_id)))
if grace_left is not None and grace_left > 0:
_echo(
f" Undo: abandon-pick {app_id}"
f" ({grace_left:.1f} of {_MANUAL_GRACE_DAYS} grace day(s) left)"
)
return True
return False
def _show_manual_pick_lock_message(state: State) -> None:
"""Print the aggressive lock-active message to stdout."""
picks = active_manual_picks(state)
_echo("\n" + "=" * 60)
_echo(" *** MANUAL PICK LOCK ACTIVE ***")
_echo("=" * 60)
_echo(f"\nYou manually picked {len(picks)} game(s):")
any_in_grace = False
for pick in picks:
any_in_grace |= _describe_pick(state, pick)
_echo(
"\nYou CANNOT use any other feature until you finish these games"
"\n(100% achievements) or their 2-week deadlines pass."
"\n\nTo release the lock: finish them, then run 'done' or 'check'."
)
# 'abandon-pick' is dropped from the allowed list once no pick is still
# inside its window, rather than offered as a command that would refuse.
usable = set(_MANUAL_LOCK_EXEMPT_COMMANDS)
if not any_in_grace:
usable.discard("abandon-pick")
_echo(f"\nAllowed commands: {', '.join(sorted(usable))}")
_echo("=" * 60 + "\n")
def _enforce_manual_pick_lock(command: str, state: State) -> None:
"""Exit with a lock message if command is blocked by the manual pick."""
if not _is_manual_pick_locked(state):
return
if command in _MANUAL_LOCK_EXEMPT_COMMANDS:
return
_show_manual_pick_lock_message(state)
sys.exit(1)