3
\$\begingroup\$

...with properly packaged/type-hinted code, automated testing, and no dependencies. Do note that this package requires Python 3.12 or later.

Only pyproject.toml and non-test .py files are included here; still, there are about 1400 LOC. For non-code files (and/or a better code-reading experience), view the project on GitHub.

Please ignore my non-PEP8/PEP257-compliant code style. Other than that, suggestions regarding design patterns, naming conventions, APIs, etc. are all welcomed.

The project structure

.
├── LICENSE
├── README.md
├── pyproject.toml
├── src
│   ├── _hangman
│   │   └── runner.py
│   └── hangman
│       ├── __init__.py
│       ├── _lax_enum.py
│       ├── _static_reader.py
│       ├── assets
│       │   ├── gallows.txt
│       │   ├── head.txt
│       │   ├── instructions.txt
│       │   ├── left_arm.txt
│       │   ├── left_leg.txt
│       │   ├── right_arm.txt
│       │   ├── right_leg.txt
│       │   ├── title.txt
│       │   ├── trunk.txt
│       │   └── you_lost.txt
│       ├── canvas.py
│       ├── choice_list.py
│       ├── conversation.py
│       ├── game.py
│       ├── py.typed
│       ├── word.py
│       ├── word_list.py
│       └── words
│           ├── easy.txt
│           ├── hard.txt
│           ├── medium.txt
│           └── unix.txt
├── tests
└── tox.ini

pyproject.toml

[project]
name = "hangman"
version = "0.2.1"
description = "A CLI hangman game"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "Unlicense" }
authors = [
    { name = "InSyncWithFoo", email = "[email protected]" }
]
classifiers = [
    "License :: OSI Approved :: The Unlicense (Unlicense)",
    "Topic :: Games/Entertainment"
]

[project.optional-dependencies]
dev = [
    "build~=1.0.3",
    "mypy~=1.6.0",
    "pytest~=7.4.2",
    "pytest-cov~=4.1.0",
    "setuptools~=68.2.2",
    "tox~=4.11.3"
]

[project.urls]
Homepage = "https://github.com/InSyncWithFoo/hangman"

[project.scripts]
hangman = "_hangman.runner:main"

[build-system]
requires = ["setuptools>=68.0.0", "wheel"]
build-backend = "setuptools.build_meta"

[tool.setuptools]
package-data = { "*" = ["*.txt"] }

src/_hangman/runner.py

from hangman import Game


def main():
    Game().start()


if __name__ == '__main__':
    main()

src/hangman/init.py

from .canvas import Canvas, Layer
from .choice_list import ChoiceList, Choices
from .conversation import Conversation
from .game import Game
from .word_list import Level, WordList


__all__ = [
    'Game',
    'Canvas',
    'Choices',
    'ChoiceList',
    'Conversation',
    'Layer',
    'Level',
    'WordList'
]

src/hangman/_lax_enum.py

import re
from collections.abc import Generator
from re import Pattern
from typing import ClassVar


class LaxEnum(type):
    '''
    Despite its name, a LaxEnum is no different from
    a normal class except for that it yields every
    item that is not a dunder when being iterated over.
    '''
    
    _dunder: ClassVar[Pattern[str]] = re.compile(r'__.+__')
    
    def __iter__(cls) -> Generator[object, None, None]:
        for member_name, member in cls.__dict__.items():
            if not cls._dunder.fullmatch(member_name):
                yield member

src/hangman/_static_reader.py

from functools import partial
from os import PathLike
from pathlib import Path


package_directory = Path(__file__).resolve().parent
assets_directory = package_directory / 'assets'
word_list_directory = package_directory / 'words'


def _read(base_directory: Path, filename: PathLike[str]) -> str:
    '''
    Read a file and return its contents.
    
    :param base_directory: The base directory to look up the file
    :param filename: The name of the file
    :return: The contents of the file
    '''
    
    with open(base_directory / filename) as file:
        return file.read()


get_asset = partial(_read, assets_directory)
get_word_list = partial(_read, word_list_directory)

src/hangman/canvas.py

from __future__ import annotations

import dataclasses
from collections.abc import Collection, Generator, Iterator, Sequence
from dataclasses import dataclass
from functools import partial
from itertools import batched
from typing import overload, Self

from ._lax_enum import LaxEnum
from ._static_reader import get_asset


@dataclass(frozen = True)
class LayerCell:
    '''
    Represents a layer cell.
    
    ``value`` must be a single character.
    '''
    
    row: int
    column: int
    value: str
    
    def __post_init__(self) -> None:
        if len(self.value) != 1:
            raise ValueError('"value" must be a single character')
    
    @property
    def is_transparent(self) -> bool:
        '''
        Whether the value contains only whitespaces.
        '''
        
        return self.value.strip() == ''


_GridOfStrings = Sequence[Sequence[str]]


