cube-cli 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.
cube_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ '''cube_cli - CLI frontend for cube_model.'''
cube_cli/_version.py ADDED
@@ -0,0 +1,9 @@
1
+ '''Package version string.'''
2
+ from importlib.metadata import PackageNotFoundError, version
3
+
4
+ def get_version() -> str:
5
+ '''Return the installed package version, or a dev fallback.'''
6
+ try:
7
+ return version('cube-cli')
8
+ except PackageNotFoundError:
9
+ return '0.0.0.dev0'
cube_cli/command.py ADDED
@@ -0,0 +1,355 @@
1
+ '''Abstract Command base and all concrete REPL command types.'''
2
+ import re
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import Self
6
+
7
+ from cube_model import Color, Cube, Side, shuffled, solved
8
+ from cube_model.action import Action, ParseError, act, parse_actions
9
+ from cube_model.navigation import all_colors, side_color
10
+
11
+ from .repl_state import (
12
+ Exit,
13
+ LoadError,
14
+ ReplState,
15
+ UndoCube,
16
+ UndoItem,
17
+ UndoMove,
18
+ are_you_sure,
19
+ )
20
+ from .save_load import SaveError, load, save
21
+
22
+ DEFAULT_FILE: str = 'cube.json'
23
+
24
+ _LOAD_RE: re.Pattern[str] = re.compile(r'^load(?:\s+(\S.*?))?$', re.IGNORECASE)
25
+ _SAVE_RE: re.Pattern[str] = re.compile(r'^save(?:\s+(\S.*?))?$', re.IGNORECASE)
26
+
27
+ def _resolve_file(rs: ReplState, filename: str | None) -> str:
28
+ '''Resolve the filename to use for a save or load.
29
+
30
+ Uses filename if given, otherwise the last used file, otherwise
31
+ the default file.
32
+ '''
33
+ if filename is not None:
34
+ return filename
35
+ if rs.last_file is not None:
36
+ return rs.last_file
37
+ return DEFAULT_FILE
38
+
39
+ def _is_solved(cube: Cube) -> bool:
40
+ '''Return True if every side shows a single uniform color.'''
41
+ colors: dict[Side, list[Color]] = all_colors(cube)
42
+ side: Side
43
+ for side in colors:
44
+ center: Color = side_color(cube, side)
45
+ if any(c != center for c in colors[side]):
46
+ return False
47
+ return True
48
+
49
+ @dataclass
50
+ class Command(ABC):
51
+ '''Abstract base for all REPL commands.'''
52
+
53
+ @classmethod
54
+ @abstractmethod
55
+ def parse(cls, cmd: str) -> Self | None:
56
+ '''Parse a raw input string; return an instance if it matches.
57
+ Assumes that strip() has already been applied.'''
58
+
59
+ @abstractmethod
60
+ def run(self, rs: ReplState) -> Exit | None:
61
+ '''Execute the command against the given REPL state.'''
62
+
63
+ @dataclass
64
+ class Shuffle(Command):
65
+ '''Randomize the cube.'''
66
+
67
+ @classmethod
68
+ def parse(cls, cmd: str) -> Self | None:
69
+ '''Return a Shuffle if cmd is "shuffle".'''
70
+ if cmd == 'shuffle':
71
+ return cls()
72
+ return None
73
+
74
+ def run(self, rs: ReplState) -> Exit | None:
75
+ '''Replace the cube with a shuffled copy, if confirmed.'''
76
+ if not are_you_sure(rs):
77
+ return None
78
+ UndoCube.push(rs)
79
+ rs.cube = shuffled(initial=rs.cube)
80
+ rs.unsaved = True
81
+ return None
82
+
83
+ @dataclass
84
+ class Solve(Command):
85
+ '''Return the cube to its solved state.'''
86
+
87
+ @classmethod
88
+ def parse(cls, cmd: str) -> Self | None:
89
+ '''Return a Solve if cmd is "solve".'''
90
+ if cmd == 'solve':
91
+ return cls()
92
+ return None
93
+
94
+ def run(self, rs: ReplState) -> Exit | None:
95
+ '''Replace the cube with a solved copy, if confirmed.'''
96
+ if not are_you_sure(rs):
97
+ return None
98
+ UndoCube.push(rs)
99
+ rs.cube = solved(initial=rs.cube)
100
+ rs.unsaved = False
101
+ return None
102
+
103
+ @dataclass
104
+ class Load(Command):
105
+ '''Load cube state from a file.'''
106
+ filename: str | None
107
+
108
+ @classmethod
109
+ def parse(cls, cmd: str) -> Self | None:
110
+ '''Return a Load if cmd starts with "load".
111
+
112
+ The filename, if any, runs from the first non-whitespace
113
+ character after "load" to the end of the line, with only
114
+ trailing whitespace stripped.
115
+ '''
116
+ m: re.Match[str] | None = _LOAD_RE.match(cmd)
117
+ if m is None:
118
+ return None
119
+ return cls(filename=m.group(1))
120
+
121
+ def run(self, rs: ReplState) -> Exit | None:
122
+ '''Load cube state from file, if confirmed.'''
123
+ if not are_you_sure(rs):
124
+ return None
125
+ target: str = _resolve_file(rs, self.filename)
126
+ result: Cube | LoadError = load(target, rs.cube)
127
+ if not isinstance(result, Cube):
128
+ rs.load_error = result
129
+ rs.print_cube = False
130
+ return None
131
+ UndoCube.push(rs)
132
+ rs.cube = result
133
+ rs.last_file = target
134
+ rs.unsaved = False
135
+ return None
136
+
137
+ @dataclass
138
+ class Save(Command):
139
+ '''Save cube state to a file.'''
140
+ filename: str | None
141
+
142
+ @classmethod
143
+ def parse(cls, cmd: str) -> Self | None:
144
+ '''Return a Save if cmd starts with "save".
145
+
146
+ The filename, if any, runs from the first non-whitespace
147
+ character after "save" to the end of the line, with only
148
+ trailing whitespace stripped.
149
+ '''
150
+ m: re.Match[str] | None = _SAVE_RE.match(cmd)
151
+ if m is None:
152
+ return None
153
+ return cls(filename=m.group(1))
154
+
155
+ def run(self, rs: ReplState) -> Exit | None:
156
+ '''Save cube state to file.'''
157
+ rs.print_cube = False
158
+ target: str = _resolve_file(rs, self.filename)
159
+ err: SaveError | None = save(rs.cube, target)
160
+ if err is not None:
161
+ print(f'Could not save to {target}: {err}')
162
+ return None
163
+ print(f'Cube saved to {target}')
164
+ rs.last_file = target
165
+ rs.unsaved = False
166
+ return None
167
+
168
+ @dataclass
169
+ class Undo(Command):
170
+ '''Undo the last command.'''
171
+
172
+ @classmethod
173
+ def parse(cls, cmd: str) -> Self | None:
174
+ '''Return an Undo if cmd is "undo".'''
175
+ if cmd == 'undo':
176
+ return cls()
177
+ return None
178
+
179
+ def run(self, rs: ReplState) -> Exit | None:
180
+ '''Pop and apply the last undo item, if any.
181
+
182
+ If the undo buffer is empty, print 'Nothing to undo' instead
183
+ and do not print the cube.
184
+ '''
185
+ if not rs.undo_buf:
186
+ print('Nothing to undo')
187
+ rs.print_cube = False
188
+ return None
189
+ item: UndoItem = rs.undo_buf.pop()
190
+ item.undo(rs)
191
+ rs.redo_buf.append(item)
192
+ return None
193
+
194
+ @dataclass
195
+ class Redo(Command):
196
+ '''Redo the last undone command.'''
197
+
198
+ @classmethod
199
+ def parse(cls, cmd: str) -> Self | None:
200
+ '''Return a Redo if cmd is "redo".'''
201
+ if cmd == 'redo':
202
+ return cls()
203
+ return None
204
+
205
+ def run(self, rs: ReplState) -> Exit | None:
206
+ '''Pop and apply the last redo item, if any.
207
+
208
+ If the redo buffer is empty, print 'Nothing to redo' instead
209
+ and do not print the cube.
210
+ '''
211
+ if not rs.redo_buf:
212
+ print('Nothing to redo')
213
+ rs.print_cube = False
214
+ return None
215
+ item: UndoItem = rs.redo_buf.pop()
216
+ item.redo(rs)
217
+ rs.undo_buf.append(item)
218
+ return None
219
+
220
+ @dataclass
221
+ class Quit(Command):
222
+ '''Exit the REPL.'''
223
+
224
+ @classmethod
225
+ def parse(cls, cmd: str) -> Self | None:
226
+ '''Return a Quit if cmd is "quit" or "q".'''
227
+ if cmd in ('quit', 'q'):
228
+ return cls()
229
+ return None
230
+
231
+ def run(self, rs: ReplState) -> Exit | None:
232
+ '''Signal the REPL to exit, if confirmed.'''
233
+ if not are_you_sure(rs):
234
+ return None
235
+ return Exit.EXIT
236
+
237
+ # Command reference text, shared with the --help epilog.
238
+ HELP_TEXT: str = '''\
239
+ Commands:
240
+
241
+ Before you enter a command, the current state of the cube is
242
+ printed in a textual format.
243
+
244
+ <moves> A sequence of moves in standard cube move syntax.
245
+ Case-insensitive. To specify a wide move, use a
246
+ 'w' suffix.
247
+
248
+ ^<moves> A sequence of moves in standard cube move syntax.
249
+ Case-sensitive. To specify a wide move, use either
250
+ a lower-case face letter or a 'w' suffix.
251
+
252
+ solve Return the cube to its initial solved position.
253
+
254
+ shuffle Randomize the position of the cube.
255
+
256
+ undo Undo the last previous command.
257
+
258
+ redo If the last previous command was an undo, reverse
259
+ its effect.
260
+
261
+ save [file] Save the cube to a file. If [file] is not
262
+ specified, save to the last file used for a load
263
+ or save, or to cube.json in the current directory.
264
+
265
+ load [file] Load a saved cube. If [file] is not specified,
266
+ load from the last file used for a load or save,
267
+ or to cube.json in the current directory.
268
+
269
+ help, ? Print this command reference.
270
+
271
+ quit, q Exit cube.'''
272
+
273
+ @dataclass
274
+ class Help(Command):
275
+ '''Print a summary of available commands.'''
276
+
277
+ @classmethod
278
+ def parse(cls, cmd: str) -> Self | None:
279
+ '''Return a Help if cmd is "help" or "?".'''
280
+ if cmd in ('help', '?'):
281
+ return cls()
282
+ return None
283
+
284
+ def run(self, rs: ReplState) -> Exit | None:
285
+ '''Print the command reference to stdout.'''
286
+ print(HELP_TEXT)
287
+ return None
288
+
289
+ @dataclass
290
+ class Noop(Command):
291
+ '''Do nothing.'''
292
+
293
+ @classmethod
294
+ def parse(cls, cmd: str) -> Self | None:
295
+ '''Return a Noop if cmd is the empty string.'''
296
+ if not cmd:
297
+ return cls()
298
+ return None
299
+
300
+ def run(self, rs: ReplState) -> Exit | None:
301
+ '''Do nothing. Not even print the cube.'''
302
+ rs.print_cube = False
303
+ return None
304
+
305
+ @dataclass
306
+ class Move(Command):
307
+ '''Apply a parsed sequence of actions to the cube.'''
308
+ actions: list[Action]
309
+
310
+ @classmethod
311
+ def parse(cls, cmd: str) -> Self | None:
312
+ '''Parse a move sequence, with optional "^" prefix for case-sensitivity.
313
+
314
+ A leading "^" enables case-sensitive parsing. Without it, parsing
315
+ is case-insensitive. Returns None if the input is empty or cannot
316
+ be parsed as a valid move sequence.
317
+ '''
318
+ if cmd.startswith('^'):
319
+ tail: str = cmd[1:]
320
+ if not tail:
321
+ return None
322
+ ci: bool = False
323
+ else:
324
+ tail = cmd
325
+ ci = True
326
+ try:
327
+ actions: list[Action] = parse_actions(tail, ci=ci)
328
+ except ParseError:
329
+ return None
330
+ if not actions:
331
+ return None
332
+ return cls(actions=actions)
333
+
334
+ def run(self, rs: ReplState) -> Exit | None:
335
+ '''Apply each action in sequence to the cube.'''
336
+ UndoMove.push(rs, self.actions)
337
+ action: Action
338
+ for action in self.actions:
339
+ act(action, rs.cube)
340
+ rs.unsaved = not _is_solved(rs.cube)
341
+ return None
342
+
343
+ # All command types in parse-priority order.
344
+ all_commands: list[type[Command]] = [
345
+ Shuffle,
346
+ Solve,
347
+ Load,
348
+ Save,
349
+ Undo,
350
+ Redo,
351
+ Quit,
352
+ Help,
353
+ Noop,
354
+ Move,
355
+ ]
cube_cli/cube_json.py ADDED
@@ -0,0 +1,104 @@
1
+ '''JSON serialisation and deserialisation for the Cube model.'''
2
+ from typing import Any, NewType, cast
3
+
4
+ from cube_model import Color, CornerSticker, Cube, EdgeSticker
5
+
6
+ # JSON alias for a serialised cube object
7
+ type CubeJSON = dict[str, str | dict[str, str]]
8
+
9
+ # JSONError is a NewType of str. At runtime it is just str; use
10
+ # isinstance(result, Cube) to distinguish success from error.
11
+ JSONError = NewType('JSONError', str)
12
+
13
+ def _color_ch(c: Color) -> str:
14
+ return c.name[0].lower()
15
+
16
+ def _corner_str(cs: CornerSticker) -> str:
17
+ '''Encode a CornerSticker as a 3-char string.'''
18
+ return ''.join([
19
+ _color_ch(cs.color),
20
+ _color_ch(cs.other.color),
21
+ _color_ch(cs.other.other.color),
22
+ ])
23
+
24
+ def _edge_str(es: EdgeSticker) -> str:
25
+ '''Encode an EdgeSticker as a 2-char string.'''
26
+ return ''.join([_color_ch(es.color), _color_ch(es.other.color)])
27
+
28
+ def cube_to_json(cube: Cube) -> CubeJSON:
29
+ '''Serialise a Cube to a CubeJSON dict.'''
30
+ return {
31
+ 'front_color': _color_ch(cube.front_color),
32
+ 'top_color': _color_ch(cube.top_color),
33
+ 'home': _corner_str(cube.home),
34
+ 'next_edge': {
35
+ _corner_str(cs): _edge_str(es)
36
+ for cs, es in cube.next_edge.items()
37
+ },
38
+ 'next_corner': {
39
+ _edge_str(es): _corner_str(cs)
40
+ for es, cs in cube.next_corner.items()
41
+ },
42
+ }
43
+
44
+ def json_to_cube(cube_json: CubeJSON, initial: Cube) -> Cube | JSONError:
45
+ '''Deserialise a CubeJSON dict into a Cube, reusing stickers from initial.
46
+
47
+ Assumes well-formed input (i.e. produced by cube_to_json). Returns a
48
+ JSONError string on failure. Use isinstance(result, Cube) to distinguish
49
+ success from error at runtime.
50
+ '''
51
+ ch_to_color: dict[str, Color] = {_color_ch(c): c for c in Color}
52
+ corners: dict[str, CornerSticker] = {
53
+ _corner_str(cs): cs for cs in initial.next_edge
54
+ }
55
+ edges: dict[str, EdgeSticker] = {
56
+ _edge_str(es): es for es in initial.next_corner
57
+ }
58
+ try:
59
+ return Cube(
60
+ home=corners[cast(str, cube_json['home'])],
61
+ front_color=ch_to_color[cast(str, cube_json['front_color'])],
62
+ top_color=ch_to_color[cast(str, cube_json['top_color'])],
63
+ next_edge={
64
+ corners[k]: edges[v]
65
+ for k, v in cast(dict[str, str], cube_json['next_edge']).items()
66
+ },
67
+ next_corner={
68
+ edges[k]: corners[v]
69
+ for k, v in cast(dict[str, str], cube_json['next_corner']).items()
70
+ },
71
+ )
72
+ except (KeyError, AttributeError, TypeError) as e:
73
+ return JSONError(str(e))
74
+
75
+ def any_to_json(value: Any) -> CubeJSON | JSONError:
76
+ '''Validate that an arbitrary parsed JSON value is a valid CubeJSON.
77
+
78
+ Returns a JSONError (str at runtime) on failure. Use isinstance(result,
79
+ dict) to distinguish success from error.
80
+ '''
81
+ if not isinstance(value, dict):
82
+ return JSONError('expected a JSON object at the top level')
83
+ result: CubeJSON = {}
84
+ k: object
85
+ v: object
86
+ for k, v in value.items():
87
+ if not isinstance(k, str):
88
+ return JSONError(f'non-string key: {k!r}')
89
+ if isinstance(v, str):
90
+ result[k] = v
91
+ elif isinstance(v, dict):
92
+ inner: dict[str, str] = {}
93
+ ik: object
94
+ iv: object
95
+ for ik, iv in v.items():
96
+ if not isinstance(ik, str) or not isinstance(iv, str):
97
+ return JSONError(f'non-string entry in nested object {k!r}')
98
+ inner[ik] = iv
99
+ result[k] = inner
100
+ else:
101
+ return JSONError(
102
+ f'value for {k!r} must be str or object, got {type(v).__name__}'
103
+ )
104
+ return result
cube_cli/main.py ADDED
@@ -0,0 +1,39 @@
1
+ '''Entry point for the cube_cli application.'''
2
+ import argparse
3
+
4
+ from ._version import get_version
5
+ from .command import HELP_TEXT
6
+ from .repl import repl
7
+
8
+ def _make_parser() -> argparse.ArgumentParser:
9
+ '''Build the argument parser for the cube command.'''
10
+ parser: argparse.ArgumentParser = argparse.ArgumentParser(
11
+ prog='cube',
12
+ description=(
13
+ 'A textual CLI environment for manipulating a 3x3x3 cube\n'
14
+ 'and solving puzzles based on it.'
15
+ ),
16
+ epilog=HELP_TEXT,
17
+ formatter_class=argparse.RawDescriptionHelpFormatter,
18
+ )
19
+ parser.add_argument(
20
+ '--version',
21
+ action='version',
22
+ version=f'cube {get_version()}',
23
+ )
24
+ parser.add_argument(
25
+ 'file',
26
+ nargs='?',
27
+ metavar='file',
28
+ help=(
29
+ 'Saved cube from a previous session. '
30
+ 'If not specified, the cube starts solved.'
31
+ ),
32
+ )
33
+ return parser
34
+
35
+ def main() -> None:
36
+ '''Run the cube CLI.'''
37
+ parser: argparse.ArgumentParser = _make_parser()
38
+ args: argparse.Namespace = parser.parse_args()
39
+ repl(args.file)
cube_cli/print.py ADDED
@@ -0,0 +1,86 @@
1
+ '''Render a Cube as ASCII text lines.'''
2
+
3
+ from collections.abc import Iterator
4
+
5
+ from cube_model import Color, Cube, Side, all_colors, side_color
6
+
7
+ def _center(cube: Cube, side: Side) -> str:
8
+ '''The color of the center of a side.'''
9
+ c: str = side_color(cube, side).name[0].lower()
10
+ return c
11
+
12
+ def _face_grid(
13
+ cube: Cube, side: Side, indices: list[int]
14
+ ) -> list[str]:
15
+ '''The three display rows for a face.
16
+
17
+ indices is a list of 9 values indexing into all_colors for this
18
+ side, or -1 for the center, in order left-to-right then
19
+ top-to-bottom.
20
+ '''
21
+ ac: list[Color] = all_colors(cube)[side]
22
+ raw: list[str] = [
23
+ ac[i].name[0].lower() if i >= 0
24
+ else _center(cube, side)
25
+ for i in indices
26
+ ]
27
+ return [
28
+ ' '.join(raw[0:3]),
29
+ ' '.join(raw[3:6]),
30
+ ' '.join(raw[6:9]),
31
+ ]
32
+
33
+ def print_cube(cube: Cube) -> Iterator[str]:
34
+ '''Render an ASCII representation of a cube.
35
+
36
+ Layout:
37
+
38
+ top
39
+ -----
40
+ left | front | right | back
41
+ -----
42
+ bottom
43
+
44
+ Each face is three rows of three color chars separated by spaces.
45
+ Faces in the horizontal row are separated by " | ".
46
+ Faces in the vertical column are separated by "-----".
47
+ Top/bottom are indented to align with front.
48
+ '''
49
+ # For each face, the indices into the all_colors color list,
50
+ # or -1 for the center, to get the colors for that face
51
+ # in the correct order for display.
52
+ # The indices for a face depend on the starting corner of
53
+ # the all_corners color list for the face.
54
+ face_indices: dict[Side, list[int]] = {
55
+ # front: top-left start
56
+ Side.FRONT: [0, 1, 2, 7, -1, 3, 6, 5, 4],
57
+ # top: front-left start
58
+ Side.TOP: [2, 3, 4, 1, -1, 5, 0, 7, 6],
59
+ # bottom: left-front start
60
+ Side.BOTTOM: [0, 1, 2, 7, -1, 3, 6, 5, 4],
61
+ # left: top-front start
62
+ Side.LEFT: [6, 7, 0, 5, -1, 1, 4, 3, 2],
63
+ # right: front-top start
64
+ Side.RIGHT: [0, 1, 2, 7, -1, 3, 6, 5, 4],
65
+ # back: top-right start
66
+ Side.BACK: [6, 7, 0, 5, -1, 1, 4, 3, 2],
67
+ }
68
+ indent: str = ' ' * 8
69
+ sep: str = '-' * 5
70
+ rows: dict[Side, list[str]] = {
71
+ s: _face_grid(cube, s, face_indices[s])
72
+ for s in Side
73
+ }
74
+ row: str
75
+ for row in rows[Side.TOP]:
76
+ yield indent + row
77
+ yield indent + sep
78
+ middle: tuple[Side, Side, Side, Side] = (
79
+ Side.LEFT, Side.FRONT, Side.RIGHT, Side.BACK
80
+ )
81
+ l: str; f: str; r: str; b: str
82
+ for l, f, r, b in zip(*(rows[s] for s in middle)):
83
+ yield f'{l} | {f} | {r} | {b}'
84
+ yield indent + sep
85
+ for row in rows[Side.BOTTOM]:
86
+ yield indent + row
cube_cli/repl.py ADDED
@@ -0,0 +1,65 @@
1
+ '''REPL loop and command parser for the cube CLI.'''
2
+ import readline
3
+
4
+ from cube_model import Cube, solved
5
+
6
+ from .command import Command, Quit, all_commands
7
+ from .print import print_cube
8
+ from .repl_state import Exit, LoadError, ReplState
9
+ from .save_load import SaveError, load
10
+
11
+ def parse_command(inp: str) -> Command | None:
12
+ '''Try each command type in order; return the first match.'''
13
+ cmd_type: type[Command]
14
+ for cmd_type in all_commands:
15
+ cmd: Command | None = cmd_type.parse(inp)
16
+ if cmd is not None:
17
+ return cmd
18
+ return None
19
+
20
+ def _initial_state(filename: str | None) -> ReplState:
21
+ '''Build the initial REPL state, loading filename if given.
22
+
23
+ If the load fails, report the error.
24
+ '''
25
+ if filename is None:
26
+ return ReplState(cube=solved())
27
+ result: Cube | LoadError = load(filename, solved())
28
+ if isinstance(result, Cube):
29
+ return ReplState(cube=result, last_file=filename)
30
+ return ReplState(cube=solved(), load_error=result)
31
+
32
+ def repl(filename: str | None) -> None:
33
+ '''Run the interactive REPL.
34
+
35
+ If filename is given, load it as the initial cube state. If the
36
+ load fails, fall back to a solved cube and report the error.
37
+ '''
38
+ cmd: Command | None
39
+ rs: ReplState = _initial_state(filename)
40
+ while True:
41
+ if rs.print_cube:
42
+ line: str
43
+ for line in print_cube(rs.cube):
44
+ print(line)
45
+ else:
46
+ rs.print_cube = True # reset for next time
47
+ if rs.load_error is not None:
48
+ fn: str = rs.load_error.filename
49
+ msg: str = rs.load_error.msg
50
+ print(f'Could not load from {fn}: {msg}')
51
+ rs.load_error = None # reset for next time
52
+ try:
53
+ inp: str = input(
54
+ 'Command (? for help, q to quit): ').strip()
55
+ except EOFError:
56
+ cmd = Quit()
57
+ else:
58
+ cmd = parse_command(inp)
59
+ if cmd is None:
60
+ print('Invalid command')
61
+ rs.print_cube = False
62
+ continue
63
+ result: Exit | None = cmd.run(rs)
64
+ if result is Exit.EXIT:
65
+ break
cube_cli/repl_state.py ADDED
@@ -0,0 +1,146 @@
1
+ '''REPL state and exit signal for the cube CLI.'''
2
+ from __future__ import annotations
3
+
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+
8
+ from cube_model import Cube, Move
9
+ from cube_model.action import Action, Rotation, SliceMove, WideMove, act
10
+ from cube_model.move import invert
11
+
12
+ class Exit(Enum):
13
+ '''Singleton signal that the REPL should exit.'''
14
+ EXIT = 'exit'
15
+
16
+ @dataclass
17
+ class LoadError:
18
+ '''The error message resulting from a failed load of the cube from
19
+ a file.
20
+ '''
21
+ filename: str
22
+ msg: str
23
+
24
+ def _invert_action(action: Action) -> Action:
25
+ '''Return the inverse of a single action.
26
+
27
+ Inverting a Move inverts its multiplicity directly. Inverting a
28
+ Rotation, WideMove, or SliceMove inverts the multiplicity of the
29
+ Move it wraps, preserving the wrapper type.
30
+ '''
31
+ match action:
32
+ case Rotation(move=m):
33
+ return Rotation(Move(m.face, invert[m.mult]))
34
+ case WideMove(move=m):
35
+ return WideMove(Move(m.face, invert[m.mult]))
36
+ case SliceMove(move=m):
37
+ return SliceMove(Move(m.face, invert[m.mult]))
38
+ case Move() as m:
39
+ return Move(m.face, invert[m.mult])
40
+
41
+ def _invert_actions(actions: list[Action]) -> list[Action]:
42
+ '''Return the inverse of a sequence of actions.
43
+
44
+ The order of the actions is reversed, and each action's
45
+ multiplicity is inverted.
46
+ '''
47
+ return [_invert_action(a) for a in reversed(actions)]
48
+
49
+ @dataclass
50
+ class UndoItem(ABC):
51
+ '''Abstract base for an entry in a ReplState undo or redo stack.'''
52
+ unsaved: bool
53
+
54
+ @abstractmethod
55
+ def undo(self, rs: ReplState) -> None:
56
+ '''Undo this item against rs, preparing self for the redo
57
+ buffer.
58
+ '''
59
+
60
+ @abstractmethod
61
+ def redo(self, rs: ReplState) -> None:
62
+ '''Redo this item against rs, preparing self for the undo
63
+ buffer.
64
+ '''
65
+
66
+ @dataclass
67
+ class UndoCube(UndoItem):
68
+ '''Undo item capturing a full previous cube and saved state.
69
+
70
+ Used by the Shuffle, Solve, and Load commands, which completely
71
+ replace the cube.
72
+ '''
73
+ cube: Cube
74
+
75
+ @classmethod
76
+ def push(cls, rs: ReplState) -> None:
77
+ '''Snapshot rs's current cube and unsaved flag onto the undo
78
+ buffer, and clear the redo buffer.
79
+ '''
80
+ rs.undo_buf.append(cls(unsaved=rs.unsaved, cube=rs.cube))
81
+ rs.redo_buf.clear()
82
+
83
+ def undo(self, rs: ReplState) -> None:
84
+ '''Swap unsaved and cube with rs.'''
85
+ rs.unsaved, self.unsaved = self.unsaved, rs.unsaved
86
+ rs.cube, self.cube = self.cube, rs.cube
87
+
88
+ def redo(self, rs: ReplState) -> None:
89
+ '''Identical to undo: swap unsaved and cube with rs.'''
90
+ self.undo(rs)
91
+
92
+ @dataclass
93
+ class UndoMove(UndoItem):
94
+ '''Undo item capturing a sequence of actions to invert or reapply.
95
+
96
+ Used by the Move command.
97
+ '''
98
+ actions: list[Action]
99
+
100
+ @classmethod
101
+ def push(cls, rs: ReplState, actions: list[Action]) -> None:
102
+ '''Snapshot rs's current unsaved flag and the actions about to
103
+ be applied onto the undo buffer, and clear the redo buffer.
104
+ '''
105
+ rs.undo_buf.append(cls(unsaved=rs.unsaved, actions=actions))
106
+ rs.redo_buf.clear()
107
+
108
+ def undo(self, rs: ReplState) -> None:
109
+ '''Swap unsaved with rs and apply the inverse of the actions.'''
110
+ rs.unsaved, self.unsaved = self.unsaved, rs.unsaved
111
+ action: Action
112
+ for action in _invert_actions(self.actions):
113
+ act(action, rs.cube)
114
+
115
+ def redo(self, rs: ReplState) -> None:
116
+ '''Swap unsaved with rs and reapply the actions.'''
117
+ rs.unsaved, self.unsaved = self.unsaved, rs.unsaved
118
+ action: Action
119
+ for action in self.actions:
120
+ act(action, rs.cube)
121
+
122
+ @dataclass
123
+ class ReplState:
124
+ '''Mutable state threaded through REPL command execution.'''
125
+ cube: Cube
126
+ unsaved: bool = False
127
+ last_file: str | None = None
128
+ load_error: LoadError | None = None
129
+ print_cube: bool = True
130
+ undo_buf: list[UndoItem] = field(default_factory=list)
131
+ redo_buf: list[UndoItem] = field(default_factory=list)
132
+
133
+ def are_you_sure(rs: ReplState) -> bool:
134
+ '''Prompt for confirmation if the cube is unsaved.
135
+
136
+ Returns True if the action should proceed: either the cube is
137
+ already saved, or the user confirmed with (case-insensitive)
138
+ "yes" or "y". Returns False otherwise.
139
+ '''
140
+ if not rs.unsaved:
141
+ return True
142
+ answer: str = input('The cube is not saved. Are you sure? ')
143
+ if answer.strip().lower() in ('yes', 'y'):
144
+ return True
145
+ rs.print_cube = False
146
+ return False
cube_cli/save_load.py ADDED
@@ -0,0 +1,55 @@
1
+ '''Save and load cube state to and from JSON files.'''
2
+ import json
3
+ from typing import NewType
4
+
5
+ from cube_model import Cube
6
+
7
+ from ._version import get_version
8
+ from .cube_json import (CubeJSON, JSONError, any_to_json,
9
+ cube_to_json, json_to_cube)
10
+ from .repl_state import LoadError
11
+
12
+ # The error message resulting from a failed save of the cube to
13
+ # a file.
14
+ SaveError = NewType('SaveError', str)
15
+
16
+ def save(cube: Cube, filename: str) -> SaveError | None:
17
+ '''Serialise cube to a JSON file, including the current version.
18
+
19
+ Returns a SaveError on failure, None on success.
20
+ '''
21
+ cj: CubeJSON = cube_to_json(cube)
22
+ cj['version'] = get_version()
23
+ try:
24
+ with open(filename, 'w') as f:
25
+ json.dump(cj, f)
26
+ except PermissionError:
27
+ return SaveError('permission denied')
28
+ except OSError:
29
+ return SaveError('could not write file')
30
+ return None
31
+
32
+ def load(filename: str, initial: Cube) -> Cube | LoadError:
33
+ '''Deserialise a Cube from a JSON file, ignoring the version field.
34
+
35
+ Returns a LoadError on failure. Use isinstance(result, Cube) to
36
+ distinguish success from error at runtime.
37
+ '''
38
+ try:
39
+ with open(filename) as f:
40
+ raw = json.load(f)
41
+ except FileNotFoundError:
42
+ return LoadError(filename, 'file not found')
43
+ except PermissionError:
44
+ return LoadError(filename, 'permission denied')
45
+ except OSError:
46
+ return LoadError(filename, 'could not read file')
47
+ except json.JSONDecodeError:
48
+ return LoadError(filename, 'not a valid JSON file')
49
+ cj: CubeJSON | JSONError = any_to_json(raw)
50
+ if not isinstance(cj, dict):
51
+ return LoadError(filename, 'JSON has invalid format')
52
+ result: Cube | JSONError = json_to_cube(cj, initial)
53
+ if not isinstance(result, Cube):
54
+ return LoadError(filename, 'JSON has invalid format')
55
+ return result
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: cube-cli
3
+ Version: 0.3.0
4
+ Summary: A CLI application for interacting with cube_model
5
+ Requires-Python: >=3.13,<4.0
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.13
8
+ Classifier: Programming Language :: Python :: 3.14
9
+ Requires-Dist: cube-model (>=1.0.4,<2.0.0)
10
+ Project-URL: Repository, https://github.com/ygale/cube-cli
11
+ Description-Content-Type: text/markdown
12
+
13
+ # cube-cli
14
+
15
+ A textual cli environment for manipulating a 3x3x3 cube and solving
16
+ puzzles based on it.
17
+
18
+ ## Usage
19
+
20
+ cube [options] [file]
21
+
22
+ [file] is a saved cube from a previous session. If not specified,
23
+ the cube is initialized to its solved state.
24
+
25
+ ## Options
26
+
27
+ --help
28
+ --version
29
+
30
+ ## Commands
31
+
32
+ Before you enter a command, the current state of the cube is printed
33
+ in a textual format.
34
+
35
+ \<moves> A sequence of moves in standard cube move syntax.
36
+ Case-insensitive. To specify a wide move, use a 'w' suffix.
37
+
38
+ ^\<moves> A sequence of moves in standard cube move syntax.
39
+ Case-sensitive. To specify a wide move, use either a lower-case face
40
+ letter or a 'w' suffix.
41
+
42
+ solve Return the cube to its initial solved position.
43
+
44
+ shuffle Randomize the position of the cube.
45
+
46
+ undo Undo the last previous command.
47
+
48
+ redo If the last previous command was an undo, reverse its effect.
49
+
50
+ save [file] Save the cube to a file. If [file] is not specified, save
51
+ to the last file used for a load or save, or to cube.json in the
52
+ current directory.
53
+
54
+ load [file] Load a saved cube. If [file] is not specified, load from
55
+ the last file used for a load or save, or to cube.json in the
56
+ current directory.
57
+
58
+ help, ? Print this command reference.
59
+
60
+ quit, q Exit cube.
61
+
@@ -0,0 +1,13 @@
1
+ cube_cli/__init__.py,sha256=7GnaFYeAAHVLgWr0qRe-Q0SOJc0KbJXr4ARAv-oymBA,46
2
+ cube_cli/_version.py,sha256=AM0tu5yAmFzzrAIEGNw8-GkTMy7SgyEDHXGCPcFdJd4,276
3
+ cube_cli/command.py,sha256=qkYJKgVldOWvSvoya-PJk7IQlRZkHl-3QjvIyLe6hiQ,9411
4
+ cube_cli/cube_json.py,sha256=D3076BND-7NAIwRpzXbuT5xKSgd1gP4ZJTY409dzu80,3439
5
+ cube_cli/main.py,sha256=bQ8D4eIywWXImKo8q4BOf2cm0jANRGzxGXwadRuM0ZE,1022
6
+ cube_cli/print.py,sha256=MOVBnVw_57SrpyJDwvNOFKaUPB6haxTllj50Cp61CgU,2527
7
+ cube_cli/repl.py,sha256=i37a6c4NWMZfZjpeip6NRO001S6BONzKLyv_VjlbY6Q,1957
8
+ cube_cli/repl_state.py,sha256=P73IY-JYuQgo4aIeRtuO9ZZ1Ejt3mlYP9Rw7kpbFTpc,4355
9
+ cube_cli/save_load.py,sha256=xl1g7QREGOnyLCbFpGE1tGsD3CMP6ouq5LenFzBVe3M,1778
10
+ cube_cli-0.3.0.dist-info/METADATA,sha256=v3X8UHs21sZr6uhx_RyesdizkXc93vjC-o8AwyCmb3Q,1663
11
+ cube_cli-0.3.0.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
12
+ cube_cli-0.3.0.dist-info/entry_points.txt,sha256=4SfY9yBwJ4QxCZeyocL01xiLt-L3wrSX-jLcOuJbBQs,43
13
+ cube_cli-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.4.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ cube=cube_cli.main:main
3
+