wizlib 2.0.15__tar.gz → 2.0.17__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.

Potentially problematic release.


This version of wizlib might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wizlib
3
- Version: 2.0.15
3
+ Version: 2.0.17
4
4
  Summary: Framework for flexible and powerful command-line applications
5
5
  License: MIT
6
6
  Author: Steampunk Wizard
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "wizlib"
3
- version = "2.0.15"
3
+ version = "2.0.17"
4
4
  description = "Framework for flexible and powerful command-line applications"
5
5
  authors = ["Steampunk Wizard <wizlib@steampunkwizard.ca>"]
6
6
  license = "MIT"
@@ -2,12 +2,6 @@
2
2
  # interfaces (shell, curses, Slack, etc) to drive the same data model without
3
3
  # the data model needing specific UI knowledge.
4
4
  #
5
- # Possibly outdated info: The UI keeps a reference to the Handler. The Handler
6
- # will bring a Command with it, and this is the main root command. In the case
7
- # of a Shell-type UI, that is the command to be executed. In the case of an
8
- # interactive UI, it's really just a placeholder; the UI will instantiate other
9
- # commands as it proceeds. But the Handler won't know about those.
10
- #
11
5
  # Commands might call back to the UI for confirmations or arguments that are
12
6
  # previously omitted, using the get_ methods.
13
7
 
@@ -45,41 +39,52 @@ from wizlib.class_family import ClassFamily
45
39
 
46
40
  @dataclass
47
41
  class Choice:
48
- """One option of several"""
42
+ """A single option, equivalent to a single radio button or select option in
43
+ HTML.
44
+
45
+ text - text of the option
46
+
47
+ keys - keystrokes that might indicate this option in a keyboard-driven UI
48
+
49
+ action - what to do when chosen
50
+ """
49
51
 
50
- keys: list[str] = field(default_factory=list)
51
- words: list[str] = field(default_factory=list)
52
+ text: str = ''
53
+ keys: str = ''
52
54
  action: object = None
53
55
 
54
56
  @property
55
57
  def key(self):
56
- return self.keys[0]
57
-
58
- @property
59
- def word(self):
60
- return self.words[0]
58
+ return self.keys[0] if self.keys else ''
61
59
 
62
60
  def hit_key(self, key):
63
61
  return key in self.keys
64
62
 
65
- def hit_word(self, word):
66
- return word in self.words
63
+ def hit_text(self, text):
64
+ return self.text.startswith(text)
67
65
 
66
+ @property
68
67
  def value(self):
69
- return self.word if (self.action is None) else self.action
68
+ return self.text if (self.action is None) else self.action
69
+
70
+ @property
71
+ def key_prompt(self):
72
+ """Text with keystroke in parens"""
73
+ for key in self.keys:
74
+ if key in self.text:
75
+ pre, it, post = self.text.partition(key)
76
+ return f"{pre}({it}){post}"
77
+ return f"({self.keys[0]}){self.text}"
70
78
 
71
- # def call(self, *args, **kwargs):
72
- # if callable(self.action):
73
- # return self.action(*args, **kwargs)
74
79
 
80
+ # TODO: Decide whether the Prompt/Chooser division makes sense
75
81
 
76
- @dataclass
77
82
  class Prompt:
78
83
  """Tell the user what kind of input to provide"""
79
84
 
80
- intro: str = ""
81
- default: str = ""
82
- choices: list = field(default_factory=list)
85
+ def __init__(self, intro: str = '', default: str = ''):
86
+ self.intro = intro
87
+ self.default = default
83
88
 
84
89
  @property
85
90
  def prompt_string(self):
@@ -90,35 +95,50 @@ class Prompt:
90
95
  if self.default:
91
96
  result.append(f"[{self.default}]")
92
97
  for choice in self.choices:
93
- if choice.word == self.default:
98
+ if choice.text == self.default:
94
99
  continue
95
- pre, it, post = choice.word.partition(choice.key)
96
- result.append(f"{pre}({it}){post}")
100
+ result.append(choice.key_prompt)
97
101
  return " ".join(result) + ": "
98
102
 
99
103
 
100
- @dataclass
101
104
  class Chooser(Prompt):
102
105
  """Hold a set of choices and get the result"""
103
106
 