class Layer:
    
    r'''
    A rectangle grid of :class:`LayerCell`\ s.
    '''
    
    __slots__ = ('_cells', '_height', '_width')
    
    _cells: list[LayerCell]
    _height: int
    _width: int
    
    def __new__(cls, argument: _GridOfStrings) -> 'Self':  # PY-62301
        '''
        Construct a :class:`Layer`.
        
        :param argument: A grid of strings. Cannot be a string itself.
        '''
        
        if isinstance(argument, str):
            raise TypeError('"rows" must not be a string')
        
        instance = super().__new__(cls)
        grid = [list(row) for row in argument]
        first_row = grid[0]
        
        same_width = all(len(row) == len(first_row) for row in grid)
        
        if not same_width:
            raise ValueError('All rows must have the same width')
        
        instance._height = len(grid)
        instance._width = len(first_row)
        instance._cells = []
        
        for row_index, row in enumerate(grid):
            for column_index, cell_value in enumerate(row):
                cell = LayerCell(row_index, column_index, cell_value)
                
                instance._cells.append(cell)
        
        return instance
    
    def __repr__(self) -> str:
        horizontal_frame = f'+{'-' * self._width}+'
        
        joined_rows = (
            f'|{''.join(cell.value for cell in row)}|'
            for row in self.rows()
        )
        
        return '\n'.join([
            horizontal_frame,
            *joined_rows,
            horizontal_frame
        ])
    
    @overload
    def __getitem__(self, item: int) -> LayerCell:
        ...
    
    @overload
    def __getitem__(self, item: tuple[int, int]) -> LayerCell:
        ...
    
    def __getitem__(self, item: int | tuple[int, int]) -> LayerCell:
        if isinstance(item, int):
            return self._cells[item]
        
        if isinstance(item, tuple) and len(item) == 2:
            row_index, column_index = item
            
            if row_index >= self._height or column_index >= self._width:
                raise IndexError('Row or column index is out of bounds')
            
            index = self._width * row_index + column_index
            
            return self[index]
        
        raise TypeError(f'Invalid index')
    
    def __iter__(self) -> Iterator[LayerCell]:
        yield from self._cells
    
    def __len__(self) -> int:
        return len(self._cells)
    
    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Layer):
            return NotImplemented
        
        return self._cells == other._cells
    
    def __add__(self, other: Layer) -> Layer:
        if not isinstance(other, Layer):
            return NotImplemented
        
        copied = self.copy()
        copied += other
        
        return copied
    
    def __iadd__(self, other: Layer) -> Self:
        if not isinstance(other, Layer):
            return NotImplemented
        
        if self.height != other.height or self.width != other.width:
            raise ValueError(
                'To be added, two layers must have the same height and width'
            )
        
        copy_cell = dataclasses.replace
        
        for index, other_cell in enumerate(other):
            if not other_cell.is_transparent:
                self._cells[index] = copy_cell(other_cell)
        
        return self
    
    @property
    def height(self) -> int:
        '''
        The height of the layer.
        '''
        
        return self._height
    
    @property
    def width(self) -> int:
        '''
        The width of the layer.
        '''
        
        return self._width
    
    @classmethod
    def from_text(cls, text: str, width: int | None = None) -> Self:
        '''
        Construct a :class:`Layer` from a piece of text.
        
        :param text: Any string, with one or more lines.
        :param width: \
            The width of the layer.
        :return: A new :class:`Layer`.
        '''
        
        if width is None:
            width = -1
        
        lines = text.splitlines()
        longest_line_length = max(len(line) for line in lines)
        
        width = max([longest_line_length, width])
        
        return cls([line.ljust(width) for line in text.splitlines()])
    
    @classmethod
    def from_sequence(cls, cells: Sequence[str], width: int) -> Self:
        '''
        Construct a :class:`Layer` from a sequence of strings.
        
        :param cells: A :class:`Sequence` of strings.
        :param width: \
            The number of cells per chunk. \
            The last chunk is padded with spaces.
        :return: A new :class:`Layer`.
        '''
        
        rows = []
        
        for row in batched(cells, width):
            if len(row) < width:
                padder = ' ' * (width - len(row))
                rows.append([*row, *padder])
            else:
                rows.append(list(row))
        
        return cls(rows)
    
    def rows(self) -> Generator[tuple[LayerCell, ...], None, None]:
        r'''
        Yield a tuple of :class:`LayerCell`\ s for each row.
        '''
        
        yield from batched(self._cells, self._width)
    
    def columns(self) -> Generator[tuple[LayerCell, ...], None, None]:
        r'''
        Yield a tuple of :class:`LayerCell`\ s for each column.
        '''
        
        for column in zip(*self.rows()):
            yield column
    
    def cells(self) -> Generator[LayerCell, None, None]:
        '''
        Synonym of :meth:`__iter__`.
        '''
        
        yield from self
    
    def copy(self) -> Self:
        '''
        Construct a new :class:`Layer` from this one.
        '''
        
        string_cells = [cell.value for cell in self]
        
        return self.__class__.from_sequence(string_cells, self._width)


