steam-backlog-enforcer/steam_backlog_enforcer/_allowed_games.py
Krzysztof kuhy Rudnicki e5d0627a30
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
fix(game_install): refuse to delete folders matching an allowed game's name
_remove_game_dirs deleted exactly one path at a time (the manifest's
current installdir, app-id gated by callers), but a real incident showed
that's not enough: an allowed game's install can end up split across
naming variants (spaced display name vs. installdir vs. a typo'd
variant), and any code path touching the wrong one risks destroying real
files. Add a fuzzy name-match safety net inside _remove_game_dirs itself
so it refuses to rmtree anything that plausibly names one of the
currently-allowed games, tolerating case/spacing/punctuation, spelled-out
numbers vs. digits vs. roman numerals, and minor typos -- independent of
whatever app-id gating the caller already does.

Extracted active_manual_picks/allowed_games/_pick_is_active out of
_actions.py into a new leaf module (_allowed_games.py) so game_install.py
can import allowed_games at the top level for this check without a
circular import (_actions.py already imports from game_install.py for
unrelated functions). _actions.py re-exports the two names other modules
still import from it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 15:50:29 +02:00

85 lines
2.8 KiB
Python

"""Which games the enforcer currently must leave installed and visible.
Pure functions over ``State`` with no dependency on ``game_install.py``.
Kept as its own leaf module (rather than living in ``_actions.py``, which
already imports from ``game_install.py`` for unrelated functions) so that
``game_install.py`` can import ``allowed_games`` at the top level for its
deletion safety net without creating a circular import.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from steam_backlog_enforcer.config import State
# Days before the manual-pick lock automatically expires. Single source of
# truth: both ``main.py`` and the MCP server import it via ``_actions``.
MANUAL_LOCK_DAYS = 14
def _pick_is_active(state: State, pick: dict[str, Any]) -> bool:
"""Return ``True`` if *pick* still holds the lock.
A pick stops being active once its game is finished (100% achievements) or
once ``MANUAL_LOCK_DAYS`` have elapsed. A missing or malformed timestamp
keeps it active: with no deadline to evaluate, the safe answer for an
enforcement tool is "still locked".
Args:
state: The loaded enforcer state (for ``finished_app_ids``).
pick: One ``state.manual_picks`` entry.
Returns:
Whether this pick still counts toward the lock.
"""
app_id = pick.get("app_id")
if app_id is None or app_id in state.finished_app_ids:
return False
started_at = pick.get("started_at") or ""
if started_at:
try:
started = datetime.fromisoformat(started_at)
except ValueError:
return True
if datetime.now(timezone.utc) >= started + timedelta(days=MANUAL_LOCK_DAYS):
return False
return True
def active_manual_picks(state: State) -> list[dict[str, Any]]:
"""Return the manual picks that still hold the lock, oldest first.
Args:
state: The loaded enforcer state.
Returns:
The subset of ``state.manual_picks`` that is neither finished nor past
its own deadline.
"""
return [p for p in state.manual_picks if _pick_is_active(state, p)]
def allowed_games(state: State) -> list[tuple[int, str]]:
"""Return ``(app_id, name)`` for every game the enforcer must keep.
Args:
state: The loaded enforcer state.
Returns:
Allowed games as ``(app_id, name)`` pairs, without duplicates. The
current assignment comes first.
"""
games: list[tuple[int, str]] = []
if state.current_app_id is not None:
games.append((state.current_app_id, state.current_game_name))
for pick in active_manual_picks(state):
app_id = pick.get("app_id")
if app_id is not None and all(app_id != aid for aid, _ in games):
games.append((app_id, pick.get("game_name", "")))
return games