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 +1 -0
- cube_cli/_version.py +9 -0
- cube_cli/command.py +355 -0
- cube_cli/cube_json.py +104 -0
- cube_cli/main.py +39 -0
- cube_cli/print.py +86 -0
- cube_cli/repl.py +65 -0
- cube_cli/repl_state.py +146 -0
- cube_cli/save_load.py +55 -0
- cube_cli-0.3.0.dist-info/METADATA +61 -0
- cube_cli-0.3.0.dist-info/RECORD +13 -0
- cube_cli-0.3.0.dist-info/WHEEL +4 -0
- cube_cli-0.3.0.dist-info/entry_points.txt +3 -0
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,,
|