cube-cli 0.3.0__tar.gz → 0.4.1__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cube-cli
3
- Version: 0.3.0
4
- Summary: A CLI application for interacting with cube_model
3
+ Version: 0.4.1
4
+ Summary: A CLI application for the 3x3x3 cube
5
5
  Requires-Python: >=3.13,<4.0
6
6
  Classifier: Programming Language :: Python :: 3
7
7
  Classifier: Programming Language :: Python :: 3.13
@@ -1,7 +1,7 @@
1
1
  [tool.poetry]
2
2
  name = "cube-cli"
3
- version = "0.3.0"
4
- description = "A CLI application for interacting with cube_model"
3
+ version = "0.4.1"
4
+ description = "A CLI application for the 3x3x3 cube"
5
5
  authors = []
6
6
  readme = "README.md"
7
7
  repository = "https://github.com/ygale/cube-cli"
@@ -16,7 +16,7 @@ cube = "cube_cli.main:main"
16
16
 
17
17
  [tool.poetry.group.dev.dependencies]
18
18
  pytest = "^8.0"
19
- mypy = "^1.10"
19
+ mypy = ">=1.10,<3.0"
20
20
 
21
21
  [build-system]
22
22
  requires = ["poetry-core"]
@@ -2,13 +2,14 @@
2
2
  import re
3
3
  from abc import ABC, abstractmethod
4
4
  from dataclasses import dataclass
5
- from typing import Self
5
+ from typing import ClassVar, Self
6
6
 
7
7
  from cube_model import Color, Cube, Side, shuffled, solved
8
8
  from cube_model.action import Action, ParseError, act, parse_actions
9
9
  from cube_model.navigation import all_colors, side_color
10
10
 
