mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
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
117 lines
4.3 KiB
Python
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')})")
|