dedoku 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dedoku/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """A pure-Python Sudoku solver based on human-style logical deduction.
2
+
3
+ The package exposes an object-oriented board model (:class:`Cell`,
4
+ :class:`Row`, :class:`Column`, :class:`Subgrid`, :class:`Grid`) and a
5
+ logic-only solving engine (:class:`SudokuSolver`) that never resorts to
6
+ backtracking. It has no external dependencies.
7
+
8
+ Individual techniques live in :mod:`dedoku.techniques`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .cell import DIGITS, Cell
14
+ from .exceptions import ContradictionError, InvalidGridError, SudokuError
15
+ from .grid import Grid
16
+ from .solver import SolveResult, SudokuSolver
17
+ from .techniques import Step, Technique
18
+ from .units import Column, Row, Subgrid, Unit
19
+
20
+ __version__ = "0.3.0"
21
+
22
+ __all__ = [
23
+ "DIGITS",
24
+ "Cell",
25
+ "Column",
26
+ "ContradictionError",
27
+ "Grid",
28
+ "InvalidGridError",
29
+ "Row",
30
+ "SolveResult",
31
+ "Step",
32
+ "Subgrid",
33
+ "SudokuError",
34
+ "SudokuSolver",
35
+ "Technique",
36
+ "Unit",
37
+ "__version__",
38
+ ]
dedoku/cell.py ADDED
@@ -0,0 +1,306 @@
1
+ """Cell model for the Sudoku board.
2
+
3
+ This module defines :class:`Cell`, the smallest building block of a Sudoku
4
+ grid. A cell knows its position, its solved value (if any), its remaining
5
+ candidate digits, and holds references to the three houses it belongs to:
6
+ its :class:`~dedoku.units.Row`, :class:`~dedoku.units.Column`,
7
+ and :class:`~dedoku.units.Subgrid`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ from .exceptions import ContradictionError
15
+
16
+ if TYPE_CHECKING:
17
+ from .units import Column, Row, Subgrid
18
+
19
+ __all__ = ["DIGITS", "Cell"]
20
+
21
+ DIGITS: frozenset[int] = frozenset(range(1, 10))
22
+ """frozenset[int]: The nine digits a Sudoku cell may contain."""
23
+
24
+
25
+ class Cell:
26
+ """A single cell of a 9x9 Sudoku grid.
27
+
28
+ A cell starts unsolved with all nine digits as candidates. Placing a
29
+ value with :meth:`set_value` automatically removes that digit from the
30
+ candidates of every peer (the cells sharing a row, column, or subgrid).
31
+
32
+ :param row_index: Zero-based index of the row the cell lies in (0-8).
33
+ :type row_index: int
34
+ :param column_index: Zero-based index of the column the cell lies in (0-8).
35
+ :type column_index: int
36
+ :raises ValueError: If either index is outside the 0-8 range.
37
+ """
38
+
39
+ __slots__ = ("_row_index", "_column_index", "_value", "_candidates",
40
+ "_row", "_column", "_subgrid", "_is_given",
41
+ "_peers_cache", "_candidates_view")
42
+
43
+ def __init__(self, row_index: int, column_index: int) -> None:
44
+ if row_index not in range(9) or column_index not in range(9):
45
+ raise ValueError(
46
+ f"cell position must be within 0-8, "
47
+ f"got ({row_index}, {column_index})"
48
+ )
49
+ self._row_index = row_index
50
+ self._column_index = column_index
51
+ self._value: int | None = None
52
+ self._candidates: set[int] = set(DIGITS)
53
+ self._row: Row | None = None
54
+ self._column: Column | None = None
55
+ self._subgrid: Subgrid | None = None
56
+ self._is_given = False
57
+ self._peers_cache: tuple[Cell, ...] | None = None
58
+ self._candidates_view: frozenset[int] | None = None
59
+
60
+ # ------------------------------------------------------------------
61
+ # Wiring
62
+ # ------------------------------------------------------------------
63
+ def attach(self, row: Row, column: Column, subgrid: Subgrid) -> None:
64
+ """Wire the cell to its three houses and register it with each.
65
+
66
+ This is called once by :class:`~dedoku.grid.Grid` while the
67
+ board is being built and must not be called again afterwards.
68
+
69
+ :param row: The row the cell belongs to.
70
+ :type row: Row
71
+ :param column: The column the cell belongs to.
72
+ :type column: Column
73
+ :param subgrid: The 3x3 subgrid the cell belongs to.
74
+ :type subgrid: Subgrid
75
+ :raises RuntimeError: If the cell is already attached.
76
+ """
77
+ if self._row is not None:
78
+ raise RuntimeError(f"cell {self.label} is already attached")
79
+ self._row = row
80
+ self._column = column
81
+ self._subgrid = subgrid
82
+ for unit in (row, column, subgrid):
83
+ unit.register(self)
84
+
85
+ # ------------------------------------------------------------------
86
+ # Read-only state
87
+ # ------------------------------------------------------------------
88
+ @property
89
+ def row_index(self) -> int:
90
+ """int: Zero-based index of the cell's row."""
91
+ return self._row_index
92
+
93
+ @property
94
+ def column_index(self) -> int:
95
+ """int: Zero-based index of the cell's column."""
96
+ return self._column_index
97
+
98
+ @property
99
+ def position(self) -> tuple[int, int]:
100
+ """tuple[int, int]: The ``(row_index, column_index)`` pair."""
101
+ return (self._row_index, self._column_index)
102
+
103
+ @property
104
+ def label(self) -> str:
105
+ """str: Human-readable, one-based position label such as ``R4C7``."""
106
+ return f"R{self._row_index + 1}C{self._column_index + 1}"
107
+
108
+ @property
109
+ def row(self) -> Row:
110
+ """Row: The row the cell belongs to.
111
+
112
+ :raises RuntimeError: If the cell has not been attached to a grid.
113
+ """
114
+ if self._row is None:
115
+ raise RuntimeError(f"cell {self.label} is not attached to a grid")
116
+ return self._row
117
+
118
+ @property
119
+ def column(self) -> Column:
120
+ """Column: The column the cell belongs to.
121
+
122
+ :raises RuntimeError: If the cell has not been attached to a grid.
123
+ """
124
+ if self._column is None:
125
+ raise RuntimeError(f"cell {self.label} is not attached to a grid")
126
+ return self._column
127
+
128
+ @property
129
+ def subgrid(self) -> Subgrid:
130
+ """Subgrid: The 3x3 subgrid the cell belongs to.
131
+
132
+ :raises RuntimeError: If the cell has not been attached to a grid.
133
+ """
134
+ if self._subgrid is None:
135
+ raise RuntimeError(f"cell {self.label} is not attached to a grid")
136
+ return self._subgrid
137
+
138
+ @property
139
+ def value(self) -> int | None:
140
+ """int | None: The solved digit, or ``None`` while unsolved."""
141
+ return self._value
142
+
143
+ @property
144
+ def candidates(self) -> frozenset[int]:
145
+ """frozenset[int]: Immutable view of the remaining candidates.
146
+
147
+ A solved cell exposes an empty set. The view is cached between
148
+ mutations, so repeated reads are cheap.
149
+ """
150
+ if self._candidates_view is None:
151
+ self._candidates_view = frozenset(self._candidates)
152
+ return self._candidates_view
153
+
154
+ @property
155
+ def is_solved(self) -> bool:
156
+ """bool: Whether the cell holds a definitive value."""
157
+ return self._value is not None
158
+
159
+ @property
160
+ def is_given(self) -> bool:
161
+ """bool: Whether the value was part of the original puzzle.
162
+
163
+ Uniqueness-based techniques (such as avoidable rectangles) must
164
+ distinguish clues supplied by the puzzle from values deduced
165
+ during solving.
166
+ """
167
+ return self._is_given
168
+
169
+ @property
170
+ def is_bivalue(self) -> bool:
171
+ """bool: Whether the cell is unsolved with exactly two candidates.
172
+
173
+ Bivalue cells are the anchors of several advanced techniques
174
+ (remote pairs, W-Wing, Y-Wing, BUG, ...).
175
+ """
176
+ return self._value is None and len(self._candidates) == 2
177
+
178
+ @property
179
+ def peers(self) -> tuple[Cell, ...]:
180
+ """tuple[Cell, ...]: The 20 distinct cells sharing a house with this one.
181
+
182
+ Peers are returned sorted by position for deterministic iteration
183
+ and cached after the first access, since the wiring never changes.
184
+
185
+ :raises RuntimeError: If the cell has not been attached to a grid.
186
+ """
187
+ if self._peers_cache is None:
188
+ seen: dict[int, Cell] = {}
189
+ for unit in (self.row, self.column, self.subgrid):
190
+ for cell in unit.cells:
191
+ if cell is not self:
192
+ seen[id(cell)] = cell
193
+ self._peers_cache = tuple(
194
+ sorted(seen.values(), key=lambda cell: cell.position)
195
+ )
196
+ return self._peers_cache
197
+
198
+ def sees(self, other: Cell) -> bool:
199
+ """Report whether ``other`` shares a house with this cell.
200
+
201
+ A cell never sees itself.
202
+
203
+ :param other: The cell to test against.
204
+ :type other: Cell
205
+ :returns: ``True`` if the two cells share a row, column, or
206
+ subgrid.
207
+ :rtype: bool
208
+ """
209
+ return other is not self and (
210
+ self._row_index == other.row_index
211
+ or self._column_index == other.column_index
212
+ or self.subgrid.index == other.subgrid.index
213
+ )
214
+
215
+ # ------------------------------------------------------------------
216
+ # Mutation
217
+ # ------------------------------------------------------------------
218
+ def set_value(self, digit: int) -> None:
219
+ """Solve the cell with ``digit`` and propagate to all peers.
220
+
221
+ The digit is removed from the candidates of every peer, which may
222
+ in turn reveal a contradiction elsewhere on the board.
223
+
224
+ :param digit: The digit to place, between 1 and 9.
225
+ :type digit: int
226
+ :raises ValueError: If ``digit`` is not a digit between 1 and 9,
227
+ or the cell is already solved.
228
+ :raises ContradictionError: If ``digit`` is not among the cell's
229
+ candidates, or placing it strips the last candidate from an
230
+ unsolved peer.
231
+ """
232
+ _validate_digit(digit)
233
+ if self._value is not None:
234
+ raise ValueError(
235
+ f"cell {self.label} is already solved with {self._value}"
236
+ )
237
+ if digit not in self._candidates:
238
+ raise ContradictionError(
239
+ f"digit {digit} is not a candidate of cell {self.label}"
240
+ )
241
+ self._value = digit
242
+ self._candidates.clear()
243
+ self._candidates_view = None
244
+ for peer in self.peers:
245
+ peer.remove_candidate(digit)
246
+
247
+ def mark_as_given(self) -> None:
248
+ """Flag the cell's value as an original clue of the puzzle.
249
+
250
+ This is called by :meth:`dedoku.grid.Grid.from_string`
251
+ right after each given is placed.
252
+
253
+ :raises ValueError: If the cell has no value yet.
254
+ """
255
+ if self._value is None:
256
+ raise ValueError(
257
+ f"cell {self.label} is unsolved and cannot be a given"
258
+ )
259
+ self._is_given = True
260
+
261
+ def remove_candidate(self, digit: int) -> bool:
262
+ """Eliminate ``digit`` from the cell's candidates.
263
+
264
+ Removing a candidate from a solved cell, or a digit that is already
265
+ absent, is a harmless no-op.
266
+
267
+ :param digit: The digit to eliminate, between 1 and 9.
268
+ :type digit: int
269
+ :returns: ``True`` if the candidate was present and got removed.
270
+ :rtype: bool
271
+ :raises ValueError: If ``digit`` is not a digit between 1 and 9.
272
+ :raises ContradictionError: If the removal leaves an unsolved cell
273
+ with no candidates at all.
274
+ """
275
+ _validate_digit(digit)
276
+ if self._value is not None or digit not in self._candidates:
277
+ return False
278
+ self._candidates.discard(digit)
279
+ self._candidates_view = None
280
+ if not self._candidates:
281
+ raise ContradictionError(
282
+ f"cell {self.label} has no candidates left"
283
+ )
284
+ return True
285
+
286
+ def __repr__(self) -> str:
287
+ """Return a debugging representation of the cell.
288
+
289
+ :returns: A string such as ``<Cell R1C1 value=5>`` or
290
+ ``<Cell R1C2 candidates={1, 2}>``.
291
+ :rtype: str
292
+ """
293
+ if self._value is not None:
294
+ return f"<Cell {self.label} value={self._value}>"
295
+ return f"<Cell {self.label} candidates={sorted(self._candidates)}>"
296
+
297
+
298
+ def _validate_digit(digit: int) -> None:
299
+ """Ensure ``digit`` is an integer between 1 and 9.
300
+
301
+ :param digit: The value to validate.
302
+ :type digit: int
303
+ :raises ValueError: If ``digit`` is not a valid Sudoku digit.
304
+ """
305
+ if not isinstance(digit, int) or digit not in DIGITS:
306
+ raise ValueError(f"digit must be an integer between 1 and 9, got {digit!r}")
dedoku/exceptions.py ADDED
@@ -0,0 +1,31 @@
1
+ """Exception hierarchy for the :mod:`dedoku` package.
2
+
3
+ All exceptions raised by this library derive from :class:`SudokuError`,
4
+ so callers can catch a single base class to handle any library failure.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ["SudokuError", "InvalidGridError", "ContradictionError"]
10
+
11
+
12
+ class SudokuError(Exception):
13
+ """Base class for every exception raised by :mod:`dedoku`."""
14
+
15
+
16
+ class InvalidGridError(SudokuError):
17
+ """Raised when a puzzle description cannot be turned into a valid grid.
18
+
19
+ Typical causes are a wrong number of cells, characters outside the
20
+ accepted alphabet, or givens that immediately contradict each other
21
+ (for example two identical digits in the same row).
22
+ """
23
+
24
+
25
+ class ContradictionError(SudokuError):
26
+ """Raised when the board reaches a logically impossible state.
27
+
28
+ This happens when an unsolved cell loses its last candidate or a digit
29
+ has no remaining home inside a unit. During solving it means the puzzle
30
+ (or the technique that produced the state) is inconsistent.
31
+ """
dedoku/grid.py ADDED
@@ -0,0 +1,229 @@
1
+ """The full 9x9 Sudoku board.
2
+
3
+ :class:`Grid` builds the 81 :class:`~dedoku.cell.Cell` objects, wires
4
+ each of them to its :class:`~dedoku.units.Row`,
5
+ :class:`~dedoku.units.Column`, and
6
+ :class:`~dedoku.units.Subgrid`, and offers parsing and serialisation
7
+ helpers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Iterator
13
+
14
+ from .cell import Cell
15
+ from .exceptions import ContradictionError, InvalidGridError
16
+ from .units import Column, Row, Subgrid, Unit
17
+
18
+ __all__ = ["Grid"]
19
+
20
+ _EMPTY_CHARS = ".0"
21
+ _IGNORED_CHARS = " \t\r\n|+-"
22
+
23
+
24
+ class Grid:
25
+ """A 9x9 Sudoku board made of cells, rows, columns, and subgrids.
26
+
27
+ A freshly constructed grid is empty: every cell is unsolved with all
28
+ nine candidates. Use :meth:`from_string` to load a puzzle.
29
+ """
30
+
31
+ def __init__(self) -> None:
32
+ self._rows: tuple[Row, ...] = tuple(Row(i) for i in range(9))
33
+ self._columns: tuple[Column, ...] = tuple(Column(i) for i in range(9))
34
+ self._subgrids: tuple[Subgrid, ...] = tuple(Subgrid(i) for i in range(9))
35
+ cells: list[Cell] = []
36
+ for row_index in range(9):
37
+ for column_index in range(9):
38
+ cell = Cell(row_index, column_index)
39
+ cell.attach(
40
+ self._rows[row_index],
41
+ self._columns[column_index],
42
+ self._subgrids[self.subgrid_index(row_index, column_index)],
43
+ )
44
+ cells.append(cell)
45
+ self._cells: tuple[Cell, ...] = tuple(cells)
46
+ self._units: tuple[Unit, ...] = (
47
+ self._rows + self._columns + self._subgrids
48
+ )
49
+
50
+ # ------------------------------------------------------------------
51
+ # Construction
52
+ # ------------------------------------------------------------------
53
+ @classmethod
54
+ def from_string(cls, text: str) -> Grid:
55
+ """Build a grid from an 81-character puzzle description.
56
+
57
+ Cells are read left to right, top to bottom. Digits ``1``-``9``
58
+ are givens, while ``0`` and ``.`` mark empty cells. Whitespace and
59
+ the decoration characters ``|``, ``+``, ``-`` are ignored, so the
60
+ output of :meth:`__str__` can be parsed back.
61
+
62
+ :param text: The puzzle description.
63
+ :type text: str
64
+ :returns: A grid with all givens placed and candidates propagated.
65
+ :rtype: Grid
66
+ :raises InvalidGridError: If the description does not contain
67
+ exactly 81 cells, uses unexpected characters, or its givens
68
+ contradict each other.
69
+ """
70
+ symbols: list[str] = []
71
+ for char in text:
72
+ if char in _IGNORED_CHARS:
73
+ continue
74
+ if char.isdigit() or char in _EMPTY_CHARS:
75
+ symbols.append(char)
76
+ else:
77
+ raise InvalidGridError(
78
+ f"unexpected character {char!r} in puzzle description"
79
+ )
80
+ if len(symbols) != 81:
81
+ raise InvalidGridError(
82
+ f"puzzle must describe 81 cells, got {len(symbols)}"
83
+ )
84
+ grid = cls()
85
+ for position, symbol in enumerate(symbols):
86
+ if symbol in _EMPTY_CHARS:
87
+ continue
88
+ try:
89
+ grid._cells[position].set_value(int(symbol))
90
+ except ContradictionError as exc:
91
+ raise InvalidGridError(
92
+ f"the givens contradict each other: {exc}"
93
+ ) from exc
94
+ grid._cells[position].mark_as_given()
95
+ return grid
96
+
97
+ # ------------------------------------------------------------------
98
+ # Geometry helpers
99
+ # ------------------------------------------------------------------
100
+ @staticmethod
101
+ def subgrid_index(row_index: int, column_index: int) -> int:
102
+ """Return the subgrid index covering the given board position.
103
+
104
+ :param row_index: Zero-based row index (0-8).
105
+ :type row_index: int
106
+ :param column_index: Zero-based column index (0-8).
107
+ :type column_index: int
108
+ :returns: The zero-based subgrid index (0-8).
109
+ :rtype: int
110
+ """
111
+ return (row_index // 3) * 3 + column_index // 3
112
+
113
+ def cell(self, row_index: int, column_index: int) -> Cell:
114
+ """Return the cell at the given board position.
115
+
116
+ :param row_index: Zero-based row index (0-8).
117
+ :type row_index: int
118
+ :param column_index: Zero-based column index (0-8).
119
+ :type column_index: int
120
+ :returns: The requested cell.
121
+ :rtype: Cell
122
+ :raises ValueError: If either index is outside the 0-8 range.
123
+ """
124
+ if row_index not in range(9) or column_index not in range(9):
125
+ raise ValueError(
126
+ f"cell position must be within 0-8, "
127
+ f"got ({row_index}, {column_index})"
128
+ )
129
+ return self._cells[row_index * 9 + column_index]
130
+
131
+ # ------------------------------------------------------------------
132
+ # Collections
133
+ # ------------------------------------------------------------------
134
+ @property
135
+ def cells(self) -> tuple[Cell, ...]:
136
+ """tuple[Cell, ...]: All 81 cells, left to right, top to bottom."""
137
+ return self._cells
138
+
139
+ @property
140
+ def rows(self) -> tuple[Row, ...]:
141
+ """tuple[Row, ...]: The nine rows, top to bottom."""
142
+ return self._rows
143
+
144
+ @property
145
+ def columns(self) -> tuple[Column, ...]:
146
+ """tuple[Column, ...]: The nine columns, left to right."""
147
+ return self._columns
148
+
149
+ @property
150
+ def subgrids(self) -> tuple[Subgrid, ...]:
151
+ """tuple[Subgrid, ...]: The nine subgrids, left to right, top to bottom."""
152
+ return self._subgrids
153
+
154
+ @property
155
+ def units(self) -> tuple[Unit, ...]:
156
+ """tuple[Unit, ...]: All 27 houses: rows, then columns, then subgrids."""
157
+ return self._units
158
+
159
+ def __iter__(self) -> Iterator[Cell]:
160
+ """Iterate over all 81 cells in board order.
161
+
162
+ :returns: An iterator over the grid's cells.
163
+ :rtype: Iterator[Cell]
164
+ """
165
+ return iter(self._cells)
166
+
167
+ # ------------------------------------------------------------------
168
+ # Board state
169
+ # ------------------------------------------------------------------
170
+ def is_solved(self) -> bool:
171
+ """Check whether every cell holds a value and the board is valid.
172
+
173
+ :returns: ``True`` if the puzzle is completely and correctly solved.
174
+ :rtype: bool
175
+ """
176
+ return all(cell.is_solved for cell in self._cells) and self.is_valid()
177
+
178
+ def is_valid(self) -> bool:
179
+ """Check that no house contains a duplicated solved digit.
180
+
181
+ :returns: ``True`` if every row, column, and subgrid is valid.
182
+ :rtype: bool
183
+ """
184
+ return all(unit.is_valid() for unit in self.units)
185
+
186
+ # ------------------------------------------------------------------
187
+ # Serialisation
188
+ # ------------------------------------------------------------------
189
+ def to_string(self, empty: str = ".") -> str:
190
+ """Serialise the board to an 81-character single-line string.
191
+
192
+ :param empty: The character used for unsolved cells.
193
+ :type empty: str
194
+ :returns: The board, left to right, top to bottom.
195
+ :rtype: str
196
+ """
197
+ return "".join(
198
+ str(cell.value) if cell.is_solved else empty for cell in self._cells
199
+ )
200
+
201
+ def __str__(self) -> str:
202
+ """Render the board as a human-readable 9x9 diagram.
203
+
204
+ :returns: A pretty-printed board with subgrid separators.
205
+ :rtype: str
206
+ """
207
+ lines: list[str] = []
208
+ for row_index, row in enumerate(self._rows):
209
+ if row_index in (3, 6):
210
+ lines.append("------+-------+------")
211
+ chunks = []
212
+ for start in range(0, 9, 3):
213
+ chunks.append(
214
+ " ".join(
215
+ str(cell.value) if cell.is_solved else "."
216
+ for cell in row.cells[start:start + 3]
217
+ )
218
+ )
219
+ lines.append(" | ".join(chunks))
220
+ return "\n".join(lines)
221
+
222
+ def __repr__(self) -> str:
223
+ """Return a debugging representation of the grid.
224
+
225
+ :returns: A string reporting how many cells are solved.
226
+ :rtype: str
227
+ """
228
+ solved = sum(1 for cell in self._cells if cell.is_solved)
229
+ return f"<Grid {solved}/81 solved>"