steam-backlog-enforcer/steam_backlog_enforcer/_stats_display.py
Krzysztof kuhy Rudnicki 7323c896f6
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Import split-out names from the modules that own them; drop the four lazy facades
hltb, _cmd_done, _whitelist and _actions each kept a module __getattr__
that served names moved out in the 250-line split, via importlib to
dodge the resulting import cycle. pylint cannot see through that, and
graded every such import as no-name-in-module (26 of them). The 32
import statements now name the real module; with nothing left going
through the facades, they are gone.

Also the pylint hook on 4.0.8 with the source-only scope config, the
three implicit-booleaness disables the reference repos carry, the
http.server do_GET admission, and the third-party pins the hook was
missing (aiohttp, websockets, howlongtobeatpy). Gate still 8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 20:22:44 +02:00

117 lines
4.3 KiB
Python

"""Formatting helpers for the ``stats`` command's printed report.
Split out of :mod:`steam_backlog_enforcer._stats` to keep both files under
the 250-line cap. Leaf helpers: nothing here calls back into ``_stats``.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import secrets
from typing import TYPE_CHECKING
from urllib.parse import quote_plus
from steam_backlog_enforcer._hltb_confidence import fetch_hltb_detail_missing
from steam_backlog_enforcer._hltb_types import (
HLTB_BASE_URL,
load_hltb_game_id_cache,
)
from steam_backlog_enforcer._stats_types import _HLTB_SEARCH_BASE, _GameTimes
from steam_backlog_enforcer.game_install import _echo
if TYPE_CHECKING:
from steam_backlog_enforcer.config import State
def _print_worst_example(entries: list[_GameTimes]) -> None:
"""Print a randomly selected example from the worst-case qualified games."""
examples = [e for e in entries if e.worst_hours > 0]
if not examples:
return
example = secrets.choice(examples)
_echo(f"\n Example game: {example.game.name!r}")
_echo(f" Worst case: {example.worst_hours:.1f} h")
if example.rush_hours > 0:
_echo(f" Rush: {example.rush_hours:.1f} h")
if example.leisure_100h > 0:
_echo(f" Leisure: {example.leisure_100h:.1f} h")
hltb_game_id = example.hltb_game_id
if hltb_game_id == 0:
# On-demand backfill: one search to get the HLTB game ID for this game.
fetch_hltb_detail_missing([(example.game.app_id, example.game.name)])
hltb_game_id = load_hltb_game_id_cache().get(example.game.app_id, 0)
if hltb_game_id > 0:
_echo(f" HLTB: {HLTB_BASE_URL}/game/{hltb_game_id}")
else:
_echo(f" HLTB: {_HLTB_SEARCH_BASE}{quote_plus(example.game.name)}")
def _sum_hours(entries: list[_GameTimes], attr: str) -> tuple[float, int]:
"""Sum a time attribute across entries; return (total_hours, missing_count).
Games where the attribute is ≤ 0 contribute 0 to the sum and are counted
in ``missing_count`` so the user knows the estimate may be an undercount.
"""
total = 0.0
missing = 0
for e in entries:
val: float = getattr(e, attr)
if val > 0:
total += val
else:
missing += 1
return round(total, 1), missing
def _format_completion_date(hours: float, daily_hours: float) -> str:
"""Return 'N days (YYYY-MM-DD)' for finishing hours at daily_hours per day."""
if hours <= 0 or daily_hours <= 0:
return "N/A"
days = int(hours / daily_hours)
target = datetime.now(UTC) + timedelta(days=days)
return f"{days} days ({target.strftime('%Y-%m-%d')})"
def _print_pace_scenario(state: State, remaining: int, games_done: int) -> None:
"""Print the pace-based completion estimate.
``games_done`` must be the count of games completed ON OR AFTER
``state.enforcement_started_at`` (use ``count_complete_since_start``).
Pre-enforcement completions inflate the rate and are excluded.
"""
_echo("\n 1. AT YOUR CURRENT PACE")
if not state.enforcement_started_at:
_echo(" No start date recorded.")
_echo(" Set enforcement_started_at in state.json (ISO-8601 UTC)")
_echo(" to enable this estimate.")
return
try:
started = datetime.fromisoformat(state.enforcement_started_at)
except ValueError:
_echo(f" Invalid enforcement_started_at: {state.enforcement_started_at!r}")
return
now = datetime.now(UTC)
days_elapsed = max(1, (now - started).days)
if games_done == 0:
_echo(f" Started: {started.strftime('%Y-%m-%d')}")
_echo(" No games finished yet — pace cannot be estimated.")
return
rate = games_done / days_elapsed
_echo(f" Started: {started.strftime('%Y-%m-%d')}")
_echo(
f" Finished: {games_done} games in {days_elapsed} days "
"(since enforcement start)"
)
_echo(
f" Pace: {rate:.4f} games/day (1 game every {1 / rate:.1f} days)"
)
_echo(f" Remaining: {remaining} games")
days_to_go = int(remaining / rate)
finish = now + timedelta(days=days_to_go)
_echo(f" Est. complete: {days_to_go} days ({finish.strftime('%Y-%m-%d')})")