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:
Krzysztof Rudnicki 2026-09-25 18:45:27 +02:00
parent 30fcaacc5d
commit f1c8f04287
33 changed files with 1062 additions and 107 deletions

View File

@ -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,

View File

@ -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}...")

View 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

View File

@ -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

View File

@ -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:

View File

@ -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.
"""

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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}...")

View 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()

View File

@ -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(

View File

@ -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(

View File

@ -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,
):

View 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()

View 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

View 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]

View File

@ -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 (

View File

@ -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"),
):

View File

@ -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"),
):

View File

@ -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",

View File

@ -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"),

View File

@ -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",

View File

@ -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=[],

View File

@ -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],

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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,
)
)

View File

@ -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(