11
11
  from .repl_state import (
12
+ Alias,
12
13
  Exit,
13
14
  LoadError,
14
15
  ReplState,
@@ -61,13 +62,24 @@ class Command(ABC):
61
62
  '''Execute the command against the given REPL state.'''
62
63
 
63
64
  @dataclass
64
- class Shuffle(Command):
65
+ class Tabbable:
66
+ '''Mixin for commands that can be tab-completed in the REPL.'''
67
+ aliases: ClassVar[list[Alias]] = []
68
+
69
+ @classmethod
70
+ def match(cls, inp: str) -> bool:
71
+ '''Return True if inp equals the name of one of cls's aliases.'''
72
+ return any(inp == alias.name for alias in cls.aliases)
73
+
74
+ @dataclass
75
+ class Shuffle(Tabbable, Command):
65
76
  '''Randomize the cube.'''
77
+ aliases: ClassVar[list[Alias]] = [Alias(name='shuffle', min_chars=2)]
66
78
 
67
79
  @classmethod
68
80
  def parse(cls, cmd: str) -> Self | None:
69
- '''Return a Shuffle if cmd is "shuffle".'''
70
- if cmd == 'shuffle':
81
+ '''Return a Shuffle if cmd matches one of its aliases.'''
82
+ if cls.match(cmd):
71
83
  return cls()
72
84
  return None
73
85
 
@@ -81,13 +93,14 @@ class Shuffle(Command):
81
93
  return None
82
94
 
83
95
  @dataclass
84
- class Solve(Command):
96
+ class Solve(Tabbable, Command):
85
97
  '''Return the cube to its solved state.'''
98
+ aliases: ClassVar[list[Alias]] = [Alias(name='solve', min_chars=2)]
86
99
 
87
100
  @classmethod
88
101
  def parse(cls, cmd: str) -> Self | None:
89
- '''Return a Solve if cmd is "solve".'''
90
- if cmd == 'solve':
102
+ '''Return a Solve if cmd matches one of its aliases.'''
103
+ if cls.match(cmd):
91
104
  return cls()
92
105
  return None
93
106
 
@@ -101,8 +114,9 @@ class Solve(Command):
101
114
  return None
102
115
 
103
116
  @dataclass
104
- class Load(Command):
117
+ class Load(Tabbable, Command):
105
118
  '''Load cube state from a file.'''
119
+ aliases: ClassVar[list[Alias]] = [Alias(name='load', min_chars=2)]
106
120
  filename: str | None
107
121
 
108
122
  @classmethod
@@ -135,8 +149,9 @@ class Load(Command):
135
149
  return None
136
150
 
137
151
  @dataclass
138
- class Save(Command):
152
+ class Save(Tabbable, Command):
139
153
  '''Save cube state to a file.'''
154
+ aliases: ClassVar[list[Alias]] = [Alias(name='save', min_chars=2)]
140
155
  filename: str | None
141
156
 
142
157
  @classmethod
@@ -166,13 +181,14 @@ class Save(Command):
166
181
  return None
167
182
 
168
183
  @dataclass
169
- class Undo(Command):
184
+ class Undo(Tabbable, Command):
170
185
  '''Undo the last command.'''
186
+ aliases: ClassVar[list[Alias]] = [Alias(name='undo', min_chars=2)]
171
187
 
172
188
  @classmethod
173
189
  def parse(cls, cmd: str) -> Self | None:
174
- '''Return an Undo if cmd is "undo".'''
175
- if cmd == 'undo':
190
+ '''Return an Undo if cmd matches one of its aliases.'''
191
+ if cls.match(cmd):
176
192
  return cls()
177
193
  return None
178
194
 
@@ -192,13 +208,14 @@ class Undo(Command):
192
208
  return None
193
209
 
194
210
  @dataclass
195
- class Redo(Command):
211
+ class Redo(Tabbable, Command):
196
212
  '''Redo the last undone command.'''
213
+ aliases: ClassVar[list[Alias]] = [Alias(name='redo', min_chars=4)]
197
214
 
198
215
  @classmethod
199
216
  def parse(cls, cmd: str) -> Self | None:
200
- '''Return a Redo if cmd is "redo".'''
201
- if cmd == 'redo':
217
+ '''Return a Redo if cmd matches one of its aliases.'''
218
+ if cls.match(cmd):
202
219
  return cls()
203
220
  return None
204
221
 
@@ -218,13 +235,14 @@ class Redo(Command):
218
235
  return None
219
236
 
220
237
  @dataclass
221
- class Quit(Command):
238
+ class Quit(Tabbable, Command):
222
239
  '''Exit the REPL.'''
240
+ aliases: ClassVar[list[Alias]] = [Alias(name='quit'), Alias(name='q')]
223
241
 
224
242
  @classmethod
225
243
  def parse(cls, cmd: str) -> Self | None:
226
- '''Return a Quit if cmd is "quit" or "q".'''
227
- if cmd in ('quit', 'q'):
244
+ '''Return a Quit if cmd matches one of its aliases.'''
245
+ if cls.match(cmd):
228
246
  return cls()
229
247
  return None
230
248
 
@@ -271,13 +289,14 @@ Commands:
271
289
  quit, q Exit cube.'''
272
290
 
273
291
  @dataclass
274
- class Help(Command):
292
+ class Help(Tabbable, Command):
275
293
  '''Print a summary of available commands.'''
294
+ aliases: ClassVar[list[Alias]] = [Alias(name='help'), Alias(name='?')]
276
295
 
277
296
  @classmethod
278
297
  def parse(cls, cmd: str) -> Self | None:
279
- '''Return a Help if cmd is "help" or "?".'''
280
- if cmd in ('help', '?'):
298
+ '''Return a Help if cmd matches one of its aliases.'''
299
+ if cls.match(cmd):
281
300
  return cls()
282
301
  return None
283
302
 
@@ -3,11 +3,56 @@ import readline
3
3
 
4
4
  from cube_model import Cube, solved
5
5
 
6
- from .command import Command, Quit, all_commands
6
+ from .command import Command, Quit, Tabbable, all_commands
7
7
  from .print import print_cube
8
- from .repl_state import Exit, LoadError, ReplState
8
+ from .repl_state import Alias, Exit, LoadError, ReplState
9
9
  from .save_load import SaveError, load
10
10
 
11
+ # All tab completion candidates
12
+ _COMPLETIONS: list[str] = sorted(a.name
13
+ for c in all_commands if issubclass(c, Tabbable)
14
+ for a in c.aliases)
15
+
16
+ # Do not tab complete if it might be a Move.
17
+ # Avoid comprehension due to mypy bug.
18
+ _NON_COMPLETIONS: set[str] = set()
19
+ cmd: type[Command]
20
+ alias: Alias
21
+ for cmd in all_commands:
22
+ if issubclass(cmd, Tabbable):
23
+ for alias in cmd.aliases:
24
+ if alias.min_chars > 1:
25
+ _NON_COMPLETIONS.add(alias.name[:alias.min_chars - 1])
26
+
27
+ # Cached tab completion candidates for a specific input text
28
+ _matches: list[str] = []
29
+
30
+ def _complete(text: str, state: int) -> str | None:
31
+ '''Readline completer for REPL commands.
32
+
33
+ Matches text against _COMPLETIONS case-insensitively, always
34
+ returning lower-case completions. Returns no completions at all
35
+ if text is a prefix of any entry in _NON_COMPLETIONS.
36
+ '''
37
+ global _matches
38
+ text_lower: str = text.lower()
39
+ if state == 0:
40
+ if any(nc.startswith(text_lower) for nc in _NON_COMPLETIONS):
41
+ _matches = []
42
+ else:
43
+ _matches = [c for c in _COMPLETIONS if c.startswith(text_lower)]
44
+ if state < len(_matches):
45
+ return _matches[state]
46
+ return None
47
+
48
+ def _setup_readline() -> None:
49
+ '''Register the REPL's tab completer with readline.'''
50
+ readline.set_completer(_complete)
51
+ if readline.__doc__ is not None and 'libedit' in readline.__doc__:
52
+ readline.parse_and_bind('bind ^I rl_complete')
53
+ else:
54
+ readline.parse_and_bind('tab: complete')
55
+
11
56
  def parse_command(inp: str) -> Command | None:
12
57
  '''Try each command type in order; return the first match.'''
13
58
  cmd_type: type[Command]
@@ -35,6 +80,7 @@ def repl(filename: str | None) -> None:
35
80
  If filename is given, load it as the initial cube state. If the
36
81
  load fails, fall back to a solved cube and report the error.
37
82
  '''
83
+ _setup_readline()
38
84
  cmd: Command | None
39
85
  rs: ReplState = _initial_state(filename)
40
86
  while True:
@@ -53,6 +99,7 @@ def repl(filename: str | None) -> None:
53
99
  inp: str = input(
54
100
  'Command (? for help, q to quit): ').strip()
55
101
  except EOFError:
102
+ print()
56
103
  cmd = Quit()
57
104
  else:
58
105
  cmd = parse_command(inp)
@@ -21,6 +21,16 @@ class LoadError:
21
21
  filename: str
22
22
  msg: str
23
23
 
24
+ @dataclass
25
+ class Alias:
26
+ '''A name by which a Tabbable command can be invoked in the REPL.
27
+
28
+ min_chars is the minimum number of characters of name that must
29
+ be typed before tab completion will offer it.
30
+ '''
31
+ name: str
32
+ min_chars: int = 1
33
+
24
34
  def _invert_action(action: Action) -> Action:
25
35
  '''Return the inverse of a single action.
26
36
 
File without changes
File without changes
File without changes