107
+ def __init__(self, intro: str = '', default: str = '', choices=None):
108
+ super().__init__(intro, default)
109
+ if isinstance(choices, dict):
110
+ self.choices = [Choice(choices[k], k) for k in choices]
111
+ elif isinstance(choices, list):
112
+ self.choices = [(c if isinstance(c, Choice) else Choice(c))
113
+ for c in choices]
114
+ else:
115
+ self.choices = []
116
+
104
117
  def add_choice(self, *args, **kwargs):
105
118
  choice = Choice(*args, **kwargs)
106
119
  self.choices.append(choice)
107
120
 
108
121
  def choice_by_key(self, key):
109
- chosenlist = [o.value for o in self.choices if o.hit_key(key)]
110
- return self._choose(chosenlist)
122
+ if key == '\n':
123
+ choice = self.default
124
+ else:
125
+ choice = next(
126
+ (o.value for o in self.choices if key in o.keys), None)
127
+ return choice() if callable(choice) else choice
111
128
 
112
- def choice_by_word(self, word):
113
- chosenlist = [o.value for o in self.choices if o.hit_word(word)]
114
- return self._choose(chosenlist)
129
+ def choices_by_text(self, text):
130
+ return [o.value for o in self.choices if o.hit_text(text)]
115
131
 
116
- def _choose(self, chosenlist):
117
- choice = next(iter(chosenlist), None)
118
- if callable(choice):
119
- return choice()
120
- else:
121
- return choice
132
+ # def choice_by_word(self, word):
133
+ # chosenlist = [o.value for o in self.choices if o.hit_word(word)]
134
+ # return self._choose(chosenlist)
135
+
136
+ # def _choose(self, chosenlist):
137
+ # choice = next(iter(chosenlist), None)
138
+ # if callable(choice):
139
+ # return choice()
140
+ # else:
141
+ # return choice
122
142
 
123
143
 
124
144
  @dataclass
@@ -131,8 +151,8 @@ class UI(ClassFamily):
131
151
  # def start(self):
132
152
  # pass
133
153
 
134
- # # TODO: Make get_option an abstract method
135
- # def get_option(self, chooser: Chooser):
154
+ # # TODO: Make get_option_mini an abstract method
155
+ # def get_option_mini(self, chooser: Chooser):
136
156
  # """Get an option from a list from the user"""
137
157
  # pass
138
158
 
