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 +38 -0
- dedoku/cell.py +306 -0
- dedoku/exceptions.py +31 -0
- dedoku/grid.py +229 -0
- dedoku/solver.py +169 -0
- dedoku/techniques/__init__.py +63 -0
- dedoku/techniques/aic.py +215 -0
- dedoku/techniques/als.py +148 -0
- dedoku/techniques/base.py +78 -0
- dedoku/techniques/bug.py +73 -0
- dedoku/techniques/chains.py +309 -0
- dedoku/techniques/chute.py +169 -0
- dedoku/techniques/colouring.py +198 -0
- dedoku/techniques/fish.py +256 -0
- dedoku/techniques/hidden.py +142 -0
- dedoku/techniques/intersections.py +111 -0
- dedoku/techniques/medusa.py +212 -0
- dedoku/techniques/naked.py +128 -0
- dedoku/techniques/rectangles.py +232 -0
- dedoku/techniques/wings.py +135 -0
- dedoku/techniques/wwing.py +81 -0
- dedoku/units.py +186 -0
- dedoku-0.3.0.dist-info/METADATA +230 -0
- dedoku-0.3.0.dist-info/RECORD +27 -0
- dedoku-0.3.0.dist-info/WHEEL +5 -0
- dedoku-0.3.0.dist-info/licenses/LICENSE +21 -0
- dedoku-0.3.0.dist-info/top_level.txt +1 -0
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>"
|