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.
This commit is contained in:
Dash committed 2026-10-17 15:55:58 +02:00
1 parent 0c1d5185a4
commit 9155ef418b
14 files changed
+78 -49

No files matched your search

+19 -5
View File
@@ -1,10 +1,11 @@
import contextlib
import datetime import datetime
import functools import functools
import itertools import itertools
import logging import logging
import math import math
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Iterator, Sequence from collections.abc import Callable, Generator, Iterator, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, cast from typing import Any, cast
@@ -42,7 +43,21 @@ _SIGNALS: dict[tuple["SignalMixin", str], list[Callable[..., Any]]] = (
class SignalMixin: 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: def emit(self, signal_name: str, *user_data: Any) -> None:
if SignalMixin._muted:
return
for callback in _SIGNALS[self, signal_name]: for callback in _SIGNALS[self, signal_name]:
callback(*user_data) callback(*user_data)
@@ -683,12 +698,11 @@ class Simulation:
caption = "" caption = ""
choice = random.below(5) choice = random.below(5)
if choice == 0: if choice == 0:
self.player.quest_book.monster = unnamed_monster( monster = unnamed_monster(
self.data, self.player.level, iterations=3 self.data, self.player.level, iterations=3
) )
caption = "Exterminate " + definite( self.player.quest_book.monster = monster
self.player.quest_book.monster.name, 2 caption = "Exterminate " + definite(monster.name, 2)
)
elif choice == 1: elif choice == 1:
caption = "Seek " + definite(interesting_item(self.data), 1) caption = "Seek " + definite(interesting_item(self.data), 1)
elif choice == 2: elif choice == 2:
+2 -2
View File
@@ -1,6 +1,6 @@
import contextlib import contextlib
import time import time
from collections.abc import Callable, Iterator, Sequence from collections.abc import Callable, Generator, Sequence
from typing import Any from typing import Any
RngState = tuple[float, float, float, int] RngState = tuple[float, float, float, int]
@@ -91,7 +91,7 @@ def seed(source: str) -> None:
@contextlib.contextmanager @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.""" """Draw from rng instead of the shared generator inside the block."""
global _current global _current
previous = _current previous = _current
+2 -2
View File
@@ -3,7 +3,7 @@ import curses
import os import os
import signal import signal
import time import time
from typing import Any, cast from typing import Any
from pqcli import lingo from pqcli import lingo
from pqcli.config import Class, Race from pqcli.config import Class, Race
@@ -91,7 +91,7 @@ class CursesUserInterface(BaseUserInterface):
self._switch_to_roster_view() self._switch_to_roster_view()
while True: while True:
key = cast(int, self._screen.getch()) key = self._screen.getch()
if key == curses.ERR: if key == curses.ERR:
time.sleep(0.1) time.sleep(0.1)
+25 -11
View File
@@ -1,7 +1,8 @@
import argparse import argparse
import curses import curses
import curses.ascii
import datetime import datetime
from typing import Any from typing import Any, Protocol, runtime_checkable
from pqcli.game_data import GameData from pqcli.game_data import GameData
from pqcli.mechanic import Player, SignalMixin, Simulation from pqcli.mechanic import Player, SignalMixin, Simulation
@@ -16,7 +17,7 @@ from pqcli.ui.curses.util import (
KEYS_UP, KEYS_UP,
) )
from pqcli.ui.curses.views.base_view import BaseView 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 .character_sheet_window import CharacterSheetWindow
from .equipment_window import EquipmentWindow from .equipment_window import EquipmentWindow
@@ -27,6 +28,19 @@ from .spell_book_window import SpellBookWindow
from .task_progress_window import TaskProgressWindow 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): class GameView(BaseView):
def __init__( def __init__(
self, self,
@@ -53,11 +67,11 @@ class GameView(BaseView):
self.on_exit() self.on_exit()
elif key in {curses.KEY_PPAGE}: elif key in {curses.KEY_PPAGE}:
if hasattr(focused, "scroll_page_up"): if isinstance(focused, PageScrollable):
focused.scroll_page_up() focused.scroll_page_up()
elif key in {curses.KEY_NPAGE}: elif key in {curses.KEY_NPAGE}:
if hasattr(focused, "scroll_page_down"): if isinstance(focused, PageScrollable):
focused.scroll_page_down() focused.scroll_page_down()
elif key in KEYS_CYCLE: elif key in KEYS_CYCLE:
@@ -118,11 +132,9 @@ class GameView(BaseView):
self._simulation.tick() self._simulation.tick()
elif self._args.cheats and key == curses.ascii.DC4: # ^t elif self._args.cheats and key == curses.ascii.DC4: # ^t
old = SignalMixin.emit with SignalMixin.muted():
SignalMixin.emit = lambda *_: None
for _ in range(10000): for _ in range(10000):
self._simulation.tick() self._simulation.tick()
SignalMixin.emit = old
for child in self._children: for child in self._children:
child.sync() child.sync()
@@ -225,16 +237,18 @@ class GameView(BaseView):
x=0, x=0,
) )
self._focusable_children: list[Focusable] = [ focusable_children = (
self._char_sheet_win, self._char_sheet_win,
self._spell_book_win, self._spell_book_win,
self._equipment_win, self._equipment_win,
self._inventory_win, self._inventory_win,
self._plot_win, self._plot_win,
self._quest_book_win, self._quest_book_win,
] )
self._children: list[Widget] = self._focusable_children + [ self._focusable_children: list[Focusable] = list(focusable_children)
self._task_win self._children: list[GameWindow] = [
*focusable_children,
self._task_win,
] ]
def stop(self) -> None: def stop(self) -> None:
@@ -1,12 +1,11 @@
from typing import Any from typing import Any
from pqcli.mechanic import Player, StatType from pqcli.mechanic import Player, StatType
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import DataTableProgressBarWindow from .progress_bar_window import DataTableProgressBarWindow
class CharacterSheetWindow(Focusable, DataTableProgressBarWindow): class CharacterSheetWindow(DataTableProgressBarWindow):
def __init__( def __init__(
self, player: Player, parent: Any, h: int, w: int, y: int, x: int self, player: Player, parent: Any, h: int, w: int, y: int, x: int
) -> None: ) -> None:
@@ -1,12 +1,11 @@
from typing import Any from typing import Any
from pqcli.mechanic import InventoryItem, Player from pqcli.mechanic import InventoryItem, Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import DataTableProgressBarWindow from .progress_bar_window import DataTableProgressBarWindow
class InventoryWindow(Focusable, DataTableProgressBarWindow): class InventoryWindow(DataTableProgressBarWindow):
def __init__( def __init__(
self, player: Player, parent: Any, h: int, w: int, y: int, x: int self, player: Player, parent: Any, h: int, w: int, y: int, x: int
) -> None: ) -> None:
@@ -2,12 +2,11 @@ from typing import Any
from pqcli.lingo import act_name from pqcli.lingo import act_name
from pqcli.mechanic import Player from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import ListBoxProgressBarWindow from .progress_bar_window import ListBoxProgressBarWindow
class PlotWindow(Focusable, ListBoxProgressBarWindow): class PlotWindow(ListBoxProgressBarWindow):
cutoff = 100 cutoff = 100
def __init__( def __init__(
@@ -10,7 +10,7 @@ from pqcli.ui.curses.widgets import (
) )
class ProgressBarWindow(WindowWrapper): class ProgressBarWindow(Focusable, WindowWrapper):
def __init__( def __init__(
self, self,
parent: Any, parent: Any,
@@ -51,7 +51,7 @@ class ProgressBarWindow(WindowWrapper):
self._progress_bar_win.erase() 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( self._progress_bar_win.border(
curses.ACS_VLINE, curses.ACS_VLINE,
curses.ACS_VLINE, curses.ACS_VLINE,
@@ -105,7 +105,7 @@ class DataTableProgressBarWindow(ProgressBarWindow):
if not self._win: if not self._win:
return return
with Focusable.focus_standout(self, self._win): with self.focus_standout(self._win):
self._win.box() self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2) x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr( self._win.addnstr(
@@ -143,7 +143,7 @@ class ListBoxProgressBarWindow(ProgressBarWindow):
if not self._win: if not self._win:
return return
with Focusable.focus_standout(self, self._win): with self.focus_standout(self._win):
self._win.box() self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2) x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr( self._win.addnstr(
@@ -1,12 +1,11 @@
from typing import Any from typing import Any
from pqcli.mechanic import Player from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import ListBoxProgressBarWindow from .progress_bar_window import ListBoxProgressBarWindow
class QuestBookWindow(Focusable, ListBoxProgressBarWindow): class QuestBookWindow(ListBoxProgressBarWindow):
cutoff = 100 cutoff = 100
def __init__( def __init__(
+9 -7
View File
@@ -38,15 +38,17 @@ class MainMenu(Menu):
scr_height=scr_height, scr_height=scr_height,
scr_width=scr_width, scr_width=scr_width,
) )
pad = self._pad
assert pad is not None
if has_colors(): if has_colors():
for y in range(len(self._header_lines)): for y in range(len(self._header_lines)):
self._pad.chgat(y, 0, curses.color_pair(COLOR_LOGO)) pad.chgat(y, 0, curses.color_pair(COLOR_LOGO))
self._pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT)) 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(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT))
class RosterView(BaseView): class RosterView(BaseView):
+2 -2
View File
@@ -29,14 +29,14 @@ class ListBox(Scrollable):
idx %= len(self._items) idx %= len(self._items)
try: try:
return self._items[idx] return self._items[idx]
except ValueError: except IndexError:
return None return None
def delete(self, idx: int, count: int = 1) -> None: def delete(self, idx: int, count: int = 1) -> None:
del self._items[idx : idx + count] del self._items[idx : idx + count]
def select(self, idx: int | None) -> None: 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) idx %= len(self._items)
self._selected = idx self._selected = idx
+4 -3
View File
@@ -28,11 +28,12 @@ class Menu(Widget):
w = max(map(len, all_lines)) + 1 w = max(map(len, all_lines)) + 1
h = 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): for y, line in enumerate(self._header_lines):
self._pad.move(y, 0) pad.move(y, 0)
self._pad.addstr(line) pad.addstr(line)
self._active_choice = active_choice self._active_choice = active_choice
+6 -4
View File
@@ -34,10 +34,12 @@ def run_trace(seed: str, ticks: int = 3000) -> list[str]:
simulation = Simulation(player, data) simulation = Simulation(player, data)
lines: list[str] = [] lines: list[str] = []
player.connect(
"new_task", def record_task() -> None:
lambda: lines.append(f"task: {player.task.description}"), assert player.task is not None
) lines.append(f"task: {player.task.description}")
player.connect("new_task", record_task)
for _ in range(ticks): for _ in range(ticks):
simulation.tick(100000.0) simulation.tick(100000.0)
+1 -1
View File
@@ -179,4 +179,4 @@ def test_game_data_is_immutable() -> None:
data = load_game_data() data = load_game_data()
assert isinstance(data, GameData) assert isinstance(data, GameData)
with pytest.raises(AttributeError): with pytest.raises(AttributeError):
data.spells = () # type: ignore[misc] setattr(data, "spells", ())