class Canvas(Collection[Layer]):
    
    r'''
    A collection of :class:`Layers`.
    Its string representation is that of all its layers merged.
    '''
    
    __slots__ = ('_height', '_width', '_layers')
    
    _height: int
    _width: int
    _layers: list[Layer]
    
    def __init__(self, height: int, width: int) -> None:
        '''
        Construct a :class:`Canvas` of given height and width.
        
        :param height: The height of the canvas.
        :param width: The width of the canvas.
        '''
        
        self._height = height
        self._width = width
        self._layers = []
    
    def __str__(self) -> str:
        if not self._layers:
            return '\n'.join([' ' * self._width] * self._height)
        
        first, *others = self._layers
        flattened = sum(others, start = first)
        
        joined_rows = [
            ''.join(cell.value for cell in row)
            for row in flattened.rows()
        ]
        
        return '\n'.join(joined_rows)
    
    def __contains__(self, layer: object) -> bool:
        return layer in self._layers
    
    def __iter__(self) -> Iterator[Layer]:
        return iter(self._layers)
    
    def __len__(self) -> int:
        return len(self._layers)
    
    @property
    def height(self) -> int:
        '''
        The height of the canvas.
        '''
        
        return self._height
    
    @property
    def width(self) -> int:
        '''
        The width of the canvas.
        '''
        
        return self._width
    
    @property
    def layers(self) -> Generator[Layer, None, None]:
        '''
        Yield every layer the canvas contains.
        '''
        
        for layer in self._layers:
            yield layer
    
    @classmethod
    def from_layer(cls, layer: Layer) -> Self:
        '''
        Construct a :class:`Canvas` from a layer
        using its height and width.
        
        :param layer: A :class:`Layer`.
        :return: A new :class:`Canvas`.
        '''
        
        canvas = cls(height = layer.height, width = layer.width)
        canvas.add_layers(layer)
        
        return canvas
    
    def _fits_layer(self, layer: Layer) -> bool:
        '''
        Whether the layer has same height and width as the canvas.
        
        :param layer: A :class:`Layer`.
        :return: ``True`` if the layer fits, ``False`` otherwise.
        '''
        
        return self._height == layer.height and self._width == layer.width
    
    def add_layers(self, *layers: Layer) -> None:
        r'''
        Add one or more :class:`Layer`\ s to the canvas.
        
        :param layers: One or more :class:`Layer`\ s.
        '''
        
        if not all(self._fits_layer(layer) for layer in layers):
            raise ValueError(
                'Layers must have same height and width as canvas'
            )
        
        self._layers.extend(layers)


_make_80_wide_layer = partial(Layer.from_text, width = 80)


class Component(metaclass = LaxEnum):
    r'''
    Pre-built :class:`Layer`\ s to be used in the game.
    '''
    
    GALLOWS = _make_80_wide_layer(get_asset('gallows.txt'))
    HEAD = _make_80_wide_layer(get_asset('head.txt'))
    TRUNK = _make_80_wide_layer(get_asset('trunk.txt'))
    LEFT_ARM = _make_80_wide_layer(get_asset('left_arm.txt'))
    RIGHT_ARM = _make_80_wide_layer(get_asset('right_arm.txt'))
    LEFT_LEG = _make_80_wide_layer(get_asset('left_leg.txt'))
    RIGHT_LEG = _make_80_wide_layer(get_asset('right_leg.txt'))
    YOU_LOST = _make_80_wide_layer(get_asset('you_lost.txt'))

src/hangman/choice_list.py

from __future__ import annotations

from collections.abc import Generator
from dataclasses import dataclass
from typing import Mapping, NamedTuple, Self

from ._lax_enum import LaxEnum
from .word_list import Level


@dataclass(frozen = True, slots = True)
class Choice:
    '''
    Represents a valid choice of a :class:`ChoiceList`.
    '''
    
    shortcut: str
    description: str
    aliases: frozenset[str]
    value: str | None = None
    
    def __str__(self) -> str:
        return f'[{self.shortcut}] {self.description}'


_ChoiceDescriptor = tuple[str, set[str], str | None]


class ChoiceDescriptor(NamedTuple):
    '''
    Syntactic sugar for a bare tuple containing three elements:
    ``description``, ``aliases``, and ``value``.
    '''
    
    description: str
    aliases: set[str] = set()
    value: str | None = None


class _LengthList(list[int]):
    '''
    A list of integers which keeps track of the sum.
    Meant for internal use only.
    '''
    
    total: int
    
    def __new__(cls) -> 'Self':  # PY-62301
        instance = super().__new__(cls)
        instance.total = 0
        
        return instance
    
    def append(self, value: int) -> None:
        '''
        Append an integer value to the end of
        the list and add it the total.
        
        :param value: A length.
        '''
        self.total += value
        super().append(value)


