mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:27:03 +01:00
Install games with steam-game-installer (depotdownloader) for speed, falling back to steam://install
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
30fcaacc5d
commit
f1c8f04287
@ -20,6 +20,7 @@ from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached
|
||||
from steam_backlog_enforcer._hltb_types import load_hltb_cache
|
||||
from steam_backlog_enforcer._pick_completion import mark_finished
|
||||
from steam_backlog_enforcer._snapshot import load_snapshot
|
||||
from steam_backlog_enforcer._steam_state import is_game_fully_installed
|
||||
from steam_backlog_enforcer.enforcer import (
|
||||
enforce_allowed_game,
|
||||
send_notification,
|
||||
@ -27,7 +28,6 @@ from steam_backlog_enforcer.enforcer import (
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
install_game,
|
||||
is_game_installed,
|
||||
uninstall_other_games,
|
||||
)
|
||||
from steam_backlog_enforcer.library_hider import hide_others_and_report
|
||||
@ -74,7 +74,7 @@ def _finalize_completion(
|
||||
|
||||
hide_others_and_report(config, state)
|
||||
|
||||
if not is_game_installed(state.current_app_id):
|
||||
if not is_game_fully_installed(state.current_app_id):
|
||||
logger.info(
|
||||
"Assigned game still missing after library reconciliation; "
|
||||
"re-triggering install"
|
||||
@ -120,7 +120,7 @@ def _enforce_on_done(config: Config, state: State) -> None:
|
||||
if count:
|
||||
_echo(f" Uninstalled {count} unauthorized game(s)")
|
||||
|
||||
if not is_game_installed(state.current_app_id):
|
||||
if not is_game_fully_installed(state.current_app_id):
|
||||
_echo(f" Re-installing {state.current_game_name}...")
|
||||
install_game(
|
||||
state.current_app_id,
|
||||
|
||||
@ -12,9 +12,13 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
|
||||
from steam_backlog_enforcer._echo import _echo
|
||||
from steam_backlog_enforcer._fast_install import poll_fast_installs
|
||||
from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer._steam_client import is_game_installed
|
||||
from steam_backlog_enforcer._steam_state import steam_library_ready
|
||||
from steam_backlog_enforcer._steam_state import (
|
||||
is_game_fully_installed,
|
||||
steam_library_ready,
|
||||
)
|
||||
from steam_backlog_enforcer.enforcer import send_notification
|
||||
from steam_backlog_enforcer.game_install import install_game
|
||||
from steam_backlog_enforcer.game_uninstall import uninstall_other_games
|
||||
@ -41,6 +45,9 @@ def _reinstall_missing_allowed(config: Config, state: State) -> None:
|
||||
if not steam_library_ready():
|
||||
return
|
||||
|
||||
# Reap/stall-check a detached fast install every pass: the game it is
|
||||
# installing may already have a manifest, so install_game never runs.
|
||||
poll_fast_installs()
|
||||
for app_id, name in allowed_games(state):
|
||||
if is_game_installed(app_id):
|
||||
continue
|
||||
@ -83,7 +90,7 @@ def _enforce_auto_install(config: Config, state: State) -> None:
|
||||
state: Current enforcer state.
|
||||
"""
|
||||
for app_id, name in allowed_games(state):
|
||||
if is_game_installed(app_id):
|
||||
if is_game_fully_installed(app_id):
|
||||
_echo(f" Allowed game already installed: {name}")
|
||||
continue
|
||||
_echo(f" Auto-installing {name}...")
|
||||
|
||||
237
steam_backlog_enforcer/_fast_install.py
Normal file
237
steam_backlog_enforcer/_fast_install.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""Installing games through steam-game-installer, for download speed.
|
||||
|
||||
depotdownloader saturates the link far better than the Steam client, and
|
||||
steam-game-installer (``~/src/steam-game-installer``) wraps it so Steam accepts
|
||||
the result as a real install. It also adopts whatever Steam already staged, so
|
||||
taking over a slow client download loses nothing.
|
||||
|
||||
Two modes, picked by whether anyone can answer a prompt:
|
||||
|
||||
* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run to
|
||||
completion with the terminal attached, so depotdownloader's progress shows
|
||||
and a password / Steam Guard prompt can be answered.
|
||||
* **Detached** (the daemon): spawn in its own session and return. Every later
|
||||
pass reaps it through :func:`poll_fast_installs`, which records a failure (so
|
||||
the caller falls back to ``steam://install``) and interrupts a run whose
|
||||
output has stalled - most likely a login prompt nobody will ever answer,
|
||||
sitting there with Steam shut down.
|
||||
|
||||
The installer closes Steam to do its work, so it is refused outright while a
|
||||
game is running or another app is mid-update (the AoE2 corruption case).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from steam_backlog_enforcer import config as _config
|
||||
from steam_backlog_enforcer._desktop_env import desktop_user_cmd, resolve_desktop_user
|
||||
from steam_backlog_enforcer._echo import _echo
|
||||
from steam_backlog_enforcer._steam_restart_guard import game_is_running
|
||||
from steam_backlog_enforcer._steam_state import (
|
||||
STEAMAPPS_PATH,
|
||||
steam_update_in_progress,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INSTALLER = Path("src", "steam-game-installer", "run.sh")
|
||||
_INSTALLER_LOG = "~/.local/state/steam-game-installer/latest.log"
|
||||
# Depot content only: the app itself is ours to choose, the language is not
|
||||
# worth a prompt nobody is there to answer.
|
||||
_LANGUAGE = "english"
|
||||
# depotdownloader prints a line per finished chunk, so a healthy download is
|
||||
# never silent this long; a login prompt waiting on /dev/null is.
|
||||
STALL_SECONDS = 15 * 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DetachedRun:
|
||||
"""A daemon-spawned installer run, kept so it can be reaped."""
|
||||
|
||||
app_id: int
|
||||
proc: subprocess.Popen[bytes]
|
||||
log_path: Path
|
||||
|
||||
|
||||
_RUNS: list[_DetachedRun] = []
|
||||
# Apps whose installer run failed in this process. They go straight to the
|
||||
# steam:// fallback from then on, so a broken login cannot close Steam on
|
||||
# every 3-second pass.
|
||||
_FAILED: set[int] = set()
|
||||
|
||||
|
||||
def _installer_path(user: str | None) -> Path | None:
|
||||
"""steam-game-installer's ``run.sh`` in the desktop user's home, if present.
|
||||
|
||||
Resolved from the passwd entry rather than ``Path.home()``: the daemon runs
|
||||
as root, and the installer, its venv and its credentials are per-user.
|
||||
"""
|
||||
try:
|
||||
home = Path(pwd.getpwnam(user).pw_dir) if user else Path.home()
|
||||
except KeyError:
|
||||
return None
|
||||
path = home / _INSTALLER
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _steam_account_name() -> str | None:
|
||||
"""The account Steam itself last signed in with, from ``loginusers.vdf``.
|
||||
|
||||
Read from Steam's own state rather than a copy in the enforcer config, so
|
||||
it cannot drift from the account that actually owns the games.
|
||||
"""
|
||||
path = STEAMAPPS_PATH.parent / "config" / "loginusers.vdf"
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
match = re.search(r'"AccountName"\s+"([^"]+)"', content)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _refusal(app_id: int) -> str | None:
|
||||
"""Why closing Steam right now would do damage, or None if it is safe."""
|
||||
if game_is_running():
|
||||
return "a game is running and closing Steam would kill it"
|
||||
if steam_update_in_progress(exclude_app_id=app_id):
|
||||
return (
|
||||
"another Steam download is in progress and closing Steam could corrupt it"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _build_cmd(installer: Path, app_id: int, user: str | None) -> list[str]:
|
||||
"""The installer invocation, dropped to the desktop user when root."""
|
||||
cmd = [
|
||||
str(installer),
|
||||
# install.sh reaches for yay; dependencies are installed once, by hand.
|
||||
"--no-deps",
|
||||
"--appid",
|
||||
str(app_id),
|
||||
"--yes",
|
||||
"--language",
|
||||
_LANGUAGE,
|
||||
# Finishes the manifest from depotdownloader's own data, under the same
|
||||
# fail-closed checks: no second login, no 70 GB re-hash by steamcmd.
|
||||
"--skip-steamcmd",
|
||||
]
|
||||
account = _steam_account_name()
|
||||
if account:
|
||||
cmd += ["--username", account]
|
||||
return desktop_user_cmd(cmd, user)
|
||||
|
||||
|
||||
def is_fast_installing(app_id: int) -> bool:
|
||||
"""Whether this process has a live detached installer run for *app_id*."""
|
||||
return any(run.app_id == app_id for run in _RUNS)
|
||||
|
||||
|
||||
def _stalled(run: _DetachedRun) -> bool:
|
||||
"""Whether a live run has printed nothing for :data:`STALL_SECONDS`."""
|
||||
try:
|
||||
return time.time() - run.log_path.stat().st_mtime > STALL_SECONDS
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def poll_fast_installs() -> None:
|
||||
"""Reap finished detached runs and interrupt stalled ones.
|
||||
|
||||
SIGINT, not SIGTERM: the installer turns it into KeyboardInterrupt, and its
|
||||
``finally`` restarts the Steam client it shut down. SIGTERM would leave
|
||||
Steam closed.
|
||||
"""
|
||||
for run in list(_RUNS):
|
||||
returncode = run.proc.poll()
|
||||
if returncode is None:
|
||||
if _stalled(run):
|
||||
logger.warning(
|
||||
"steam-game-installer for AppID=%d silent for %ds — "
|
||||
"interrupting (see %s).",
|
||||
run.app_id,
|
||||
STALL_SECONDS,
|
||||
run.log_path,
|
||||
)
|
||||
os.killpg(run.proc.pid, signal.SIGINT)
|
||||
continue
|
||||
_RUNS.remove(run)
|
||||
if returncode == 0:
|
||||
logger.info("steam-game-installer finished AppID=%d.", run.app_id)
|
||||
continue
|
||||
_FAILED.add(run.app_id)
|
||||
logger.warning(
|
||||
"steam-game-installer failed for AppID=%d (exit %d); falling back "
|
||||
"to Steam's own download. Log: %s",
|
||||
run.app_id,
|
||||
returncode,
|
||||
run.log_path,
|
||||
)
|
||||
|
||||
|
||||
def _spawn_detached(cmd: list[str], app_id: int) -> None:
|
||||
"""Start the installer in its own session, output to a log file."""
|
||||
log_path = _config.CONFIG_DIR / "fast_install.log"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# The unit is KillMode=control-group: restarting the daemon would SIGTERM
|
||||
# an installer left in its cgroup, skipping the `finally` that restarts
|
||||
# Steam. A transient scope moves it out; --scope execs in place, so the
|
||||
# Popen pid below is still the session leader killpg needs.
|
||||
if os.geteuid() == 0 and shutil.which("systemd-run"):
|
||||
cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd]
|
||||
with log_path.open("ab") as log:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
_RUNS.append(_DetachedRun(app_id, proc, log_path))
|
||||
logger.info("Started steam-game-installer for AppID=%d (log: %s)", app_id, log_path)
|
||||
|
||||
|
||||
def fast_install(app_id: int, label: str) -> bool:
|
||||
"""Install *app_id* with steam-game-installer.
|
||||
|
||||
Returns:
|
||||
True if the game was installed (foreground) or the run was started
|
||||
(detached); False if the caller should fall back to Steam's download.
|
||||
"""
|
||||
if app_id in _FAILED:
|
||||
return False
|
||||
user = resolve_desktop_user()
|
||||
installer = _installer_path(user)
|
||||
if installer is None:
|
||||
logger.warning("steam-game-installer not found; using Steam's download.")
|
||||
return False
|
||||
reason = _refusal(app_id)
|
||||
if reason is not None:
|
||||
_echo(f" Fast install of {label} skipped: {reason}.")
|
||||
return False
|
||||
|
||||
cmd = _build_cmd(installer, app_id, user)
|
||||
if not sys.stdin.isatty():
|
||||
_spawn_detached(cmd, app_id)
|
||||
return True
|
||||
|
||||
_echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...")
|
||||
returncode = subprocess.run(cmd, check=False).returncode
|
||||
if returncode == 0:
|
||||
return True
|
||||
_FAILED.add(app_id)
|
||||
_echo(
|
||||
f" steam-game-installer failed (exit {returncode}); falling back to "
|
||||
f"Steam's own download. Log: {_INSTALLER_LOG}"
|
||||
)
|
||||
return False
|
||||
@ -19,10 +19,10 @@ from steam_backlog_enforcer._scanning_confidence import (
|
||||
_apply_cached_confidence_to_candidates,
|
||||
_report_poll_confidence,
|
||||
)
|
||||
from steam_backlog_enforcer._steam_state import is_game_fully_installed
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
install_game,
|
||||
is_game_installed,
|
||||
uninstall_other_games,
|
||||
)
|
||||
|
||||
@ -93,7 +93,7 @@ def _assign_chosen_game(
|
||||
count = uninstall_other_games(allowed_app_ids(state))
|
||||
if count:
|
||||
_echo(f"\n Uninstalled {count} non-assigned games")
|
||||
if not is_game_installed(chosen.app_id):
|
||||
if not is_game_fully_installed(chosen.app_id):
|
||||
_echo(f"\n Auto-installing {chosen.name}...")
|
||||
install_game(
|
||||
chosen.app_id, chosen.name, config.steam_id, use_steam_protocol=True
|
||||
|
||||
@ -21,6 +21,7 @@ from steam_backlog_enforcer._desktop_env import (
|
||||
)
|
||||
from steam_backlog_enforcer._steam_launch import steam_is_installed
|
||||
from steam_backlog_enforcer._steam_process import spawn_detached
|
||||
from steam_backlog_enforcer._steam_restart_guard import fast_install_running
|
||||
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -65,6 +66,9 @@ def _ensure_steam_running() -> None:
|
||||
if not steam_is_installed():
|
||||
logger.info("Steam is not installed — skipping client start.")
|
||||
return
|
||||
if fast_install_running():
|
||||
logger.info("steam-game-installer is running — leaving Steam closed.")
|
||||
return
|
||||
|
||||
# Check if any steam process is running (main client, not just helpers).
|
||||
try:
|
||||
|
||||
@ -50,3 +50,13 @@ class GameInProgressError(SteamUnavailableError):
|
||||
unrelated change killed a live session. Library hiding can wait; a player's
|
||||
afternoon cannot be given back.
|
||||
"""
|
||||
|
||||
|
||||
class FastInstallInProgressError(SteamUnavailableError):
|
||||
"""Raised to keep Steam closed while steam-game-installer is running.
|
||||
|
||||
The installer shuts Steam down because a live client owns
|
||||
``appmanifest_<appid>.acf`` and rewrites it from memory. A Steam started
|
||||
underneath it discards the manifest the installer is about to write and
|
||||
resumes the slow client download - so every launch path defers instead.
|
||||
"""
|
||||
|
||||
@ -31,7 +31,9 @@ from steam_backlog_enforcer._steam_errors import (
|
||||
)
|
||||
from steam_backlog_enforcer._steam_process import _run_as_user
|
||||
from steam_backlog_enforcer._steam_restart_guard import (
|
||||
assert_no_fast_install,
|
||||
assert_safe_to_restart,
|
||||
fast_install_running,
|
||||
game_is_running,
|
||||
)
|
||||
from steam_backlog_enforcer._steam_state import steam_update_in_progress
|
||||
@ -55,10 +57,6 @@ def steam_is_installed() -> bool:
|
||||
return Path(_STEAM_BINARY).exists()
|
||||
|
||||
|
||||
# Handles for fire-and-forget launches, kept only so they can be reaped.
|
||||
_SPAWNED: list[subprocess.Popen[bytes]] = []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Ensure Steam is running with devtools port
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@ -170,6 +168,7 @@ def ensure_steam_debug_port() -> None:
|
||||
SteamUnavailableError: If Steam is not installed, or is installed but
|
||||
never opens its debug port.
|
||||
"""
|
||||
assert_no_fast_install()
|
||||
if _steam_has_debug_port():
|
||||
logger.debug("Steam CDP port already available.")
|
||||
return
|
||||
@ -231,10 +230,10 @@ def restart_steam() -> None:
|
||||
)
|
||||
return
|
||||
|
||||
if steam_update_in_progress():
|
||||
if steam_update_in_progress() or fast_install_running():
|
||||
logger.warning(
|
||||
"Skipping Steam restart — a game update is in progress; "
|
||||
"restarting now could corrupt it.",
|
||||
"Skipping Steam restart — a game update or fast install is in "
|
||||
"progress; restarting now could corrupt it.",
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@ -12,8 +12,10 @@ Split out of :mod:`_steam_launch` to keep it under the 250-line cap, and because
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from steam_backlog_enforcer._steam_errors import (
|
||||
FastInstallInProgressError,
|
||||
GameInProgressError,
|
||||
SteamUpdateInProgressError,
|
||||
)
|
||||
@ -64,3 +66,40 @@ def assert_safe_to_restart() -> None:
|
||||
)
|
||||
logger.info(msg)
|
||||
raise SteamUpdateInProgressError(msg)
|
||||
|
||||
|
||||
# Matched against /proc cmdlines, so a run started by hand counts exactly like
|
||||
# one the enforcer spawned. run.sh as well as the module it execs: the sudo
|
||||
# and bash wrappers exist the moment Popen returns, the Python process only a
|
||||
# moment later, and a Steam launch in that gap would race the shutdown.
|
||||
_INSTALLER_MARKERS = (b"steam-game-installer/run.sh", b"steam_installer.cli")
|
||||
|
||||
|
||||
def fast_install_running() -> bool:
|
||||
"""Whether a steam-game-installer run is live (and so owns Steam's state).
|
||||
|
||||
Returns:
|
||||
True if any process is running the installer's CLI module.
|
||||
"""
|
||||
for entry in Path("/proc").iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmdline = (entry / "cmdline").read_bytes()
|
||||
except OSError:
|
||||
continue
|
||||
if any(marker in cmdline for marker in _INSTALLER_MARKERS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def assert_no_fast_install() -> None:
|
||||
"""Refuse to launch Steam while steam-game-installer holds it closed.
|
||||
|
||||
Raises:
|
||||
FastInstallInProgressError: If an installer run is live.
|
||||
"""
|
||||
if fast_install_running():
|
||||
msg = "Deferring Steam launch: steam-game-installer is running."
|
||||
logger.info(msg)
|
||||
raise FastInstallInProgressError(msg)
|
||||
|
||||
@ -79,7 +79,7 @@ def _manifest_transfer_active(manifest: Path) -> bool:
|
||||
) > _field("BytesStaged")
|
||||
|
||||
|
||||
def steam_update_in_progress() -> bool:
|
||||
def steam_update_in_progress(exclude_app_id: int | None = None) -> bool:
|
||||
"""Return True if any installed Steam app is mid-update.
|
||||
|
||||
Steam records live transfer progress in each ``appmanifest_*.acf``: a game
|
||||
@ -90,11 +90,40 @@ def steam_update_in_progress() -> bool:
|
||||
failure that corrupted Age of Empires II DE into a deterministic launch
|
||||
crash).
|
||||
|
||||
Args:
|
||||
exclude_app_id: An app whose own transfer does not count - the one a
|
||||
fast install is about to take over.
|
||||
|
||||
Returns:
|
||||
True if at least one appmanifest shows an unfinished download/stage.
|
||||
"""
|
||||
skip = f"appmanifest_{exclude_app_id}.acf"
|
||||
try:
|
||||
manifests = list(STEAMAPPS_PATH.glob("appmanifest_*.acf"))
|
||||
except OSError:
|
||||
return False
|
||||
return any(_manifest_transfer_active(m) for m in manifests)
|
||||
return any(_manifest_transfer_active(m) for m in manifests if m.name != skip)
|
||||
|
||||
|
||||
def is_game_fully_installed(app_id: int) -> bool:
|
||||
"""Whether *app_id* is completely installed, not merely queued.
|
||||
|
||||
:func:`_steam_client.is_game_installed` counts any manifest, which is right
|
||||
for "has an install been triggered". This is the stricter question a fast
|
||||
install needs: a manifest Steam wrote for its own slow download (no
|
||||
FullyInstalled bit, or bytes still in flight) is work to take over.
|
||||
|
||||
Args:
|
||||
app_id: Steam application ID.
|
||||
|
||||
Returns:
|
||||
True if the manifest has StateFlags bit 4 and no transfer in flight.
|
||||
"""
|
||||
manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
|
||||
try:
|
||||
content = manifest.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
match = re.search(r'"StateFlags"\s+"(\d+)"', content)
|
||||
flags = int(match.group(1)) if match else 0
|
||||
return bool(flags & 4) and not _manifest_transfer_active(manifest)
|
||||
|
||||
@ -13,14 +13,21 @@ from steam_backlog_enforcer._desktop_env import (
|
||||
desktop_user_cmd,
|
||||
)
|
||||
from steam_backlog_enforcer._echo import _echo
|
||||
from steam_backlog_enforcer._fast_install import (
|
||||
fast_install,
|
||||
is_fast_installing,
|
||||
poll_fast_installs,
|
||||
)
|
||||
from steam_backlog_enforcer._steam_client import (
|
||||
_ensure_steam_running,
|
||||
_get_real_user,
|
||||
_get_uid_gid_for_user,
|
||||
is_game_installed,
|
||||
)
|
||||
from steam_backlog_enforcer._steam_restart_guard import fast_install_running
|
||||
from steam_backlog_enforcer._steam_state import (
|
||||
STEAMAPPS_PATH,
|
||||
is_game_fully_installed,
|
||||
steam_library_ready,
|
||||
)
|
||||
from steam_backlog_enforcer.game_uninstall import (
|
||||
@ -49,22 +56,6 @@ __all__ = [
|
||||
# rather than once per game per pass.
|
||||
_LIBRARY_WARNED: set[str] = set()
|
||||
|
||||
_UNINSTALL_EXPORTS = frozenset(
|
||||
{
|
||||
"get_installed_games",
|
||||
"is_protected_app",
|
||||
"uninstall_game",
|
||||
"uninstall_other_games",
|
||||
}
|
||||
)
|
||||
|
||||
# Folder-name safety net for _remove_game_dirs. Independent of the app-id
|
||||
# gating callers already do (uninstall_other_games skips allowed app ids) --
|
||||
# this protects against deleting the *wrong* directory for an allowed game
|
||||
# when its name has been written inconsistently (e.g. "KingdomComeDeliverance2"
|
||||
# vs "Kingdom Come: Deliverance II" vs a typo'd variant), which is exactly the
|
||||
# kind of multi-name confusion that caused real data loss once already.
|
||||
|
||||
|
||||
def _trigger_steam_install(app_id: int, label: str) -> bool:
|
||||
"""Ask Steam to install a game via the ``steam://install`` URI.
|
||||
@ -98,6 +89,48 @@ def _trigger_steam_install(app_id: int, label: str) -> bool:
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _install_precheck(app_id: int, label: str) -> bool | None:
|
||||
"""Settle an install without starting one, where that is possible.
|
||||
|
||||
Returns:
|
||||
True if the game is installed or already being fast-installed, False
|
||||
if no install may start right now, None to go ahead and install.
|
||||
"""
|
||||
# Re-arm the one-shot warning before any early return, so a library that
|
||||
# comes back (Steam finally signed in) is reported again if it vanishes.
|
||||
if steam_library_ready():
|
||||
_LIBRARY_WARNED.discard("missing")
|
||||
|
||||
poll_fast_installs()
|
||||
if is_game_fully_installed(app_id):
|
||||
logger.info("Game already installed: %s", label)
|
||||
return True
|
||||
|
||||
# A live installer holds Steam closed; steam:// would reopen it underneath
|
||||
# the manifest the installer is about to write. Wait for it to finish.
|
||||
if fast_install_running():
|
||||
if is_fast_installing(app_id):
|
||||
return True
|
||||
logger.info("Deferring install of %s: steam-game-installer busy.", label)
|
||||
return False
|
||||
|
||||
# No library means no install can succeed — neither the steam:// handler
|
||||
# nor the appmanifest write below. Without this the enforce loop retried
|
||||
# every allowed game every pass (measured: 1656 times in 30 minutes).
|
||||
if not steam_library_ready():
|
||||
if "missing" not in _LIBRARY_WARNED:
|
||||
logger.warning(
|
||||
"Steam library not initialised (%s missing) — skipping installs "
|
||||
"until Steam has been signed in to at least once.",
|
||||
STEAMAPPS_PATH,
|
||||
)
|
||||
_LIBRARY_WARNED.add("missing")
|
||||
else:
|
||||
logger.debug("Skipping install of %s: no Steam library.", label)
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def install_game(
|
||||
app_id: int,
|
||||
game_name: str,
|
||||
@ -105,7 +138,10 @@ def install_game(
|
||||
*,
|
||||
use_steam_protocol: bool = False,
|
||||
) -> bool:
|
||||
"""Install a game by triggering a Steam download.
|
||||
"""Install a game, through steam-game-installer when it can be used.
|
||||
|
||||
The fast path (:mod:`_fast_install`) is always tried first; everything
|
||||
below it is the fallback for when it is refused or fails.
|
||||
|
||||
When *use_steam_protocol* is True the ``steam://install`` URI handler
|
||||
is used, which lets Steam determine the correct install directory from
|
||||
@ -128,30 +164,16 @@ def install_game(
|
||||
Returns True if the install was triggered successfully.
|
||||
"""
|
||||
label = game_name or f"AppID={app_id}"
|
||||
ready = _install_precheck(app_id, label)
|
||||
if ready is not None:
|
||||
return ready
|
||||
|
||||
# Re-arm the one-shot warning before any early return, so a library that
|
||||
# comes back (Steam finally signed in) is reported again if it vanishes.
|
||||
if steam_library_ready():
|
||||
_LIBRARY_WARNED.discard("missing")
|
||||
|
||||
if is_game_installed(app_id):
|
||||
logger.info("Game already installed: %s", label)
|
||||
if fast_install(app_id, label):
|
||||
return True
|
||||
# Steam already has a download of its own going: let it carry on, rather
|
||||
# than overwrite its manifest with the fabricated one below.
|
||||
if is_game_installed(app_id):
|
||||
return True
|
||||
|
||||
# No library means no install can succeed — neither the steam:// handler
|
||||
# nor the appmanifest write below. Without this the enforce loop retried
|
||||
# every allowed game every pass (measured: 1656 times in 30 minutes).
|
||||
if not steam_library_ready():
|
||||
if "missing" not in _LIBRARY_WARNED:
|
||||
logger.warning(
|
||||
"Steam library not initialised (%s missing) — skipping installs "
|
||||
"until Steam has been signed in to at least once.",
|
||||
STEAMAPPS_PATH,
|
||||
)
|
||||
_LIBRARY_WARNED.add("missing")
|
||||
else:
|
||||
logger.debug("Skipping install of %s: no Steam library.", label)
|
||||
return False
|
||||
|
||||
if use_steam_protocol:
|
||||
_ensure_steam_running()
|
||||
|
||||
@ -95,6 +95,10 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool:
|
||||
success = True
|
||||
if install_dir and install_dir.is_dir():
|
||||
_assert_not_real_steam(install_dir)
|
||||
# Folder-name safety net, independent of the app-id gating callers
|
||||
# already do: it stops deleting the *wrong* directory for an allowed
|
||||
# game whose name was written inconsistently ("KingdomComeDeliverance2"
|
||||
# vs "Kingdom Come: Deliverance II"), which caused real data loss once.
|
||||
if _is_protected_name(install_dir.name):
|
||||
logger.warning(
|
||||
"Refusing to remove %s: name matches an allowed game", install_dir
|
||||
|
||||
@ -6,11 +6,11 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
|
||||
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer._steam_state import is_game_fully_installed
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
get_installed_games,
|
||||
install_game,
|
||||
is_game_installed,
|
||||
is_protected_app,
|
||||
uninstall_other_games,
|
||||
)
|
||||
@ -75,7 +75,7 @@ def cmd_install(config: Config, state: State) -> None:
|
||||
_echo("No game currently assigned. Run 'scan' first.")
|
||||
return
|
||||
|
||||
if is_game_installed(state.current_app_id):
|
||||
if is_game_fully_installed(state.current_app_id):
|
||||
_echo(f"{state.current_game_name} is already installed.")
|
||||
return
|
||||
|
||||
|
||||
@ -21,10 +21,10 @@ from steam_backlog_enforcer._pick_completion import (
|
||||
warn_stale_assignment,
|
||||
)
|
||||
from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name
|
||||
from steam_backlog_enforcer._steam_state import is_game_fully_installed
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
install_game,
|
||||
is_game_installed,
|
||||
uninstall_other_games,
|
||||
)
|
||||
from steam_backlog_enforcer.library_hider import hide_others_and_report
|
||||
@ -130,7 +130,7 @@ def _apply_allowed_set(config: Config, state: State) -> None:
|
||||
_echo(f" Uninstalled {count} non-allowed game(s)")
|
||||
|
||||
for app_id, name in allowed_games(state):
|
||||
if is_game_installed(app_id):
|
||||
if is_game_fully_installed(app_id):
|
||||
_echo(f" {name} is already installed.")
|
||||
continue
|
||||
_echo(f" Installing {name}...")
|
||||
|
||||
56
steam_backlog_enforcer/tests/_no_fast_install.py
Normal file
56
steam_backlog_enforcer/tests/_no_fast_install.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""Autouse guard that keeps tests away from steam-game-installer and /proc.
|
||||
|
||||
Split out of ``conftest.py`` to keep it inside the 250-line cap; ``conftest``
|
||||
imports the fixture by name, which is what registers it.
|
||||
|
||||
The real installer shuts the user's Steam down, so no test may find it: the
|
||||
installer path is pointed at a file that does not exist. The "is an installer
|
||||
running" scan reads the real /proc, so it answers False unless a test says
|
||||
otherwise - and it is imported by name into several modules, so every binding
|
||||
is patched. Tests that exercise the real scan import the function directly,
|
||||
which captures it before this fixture runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _fast_install
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
_RUNNING_BINDINGS = (
|
||||
"steam_backlog_enforcer._steam_restart_guard.fast_install_running",
|
||||
"steam_backlog_enforcer.game_install.fast_install_running",
|
||||
"steam_backlog_enforcer._steam_client.fast_install_running",
|
||||
"steam_backlog_enforcer._steam_launch.fast_install_running",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
||||
"""Hide the real installer, fake the /proc scan, reset run bookkeeping."""
|
||||
fake_steamapps = tmp_path / "steamapps"
|
||||
fake_steamapps.mkdir(exist_ok=True)
|
||||
_fast_install._RUNS.clear()
|
||||
_fast_install._FAILED.clear()
|
||||
patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS]
|
||||
patches += [
|
||||
# Absolute, so `home / _INSTALLER` resolves here and never under ~.
|
||||
patch.object(_fast_install, "_INSTALLER", tmp_path / "no-installer" / "run.sh"),
|
||||
patch.object(_fast_install, "STEAMAPPS_PATH", fake_steamapps),
|
||||
]
|
||||
for p in patches:
|
||||
p.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
_fast_install._RUNS.clear()
|
||||
_fast_install._FAILED.clear()
|
||||
@ -91,6 +91,15 @@ def _block_real_subprocesses() -> Iterator[None]:
|
||||
"steam_backlog_enforcer._playtime_run.subprocess.run",
|
||||
noop_run,
|
||||
),
|
||||
# steam-game-installer closes the user's Steam; never reach it.
|
||||
patch(
|
||||
"steam_backlog_enforcer._fast_install.subprocess.run",
|
||||
noop_run,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._fast_install.subprocess.Popen",
|
||||
noop_popen,
|
||||
),
|
||||
# A real `npm run build` here would rewrite web/dist/index.html, which
|
||||
# then perturbs frontend_is_stale() for every later test in the run.
|
||||
patch(
|
||||
|
||||
@ -23,6 +23,7 @@ import pytest
|
||||
# Imported for its autouse side effect: naming it here registers it.
|
||||
from steam_backlog_enforcer.tests._isolate_incidents import _isolate_incidents
|
||||
from steam_backlog_enforcer.tests._isolate_playtime import _isolate_playtime
|
||||
from steam_backlog_enforcer.tests._no_fast_install import _no_fast_install
|
||||
from steam_backlog_enforcer.tests._no_subprocess import _block_real_subprocesses
|
||||
from steam_backlog_enforcer.tests._no_workout_http import _no_workout_http
|
||||
|
||||
@ -32,6 +33,7 @@ __all__ = [
|
||||
"_block_real_subprocesses",
|
||||
"_isolate_incidents",
|
||||
"_isolate_playtime",
|
||||
"_no_fast_install",
|
||||
"_no_workout_http",
|
||||
]
|
||||
|
||||
@ -81,8 +83,8 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
|
||||
fake_config / "snapshot.json",
|
||||
),
|
||||
# Steam game manifests / install dirs. STEAMAPPS_PATH is canonical in
|
||||
# _steam_state; game_install imports it, so both bindings need the
|
||||
# patch (same gotcha as HOSTS_FILE below).
|
||||
# _steam_state; game_install and _steam_client import it by name, so
|
||||
# every binding needs the patch (same gotcha as HOSTS_FILE below).
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_state.STEAMAPPS_PATH",
|
||||
fake_steamapps,
|
||||
@ -91,6 +93,10 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
|
||||
"steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
|
||||
fake_steamapps,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_client.STEAMAPPS_PATH",
|
||||
fake_steamapps,
|
||||
),
|
||||
# HLTB cache file (computed at import time from CONFIG_DIR, so
|
||||
# patching CONFIG_DIR alone does not redirect it)
|
||||
patch(
|
||||
|
||||
@ -124,7 +124,7 @@ class TestEnforceAutoInstall:
|
||||
def test_already_installed(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(f"{ENFORCE_STEPS_PKG}._echo"),
|
||||
):
|
||||
_enforce_auto_install(Config(), state)
|
||||
@ -132,7 +132,7 @@ class TestEnforceAutoInstall:
|
||||
def test_installs_successfully(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{ENFORCE_STEPS_PKG}.install_game", return_value=True),
|
||||
patch(f"{ENFORCE_STEPS_PKG}.send_notification"),
|
||||
patch(f"{ENFORCE_STEPS_PKG}._echo"),
|
||||
@ -142,7 +142,7 @@ class TestEnforceAutoInstall:
|
||||
def test_install_fails(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{ENFORCE_STEPS_PKG}.install_game", return_value=False),
|
||||
patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo,
|
||||
):
|
||||
|
||||
215
steam_backlog_enforcer/tests/test_fast_install_guards.py
Normal file
215
steam_backlog_enforcer/tests/test_fast_install_guards.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""Tests for the guards around a fast install.
|
||||
|
||||
Covers the /proc scan and launch guard in _steam_restart_guard, the
|
||||
fully-installed predicate and target-app exclusion in _steam_state, and every
|
||||
place that must keep Steam closed or defer while steam-game-installer runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _steam_state
|
||||
from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed
|
||||
from steam_backlog_enforcer._steam_client import _ensure_steam_running
|
||||
from steam_backlog_enforcer._steam_errors import (
|
||||
FastInstallInProgressError,
|
||||
SteamUnavailableError,
|
||||
)
|
||||
from steam_backlog_enforcer._steam_launch import ensure_steam_debug_port, restart_steam
|
||||
|
||||
# Bound at import, before the autouse guard replaces the module attribute, so
|
||||
# the real /proc scan is what gets tested here.
|
||||
from steam_backlog_enforcer._steam_restart_guard import (
|
||||
assert_no_fast_install,
|
||||
fast_install_running,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.game_install import install_game
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
GUARD = "steam_backlog_enforcer._steam_restart_guard"
|
||||
GI = "steam_backlog_enforcer.game_install"
|
||||
LAUNCH = "steam_backlog_enforcer._steam_launch"
|
||||
|
||||
|
||||
def _manifest(app_id: int, flags: int, *, to_download: int = 0) -> None:
|
||||
path = _steam_state.STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
|
||||
path.write_text(
|
||||
f'"AppState"\n{{\n\t"StateFlags"\t\t"{flags}"\n'
|
||||
f'\t"BytesToDownload"\t\t"{to_download}"\n\t"BytesDownloaded"\t\t"0"\n}}\n'
|
||||
)
|
||||
|
||||
|
||||
class TestFastInstallRunning:
|
||||
"""The scan matches the wrapper and the module, nothing else."""
|
||||
|
||||
def _proc(self, tmp_path: Path, cmdlines: dict[str, bytes | None]) -> Path:
|
||||
proc = tmp_path / "proc"
|
||||
for pid, cmdline in cmdlines.items():
|
||||
(proc / pid).mkdir(parents=True)
|
||||
if cmdline is not None:
|
||||
(proc / pid / "cmdline").write_bytes(cmdline)
|
||||
return proc
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmdline",
|
||||
[
|
||||
b"bash\0/home/u/src/steam-game-installer/run.sh\0--appid\0001",
|
||||
b"python\0-m\0steam_installer.cli\0--appid\0001",
|
||||
],
|
||||
)
|
||||
def test_detects_installer(self, tmp_path: Path, cmdline: bytes) -> None:
|
||||
proc = self._proc(tmp_path, {"self": b"", "10": None, "11": cmdline})
|
||||
with patch(f"{GUARD}.Path", return_value=proc):
|
||||
assert fast_install_running() is True
|
||||
|
||||
def test_nothing_running(self, tmp_path: Path) -> None:
|
||||
proc = self._proc(tmp_path, {"self": b"", "10": None, "11": b"steam\0-silent"})
|
||||
with patch(f"{GUARD}.Path", return_value=proc):
|
||||
assert fast_install_running() is False
|
||||
|
||||
def test_assert_raises_while_running(self) -> None:
|
||||
with (
|
||||
patch(f"{GUARD}.fast_install_running", return_value=True),
|
||||
pytest.raises(FastInstallInProgressError),
|
||||
):
|
||||
assert_no_fast_install()
|
||||
|
||||
def test_assert_passes_when_idle(self) -> None:
|
||||
assert_no_fast_install()
|
||||
|
||||
def test_error_degrades_like_other_steam_errors(self) -> None:
|
||||
assert issubclass(FastInstallInProgressError, SteamUnavailableError)
|
||||
|
||||
|
||||
class TestFullyInstalled:
|
||||
"""Only a finished, settled manifest counts as fully installed."""
|
||||
|
||||
def test_missing(self) -> None:
|
||||
assert _steam_state.is_game_fully_installed(1) is False
|
||||
|
||||
def test_installed(self) -> None:
|
||||
_manifest(1, 4)
|
||||
assert _steam_state.is_game_fully_installed(1) is True
|
||||
|
||||
def test_steam_still_downloading(self) -> None:
|
||||
_manifest(1, 1042, to_download=10)
|
||||
assert _steam_state.is_game_fully_installed(1) is False
|
||||
|
||||
def test_flag_set_but_bytes_in_flight(self) -> None:
|
||||
_manifest(1, 1044, to_download=10)
|
||||
assert _steam_state.is_game_fully_installed(1) is False
|
||||
|
||||
def test_no_state_flags(self) -> None:
|
||||
path = _steam_state.STEAMAPPS_PATH / "appmanifest_1.acf"
|
||||
path.write_text('"AppState"\n{\n}\n')
|
||||
assert _steam_state.is_game_fully_installed(1) is False
|
||||
|
||||
def test_update_check_can_exclude_the_target(self) -> None:
|
||||
_manifest(7, 1026, to_download=10)
|
||||
assert _steam_state.steam_update_in_progress() is True
|
||||
assert _steam_state.steam_update_in_progress(exclude_app_id=7) is False
|
||||
|
||||
|
||||
class TestInstallGameFastPath:
|
||||
"""install_game prefers the installer and never reopens Steam under it."""
|
||||
|
||||
def test_own_run_in_progress(self) -> None:
|
||||
with (
|
||||
patch(f"{GI}.fast_install_running", return_value=True),
|
||||
patch(f"{GI}.is_fast_installing", return_value=True),
|
||||
patch(f"{GI}.fast_install") as fast,
|
||||
):
|
||||
assert install_game(1, "G", "sid", use_steam_protocol=True) is True
|
||||
fast.assert_not_called()
|
||||
|
||||
def test_other_run_defers(self) -> None:
|
||||
with (
|
||||
patch(f"{GI}.fast_install_running", return_value=True),
|
||||
patch(f"{GI}.is_fast_installing", return_value=False),
|
||||
patch(f"{GI}._trigger_steam_install") as trigger,
|
||||
):
|
||||
assert install_game(1, "G", "sid", use_steam_protocol=True) is False
|
||||
trigger.assert_not_called()
|
||||
|
||||
def test_fast_install_wins(self) -> None:
|
||||
with (
|
||||
patch(f"{GI}.fast_install", return_value=True),
|
||||
patch(f"{GI}._trigger_steam_install") as trigger,
|
||||
):
|
||||
assert install_game(1, "G", "sid", use_steam_protocol=True) is True
|
||||
trigger.assert_not_called()
|
||||
|
||||
def test_steams_own_download_is_left_running(self) -> None:
|
||||
_manifest(1, 1026, to_download=10)
|
||||
before = (_steam_state.STEAMAPPS_PATH / "appmanifest_1.acf").read_text()
|
||||
with (
|
||||
patch(f"{GI}.fast_install", return_value=False),
|
||||
patch(f"{GI}._trigger_steam_install") as trigger,
|
||||
):
|
||||
assert install_game(1, "G", "sid", use_steam_protocol=True) is True
|
||||
trigger.assert_not_called()
|
||||
after = (_steam_state.STEAMAPPS_PATH / "appmanifest_1.acf").read_text()
|
||||
assert after == before
|
||||
|
||||
|
||||
class TestSteamStaysClosed:
|
||||
"""Every Steam start path defers to a live installer."""
|
||||
|
||||
def test_ensure_steam_running(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_client.steam_is_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_client.fast_install_running",
|
||||
return_value=True,
|
||||
),
|
||||
patch("steam_backlog_enforcer._steam_client.spawn_detached") as spawn,
|
||||
):
|
||||
_ensure_steam_running()
|
||||
spawn.assert_not_called()
|
||||
|
||||
def test_debug_port(self) -> None:
|
||||
with (
|
||||
patch(f"{GUARD}.fast_install_running", return_value=True),
|
||||
patch(f"{LAUNCH}._launch_steam_with_debug") as launch,
|
||||
pytest.raises(FastInstallInProgressError),
|
||||
):
|
||||
ensure_steam_debug_port()
|
||||
launch.assert_not_called()
|
||||
|
||||
def test_restart(self) -> None:
|
||||
with (
|
||||
patch(f"{LAUNCH}.game_is_running", return_value=False),
|
||||
patch(f"{LAUNCH}.steam_update_in_progress", return_value=False),
|
||||
patch(f"{LAUNCH}.fast_install_running", return_value=True),
|
||||
patch(f"{LAUNCH}._shutdown_steam") as shutdown,
|
||||
):
|
||||
restart_steam()
|
||||
shutdown.assert_not_called()
|
||||
|
||||
|
||||
class TestReinstallPollsRuns:
|
||||
"""The loop reaps detached runs even when no game is missing."""
|
||||
|
||||
def test_polls_every_pass(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._enforce_steps.steam_library_ready",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._enforce_steps.allowed_games", return_value=[]
|
||||
),
|
||||
patch("steam_backlog_enforcer._enforce_steps.poll_fast_installs") as poll,
|
||||
):
|
||||
_reinstall_missing_allowed(Config(), MagicMock(spec=State))
|
||||
poll.assert_called_once()
|
||||
136
steam_backlog_enforcer/tests/test_fast_install_helpers.py
Normal file
136
steam_backlog_enforcer/tests/test_fast_install_helpers.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Tests for _fast_install's pure helpers: paths, account, refusal, argv.
|
||||
|
||||
The run lifecycle (spawn, poll, fast_install) lives in test_fast_install_runs.py
|
||||
to keep both files under the 250-line cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install
|
||||
|
||||
PKG = "steam_backlog_enforcer._fast_install"
|
||||
|
||||
|
||||
class TestInstallerPath:
|
||||
"""run.sh is resolved from the desktop user's passwd home."""
|
||||
|
||||
def test_found_in_user_home(self, tmp_path: Path) -> None:
|
||||
installer = tmp_path / "src" / "steam-game-installer" / "run.sh"
|
||||
installer.parent.mkdir(parents=True)
|
||||
installer.write_text("#!/bin/bash\n")
|
||||
# The autouse guard points _INSTALLER at a dead absolute path; put the
|
||||
# real relative one back so the home join itself is what is tested.
|
||||
relative = Path("src", "steam-game-installer", "run.sh")
|
||||
with (
|
||||
patch.object(_fast_install, "_INSTALLER", relative),
|
||||
patch(f"{PKG}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)),
|
||||
):
|
||||
assert _fast_install._installer_path("kuhy") == installer
|
||||
|
||||
def test_missing_file_is_none(self, tmp_path: Path) -> None:
|
||||
with patch(
|
||||
f"{PKG}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)
|
||||
):
|
||||
assert _fast_install._installer_path("kuhy") is None
|
||||
|
||||
def test_unknown_user_is_none(self) -> None:
|
||||
with patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("nobody")):
|
||||
assert _fast_install._installer_path("nobody") is None
|
||||
|
||||
def test_no_user_uses_own_home(self, tmp_path: Path) -> None:
|
||||
with patch(f"{PKG}.Path.home", return_value=tmp_path):
|
||||
assert _fast_install._installer_path(None) is None
|
||||
|
||||
|
||||
class TestSteamAccountName:
|
||||
"""The username comes from Steam's own loginusers.vdf."""
|
||||
|
||||
def _write(self, content: str) -> None:
|
||||
config = _fast_install.STEAMAPPS_PATH.parent / "config"
|
||||
config.mkdir(parents=True, exist_ok=True)
|
||||
(config / "loginusers.vdf").write_text(content)
|
||||
|
||||
def test_reads_account_name(self) -> None:
|
||||
self._write('"users"\n{\n\t"7656"\n\t{\n\t\t"AccountName"\t\t"krzys"\n')
|
||||
assert _fast_install._steam_account_name() == "krzys"
|
||||
|
||||
def test_no_account_field(self) -> None:
|
||||
self._write('"users"\n{\n}\n')
|
||||
assert _fast_install._steam_account_name() is None
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
assert _fast_install._steam_account_name() is None
|
||||
|
||||
|
||||
class TestRefusal:
|
||||
"""Closing Steam is refused while it would destroy something."""
|
||||
|
||||
def test_game_running(self) -> None:
|
||||
with patch(f"{PKG}.game_is_running", return_value=True):
|
||||
assert "game is running" in (_fast_install._refusal(1) or "")
|
||||
|
||||
def test_other_update_in_progress(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.game_is_running", return_value=False),
|
||||
patch(f"{PKG}.steam_update_in_progress", return_value=True) as update,
|
||||
):
|
||||
assert "download is in progress" in (_fast_install._refusal(7) or "")
|
||||
update.assert_called_once_with(exclude_app_id=7)
|
||||
|
||||
def test_safe(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.game_is_running", return_value=False),
|
||||
patch(f"{PKG}.steam_update_in_progress", return_value=False),
|
||||
):
|
||||
assert _fast_install._refusal(7) is None
|
||||
|
||||
|
||||
class TestBuildCmd:
|
||||
"""The argv pins every choice the installer would otherwise prompt for."""
|
||||
|
||||
def test_with_account(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._steam_account_name", return_value="krzys"),
|
||||
patch(f"{PKG}.desktop_user_cmd", side_effect=lambda cmd, _u: cmd),
|
||||
):
|
||||
cmd = _fast_install._build_cmd(Path("/i/run.sh"), 42, "kuhy")
|
||||
assert cmd == [
|
||||
"/i/run.sh",
|
||||
"--no-deps",
|
||||
"--appid",
|
||||
"42",
|
||||
"--yes",
|
||||
"--language",
|
||||
"english",
|
||||
"--skip-steamcmd",
|
||||
"--username",
|
||||
"krzys",
|
||||
]
|
||||
|
||||
def test_without_account_and_drops_privileges(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._steam_account_name", return_value=None),
|
||||
patch(f"{PKG}.desktop_user_cmd", return_value=["wrapped"]) as wrap,
|
||||
):
|
||||
assert _fast_install._build_cmd(Path("/i/run.sh"), 42, "kuhy") == [
|
||||
"wrapped"
|
||||
]
|
||||
argv, user = wrap.call_args.args
|
||||
assert "--username" not in argv
|
||||
assert user == "kuhy"
|
||||
|
||||
|
||||
class TestIsFastInstalling:
|
||||
"""Only this process's own live runs count."""
|
||||
|
||||
def test_tracks_runs(self) -> None:
|
||||
assert _fast_install.is_fast_installing(5) is False
|
||||
_fast_install._RUNS.append(
|
||||
_fast_install._DetachedRun(5, MagicMock(), Path("/nonexistent"))
|
||||
)
|
||||
assert _fast_install.is_fast_installing(5) is True
|
||||
assert _fast_install.is_fast_installing(6) is False
|
||||
179
steam_backlog_enforcer/tests/test_fast_install_runs.py
Normal file
179
steam_backlog_enforcer/tests/test_fast_install_runs.py
Normal file
@ -0,0 +1,179 @@
|
||||
"""Tests for _fast_install's run lifecycle: spawn, poll, and fast_install.
|
||||
|
||||
The pure helpers live in test_fast_install_helpers.py to keep both files under
|
||||
the 250-line cap. Every subprocess here is a mock (see _no_subprocess.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install, config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
PKG = "steam_backlog_enforcer._fast_install"
|
||||
|
||||
|
||||
def _run(app_id: int, returncode: int | None, log_path: Path) -> MagicMock:
|
||||
"""Register a fake detached run whose poll() answers *returncode*."""
|
||||
proc = MagicMock(pid=4321)
|
||||
proc.poll.return_value = returncode
|
||||
_fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log_path))
|
||||
return proc
|
||||
|
||||
|
||||
class TestPollFastInstalls:
|
||||
"""Detached runs are reaped, failures remembered, stalls interrupted."""
|
||||
|
||||
def test_success_is_reaped(self, tmp_path: Path) -> None:
|
||||
_run(1, 0, tmp_path / "log")
|
||||
_fast_install.poll_fast_installs()
|
||||
assert _fast_install._RUNS == []
|
||||
assert set() == _fast_install._FAILED
|
||||
|
||||
def test_failure_is_remembered(self, tmp_path: Path) -> None:
|
||||
_run(2, 3, tmp_path / "log")
|
||||
_fast_install.poll_fast_installs()
|
||||
assert _fast_install._RUNS == []
|
||||
assert {2} == _fast_install._FAILED
|
||||
|
||||
def test_live_and_chatty_is_left_alone(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log"
|
||||
log.write_text("progress\n")
|
||||
_run(3, None, log)
|
||||
with patch(f"{PKG}.os.killpg") as killpg:
|
||||
_fast_install.poll_fast_installs()
|
||||
killpg.assert_not_called()
|
||||
assert _fast_install.is_fast_installing(3)
|
||||
|
||||
def test_live_without_log_is_left_alone(self, tmp_path: Path) -> None:
|
||||
_run(4, None, tmp_path / "missing.log")
|
||||
with patch(f"{PKG}.os.killpg") as killpg:
|
||||
_fast_install.poll_fast_installs()
|
||||
killpg.assert_not_called()
|
||||
|
||||
def test_stalled_run_gets_sigint(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log"
|
||||
log.write_text("Enter account password: ")
|
||||
old = log.stat().st_mtime - _fast_install.STALL_SECONDS - 60
|
||||
os.utime(log, (old, old))
|
||||
_run(5, None, log)
|
||||
with patch(f"{PKG}.os.killpg") as killpg:
|
||||
_fast_install.poll_fast_installs()
|
||||
# SIGINT so the installer's `finally` restarts Steam; kept tracked
|
||||
# until it actually exits.
|
||||
killpg.assert_called_once_with(4321, signal.SIGINT)
|
||||
assert _fast_install.is_fast_installing(5)
|
||||
|
||||
|
||||
class TestSpawnDetached:
|
||||
"""The daemon's spawn: own session, no stdin, output to the log."""
|
||||
|
||||
def test_as_user_spawns_directly(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{PKG}.subprocess.Popen") as popen,
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
args, kwargs = popen.call_args
|
||||
assert args[0] == ["run.sh"]
|
||||
assert kwargs["stdin"] == _fast_install.subprocess.DEVNULL
|
||||
assert kwargs["start_new_session"] is True
|
||||
run = _fast_install._RUNS[0]
|
||||
assert run.app_id == 9
|
||||
assert run.log_path == config.CONFIG_DIR / "fast_install.log"
|
||||
assert run.log_path.exists()
|
||||
|
||||
def test_as_root_escapes_the_service_cgroup(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}.shutil.which", return_value="/usr/bin/systemd-run"),
|
||||
patch(f"{PKG}.subprocess.Popen") as popen,
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
assert popen.call_args.args[0] == [
|
||||
"systemd-run",
|
||||
"--scope",
|
||||
"--quiet",
|
||||
"--collect",
|
||||
"run.sh",
|
||||
]
|
||||
|
||||
def test_as_root_without_systemd_run(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}.shutil.which", return_value=None),
|
||||
patch(f"{PKG}.subprocess.Popen") as popen,
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
assert popen.call_args.args[0] == ["run.sh"]
|
||||
|
||||
|
||||
class TestFastInstall:
|
||||
"""fast_install picks a mode, or tells the caller to fall back."""
|
||||
|
||||
def _ready(self, tmp_path: Path) -> Path:
|
||||
installer = tmp_path / "run.sh"
|
||||
installer.write_text("#!/bin/bash\n")
|
||||
return installer
|
||||
|
||||
def test_previously_failed_goes_straight_to_fallback(self) -> None:
|
||||
_fast_install._FAILED.add(7)
|
||||
with patch(f"{PKG}._installer_path") as find:
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
find.assert_not_called()
|
||||
|
||||
def test_no_installer(self) -> None:
|
||||
# The autouse guard already hides the real installer.
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
|
||||
def test_refused(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value="a game is running"),
|
||||
patch(f"{PKG}._echo") as echo,
|
||||
patch(f"{PKG}.subprocess.run") as run,
|
||||
):
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
run.assert_not_called()
|
||||
assert "a game is running" in echo.call_args.args[0]
|
||||
|
||||
def test_no_terminal_spawns_detached(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
patch(f"{PKG}.sys.stdin.isatty", return_value=False),
|
||||
patch(f"{PKG}._spawn_detached") as spawn,
|
||||
patch(f"{PKG}.subprocess.run") as run,
|
||||
):
|
||||
assert _fast_install.fast_install(7, "G") is True
|
||||
spawn.assert_called_once()
|
||||
run.assert_not_called()
|
||||
|
||||
def test_terminal_runs_in_foreground(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
patch(f"{PKG}.sys.stdin.isatty", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)),
|
||||
):
|
||||
assert _fast_install.fast_install(7, "G") is True
|
||||
assert set() == _fast_install._FAILED
|
||||
|
||||
def test_foreground_failure_falls_back(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
patch(f"{PKG}.sys.stdin.isatty", return_value=True),
|
||||
patch(f"{PKG}._echo") as echo,
|
||||
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=2)),
|
||||
):
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
assert {7} == _fast_install._FAILED
|
||||
assert "exit 2" in echo.call_args.args[0]
|
||||
@ -23,10 +23,13 @@ class TestInstallGame:
|
||||
"""Tests for install_game."""
|
||||
|
||||
def test_already_installed(self, tmp_path: Path) -> None:
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.touch()
|
||||
with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path):
|
||||
(tmp_path / "appmanifest_440.acf").write_text('"StateFlags"\t\t"4"\n')
|
||||
with (
|
||||
patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", tmp_path),
|
||||
patch(f"{PKG}.fast_install") as fast,
|
||||
):
|
||||
assert install_game(440, "TF2", "steam123") is True
|
||||
fast.assert_not_called()
|
||||
|
||||
def test_use_steam_protocol_success(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
|
||||
@ -85,7 +85,7 @@ class TestCmdInstall:
|
||||
def test_already_installed(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(), state)
|
||||
@ -93,7 +93,7 @@ class TestCmdInstall:
|
||||
def test_installs_ok(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
@ -102,7 +102,7 @@ class TestCmdInstall:
|
||||
def test_install_fails(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game", return_value=False),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
|
||||
@ -83,7 +83,7 @@ class TestFinalizeCompletion:
|
||||
"steam_backlog_enforcer.library_hider.try_hide_other_games",
|
||||
return_value=(0, "update in progress"),
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
|
||||
@ -50,7 +50,7 @@ class TestEnforceOnDone:
|
||||
return_value=[(1234, 999)],
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[1, 2],
|
||||
@ -72,7 +72,7 @@ class TestEnforceOnDone:
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.enforce_allowed_game", return_value=[]),
|
||||
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
@ -91,7 +91,7 @@ class TestEnforceOnDone:
|
||||
patch(f"{CMD_DONE_PKG}._echo") as mock_echo,
|
||||
# The skip line is printed by the shared hide helper.
|
||||
patch("steam_backlog_enforcer.library_hider._echo", mock_echo),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[1, 2],
|
||||
@ -115,7 +115,7 @@ class TestEnforceOnDone:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
|
||||
@ -101,7 +101,7 @@ class TestFinalizeCompletionRetries:
|
||||
"steam_backlog_enforcer.library_hider.try_hide_other_games",
|
||||
return_value=(1, None),
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||
patch.object(State, "save"),
|
||||
@ -137,7 +137,7 @@ class TestFinalizeCompletionRetries:
|
||||
"steam_backlog_enforcer.library_hider.try_hide_other_games",
|
||||
return_value=(1, None),
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||
patch.object(State, "save"),
|
||||
|
||||
@ -27,7 +27,7 @@ class TestPickManualCap:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
@ -61,7 +61,7 @@ class TestPickManualCap:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
@ -84,7 +84,7 @@ class TestPickManualCap:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0) as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[1, 100, 489830],
|
||||
@ -107,7 +107,7 @@ class TestPickManualCap:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
|
||||
@ -44,7 +44,7 @@ class TestCmdPickManual:
|
||||
patch("builtins.input", side_effect=["489830", "YES"]),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
@ -62,7 +62,7 @@ class TestCmdPickManual:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save") as mock_save,
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=2) as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
@ -92,7 +92,7 @@ class TestCmdPickManual:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
@ -110,7 +110,7 @@ class TestCmdPickManual:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
|
||||
@ -24,7 +24,7 @@ class TestPickManualCascade:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games") as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
@ -41,7 +41,7 @@ class TestPickManualCascade:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
@ -59,7 +59,7 @@ class TestPickManualCascade:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
@ -80,7 +80,7 @@ class TestPickManualCascade:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
@ -98,7 +98,7 @@ class TestPickManualCascade:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[1, 2],
|
||||
@ -125,7 +125,7 @@ class TestPickManualCascade:
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[1, 2],
|
||||
|
||||
@ -63,7 +63,7 @@ class TestPickNextGameGroup2:
|
||||
),
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -100,7 +100,7 @@ class TestPickNextGameGroup2:
|
||||
),
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -127,7 +127,7 @@ class TestPickNextGameGroup2:
|
||||
),
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -155,7 +155,7 @@ class TestPickNextGameGroup2:
|
||||
side_effect=lambda *a, **_: echoed.append(a[0]),
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -184,7 +184,7 @@ class TestPickNextGameGroup2:
|
||||
side_effect=lambda *a, **_: echoed.append(a[0]),
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
|
||||
@ -66,7 +66,7 @@ class TestPickNextGame:
|
||||
side_effect=lambda *a, **_: echoed.append(a[0]),
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -101,7 +101,7 @@ class TestPickNextGame:
|
||||
),
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
|
||||
@ -52,7 +52,7 @@ class TestPickNextGame:
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch("steam_backlog_enforcer._scanning_confidence._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -86,7 +86,7 @@ class TestPickNextGame:
|
||||
),
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -130,7 +130,7 @@ class TestPickNextGame:
|
||||
return_value=2,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch("builtins.input", return_value="1"),
|
||||
@ -151,7 +151,7 @@ class TestPickNextGame:
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch("steam_backlog_enforcer._scanning_confidence._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
@ -182,7 +182,7 @@ class TestAssignUninstallsAgainstTheAllowedSet:
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch("steam_backlog_enforcer._scanning_confidence._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
|
||||
@ -50,7 +50,7 @@ class TestPickNextGameSequential:
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
)
|
||||
)
|
||||
|
||||
@ -46,7 +46,7 @@ class TestPickNextGameGroup2:
|
||||
),
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -70,7 +70,7 @@ class TestPickNextGameGroup2:
|
||||
),
|
||||
patch("steam_backlog_enforcer.scanning._echo"),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
@ -107,7 +107,7 @@ class TestPickNextGameGroup2:
|
||||
side_effect=lambda *a, **_: echoed.append(a[0]),
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_installed",
|
||||
"steam_backlog_enforcer._scanning_assign.is_game_fully_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user