From 9155ef418b3abca41850c46d232b6f145d77cc4c Mon Sep 17 00:00:00 2001 From: Dash Date: Sat, 17 Oct 2026 11:32:05 +0200 Subject: [PATCH] misc: fix all ty diagnostics ty reported 20 diagnostics, and 0.0.89 adds a few more; this clears them all without suppressions. - The quest monster is held in a local before being stored on the quest book, so its name no longer reads from an Optional. - PgUp/PgDn narrow the focused window with isinstance against a runtime-checkable PageScrollable protocol instead of hasattr. - The ^t cheat used to monkeypatch SignalMixin.emit. SignalMixin now has a muted() context manager that emit() checks, and it restores emission even if a tick raises. - GameView keeps its children as GameWindow, a protocol declaring stop() and sync(), so the cheat's resync type-checks. - curses.ascii is imported explicitly where it is used. - ProgressBarWindow is now Focusable itself, instead of calling Focusable.focus_standout() on a class that is not Focusable. Its four subclasses drop the duplicate Focusable base; the MRO and initialisation order are unchanged. - MainMenu narrows the Optional pad before colouring the logo. - The tests that record tasks assert player.task is set. - Context managers are annotated as Generator rather than Iterator, which ty flags as deprecated for @contextmanager. - The redundant cast() around getch() is gone. - The frozen GameData test assigns through setattr(), since the direct assignment is an error by design. Bug fixes: - ListBox.select(None) crashed on `None < 0`; it now clears the selection, as DataTable.select(None) already does. - ListBox.get() caught ValueError, which indexing never raises; it now catches IndexError, so an out-of-range index returns None as intended. --- pqcli/mechanic.py | 24 ++++++++--- pqcli/random.py | 4 +- pqcli/ui/curses/__init__.py | 4 +- pqcli/ui/curses/views/game_view/__init__.py | 40 +++++++++++++------ .../views/game_view/character_sheet_window.py | 3 +- .../views/game_view/inventory_window.py | 3 +- .../ui/curses/views/game_view/plot_window.py | 3 +- .../views/game_view/progress_bar_window.py | 8 ++-- .../views/game_view/quest_book_window.py | 3 +- pqcli/ui/curses/views/roster_view.py | 16 ++++---- pqcli/ui/curses/widgets/list_box.py | 4 +- pqcli/ui/curses/widgets/menu.py | 7 ++-- tests/game_trace.py | 10 +++-- tests/test_game_data.py | 2 +- 14 files changed, 80 insertions(+), 51 deletions(-) diff --git a/pqcli/mechanic.py b/pqcli/mechanic.py index e7d42c0..8d0a1d5 100644 --- a/pqcli/mechanic.py +++ b/pqcli/mechanic.py @@ -1,10 +1,11 @@ +import contextlib import datetime import functools import itertools import logging import math from collections import defaultdict -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Generator, Iterator, Sequence from dataclasses import dataclass from typing import Any, cast @@ -42,7 +43,21 @@ _SIGNALS: dict[tuple["SignalMixin", str], list[Callable[..., Any]]] = ( class SignalMixin: + _muted = 0 + + @staticmethod + @contextlib.contextmanager + def muted() -> Generator[None, None, None]: + """Suppress all signal emission for the duration of the block.""" + SignalMixin._muted += 1 + try: + yield + finally: + SignalMixin._muted -= 1 + def emit(self, signal_name: str, *user_data: Any) -> None: + if SignalMixin._muted: + return for callback in _SIGNALS[self, signal_name]: callback(*user_data) @@ -683,12 +698,11 @@ class Simulation: caption = "" choice = random.below(5) if choice == 0: - self.player.quest_book.monster = unnamed_monster( + monster = unnamed_monster( self.data, self.player.level, iterations=3 ) - caption = "Exterminate " + definite( - self.player.quest_book.monster.name, 2 - ) + self.player.quest_book.monster = monster + caption = "Exterminate " + definite(monster.name, 2) elif choice == 1: caption = "Seek " + definite(interesting_item(self.data), 1) elif choice == 2: diff --git a/pqcli/random.py b/pqcli/random.py index 9071d7a..ae0afd1 100644 --- a/pqcli/random.py +++ b/pqcli/random.py @@ -1,6 +1,6 @@ import contextlib import time -from collections.abc import Callable, Iterator, Sequence +from collections.abc import Callable, Generator, Sequence from typing import Any RngState = tuple[float, float, float, int] @@ -91,7 +91,7 @@ def seed(source: str) -> None: @contextlib.contextmanager -def using(rng: Alea) -> Iterator[None]: +def using(rng: Alea) -> Generator[None, None, None]: """Draw from rng instead of the shared generator inside the block.""" global _current previous = _current diff --git a/pqcli/ui/curses/__init__.py b/pqcli/ui/curses/__init__.py index 5565f8d..4a4075e 100644 --- a/pqcli/ui/curses/__init__.py +++ b/pqcli/ui/curses/__init__.py @@ -3,7 +3,7 @@ import curses import os import signal import time -from typing import Any, cast +from typing import Any from pqcli import lingo from pqcli.config import Class, Race @@ -91,7 +91,7 @@ class CursesUserInterface(BaseUserInterface): self._switch_to_roster_view() while True: - key = cast(int, self._screen.getch()) + key = self._screen.getch() if key == curses.ERR: time.sleep(0.1) diff --git a/pqcli/ui/curses/views/game_view/__init__.py b/pqcli/ui/curses/views/game_view/__init__.py index a8610e4..8c46b81 100644 --- a/pqcli/ui/curses/views/game_view/__init__.py +++ b/pqcli/ui/curses/views/game_view/__init__.py @@ -1,7 +1,8 @@ import argparse import curses +import curses.ascii import datetime -from typing import Any +from typing import Any, Protocol, runtime_checkable from pqcli.game_data import GameData from pqcli.mechanic import Player, SignalMixin, Simulation @@ -16,7 +17,7 @@ from pqcli.ui.curses.util import ( KEYS_UP, ) from pqcli.ui.curses.views.base_view import BaseView -from pqcli.ui.curses.widgets import Focusable, Widget +from pqcli.ui.curses.widgets import Focusable from .character_sheet_window import CharacterSheetWindow from .equipment_window import EquipmentWindow @@ -27,6 +28,19 @@ from .spell_book_window import SpellBookWindow from .task_progress_window import TaskProgressWindow +class GameWindow(Protocol): + def stop(self) -> None: ... + + def sync(self) -> None: ... + + +@runtime_checkable +class PageScrollable(Protocol): + def scroll_page_up(self) -> None: ... + + def scroll_page_down(self) -> None: ... + + class GameView(BaseView): def __init__( self, @@ -53,11 +67,11 @@ class GameView(BaseView): self.on_exit() elif key in {curses.KEY_PPAGE}: - if hasattr(focused, "scroll_page_up"): + if isinstance(focused, PageScrollable): focused.scroll_page_up() elif key in {curses.KEY_NPAGE}: - if hasattr(focused, "scroll_page_down"): + if isinstance(focused, PageScrollable): focused.scroll_page_down() elif key in KEYS_CYCLE: @@ -118,11 +132,9 @@ class GameView(BaseView): self._simulation.tick() elif self._args.cheats and key == curses.ascii.DC4: # ^t - old = SignalMixin.emit - SignalMixin.emit = lambda *_: None - for _ in range(10000): - self._simulation.tick() - SignalMixin.emit = old + with SignalMixin.muted(): + for _ in range(10000): + self._simulation.tick() for child in self._children: child.sync() @@ -225,16 +237,18 @@ class GameView(BaseView): x=0, ) - self._focusable_children: list[Focusable] = [ + focusable_children = ( self._char_sheet_win, self._spell_book_win, self._equipment_win, self._inventory_win, self._plot_win, self._quest_book_win, - ] - self._children: list[Widget] = self._focusable_children + [ - self._task_win + ) + self._focusable_children: list[Focusable] = list(focusable_children) + self._children: list[GameWindow] = [ + *focusable_children, + self._task_win, ] def stop(self) -> None: diff --git a/pqcli/ui/curses/views/game_view/character_sheet_window.py b/pqcli/ui/curses/views/game_view/character_sheet_window.py index 7cf8c58..a8a7ccc 100644 --- a/pqcli/ui/curses/views/game_view/character_sheet_window.py +++ b/pqcli/ui/curses/views/game_view/character_sheet_window.py @@ -1,12 +1,11 @@ from typing import Any from pqcli.mechanic import Player, StatType -from pqcli.ui.curses.widgets import Focusable from .progress_bar_window import DataTableProgressBarWindow -class CharacterSheetWindow(Focusable, DataTableProgressBarWindow): +class CharacterSheetWindow(DataTableProgressBarWindow): def __init__( self, player: Player, parent: Any, h: int, w: int, y: int, x: int ) -> None: diff --git a/pqcli/ui/curses/views/game_view/inventory_window.py b/pqcli/ui/curses/views/game_view/inventory_window.py index e3d3c0c..767eb57 100644 --- a/pqcli/ui/curses/views/game_view/inventory_window.py +++ b/pqcli/ui/curses/views/game_view/inventory_window.py @@ -1,12 +1,11 @@ from typing import Any from pqcli.mechanic import InventoryItem, Player -from pqcli.ui.curses.widgets import Focusable from .progress_bar_window import DataTableProgressBarWindow -class InventoryWindow(Focusable, DataTableProgressBarWindow): +class InventoryWindow(DataTableProgressBarWindow): def __init__( self, player: Player, parent: Any, h: int, w: int, y: int, x: int ) -> None: diff --git a/pqcli/ui/curses/views/game_view/plot_window.py b/pqcli/ui/curses/views/game_view/plot_window.py index 1d91359..20dc165 100644 --- a/pqcli/ui/curses/views/game_view/plot_window.py +++ b/pqcli/ui/curses/views/game_view/plot_window.py @@ -2,12 +2,11 @@ from typing import Any from pqcli.lingo import act_name from pqcli.mechanic import Player -from pqcli.ui.curses.widgets import Focusable from .progress_bar_window import ListBoxProgressBarWindow -class PlotWindow(Focusable, ListBoxProgressBarWindow): +class PlotWindow(ListBoxProgressBarWindow): cutoff = 100 def __init__( diff --git a/pqcli/ui/curses/views/game_view/progress_bar_window.py b/pqcli/ui/curses/views/game_view/progress_bar_window.py index c14160a..2bae7be 100644 --- a/pqcli/ui/curses/views/game_view/progress_bar_window.py +++ b/pqcli/ui/curses/views/game_view/progress_bar_window.py @@ -10,7 +10,7 @@ from pqcli.ui.curses.widgets import ( ) -class ProgressBarWindow(WindowWrapper): +class ProgressBarWindow(Focusable, WindowWrapper): def __init__( self, parent: Any, @@ -51,7 +51,7 @@ class ProgressBarWindow(WindowWrapper): self._progress_bar_win.erase() - with Focusable.focus_standout(self, self._progress_bar_win): + with self.focus_standout(self._progress_bar_win): self._progress_bar_win.border( curses.ACS_VLINE, curses.ACS_VLINE, @@ -105,7 +105,7 @@ class DataTableProgressBarWindow(ProgressBarWindow): if not self._win: return - with Focusable.focus_standout(self, self._win): + with self.focus_standout(self._win): self._win.box() x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2) self._win.addnstr( @@ -143,7 +143,7 @@ class ListBoxProgressBarWindow(ProgressBarWindow): if not self._win: return - with Focusable.focus_standout(self, self._win): + with self.focus_standout(self._win): self._win.box() x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2) self._win.addnstr( diff --git a/pqcli/ui/curses/views/game_view/quest_book_window.py b/pqcli/ui/curses/views/game_view/quest_book_window.py index 25280eb..bd5073d 100644 --- a/pqcli/ui/curses/views/game_view/quest_book_window.py +++ b/pqcli/ui/curses/views/game_view/quest_book_window.py @@ -1,12 +1,11 @@ from typing import Any from pqcli.mechanic import Player -from pqcli.ui.curses.widgets import Focusable from .progress_bar_window import ListBoxProgressBarWindow -class QuestBookWindow(Focusable, ListBoxProgressBarWindow): +class QuestBookWindow(ListBoxProgressBarWindow): cutoff = 100 def __init__( diff --git a/pqcli/ui/curses/views/roster_view.py b/pqcli/ui/curses/views/roster_view.py index 163c753..e3e1183 100644 --- a/pqcli/ui/curses/views/roster_view.py +++ b/pqcli/ui/curses/views/roster_view.py @@ -38,15 +38,17 @@ class MainMenu(Menu): scr_height=scr_height, scr_width=scr_width, ) + pad = self._pad + assert pad is not None if has_colors(): for y in range(len(self._header_lines)): - self._pad.chgat(y, 0, curses.color_pair(COLOR_LOGO)) - self._pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT)) - self._pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT)) - self._pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT)) - self._pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT)) - self._pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT)) - self._pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT)) + pad.chgat(y, 0, curses.color_pair(COLOR_LOGO)) + pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT)) + pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT)) + pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT)) + pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT)) + pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT)) + pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT)) class RosterView(BaseView): diff --git a/pqcli/ui/curses/widgets/list_box.py b/pqcli/ui/curses/widgets/list_box.py index 3e0ed83..8177aaa 100644 --- a/pqcli/ui/curses/widgets/list_box.py +++ b/pqcli/ui/curses/widgets/list_box.py @@ -29,14 +29,14 @@ class ListBox(Scrollable): idx %= len(self._items) try: return self._items[idx] - except ValueError: + except IndexError: return None def delete(self, idx: int, count: int = 1) -> None: del self._items[idx : idx + count] def select(self, idx: int | None) -> None: - if idx < 0 and len(self._items): + if idx is not None and idx < 0 and self._items: idx %= len(self._items) self._selected = idx diff --git a/pqcli/ui/curses/widgets/menu.py b/pqcli/ui/curses/widgets/menu.py index 826f7db..4504d38 100644 --- a/pqcli/ui/curses/widgets/menu.py +++ b/pqcli/ui/curses/widgets/menu.py @@ -28,11 +28,12 @@ class Menu(Widget): w = max(map(len, all_lines)) + 1 h = len(all_lines) + 1 - self._pad: Any | None = curses.newpad(h, w) + pad = curses.newpad(h, w) + self._pad: Any | None = pad for y, line in enumerate(self._header_lines): - self._pad.move(y, 0) - self._pad.addstr(line) + pad.move(y, 0) + pad.addstr(line) self._active_choice = active_choice diff --git a/tests/game_trace.py b/tests/game_trace.py index 1d8bb91..eaf8de5 100644 --- a/tests/game_trace.py +++ b/tests/game_trace.py @@ -34,10 +34,12 @@ def run_trace(seed: str, ticks: int = 3000) -> list[str]: simulation = Simulation(player, data) lines: list[str] = [] - player.connect( - "new_task", - lambda: lines.append(f"task: {player.task.description}"), - ) + + def record_task() -> None: + assert player.task is not None + lines.append(f"task: {player.task.description}") + + player.connect("new_task", record_task) for _ in range(ticks): simulation.tick(100000.0) diff --git a/tests/test_game_data.py b/tests/test_game_data.py index dacd260..06bcc52 100644 --- a/tests/test_game_data.py +++ b/tests/test_game_data.py @@ -179,4 +179,4 @@ def test_game_data_is_immutable() -> None: data = load_game_data() assert isinstance(data, GameData) with pytest.raises(AttributeError): - data.spells = () # type: ignore[misc] + setattr(data, "spells", ())