class ChoiceList:
    
    __slots__ = ('_shortcut_map', '_alias_map', 'max_width', 'separator')
    
    _shortcut_map: dict[str, Choice]
    _alias_map: dict[str, Choice]
    
    separator: str
    max_width: int
    
    def __new__(
        cls, /,
        argument: Mapping[str, _ChoiceDescriptor] | None = None, *,
        separator: str = '  ',
        max_width: int = 80,
        **kwargs: _ChoiceDescriptor
    ) -> 'Self':  # PY-62301
        r'''
        Construct a list of valid choices whose string
        representation looks like the following::
        
            [A] Foobar bazqux  [BAR] Lorem ipsum
            [C] Consectetur adipiscing elit
        
        All shorcuts and aliases are case-insensitive and
        mapped to their corresponding :class:`Choice`.
        Shortcuts are uppercased in the string representation.
        
        A :class:`Choice` can be chosen by referencing
        either ``shortcut`` or any of the ``aliases``.
        
        ``argument`` and ``kwargs`` are shortcut-to-:class:`ChoiceDescriptor`
        maps. Each ``shortcut`` *should* be a single character,
        whereas the ``description``\ s need to be human-readable.
        ``value`` is the value the choice represents,
        defaults to ``None``.
        
        :param argument: A :class:``collections.abc.Mapping``.
        :param separator: \
            A string to be used as the separator
            in the string representation.
        :param max_width: \
            The maximum width of the string
            representation, in characters.
        :param kwargs: \
            Other shortcut-to-descriptor arguments.
        '''
        
        if isinstance(argument, Mapping):
            kwargs = {**argument, **kwargs}
        
        instance = super().__new__(cls)
        
        instance.separator = separator
        instance.max_width = max_width
        
        shortcut_map = instance._shortcut_map = {}
        alias_map = instance._alias_map = {}
        
        for shortcut, (description, aliases, value) in kwargs.items():
            shortcut = shortcut.upper()
            uppercased_aliases = frozenset(alias.upper() for alias in aliases)
            
            choice = Choice(
                shortcut, description,
                uppercased_aliases, value
            )
            
            shortcut_map[shortcut] = choice
            
            for alias in uppercased_aliases:
                alias_map[alias] = choice
        
        return instance
    
    def __contains__(self, item: object) -> bool:
        if not isinstance(item, str):
            return False
        
        item = item.upper()
        
        return item in self._shortcut_map or item in self._alias_map
    
    def __getitem__(self, item: str) -> Choice:
        item = item.upper()
        
        if item in self._shortcut_map:
            return self._shortcut_map[item]
        
        return self._alias_map[item]
    
    def __str__(self) -> str:
        output: list[list[str]] = [[]]
        lengths: list[_LengthList] = [_LengthList()]
        
        for choice in self:
            choice_stringified = str(choice)
            choice_length = len(choice_stringified)
            
            total_choice_length = lengths[-1].total + choice_length
            total_separator_length = len(self.separator) * len(lengths[-1])
            new_last_row_length = total_choice_length + total_separator_length
            
            if new_last_row_length > self.max_width:
                output.append([])
                lengths.append(_LengthList())
            
            output[-1].append(choice_stringified)
            lengths[-1].append(choice_length)
        
        return '\n'.join(self.separator.join(row) for row in output)
    
    def __repr__(self) -> str:
        return (
            f'{self.__class__.__name__}(' +
            ', '.join(repr(choice) for choice in self) +
            f')'
        )
    
    def __iter__(self) -> Generator[Choice, None, None]:
        yield from self._shortcut_map.values()


class Choices(metaclass = LaxEnum):
    '''
    Pre-built instances of :class:`ChoicesList`.
    '''
    
    CONFIRMATION = ChoiceList(
        Y = ChoiceDescriptor(
            description = 'Yes',
            aliases = {'Yes'},
            value = 'YES'
        ),
        N = ChoiceDescriptor(
            description = 'No',
            aliases = {'No'},
            value = 'NO'
        )
    )
    LEVEL = ChoiceList(
        E = ChoiceDescriptor(
            description = 'Easy (22.5k words)',
            aliases = {'EASY'},
            value = Level.EASY
        ),
        M = ChoiceDescriptor(
            description = 'Medium (74.5k words)',
            aliases = {'MEDIUM'},
            value = Level.MEDIUM
        ),
        H = ChoiceDescriptor(
            description = 'Hard (168k words)',
            aliases = {'HARD'},
            value = Level.HARD
        ),
        U = ChoiceDescriptor(
            description = 'Unix (205k words)',
            aliases = {'UNIX'},
            value = Level.UNIX
        )
    )

src/hangman/conversation.py

from __future__ import annotations

from collections.abc import Callable, Generator
from dataclasses import dataclass
from typing import ClassVar, Literal

from .choice_list import ChoiceList


def _response_is_valid_choice(
    response: str,
    choices: ChoiceList | None
) -> bool:
    '''
    Checks if the response is a valid choice.
    '''
    
    assert choices is not None
    
    return response in choices


def _no_op(_response: str, _choices: ChoiceList | None) -> Literal[True]:
    '''
    A validator that always returns ``True``.
    '''
    
    return True


@dataclass(frozen = True, slots = True, eq = False)
class Validator:
    '''
    Callable wrapper for a validator function.
    The second argument is the warning message
    to be output when this validator fails.
    '''
    
    predicate: ResponseValidator
    warning: str
    
    def __call__(self, response: str, choices: ChoiceList | None) -> bool:
        return self.predicate(response, choices)


InputGetter = Callable[[str], str]
OutputDisplayer = Callable[[str], None]
ResponseValidator = Callable[[str, ChoiceList | None], bool]

OneOrManyValidators = Validator | list[Validator]