@@ -0,0 +1,236 @@
1
+ from enum import Enum
2
+ import sys
3
+ import re
4
+
5
+ from readchar import readkey
6
+
7
+
8
+ if (sys.platform == "win32"):
9
+ import ctypes
10
+ from ctypes import wintypes
11
+ else:
12
+ import termios
13
+
14
+ # https://stackoverflow.com/questions/35526014/how-can-i-get-the-cursors-position-in-an-ansi-terminal
15
+
16
+
17
+ def cursorPos(): # pragma: nocover
18
+ if (sys.platform == "win32"):
19
+ OldStdinMode = ctypes.wintypes.DWORD()
20
+ OldStdoutMode = ctypes.wintypes.DWORD()
21
+ kernel32 = ctypes.windll.kernel32
22
+ kernel32.GetConsoleMode(
23
+ kernel32.GetStdHandle(-10), ctypes.byref(OldStdinMode))
24
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), 0)
25
+ kernel32.GetConsoleMode(
26
+ kernel32.GetStdHandle(-11), ctypes.byref(OldStdoutMode))
27
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
28
+ else:
29
+ OldStdinMode = termios.tcgetattr(sys.stdin)
30
+ _ = termios.tcgetattr(sys.stdin)
31
+ _[3] = _[3] & ~(termios.ECHO | termios.ICANON)
32
+ termios.tcsetattr(sys.stdin, termios.TCSAFLUSH, _)
33
+ try:
34
+ _ = ""
35
+ sys.stdout.write("\x1b[6n")
36
+ sys.stdout.flush()
37
+ while not (_ := _ + sys.stdin.read(1)).endswith('R'):
38
+ True
39
+ res = re.match(r".*\[(?P<y>\d*);(?P<x>\d*)R", _)
40
+ finally:
41
+ if (sys.platform == "win32"):
42
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-10), OldStdinMode)
43
+ kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), OldStdoutMode)
44
+ else:
45
+ termios.tcsetattr(sys.stdin, termios.TCSAFLUSH, OldStdinMode)
46
+ if (res):
47
+ return (res.group("x"), res.group("y"))
48
+ return (-1, -1)
49
+
50
+
51
+ def sequence(hexes: str) -> str:
52
+ return bytes.fromhex(hexes).decode()
53
+
54
+
55
+ LEFT = sequence("1b5b44")
56
+ RIGHT = sequence("1b5b43")
57
+ BACKSPACE = sequence("7f")
58
+ BEGINNING = sequence("01")
59
+ END = sequence("05")
60
+ RETURN = sequence("0a")
61
+ ESC = sequence("1b")
62
+ TAB = sequence("09")
63
+ SHIFT_TAB = sequence("1b5b5a")
64
+ LEFT_WORD = sequence("1b62")
65
+ RIGHT_WORD = sequence("1b66")
66
+ KILL = sequence("0b")
67
+
68
+ RESET = ESC + "[0m"
69
+ FAINT = ESC + "[2m"
70
+ BOLD = ESC + "[1m"
71
+
72
+ WORD_SEPARATORS = ' -_.,'
73
+
74
+
75
+ def write(key):
76
+ sys.stderr.write(key)
77
+ sys.stderr.flush()
78
+
79
+
80
+ # "Fill" refers to the lighter text in the editor, to the right of (or instead
81
+ # of) user-typed text.
82
+ #
83
+ # States:
84
+ # - USER: User has typed something, so no fill
85
+ # - TAB: User has hit tab or shift-tab, show tab completion if any
86
+ # - DEFAULT: editor has a default value and user has typed nothing, so show
87
+ # default if any
88
+ # - BLANK: user hit backspace to clear the fill
89
+
90
+ FillState = Enum('FillState', 'USER TAB DEFAULT BLANK')
91
+
92
+
93
+ class ShellLineEditor: # pragma: nocover
94
+
95
+ buf = ''
96
+ pos = 0
97
+ index = -1
98
+
99
+ def __init__(self, choices=[], default=''):
100
+ """Parameters:
101
+
102
+ choices: List of string options for tab completion
103
+
104
+ default: Starting string value, can be accepted by user pressing return
105
+ """
106
+ self.choices = choices
107
+ self.default = default
108
+ self.fillstate = FillState.DEFAULT
109
+
110
+ def edit(self):
111
+ write(RESET)
112
+ while True:
113
+ self.write_fill()
114
+ key = readkey()
115
+ self.clear_fill()
116
+ if key == RETURN:
117
+ break
118
+ if key.isprintable():
119
+ write(BOLD + key + self.buf[self.pos:] +
120
+ ('\b' * (len(self.buf) - self.pos)) + RESET)
121
+ self.buf = self.buf[:self.pos] + key + self.buf[self.pos:]
122
+ self.pos += 1
123
+ self.fillstate = FillState.USER
124
+ elif (key in [BACKSPACE, KILL]) and self.has_fill:
125
+ # Backspace clears the fill
126
+ self.fillstate = FillState.BLANK
127
+ elif (key == BACKSPACE) and (self.pos > 0):
128
+ write(BOLD + '\b' + self.buf[self.pos:] + ' ' +
129
+ ('\b' * (1 + len(self.buf) - self.pos)) + RESET)
130
+ self.buf = self.buf[:self.pos-1] + self.buf[self.pos:]
131
+ self.pos -= 1
132
+ self.fillstate = FillState.USER if (
133
+ self.pos > 0) else FillState.DEFAULT
134
+ elif key == LEFT and self.pos > 0:
135
+ self.move_left()
136
+ self.fillstate = FillState.USER
137
+ elif key == RIGHT and self.pos < len(self.buf):
138
+ self.move_right()
139
+ self.fillstate = FillState.USER
140
+ elif key == BEGINNING and self.pos > 0:
141
+ self.move_beginning()
142
+ self.fillstate = FillState.USER
143
+ elif key == END and self.has_fill:
144
+ self.accept_fill()
145
+ self.fillstate = FillState.USER
146
+ elif key == END and self.pos < len(self.buf):
147
+ self.move_end_buf()
148
+ self.fillstate = FillState.USER
149
+ elif key == TAB and (choices := self.valid_choices):
150
+ self.index = (self.index + 1) % len(choices)
151
+ self.fillstate = FillState.TAB
152
+ elif key == SHIFT_TAB and self.index > -1:
153
+ self.index = (self.index - 1) % len(self.valid_choices)
154
+ self.fillstate = FillState.TAB
155
+ elif key == LEFT_WORD and self.pos > 0:
156
+ while (self.pos > 0) and self.is_sep(self.pos - 1):
157
+ self.move_left()
158
+ while (self.pos > 0) and not self.is_sep(self.pos - 1):
159
+ self.move_left()
160
+ self.fillstate = FillState.USER
161
+ elif key == RIGHT_WORD and self.pos < len(self.buf):
162
+ while (self.pos < len(self.buf)) and self.is_sep(self.pos):
163
+ self.move_right()
164
+ while (self.pos < len(self.buf)) and not self.is_sep(self.pos):
165
+ self.move_right()
166
+ self.fillstate = FillState.USER
167
+ elif key == KILL:
168
+ chars = len(self.buf) - self.pos
169
+ write(' ' * chars + '\b' * chars)
170
+ self.buf = self.buf[:self.pos]
171
+ self.fillstate = FillState.USER
172
+ else:
173
+ pass
174
+ self.accept_fill()
175
+ write(RETURN)
176
+ return self.buf
177
+
178
+ def is_sep(self, pos):
179
+ return (self.buf[pos] in WORD_SEPARATORS)
180
+
181
+ @property
182
+ def has_fill(self):
183
+ return self.fillstate in [FillState.DEFAULT, FillState.TAB]
184
+
185
+ def accept_fill(self):
186
+ if self.has_fill:
187
+ write(BOLD + self.fill + RESET)
188
+ self.buf += self.fill
189
+ self.pos = len(self.buf)
190
+
191
+ def move_left(self):
192
+ write(LEFT)
193
+ self.pos -= 1
194
+
195
+ def move_right(self):
196
+ write(RIGHT)
197
+ self.pos += 1
198
+
199
+ def move_beginning(self):
200
+ while self.pos > 0:
201
+ self.move_left()
202
+
203
+ def move_end_buf(self):
204
+ while self.pos < len(self.buf):
205
+ self.move_right()
206
+
207
+ def write_fill(self):
208
+ if self.fillstate in [FillState.DEFAULT, FillState.TAB]:
209
+ self.move_end_buf()
210
+ write(FAINT + self.fill + '\b' * len(self.fill) + RESET)
211
+
212
+ def clear_fill(self):
213
+ if self.has_fill:
214
+ self.move_end_buf()
215
+ write(' ' * len(self.fill) + '\b' * len(self.fill))
216
+
217
+ @property
218
+ def fill(self):
219
+ if self.fillstate == FillState.TAB:
220
+ choice = self.valid_choices[self.index]
221
+ return choice[len(self.last_word):]
222
+ elif self.fillstate == FillState.DEFAULT:
223
+ return self.default
224
+ else:
225
+ return ''
226
+
227
+ @property
228
+ def last_word(self):
229
+ index = next((c for c in reversed(range(len(self.buf)))
230
+ if self.buf[c] in WORD_SEPARATORS), -1)
231
+ return self.buf[index+1:]
232
+
233
+ @property
234
+ def valid_choices(self):
235
+ # return [c for c in self.choices if c.startswith(self.buf)]
236
+ return [c for c in self.choices if c.startswith(self.last_word)]
@@ -3,6 +3,7 @@ import sys
3
3
  from readchar import readkey
