mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 16:03:03 +02:00
- steam_backlog_enforcer: extract _hltb_search.py and _scanning_confidence.py;
split oversized test files into *_part2/3/4.py
- screen_locker: extract _early_bird.py and _window_setup.py from screen_lock.py;
fix patch targets in tests (screen_lock.* -> _window_setup.*)
- wake_alarm: use shutil.which('xset') to avoid S607; add TestDisplayHelpers tests
- linux_configuration/usage_report: split into _parsing.py and _types.py;
add bin/__init__.py (INP001); fix RUF002 (× -> x)
- pre-commit: add require_serial: true to pytest-coverage hook to prevent
file batching across 24 CPU cores (was causing 12 parallel partial-coverage runs)
442 lines
14 KiB
Python
442 lines
14 KiB
Python
"""Main CLI for Steam Backlog Enforcer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
from python_pkg.steam_backlog_enforcer._cmd_done import cmd_done
|
|
from python_pkg.steam_backlog_enforcer._enforce_loop import (
|
|
do_enforce,
|
|
get_all_owned_app_ids,
|
|
)
|
|
from python_pkg.steam_backlog_enforcer._hltb_types import load_hltb_cache
|
|
from python_pkg.steam_backlog_enforcer._whitelist import (
|
|
WHITELIST_COOLDOWN_SECONDS,
|
|
add_pending_exception,
|
|
list_pending_exceptions,
|
|
validate_reason,
|
|
)
|
|
from python_pkg.steam_backlog_enforcer.config import (
|
|
Config,
|
|
State,
|
|
interactive_setup,
|
|
load_snapshot,
|
|
)
|
|
from python_pkg.steam_backlog_enforcer.game_install import (
|
|
_echo,
|
|
get_installed_games,
|
|
install_game,
|
|
is_game_installed,
|
|
is_protected_app,
|
|
uninstall_other_games,
|
|
)
|
|
from python_pkg.steam_backlog_enforcer.library_hider import (
|
|
hide_other_games,
|
|
restart_steam,
|
|
unhide_all_games,
|
|
)
|
|
from python_pkg.steam_backlog_enforcer.scanning import (
|
|
do_check,
|
|
do_scan,
|
|
pick_next_game,
|
|
)
|
|
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
|
|
from python_pkg.steam_backlog_enforcer.store_blocker import (
|
|
block_store,
|
|
is_store_blocked,
|
|
unblock_store,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_LIST_DISPLAY_LIMIT = 50
|
|
_MIN_CLI_ARGS = 2
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# CLI commands
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def cmd_status(_config: Config, state: State) -> None:
|
|
"""Show current status."""
|
|
_echo("=== Steam Backlog Enforcer ===\n")
|
|
|
|
if state.current_app_id:
|
|
_echo(
|
|
f"Assigned game: {state.current_game_name} (AppID={state.current_app_id})"
|
|
)
|
|
else:
|
|
_echo("No game currently assigned.")
|
|
|
|
_echo(f"Finished games: {len(state.finished_app_ids)}")
|
|
_echo(f"Store blocked: {is_store_blocked()}")
|
|
|
|
# Show installed games.
|
|
installed = get_installed_games()
|
|
real_games = [(aid, n) for aid, n in installed if not is_protected_app(aid)]
|
|
_echo(f"Installed games: {len(real_games)}")
|
|
|
|
if state.current_app_id:
|
|
is_assigned_installed = any(aid == state.current_app_id for aid, _ in installed)
|
|
_echo(f"Assigned game installed: {is_assigned_installed}")
|
|
|
|
|
|
def cmd_list(_config: Config, state: State) -> None:
|
|
"""List games from the last snapshot."""
|
|
snapshot = load_snapshot()
|
|
if snapshot is None:
|
|
_echo("No snapshot found. Run 'scan' first.")
|
|
return
|
|
|
|
games = [GameInfo.from_snapshot(d) for d in snapshot]
|
|
incomplete = [g for g in games if not g.is_complete]
|
|
complete = [g for g in games if g.is_complete]
|
|
|
|
# Sort incomplete by completionist hours.
|
|
def sort_key(g: GameInfo) -> tuple[int, float]:
|
|
if g.completionist_hours > 0:
|
|
return (0, g.completionist_hours)
|
|
return (1, 0.0)
|
|
|
|
incomplete.sort(key=sort_key)
|
|
|
|
_echo(f"\n{'─' * 70}")
|
|
_echo(f" INCOMPLETE ({len(incomplete)} games)")
|
|
_echo(f"{'─' * 70}")
|
|
for i, g in enumerate(incomplete[:_LIST_DISPLAY_LIMIT], 1):
|
|
marker = " <<< ASSIGNED" if g.app_id == state.current_app_id else ""
|
|
hrs = f" [{g.completionist_hours:.0f}h]" if g.completionist_hours > 0 else ""
|
|
pct = f"{g.completion_pct:.0f}%"
|
|
_echo(f" {i:3d}. {g.name[:40]:<40s} {pct:>5s}{hrs}{marker}")
|
|
|
|
if len(incomplete) > _LIST_DISPLAY_LIMIT:
|
|
_echo(f" ... and {len(incomplete) - _LIST_DISPLAY_LIMIT} more")
|
|
|
|
_echo(f"\n COMPLETE: {len(complete)} games")
|
|
|
|
|
|
def cmd_unblock(_config: Config, _state: State) -> None:
|
|
"""Remove store blocking."""
|
|
if unblock_store():
|
|
_echo("Steam store unblocked.")
|
|
else:
|
|
_echo("Failed to unblock. Run with sudo.")
|
|
|
|
|
|
def cmd_buy_dlc(config: Config, state: State) -> None:
|
|
"""Temporarily unblock the store so the user can buy DLC."""
|
|
if state.current_app_id is None:
|
|
_echo("No game currently assigned.")
|
|
return
|
|
|
|
_echo(f"Current game: {state.current_game_name} (AppID={state.current_app_id})")
|
|
_echo("Unblocking Steam store for DLC purchase...")
|
|
|
|
if not unblock_store():
|
|
_echo("Failed to unblock store. Run with sudo.")
|
|
return
|
|
|
|
_echo("\nStore UNBLOCKED — buy your DLC now.")
|
|
_echo("Press Enter when you're done to re-block the store...")
|
|
input()
|
|
|
|
if config.block_store:
|
|
if block_store():
|
|
_echo("Store re-blocked. Restarting Steam to clear DNS cache...")
|
|
restart_steam()
|
|
_echo("Done.")
|
|
else:
|
|
_echo("Warning: failed to re-block store.")
|
|
|
|
|
|
def cmd_reset(config: Config, state: State) -> None:
|
|
"""Reset all state (unblock, unhide, clear assignment)."""
|
|
unblock_store()
|
|
|
|
# Unhide all games in the library.
|
|
try:
|
|
owned = get_all_owned_app_ids(config)
|
|
if owned:
|
|
count = unhide_all_games(owned)
|
|
if count:
|
|
_echo(f"Unhidden {count} games.")
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
_echo(f"Warning: could not unhide games: {exc}")
|
|
|
|
state.current_app_id = None
|
|
state.current_game_name = ""
|
|
state.finished_app_ids = []
|
|
state.save()
|
|
_echo("State reset. Store unblocked.")
|
|
|
|
|
|
def cmd_installed(_config: Config, state: State) -> None:
|
|
"""Show installed games."""
|
|
installed = get_installed_games()
|
|
_echo(f"\nInstalled games ({len(installed)}):\n")
|
|
for app_id, name in installed:
|
|
protected = " [PROTECTED]" if is_protected_app(app_id) else ""
|
|
assigned = " <<< ASSIGNED" if app_id == state.current_app_id else ""
|
|
_echo(f" {app_id:>8d} {name}{protected}{assigned}")
|
|
|
|
|
|
def cmd_uninstall(_config: Config, state: State) -> None:
|
|
"""Uninstall all games except the assigned one."""
|
|
if state.current_app_id is None:
|
|
_echo("No game assigned. Run 'scan' first.")
|
|
return
|
|
|
|
installed = get_installed_games()
|
|
to_remove = [
|
|
(aid, n)
|
|
for aid, n in installed
|
|
if aid != state.current_app_id and not is_protected_app(aid)
|
|
]
|
|
|
|
if not to_remove:
|
|
_echo("No games to uninstall (only assigned game and runtimes installed).")
|
|
return
|
|
|
|
_echo(f"\nWill uninstall {len(to_remove)} games, keeping:")
|
|
_echo(f" - {state.current_game_name} (AppID={state.current_app_id})")
|
|
_echo(" - Steam runtimes and Proton versions\n")
|
|
_echo("Games to remove:")
|
|
for aid, name in to_remove:
|
|
_echo(f" - {name} (AppID={aid})")
|
|
|
|
_echo()
|
|
confirm = input("Type YES to confirm: ").strip()
|
|
if confirm != "YES":
|
|
_echo("Aborted.")
|
|
return
|
|
|
|
count = uninstall_other_games(state.current_app_id)
|
|
_echo(f"\nUninstalled {count} games.")
|
|
|
|
|
|
def cmd_setup(_config: Config, _state: State) -> None:
|
|
"""Run interactive setup."""
|
|
interactive_setup()
|
|
|
|
|
|
_MIN_ADD_EXCEPTION_ARGS = 3
|
|
_ADD_EXCEPTION_USAGE = (
|
|
'Usage: add-exception <app_id> --reason "<justification>"\n'
|
|
" app_id : numeric Steam application ID\n"
|
|
" --reason : genuine justification (>= 5 words)\n\n"
|
|
"Example:\n"
|
|
" add-exception 440 --reason "
|
|
'"TF2 is needed for a community event this weekend"\n\n'
|
|
f"Exceptions become active after a {WHITELIST_COOLDOWN_SECONDS // 3600}h "
|
|
"cooldown."
|
|
)
|
|
|
|
|
|
def cmd_add_exception(args: list[str]) -> None:
|
|
"""Request a time-locked whitelist exception.
|
|
|
|
Usage: add-exception <app_id> --reason "<text>"
|
|
|
|
The exception becomes active after a 24-hour cooldown. The reason must be
|
|
a genuine justification of at least 5 words with sufficient entropy.
|
|
|
|
Args:
|
|
args: CLI argument list after the command name.
|
|
"""
|
|
if len(args) < _MIN_ADD_EXCEPTION_ARGS or "--reason" not in args:
|
|
_echo(_ADD_EXCEPTION_USAGE)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
app_id = int(args[0])
|
|
except ValueError:
|
|
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
|
|
sys.exit(1)
|
|
|
|
reason_idx = args.index("--reason")
|
|
reason_parts = args[reason_idx + 1 :]
|
|
if not reason_parts:
|
|
_echo("Error: --reason requires a value.")
|
|
sys.exit(1)
|
|
reason = " ".join(reason_parts)
|
|
|
|
# Show validation feedback before attempting to add.
|
|
err = validate_reason(reason)
|
|
if err is not None:
|
|
_echo(f"Invalid reason: {err}")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
msg = add_pending_exception(app_id, reason)
|
|
except ValueError as exc:
|
|
_echo(f"Error: {exc}")
|
|
sys.exit(1)
|
|
|
|
_echo(msg)
|
|
|
|
# Show current pending list.
|
|
pending = list_pending_exceptions()
|
|
if pending:
|
|
_echo(f"\nPending exceptions ({len(pending)}):")
|
|
now = time.time()
|
|
for entry in pending:
|
|
aid = int(entry["app_id"])
|
|
elapsed = now - float(entry["requested_at"])
|
|
remaining = max(0.0, WHITELIST_COOLDOWN_SECONDS - elapsed)
|
|
hrs = int(remaining // 3600)
|
|
mins = int((remaining % 3600) // 60)
|
|
status = "ready" if remaining == 0.0 else f"approves in {hrs}h {mins}m"
|
|
_echo(f" AppID={aid} [{status}]")
|
|
|
|
|
|
def cmd_install(config: Config, state: State) -> None:
|
|
"""Manually trigger install of the assigned game."""
|
|
if state.current_app_id is None:
|
|
_echo("No game currently assigned. Run 'scan' first.")
|
|
return
|
|
|
|
if is_game_installed(state.current_app_id):
|
|
_echo(f"{state.current_game_name} is already installed.")
|
|
return
|
|
|
|
_echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...")
|
|
if install_game(
|
|
state.current_app_id,
|
|
state.current_game_name,
|
|
config.steam_id,
|
|
use_steam_protocol=True,
|
|
):
|
|
_echo("Done!")
|
|
else:
|
|
_echo("Failed to create install manifest.")
|
|
|
|
|
|
def cmd_hide(config: Config, state: State) -> None:
|
|
"""Hide all non-assigned games in the Steam library."""
|
|
if state.current_app_id is None:
|
|
_echo("No game assigned. Run 'scan' first.")
|
|
return
|
|
|
|
owned_ids = get_all_owned_app_ids(config)
|
|
if not owned_ids:
|
|
_echo("No owned game list available. Run 'scan' first.")
|
|
return
|
|
|
|
_echo(f"Hiding all games except {state.current_game_name}...")
|
|
hidden = hide_other_games(owned_ids, state.current_app_id)
|
|
_echo(f"Hidden {hidden} games.")
|
|
|
|
if hidden > 0:
|
|
_echo("Done! Only the assigned game should be visible in your library.")
|
|
|
|
|
|
def cmd_unhide(config: Config, _state: State) -> None:
|
|
"""Unhide all games in the Steam library."""
|
|
owned_ids = get_all_owned_app_ids(config)
|
|
if not owned_ids:
|
|
_echo("No owned game list available. Run 'scan' first.")
|
|
return
|
|
|
|
_echo("Unhiding all games...")
|
|
count = unhide_all_games(owned_ids)
|
|
_echo(f"Unhidden {count} games.")
|
|
|
|
if count > 0:
|
|
_echo("Done!")
|
|
|
|
|
|
def cmd_pick(config: Config, state: State) -> None:
|
|
"""Manually pick a new game from the shortest-first candidate list."""
|
|
snapshot_data = load_snapshot()
|
|
if not snapshot_data:
|
|
_echo("No snapshot found. Run 'scan' first.")
|
|
return
|
|
|
|
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
|
|
hltb_cache = load_hltb_cache()
|
|
for game in games:
|
|
if game.app_id in hltb_cache:
|
|
game.completionist_hours = hltb_cache[game.app_id]
|
|
|
|
pick_next_game(games, state, config)
|
|
|
|
if state.current_app_id is not None:
|
|
owned_ids = get_all_owned_app_ids(config)
|
|
if owned_ids:
|
|
hidden = hide_other_games(owned_ids, state.current_app_id)
|
|
if hidden > 0:
|
|
_echo(f"\n Library: hid {hidden} games")
|
|
|
|
|
|
COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = {
|
|
"scan": ("Scan library & assign a game", do_scan),
|
|
"check": ("Check assigned game completion", do_check),
|
|
"status": ("Show current status", cmd_status),
|
|
"list": ("List games from snapshot", cmd_list),
|
|
"enforce": ("Run enforcer: block, uninstall, kill, hide", do_enforce),
|
|
"install": ("Install the assigned game", cmd_install),
|
|
"hide": ("Hide all non-assigned games in library", cmd_hide),
|
|
"unhide": ("Unhide all games in library", cmd_unhide),
|
|
"unblock": ("Remove store blocking", cmd_unblock),
|
|
"buy-dlc": ("Temporarily unblock store to buy DLC", cmd_buy_dlc),
|
|
"reset": ("Reset all state", cmd_reset),
|
|
"installed": ("List installed games", cmd_installed),
|
|
"uninstall": ("Uninstall all non-assigned games", cmd_uninstall),
|
|
"setup": ("Run first-time setup", cmd_setup),
|
|
"done": ("Finish game, open HLTB, pick next", cmd_done),
|
|
"pick": ("Manually pick your next game from candidates", cmd_pick),
|
|
}
|
|
|
|
# Extra commands with non-standard arg handling (shown in help but not in COMMANDS).
|
|
_EXTRA_COMMAND_DESCRIPTIONS: dict[str, str] = {
|
|
"add-exception": "Request 24h-locked whitelist exception (use --reason)",
|
|
}
|
|
|
|
_ALL_COMMANDS: dict[str, str] = {
|
|
name: desc for name, (desc, _) in COMMANDS.items()
|
|
} | _EXTRA_COMMAND_DESCRIPTIONS
|
|
|
|
|
|
def main() -> None:
|
|
"""CLI entry point."""
|
|
if len(sys.argv) < _MIN_CLI_ARGS or sys.argv[1] not in _ALL_COMMANDS:
|
|
_echo("Steam Backlog Enforcer\n")
|
|
_echo("Usage: python -m python_pkg.steam_backlog_enforcer.main <command>\n")
|
|
_echo("Commands:")
|
|
for name, desc in _ALL_COMMANDS.items():
|
|
_echo(f" {name:<14s} {desc}")
|
|
sys.exit(1)
|
|
|
|
command = sys.argv[1]
|
|
|
|
# add-exception has its own argument structure; handle before config load.
|
|
if command == "add-exception":
|
|
cmd_add_exception(sys.argv[2:])
|
|
return
|
|
|
|
config = Config.load()
|
|
|
|
if command != "setup" and not config.steam_api_key:
|
|
_echo("Not configured. Run 'setup' first.")
|
|
sys.exit(1)
|
|
|
|
state = State.load()
|
|
_, func = COMMANDS[command]
|
|
func(config, state)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|