_FailingValidators = Generator[Validator, None, None]


class Conversation:
    
    '''
    Protocol for input-output operations.
    '''
    
    _INVALID_RESPONSE: ClassVar[str] = \
        'Invalid response. Please try again.'
    _INVALID_CHOICE: ClassVar[str] = \
        'Invalid choice. Please try again.'
    
    __slots__ = ('_input', '_output')
    
    _input: InputGetter
    _output: OutputDisplayer
    
    def __init__(self, ask: InputGetter, answer: OutputDisplayer) -> None:
        '''
        Construct a :class:`Conversation`.
        
        :param ask: A ``input``-like callable to be called for inputs.
        :param answer: A ``print``-like callable to be called to output.
        '''
        
        self._input = ask
        self._output = answer
    
    def _get_response(self, prompt: str) -> str:
        '''
        Get a raw response.
        
        :param prompt: The prompt to be used.
        :return: The response, uppercased.
        '''
        
        return self._input(prompt).upper()
    
    def _ask(
        self,
        prompt: str, /,
        choices: ChoiceList | None = None, *,
        validators: list[Validator]
    ) -> str:
        '''
        Get a response, then validate it against the validators.
        Repeat this process until the response passes all validations.
        '''
        
        failing_validators: Callable[[], _FailingValidators] = lambda: (
            validator for validator in validators
            if not validator(response, choices)
        )
        find_first_failing_validator: Callable[[], Validator | None] = \
            lambda: next(failing_validators(), None)
        
        response = self._get_response(prompt)
        
        while failing_validator := find_first_failing_validator():
            self.answer(failing_validator.warning)
            response = self._get_response(prompt)
        
        return response
    
    def ask(
        self,
        question: str, /,
        choices: ChoiceList | None = None, *,
        until: OneOrManyValidators | None = None
    ) -> str:
        r'''
        Thin wrapper around :meth:`_ask`.
        
        If ``choices`` is given, it will be included in
        the prompt text. If ``until`` is ``None``, a
        default validator will be used to check if
        the response is a valid choice.
        
        If both ``choices`` and ``until`` are ``None``,
        no validators will be applied.
        
        :param question: The question to ask.
        :param choices: The choices to choose from. Optional.
        :param until: \
            A :class:`Callable`, a :class:`Validator` or
            a list of :class:`Validator`\ s. Optional.
        :return: The response of the user.
        '''
        
        prompt = f'{question}\n'
        prompt += f'{choices}\n' if choices is not None else ''
        
        if choices is None and until is None:
            validators = [Validator(_no_op, '')]
        elif until is None:
            validators = [
                Validator(_response_is_valid_choice, self._INVALID_CHOICE)
            ]
        else:
            validators = [until] if callable(until) else until
        
        return self._ask(
            prompt, choices,
            validators = validators
        )
    
    def answer(self, answer: str) -> None:
        '''
        Outputs the caller's message.
        
        :param answer: The message to output.
        '''
        
        self._output(answer)

src/hangman/game.py

from __future__ import annotations

from typing import ClassVar

from ._static_reader import get_asset
from .canvas import Canvas, Component, Layer
from .choice_list import ChoiceList, Choices
from .conversation import (
    Conversation,
    InputGetter,
    OneOrManyValidators,
    OutputDisplayer,
    Validator
)
from .word import Word
from .word_list import Level, WordList


def _response_is_ascii_letter(character: str, _: ChoiceList | None) -> bool:
    return len(character) == 1 and 'A' <= character <= 'Z'


class Game:
    
    '''
    The Hangman game.
    '''
    
    TITLE: ClassVar[str] = get_asset('title.txt')
    INSTRUCTIONS: ClassVar[str] = get_asset('instructions.txt')
    
    COEFFICENTS: ClassVar[dict[Level, int]] = {
        Level.EASY: 1,
        Level.MEDIUM: 2,
        Level.HARD: 3,
        Level.UNIX: 4
    }
    
    _MAX_DISPLAY_WIDTH: ClassVar[int] = 80
    
    __slots__ = (
        '_conversation', '_used_words', '_points',
        '_reward', '_penalty', '_ended'
    )
    
    _conversation: Conversation
    _used_words: set[str]
    _points: int
    _reward: int
    _penalty: int
    _ended: bool
    
    def __init__(
        self,
        input_getter: InputGetter = input,
        output_displayer: OutputDisplayer = print,
        reward: int = 2,
        penalty: int = -1
    ) -> None:
        '''
        Initialize a new game.
        
        See :class:`Conversation` for more information on
        ``input_getter`` and ``output_displayer``.
        
        :param input_getter: \
            An ``input``-like function. Defaults to ``input``.
        :param output_displayer: \
            A ``print``-like function. Defaults to ``print``.
        :param reward: \
            The number of points to be added to
            the total on each correct guess.
        :param penalty: \
            The number of points to be subtracted from
            the total on each incorrect guess.
        '''
        
        self._used_words = set()
        self._points = 0
        self._reward, self._penalty = reward, penalty
        
        self._ended = False
        
        self._conversation = Conversation(
            ask = input_getter,
            answer = output_displayer
        )
    
    @property
    def points(self) -> int:
        '''
        The total points earned in this game.
        '''
        
        return self._points
    
    @points.setter
    def points(self, value: int) -> None:
        '''
        Called on operations such as the following:
        
            game.points += 1
        
        The number of points cannot be negative.
        '''
        
        self._points = max(value, 0)
    
    def _start(self) -> None:
        '''
        Output the title, the instructions, then start
        the first :class:`GameRound`. If that round is
        won and the user wants to continue, start another.
        
        Otherwise, if the game has not ended (user did
        not lose in the latest round), end the game.
        '''
        
        self._output_game_title()
        self._output_game_instructions()
        
        self._start_round()
        
        while not self._ended and self._prompt_for_continue_confirmation():
            self._start_round()
        
        if not self._ended:
            self.end()
    
    def _output_game_title(self) -> None:
        '''
        Output the title, which is just some fancy ASCII art.
        '''
        
        self.output(self.TITLE)
    
    def _output_game_instructions(self) -> None:
        '''
        Output the instructions.
        '''
        
        self.output(self.INSTRUCTIONS)
    
    def _prompt_for_continue_confirmation(self) -> bool:
        '''
        Ask for a response until it is a yes/no answer.
        
        :return: Whether the user wants to continue.
        '''
        
        answer = self.input('Continue?', Choices.CONFIRMATION)
        
        return answer in ('Y', 'YES')
    
    def _start_round(self) -> None:
        '''
        Ask for a level, construct a :class:`WordList` and
        a coefficient from that level, then get a random
        word that has not been used.
        
        Finally, initialize a :class:`GameRound` by passing
        the word and the coefficent to it.
        '''
        
        level = self._prompt_for_level()
        word_list = WordList.from_level(level)
        coefficient = self.COEFFICENTS[level]
        
        word = word_list.get_random_word()
        
        while word in self._used_words:
            word = word_list.get_random_word()
        
        self._used_words.add(word)
        
        game_round = self._initialize_round(word, coefficient)
        game_round.start()
    
    def _initialize_round(self, word: str, coefficient: int) -> GameRound:
        '''
        Pass ``word`` and ``coefficient`` as arguments
        to :class:`GameRound`.
        '''
        
        return GameRound(self, word, coefficient)
    
    def _prompt_for_level(self) -> Level:
        '''
        Ask for a response until it is a valid level.
        
        :return: The corresponding :class:`Level`.
        '''
        
        choices = Choices.LEVEL
        response = self._conversation.ask('Choose a level:', choices)
        
        value = choices[response].value
        
        assert value is not None
        
        return Level(value)
    
    def start(self) -> None:
        '''
        Start the game. If a :class:`KeyboardInterrupt`
        is caught, call :meth:`end`.
        '''
        
        try:
            self._start()
        except KeyboardInterrupt:
            self.end()
    
    def end(self) -> None:
        '''
        Switch a boolean flag and call
        :meth:`output_current_points`.
        '''
        
        self._ended = True
        self.output('Game over.'.center(self._MAX_DISPLAY_WIDTH, '-'))
        self.output_current_points()
    
    def input(
        self,
        prompt: str,
        choices: ChoiceList | None = None,
        validators: OneOrManyValidators | None = None
    ) -> str:
        '''
        Shorthand for ``self.conversation.ask``.
        '''
        
        return self._conversation.ask(prompt, choices, until = validators)
    
    def output(self, answer: str) -> None:
        '''
        Shorthand for ``self.conversation.answer``.
        '''
        
        return self._conversation.answer(answer)
    
    def output_current_points(self) -> None:
        '''
        Output the total number of points earned.
        '''
        
        self.output(f'Points: {self._points}')
    
    def reward_correct_guess(self, count: int, coefficient: int) -> None:
        '''
        Add ``reward`` multiplied by ``coefficient`` and ``count``
        to the number of points.
        '''
        
        self.points += self._reward * count * coefficient
    
    def penalize_incorrect_guess(self, coefficient: int) -> None:
        '''
        Substract ``penalty`` multiplied by ``coefficient``
        from the number of points.
        '''
        
        self.points += self._penalty * coefficient