4
4
  from wizlib.rlinput import rlinput
5
5
  from wizlib.ui import UI
6
+ from wizlib.ui.shell_line_editor import ShellLineEditor
6
7
 
7
8
  INTERACTIVE = sys.stdin.isatty()
8
9
 
@@ -21,7 +22,7 @@ class ShellUI(UI):
21
22
  if value:
22
23
  print(value, file=sys.stderr)
23
24
 
24
- def get_option(self, chooser):
25
+ def get_option_mini(self, chooser):
25
26
  """Get a choice from the user with a single keystroke"""
26
27
  # key = rlinput(chooser.prompt_string)
27
28
  print(chooser.prompt_string, end='', file=sys.stderr, flush=True)
@@ -35,5 +36,7 @@ class ShellUI(UI):
35
36
  def get_text(self, prompt):
36
37
  """Allow the user to input an arbitrary line of text"""
37
38
  sys.stderr.write(prompt)
38
- value = input()
39
+ sys.stderr.flush()
40
+ value = ShellLineEditor().edit()
41
+ # value = input()
39
42
  return value
@@ -0,0 +1,8 @@
1
+ from unittest.mock import Mock, patch
2
+
3
+
4
+ RK = 'wizlib.ui.shell_line_editor.readkey'
5
+
6
+
7
+ def mock_keys(keys: str):
8
+ return patch(RK, Mock(side_effect=keys))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes