"""The full 9x9 Sudoku board.
:class:`Grid` builds the 81 :class:`~dedoku.cell.Cell` objects, wires
each of them to its :class:`~dedoku.units.Row`,
:class:`~dedoku.units.Column`, and
:class:`~dedoku.units.Subgrid`, and offers parsing and serialisation
helpers.
"""
from __future__ import annotations
from typing import Iterator
from .cell import Cell
from .exceptions import ContradictionError, InvalidGridError
from .units import Column, Row, Subgrid, Unit
__all__ = ["Grid"]
_EMPTY_CHARS = ".0"
_IGNORED_CHARS = " \t\r\n|+-"
[docs]
class Grid:
"""A 9x9 Sudoku board made of cells, rows, columns, and subgrids.
A freshly constructed grid is empty: every cell is unsolved with all
nine candidates. Use :meth:`from_string` to load a puzzle.
"""
def __init__(self) -> None:
self._rows: tuple[Row, ...] = tuple(Row(i) for i in range(9))
self._columns: tuple[Column, ...] = tuple(Column(i) for i in range(9))
self._subgrids: tuple[Subgrid, ...] = tuple(Subgrid(i) for i in range(9))
cells: list[Cell] = []
for row_index in range(9):
for column_index in range(9):
cell = Cell(row_index, column_index)
cell.attach(
self._rows[row_index],
self._columns[column_index],
self._subgrids[self.subgrid_index(row_index, column_index)],
)
cells.append(cell)
self._cells: tuple[Cell, ...] = tuple(cells)
self._units: tuple[Unit, ...] = (
self._rows + self._columns + self._subgrids
)
# ------------------------------------------------------------------
# Construction
# ------------------------------------------------------------------
[docs]
@classmethod
def from_string(cls, text: str) -> Grid:
"""Build a grid from an 81-character puzzle description.
Cells are read left to right, top to bottom. Digits ``1``-``9``
are givens, while ``0`` and ``.`` mark empty cells. Whitespace and
the decoration characters ``|``, ``+``, ``-`` are ignored, so the
output of :meth:`__str__` can be parsed back.
:param text: The puzzle description.
:type text: str
:returns: A grid with all givens placed and candidates propagated.
:rtype: Grid
:raises InvalidGridError: If the description does not contain
exactly 81 cells, uses unexpected characters, or its givens
contradict each other.
"""
symbols: list[str] = []
for char in text:
if char in _IGNORED_CHARS:
continue
if char.isdigit() or char in _EMPTY_CHARS:
symbols.append(char)
else:
raise InvalidGridError(
f"unexpected character {char!r} in puzzle description"
)
if len(symbols) != 81:
raise InvalidGridError(
f"puzzle must describe 81 cells, got {len(symbols)}"
)
grid = cls()
for position, symbol in enumerate(symbols):
if symbol in _EMPTY_CHARS:
continue
try:
grid._cells[position].set_value(int(symbol))
except ContradictionError as exc:
raise InvalidGridError(
f"the givens contradict each other: {exc}"
) from exc
grid._cells[position].mark_as_given()
return grid
# ------------------------------------------------------------------
# Geometry helpers
# ------------------------------------------------------------------
[docs]
@staticmethod
def subgrid_index(row_index: int, column_index: int) -> int:
"""Return the subgrid index covering the given board position.
:param row_index: Zero-based row index (0-8).
:type row_index: int
:param column_index: Zero-based column index (0-8).
:type column_index: int
:returns: The zero-based subgrid index (0-8).
:rtype: int
"""
return (row_index // 3) * 3 + column_index // 3
[docs]
def cell(self, row_index: int, column_index: int) -> Cell:
"""Return the cell at the given board position.
:param row_index: Zero-based row index (0-8).
:type row_index: int
:param column_index: Zero-based column index (0-8).
:type column_index: int
:returns: The requested cell.
:rtype: Cell
:raises ValueError: If either index is outside the 0-8 range.
"""
if row_index not in range(9) or column_index not in range(9):
raise ValueError(
f"cell position must be within 0-8, "
f"got ({row_index}, {column_index})"
)
return self._cells[row_index * 9 + column_index]
# ------------------------------------------------------------------
# Collections
# ------------------------------------------------------------------
@property
def cells(self) -> tuple[Cell, ...]:
"""tuple[Cell, ...]: All 81 cells, left to right, top to bottom."""
return self._cells
@property
def rows(self) -> tuple[Row, ...]:
"""tuple[Row, ...]: The nine rows, top to bottom."""
return self._rows
@property
def columns(self) -> tuple[Column, ...]:
"""tuple[Column, ...]: The nine columns, left to right."""
return self._columns
@property
def subgrids(self) -> tuple[Subgrid, ...]:
"""tuple[Subgrid, ...]: The nine subgrids, left to right, top to bottom."""
return self._subgrids
@property
def units(self) -> tuple[Unit, ...]:
"""tuple[Unit, ...]: All 27 houses: rows, then columns, then subgrids."""
return self._units
def __iter__(self) -> Iterator[Cell]:
"""Iterate over all 81 cells in board order.
:returns: An iterator over the grid's cells.
:rtype: Iterator[Cell]
"""
return iter(self._cells)
# ------------------------------------------------------------------
# Board state
# ------------------------------------------------------------------
[docs]
def is_solved(self) -> bool:
"""Check whether every cell holds a value and the board is valid.
:returns: ``True`` if the puzzle is completely and correctly solved.
:rtype: bool
"""
return all(cell.is_solved for cell in self._cells) and self.is_valid()
[docs]
def is_valid(self) -> bool:
"""Check that no house contains a duplicated solved digit.
:returns: ``True`` if every row, column, and subgrid is valid.
:rtype: bool
"""
return all(unit.is_valid() for unit in self.units)
# ------------------------------------------------------------------
# Serialisation
# ------------------------------------------------------------------
[docs]
def to_string(self, empty: str = ".") -> str:
"""Serialise the board to an 81-character single-line string.
:param empty: The character used for unsolved cells.
:type empty: str
:returns: The board, left to right, top to bottom.
:rtype: str
"""
return "".join(
str(cell.value) if cell.is_solved else empty for cell in self._cells
)
def __str__(self) -> str:
"""Render the board as a human-readable 9x9 diagram.
:returns: A pretty-printed board with subgrid separators.
:rtype: str
"""
lines: list[str] = []
for row_index, row in enumerate(self._rows):
if row_index in (3, 6):
lines.append("------+-------+------")
chunks = []
for start in range(0, 9, 3):
chunks.append(
" ".join(
str(cell.value) if cell.is_solved else "."
for cell in row.cells[start:start + 3]
)
)
lines.append(" | ".join(chunks))
return "\n".join(lines)
def __repr__(self) -> str:
"""Return a debugging representation of the grid.
:returns: A string reporting how many cells are solved.
:rtype: str
"""
solved = sum(1 for cell in self._cells if cell.is_solved)
return f"<Grid {solved}/81 solved>"