class GameRound:
    
    '''
    A game round. The game ends when
    a game round ends with a loss.
    '''
    
    _INVALID_GUESS: ClassVar[str] = \
        'Invalid guess. Please input a letter.'
    _ALREADY_GUESSED: ClassVar[str] = \
        'You have already guessed this letter. Please try again.'
    
    __slots__ = (
        '_game', '_canvas', '_layer_stack',
        '_word', '_coefficient', '_guesses'
    )
    
    _game: Game
    _canvas: Canvas
    _layer_stack: list[Layer]
    _word: Word
    _coefficient: int
    _guesses: set[str]
    
    def __init__(self, game: Game, word: str, coefficient: int) -> None:
        '''
        Initialize a game round.
        
        There are initially 6 layers in the stack.
        Each incorrect guess pops one from the stack
        and adds it to the canvas. When the stack
        reaches 0, the entire game is over.
        
        See :class:`Word` for relevant checking logic.
        
        :param game: The game this round belongs to.
        :param word: The word to guess in this round.
        :param coefficient: \
            The coefficient corresponding to the level of this round.
        '''
        
        self._game = game
        self._canvas = Canvas.from_layer(Component.GALLOWS)
        self._layer_stack = [
            Component.HEAD,
            Component.TRUNK,
            Component.LEFT_ARM,
            Component.RIGHT_ARM,
            Component.LEFT_LEG,
            Component.RIGHT_LEG
        ]
        self._word = Word(word)
        self._coefficient = coefficient
        self._guesses = set()
    
    @property
    def lives_left(self) -> int:
        '''
        The number of layers left in the stack.
        '''
        
        return len(self._layer_stack)
    
    def _output_canvas(self) -> None:
        '''
        Output the canvas with all components
        lost via incorrect guesses.
        '''
        
        self._game.output(str(self._canvas))
    
    def _output_current_word_state(self) -> None:
        '''
        Output the word with unknown characters masked.
        '''
        
        self._game.output(f'Word: {self._word.current_state}')
    
    def _output_word(self) -> None:
        '''
        Output the word. Only called when the game is over.
        '''
        
        self._game.output(f'The word was "{self._word}".')
    
    def _start_turn(self) -> None:
        '''
        Call :meth:`_output_canvas` and :meth:`_output_current_word_state`.
        Ask for a new guess, then check it against
        the word and handle the result accordingly.
        '''
        
        self._output_canvas()
        self._output_current_word_state()
        
        guess = self._prompt_for_guess()
        count = self._word.count(guess)
        
        self._guesses.add(guess)
        
        if count == 0:
            self._handle_incorrect_guess()
        else:
            self._handle_correct_guess(guess, count)
    
    def _handle_incorrect_guess(self) -> None:
        '''
        Output a notice, then call :meth:`Game.penalize_incorrect_guess`
        and :meth:`Game.output_current_points`.
        
        Also call :meth:`_minus_1_life`. If the number of lives left
        is 0, add :attr:`Component.YOU_LOST` to the canvas.
        '''
        
        self._game.output('Incorrect guess.')
        self._game.penalize_incorrect_guess(self._coefficient)
        self._game.output_current_points()
        
        self._minus_1_life()
        
        if self.lives_left == 0:
            self._canvas.add_layers(Component.YOU_LOST)
    
    def _handle_correct_guess(self, guess: str, count: int) -> None:
        '''
        Output a notice, then call :meth:`Game.reward_correct_guess`
        and :meth:`Game.output_current_points`.
        
        :param guess: The character guessed.
        :param count: The number of that character's appearances in the word.
        '''
        
        if count == 1:
            self._game.output(f'There is {count} "{guess}"!')
        else:
            self._game.output(f'There are {count} "{guess}"s!')
        
        self._game.reward_correct_guess(count, self._coefficient)
        self._game.output_current_points()
    
    def _prompt_for_guess(self) -> str:
        '''
        Ask for a new guess which must be an ASCII letter.
        
        :return: The guess.
        '''
        
        validators = [
            Validator(_response_is_ascii_letter, self._INVALID_GUESS),
            Validator(self._not_previously_guessed, self._ALREADY_GUESSED)
        ]
        
        return self._game.input('Your guess:', validators = validators)
    
    def _not_previously_guessed(
        self,
        response: str,
        _choices: ChoiceList | None
    ) -> bool:
        '''
        Check if ``response`` is a previous guess.
        Meant to be called in :meth:`_prompt_for_guess`.
        
        :param response: The response to check.
        :return: Whether ``response`` is a previous guess.
        '''
        
        return response not in self._guesses
    
    def _minus_1_life(self) -> None:
        '''
        Pops a layer from the stack and add it to the canvas.
        '''
        
        self._canvas.add_layers(self._layer_stack.pop(0))
    
    def start(self) -> None:
        '''
        While the word is not completely solved and there
        are still some lives left, start a turn.
        
        If there are no lives left (user lost the game),
        call :meth:`_output_canvas` and :meth:`_output_word`,
        then :meth:`Game.end`.
        Otherwise, :meth:`_output_current_word_state`.
        '''
        
        while not self._word.all_clear and self.lives_left:
            self._start_turn()
        
        if not self.lives_left:
            self._output_canvas()
            self._output_word()
            self._game.end()
        else:
            self._output_current_word_state()

src/hangman/word.py

class Word:
    
    '''
    A word being guessed.
    '''
    
    __slots__ = ('value', '_character_indices', '_masked')
    
    value: str
    _masked: list[str]
    _character_indices: dict[str, list[int]]
    
    def __init__(self, value: str) -> None:
        self.value = value.upper()
        self._masked = ['_'] * len(value)
        self._character_indices = {}
        
        for index, character in enumerate(self.value):
            self._character_indices.setdefault(character, []).append(index)
    
    def __str__(self) -> str:
        return self.value
    
    def __contains__(self, item: str) -> bool:
        return item.upper() in self._character_indices
    
    @property
    def current_state(self) -> str:
        '''
        The letters, space-separated; unguessed
        ones are replaced with underscores.
        '''
        
        return ' '.join(self._masked)
    
    @property
    def all_clear(self) -> bool:
        '''
        Whether all letters have been guessed correctly.
        '''
        
        return all(char != '_' for char in self._masked)
    
    def count(self, guess: str) -> int:
        '''
        Count the guess's appearances in the word
        and replace those with underscores in the
        current state.
        
        :param guess: A letter.
        :return: The number of its appearances.
        '''
        
        if guess not in self:
            return 0
        
        indices = self._character_indices[guess]
        
        for index in indices:
            self._masked[index] = guess
        
        return len(indices)

src/hangman/word_list.py

import random
from enum import StrEnum
from os import PathLike
from typing import ClassVar, final, Self

from ._static_reader import word_list_directory


class Level(StrEnum):
    EASY = 'EASY'
    MEDIUM = 'MEDIUM'
    HARD = 'HARD'
    UNIX = 'UNIX'


@final
class WordList:
    
    _instances: ClassVar[dict[str, Self]] = {}
    
    __slots__ = tuple(['_list'])
    
    _list: list[str]
    
    def __new__(cls, filename: str | PathLike[str]) -> 'Self':  # PY-62301
        filename = str(filename)
        
        if filename not in cls._instances:
            cls._instances[filename] = instance = super().__new__(cls)
            
            with open(filename) as file:
                instance._list = file.read().splitlines()
        
        return cls._instances[filename]
    
    def __len__(self) -> int:
        return len(self._list)
    
    def get_random_word(self) -> str:
        return random.choice(self._list)
    
    @classmethod
    def from_list(cls, words: list[str]) -> Self:
        instance = super().__new__(cls)
        instance._list = words
        
        return instance
    
    @classmethod
    def from_level(cls, level: str) -> Self:
        match level.upper():
            case Level.EASY:
                filename = 'easy.txt'
            case Level.MEDIUM:
                filename = 'medium.txt'
            case Level.HARD:
                filename = 'hard.txt'
            case Level.UNIX:
                filename = 'unix.txt'
            case _:
                raise ValueError('No such level')
        
        return cls(word_list_directory / filename)
\$\endgroup\$
0

1 Answer 1

3
\$\begingroup\$

Overall a well-organized and carefully-crafted project that I enjoyed reviewing. The main things that jumped out at me came from conversation.py, so with that in mind here are my points of feedback:

ask

In ask, the branching behavior based on how the optional parameters are set makes it harder for the reader to understand. The optional parameters also allow the client to use the method in a probably not-intended way, e.g. by passing in both a non-empty choices and non-empty until.

Some cleaner alternatives:

  1. Explicitly create distinct methods, e.g. ask_choices(self, choices: ChoiceList, question: str) and ask_until(until: OneOrManyValidators, question: str).

  2. Use functools.singledispatchmethod to implement method overloading based on whether the client gives you a ChoiceList or OneOrManyValidators.

As for the case where neither choices or until is provided, since it's a case that's currently not being used anywhere in the project I'd just remove it and not handle it (i.e. YAGNI).

ResponseValidator

ResponseValidator can and should be simplified from Callable[[str, ChoiceList | None], bool] to Callable[[str], bool], and by doing so we can avoid the ChoiceList | None parameter leaking into other validators and method signatures.

To do this, we can create a closure with the list of choices (choices: ChoiceList) baked in. The resulting closure has the signature we want, i.e. Callable[[str], bool].

def create_choice_validator(choices: ChoiceList) -> Callable[[str], bool]:
    def fn(response: str) -> bool:
        return response in choices

    return fn

We can then use this closure to create a new Validator:

response_is_valid_choice = create_choice_validator(choices)
validators = [Validator(response_is_valid_choice, self._INVALID_CHOICE)]

_ask

While looking over _ask, I had to re-read it several times to pick up on how the updated response within the while loop was getting re-evaluated in the failing_validators lambda.

The following has a bit more ceremony with the creation of a closure, but I think it makes it easier to follow the flow of validation checking.

def _ask(self, ..., validators: list[Validator]) -> str:
    def create_validator_checker(validators: list[Validator]) -> Callable[[str], Validator | None]:
        def fn(response: str) -> Validator | None:
            for validator in validators:
                if not validator(response):
                    return validator

        return fn

    maybe_first_failing_validator = create_validator_checker(validators)

    response = self._get_response(prompt)
    while failing_validator := maybe_first_failing_validator(response):
        self.answer(failing_validator.warning)
        response = self._get_response(prompt)

Testing the Core Business Logic

I always love to see tests, so kudos for creating them. Noticeably missing from the collection, however, is a test suite that tests the core business logic of the game, for example:

  • When the player makes a correct guess, the correct corresponding letters in the target word are revealed, and the score is updated accordingly.
  • When the player makes an incorrect guess, no letters in the target word are revealed, 1 life is deducted, and the score is updated accordingly.
  • When the player correctly guesses all the letters in the target word, they win the game.
  • When the player loses all of their lives, they lose the game.

This might require refactoring Game and/or GameRound to expose the relevant parts of internal game state so they can be more easily tested, but it will be well worth the effort.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.