wizlib 3.2.0__py3-none-any.whl → 3.4.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.
- wizlib/config_handler.py +66 -23
- wizlib/io.py +18 -8
- wizlib/stream_handler.py +19 -25
- wizlib/test_case.py +8 -4
- wizlib/ui/shell/__init__.py +6 -0
- wizlib/ui/shell/line_editor.py +65 -50
- wizlib/ui/shell_ui.py +30 -4
- wizlib/ui/text_wrapper.py +103 -0
- wizlib-3.4.0.dist-info/METADATA +27 -0
- wizlib-3.4.0.dist-info/RECORD +21 -0
- {wizlib-3.2.0.dist-info → wizlib-3.4.0.dist-info}/WHEEL +1 -1
- wizlib-3.2.0.dist-info/METADATA +0 -45
- wizlib-3.2.0.dist-info/RECORD +0 -20
wizlib/config_handler.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
from argparse import Namespace
|
|
2
|
+
from functools import cached_property
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
import os
|
|
4
5
|
from dataclasses import dataclass
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
5
9
|
from unittest.mock import patch
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
from yaml import Loader
|
|
9
|
-
from wizlib.handler import Handler
|
|
11
|
+
import yaml
|
|
10
12
|
|
|
13
|
+
from wizlib.handler import Handler
|
|
11
14
|
from wizlib.error import ConfigHandlerError
|
|
12
15
|
from wizlib.parser import WizParser
|
|
13
16
|
|
|
@@ -24,14 +27,33 @@ class ConfigHandler(Handler):
|
|
|
24
27
|
|
|
25
28
|
name = 'config'
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
@classmethod
|
|
31
|
+
def setup(cls, val):
|
|
32
|
+
"""Allow for alternative setup, passing in an injected data value as a
|
|
33
|
+
dict, bypassing file loading, for testing. Possible long-term
|
|
34
|
+
alternative to .fake() below."""
|
|
35
|
+
|
|
36
|
+
if isinstance(val, str) or isinstance(val, Path):
|
|
37
|
+
return cls(file=val)
|
|
38
|
+
elif isinstance(val, dict) or isinstance(val, list):
|
|
39
|
+
return cls(data=val)
|
|
40
|
+
|
|
41
|
+
def __init__(self, file: str = None, data: dict = None):
|
|
42
|
+
"""Initiatlize with either a yaml file path or a data block to inject
|
|
43
|
+
(for testing)"""
|
|
28
44
|
self.file = file
|
|
45
|
+
self.injected_data = data
|
|
29
46
|
self.cache = {}
|
|
30
47
|
|
|
31
|
-
@
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
@cached_property
|
|
49
|
+
def data(self):
|
|
50
|
+
"""Returns the full set of configuration data, typically loaded from a
|
|
51
|
+
yaml file. Is cached in code."""
|
|
52
|
+
|
|
53
|
+
# If yaml_dict was provided, use it directly
|
|
54
|
+
if self.injected_data is not None:
|
|
55
|
+
return self.injected_data
|
|
56
|
+
|
|
35
57
|
path = None
|
|
36
58
|
if self.file:
|
|
37
59
|
path = Path(self.file)
|
|
@@ -46,34 +68,41 @@ class ConfigHandler(Handler):
|
|
|
46
68
|
path = homepath
|
|
47
69
|
if path:
|
|
48
70
|
with open(path) as file:
|
|
49
|
-
|
|
50
|
-
return
|
|
71
|
+
data = yaml.safe_load(file)
|
|
72
|
+
return data
|
|
51
73
|
|
|
52
74
|
@staticmethod
|
|
53
75
|
def env(name):
|
|
54
76
|
if (envvar := name.upper().replace('-', '_')) in os.environ:
|
|
55
77
|
return os.environ[envvar]
|
|
56
78
|
|
|
57
|
-
def get(self,
|
|
58
|
-
"""Return the value for the requested config entry
|
|
79
|
+
def get(self, data_path: str):
|
|
80
|
+
"""Return the value for the requested config entry. If the value is a
|
|
81
|
+
string, evaluate it for shell-type expressions using $(...) syntax. Can
|
|
82
|
+
also return a dict or array. Note that the value returned is cached
|
|
83
|
+
against the data_path, so future calls may not address nested paths.
|
|
84
|
+
|
|
85
|
+
data_path: Hyphen-separated path through the yaml/dict hierarchy."""
|
|
59
86
|
|
|
60
87
|
# If we already found the value, return it
|
|
61
|
-
if
|
|
62
|
-
return self.cache[
|
|
88
|
+
if data_path in self.cache:
|
|
89
|
+
return self.cache[data_path]
|
|
63
90
|
|
|
64
91
|
# Environment variables take precedence
|
|
65
|
-
if (result := self.env(
|
|
66
|
-
self.cache[
|
|
92
|
+
if (result := self.env(data_path)):
|
|
93
|
+
self.cache[data_path] = result
|
|
67
94
|
return result
|
|
68
95
|
|
|
69
|
-
# Otherwise look at the YAML
|
|
70
|
-
if (
|
|
71
|
-
split =
|
|
72
|
-
while (
|
|
73
|
-
|
|
96
|
+
# Otherwise look at the YAML or injected data
|
|
97
|
+
if (data := self.data):
|
|
98
|
+
split = data_path.split('-')
|
|
99
|
+
while (key := split.pop(0)) and (key in data):
|
|
100
|
+
data = data[key] if key in data else None
|
|
74
101
|
if not split:
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
if isinstance(data, str):
|
|
103
|
+
data = evaluate_string(data)
|
|
104
|
+
self.cache[data_path] = data
|
|
105
|
+
return data
|
|
77
106
|
|
|
78
107
|
@classmethod
|
|
79
108
|
def fake(cls, **vals):
|
|
@@ -81,3 +110,17 @@ class ConfigHandler(Handler):
|
|
|
81
110
|
self = cls()
|
|
82
111
|
self.cache = {k.replace('_', '-'): vals[k] for k in vals}
|
|
83
112
|
return self
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def os_process(match):
|
|
116
|
+
"""Run a subprocess in shell form"""
|
|
117
|
+
command_string = match.group(1).strip()
|
|
118
|
+
command = shlex.split(command_string)
|
|
119
|
+
result = subprocess.run(command, capture_output=True)
|
|
120
|
+
return result.stdout.decode().strip()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def evaluate_string(yaml: str) -> str:
|
|
124
|
+
"""Evaluate shell commands in string values"""
|
|
125
|
+
text = yaml.strip()
|
|
126
|
+
return re.sub(r'\$\((.*?)\)', os_process, text)
|
wizlib/io.py
CHANGED
|
@@ -6,22 +6,32 @@ import sys
|
|
|
6
6
|
import readchar
|
|
7
7
|
|
|
8
8
|
from wizlib.parser import WizArgumentError
|
|
9
|
+
from wizlib.ui.shell import ESC
|
|
9
10
|
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
# Is it OK to read tty input? Patch this for testing
|
|
12
13
|
|
|
14
|
+
TTY_OK = True
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
return ISATTY
|
|
16
|
+
# ISATTY = all(s.isatty() for s in (sys.stdin, sys.stdout, sys.stderr))
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
def
|
|
19
|
-
|
|
19
|
+
# def isatty():
|
|
20
|
+
# return ISATTY
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
# def stream():
|
|
24
|
+
# return '' if ISATTY else sys.stdin.read()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ttyin(): # pragma: nocover
|
|
28
|
+
"""Read a character from the tty (via readchar). Patch this for testing."""
|
|
29
|
+
if TTY_OK:
|
|
30
|
+
key = readchar.readkey()
|
|
31
|
+
# Handle specialized escape sequences
|
|
32
|
+
if key == ESC + '[1;':
|
|
33
|
+
key = key + readchar.readkey() + readchar.readkey()
|
|
34
|
+
return key
|
|
25
35
|
else:
|
|
26
36
|
raise WizArgumentError(
|
|
27
37
|
'Command designed for interactive use')
|
wizlib/stream_handler.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
# When testing, read from the object provided (probably a StreamIO)
|
|
3
|
-
|
|
1
|
+
import os
|
|
4
2
|
from pathlib import Path
|
|
5
3
|
import sys
|
|
6
4
|
|
|
@@ -10,33 +8,29 @@ import wizlib.io
|
|
|
10
8
|
|
|
11
9
|
|
|
12
10
|
class StreamHandler(Handler):
|
|
13
|
-
"""Handle non-interactive input, such as via a pipe in a shell. Only
|
|
14
|
-
when not in a tty.
|
|
11
|
+
"""Handle non-interactive input, such as via a pipe in a shell. Only
|
|
12
|
+
applies when not in a tty. Doesn't actually stream anything and should
|
|
13
|
+
probably be called PipedInputHandler in a future major upgrade."""
|
|
15
14
|
|
|
16
15
|
name = 'stream'
|
|
17
|
-
text: str = ''
|
|
18
16
|
|
|
19
17
|
def __init__(self, file=None):
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if self.file:
|
|
25
|
-
return Path(self.file).read_text()
|
|
18
|
+
if file:
|
|
19
|
+
self._text = Path(file).read_text()
|
|
20
|
+
elif sys.stdin.isatty():
|
|
21
|
+
self._text = ''
|
|
26
22
|
else:
|
|
27
|
-
|
|
23
|
+
self._text = sys.stdin.read()
|
|
24
|
+
# Reset sys.stdin to tty for possible interactions
|
|
25
|
+
# if os.path.exists(os.ctermid()):
|
|
26
|
+
try:
|
|
27
|
+
sys.stdin = open(os.ctermid(), 'r')
|
|
28
|
+
except OSError:
|
|
29
|
+
pass
|
|
28
30
|
|
|
29
31
|
@property
|
|
30
32
|
def text(self):
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# return self.text
|
|
36
|
-
|
|
37
|
-
# @classmethod
|
|
38
|
-
# def fake(cls, value):
|
|
39
|
-
# """Return a fake StreamHandler with forced values, for testing"""
|
|
40
|
-
# handler = cls(stdin=False)
|
|
41
|
-
# handler.text = value
|
|
42
|
-
# return handler
|
|
33
|
+
return self._text
|
|
34
|
+
|
|
35
|
+
def read(self):
|
|
36
|
+
return self.text
|
wizlib/test_case.py
CHANGED
|
@@ -9,7 +9,8 @@ class WizLibTestCase(TestCase):
|
|
|
9
9
|
"""Wrap your test cases in this class to use the patches correctly"""
|
|
10
10
|
|
|
11
11
|
def setUp(self):
|
|
12
|
-
|
|
12
|
+
"""Test cases should never use true interaction"""
|
|
13
|
+
self.notty = patch('wizlib.io.TTY_OK', False)
|
|
13
14
|
self.notty.start()
|
|
14
15
|
|
|
15
16
|
def tearDown(self):
|
|
@@ -18,13 +19,16 @@ class WizLibTestCase(TestCase):
|
|
|
18
19
|
@staticmethod
|
|
19
20
|
def patch_stream(val: str):
|
|
20
21
|
"""Patch stream input such as pipes for stream handler"""
|
|
21
|
-
mock = Mock(return_value=val)
|
|
22
|
-
return patch('wizlib.io.stream', mock)
|
|
22
|
+
# mock = Mock(return_value=val)
|
|
23
|
+
# return patch('wizlib.io.stream', mock)
|
|
24
|
+
# return patch('wizlib.stream_handler.StreamHandler.read', mock)
|
|
25
|
+
return patch('wizlib.stream_handler.StreamHandler.text', val)
|
|
23
26
|
|
|
24
27
|
@staticmethod
|
|
25
|
-
def patch_ttyin(val:
|
|
28
|
+
def patch_ttyin(val:str='\n'):
|
|
26
29
|
"""Patch input typed by a user in shell ui"""
|
|
27
30
|
mock = Mock(side_effect=val)
|
|
31
|
+
# mock = Mock(return_value = val)
|
|
28
32
|
return patch('wizlib.io.ttyin', mock)
|
|
29
33
|
|
|
30
34
|
@staticmethod
|
wizlib/ui/shell/__init__.py
CHANGED
|
@@ -38,3 +38,9 @@ class S(StrEnum):
|
|
|
38
38
|
MAGENTA = ESC + '[35m'
|
|
39
39
|
CYAN = ESC + '[36m'
|
|
40
40
|
CLEAR = ESC + '[2J'
|
|
41
|
+
|
|
42
|
+
# Alternative keys that can be configured in a terminal emulator
|
|
43
|
+
CUSTOM_END = ESC + '[1;5C'
|
|
44
|
+
CUSTOM_BEGINNING = ESC + '[1;5D'
|
|
45
|
+
CUSTOM_RIGHTWORD = ESC + '[1;3C'
|
|
46
|
+
CUSTOM_LEFTWORD = ESC + '[1;3D'
|
wizlib/ui/shell/line_editor.py
CHANGED
|
@@ -5,47 +5,47 @@ import re
|
|
|
5
5
|
from wizlib.ui.shell import S
|
|
6
6
|
import wizlib.io
|
|
7
7
|
|
|
8
|
-
if (sys.platform == "win32"):
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
else:
|
|
12
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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.stdouS.write("\x1b[6n")
|
|
36
|
+
# sys.stdouS.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
49
|
|
|
50
50
|
|
|
51
51
|
def write(key):
|
|
@@ -92,10 +92,7 @@ class ShellLineEditor: # pragma: nocover
|
|
|
92
92
|
if key == S.RETURN:
|
|
93
93
|
break
|
|
94
94
|
if key.isprintable():
|
|
95
|
-
|
|
96
|
-
('\b' * (len(self.buf) - self.pos)) + S.RESET)
|
|
97
|
-
self.buf = self.buf[:self.pos] + key + self.buf[self.pos:]
|
|
98
|
-
self.pos += 1
|
|
95
|
+
self.write_key(key)
|
|
99
96
|
self.fillstate = FillState.USER
|
|
100
97
|
elif (key in [S.BACKSPACE, S.KILL]) and self.has_fill:
|
|
101
98
|
# Backspace clears the fill
|
|
@@ -113,13 +110,13 @@ class ShellLineEditor: # pragma: nocover
|
|
|
113
110
|
elif key == S.RIGHT and self.pos < len(self.buf):
|
|
114
111
|
self.move_right()
|
|
115
112
|
self.fillstate = FillState.USER
|
|
116
|
-
elif key
|
|
113
|
+
elif (key in [S.BEGINNING, S.CUSTOM_BEGINNING]) and self.pos > 0:
|
|
117
114
|
self.move_beginning()
|
|
118
115
|
self.fillstate = FillState.USER
|
|
119
|
-
elif key
|
|
116
|
+
elif (key in [S.END, S.CUSTOM_END]) and self.has_fill:
|
|
120
117
|
self.accept_fill()
|
|
121
118
|
self.fillstate = FillState.USER
|
|
122
|
-
elif key
|
|
119
|
+
elif (key in [S.END, S.CUSTOM_END]) and self.pos < len(self.buf):
|
|
123
120
|
self.move_end_buf()
|
|
124
121
|
self.fillstate = FillState.USER
|
|
125
122
|
elif key == S.TAB and (choices := self.valid_choices):
|
|
@@ -128,13 +125,25 @@ class ShellLineEditor: # pragma: nocover
|
|
|
128
125
|
elif key == S.SHIFT_TAB and self.index > -1:
|
|
129
126
|
self.index = (self.index - 1) % len(self.valid_choices)
|
|
130
127
|
self.fillstate = FillState.TAB
|
|
131
|
-
elif key
|
|
128
|
+
elif (key in [S.LEFT_WORD, S.CUSTOM_LEFTWORD]) and self.pos > 0:
|
|
132
129
|
while (self.pos > 0) and self.is_sep(self.pos - 1):
|
|
133
130
|
self.move_left()
|
|
134
131
|
while (self.pos > 0) and not self.is_sep(self.pos - 1):
|
|
135
132
|
self.move_left()
|
|
136
133
|
self.fillstate = FillState.USER
|
|
137
|
-
elif key
|
|
134
|
+
elif (key in [S.RIGHT_WORD, S.CUSTOM_RIGHTWORD]) and self.has_fill:
|
|
135
|
+
fill = self.fill
|
|
136
|
+
self.fillstate = FillState.USER
|
|
137
|
+
while fill:
|
|
138
|
+
char = fill[0]
|
|
139
|
+
self.write_key(char)
|
|
140
|
+
fill = fill[1:]
|
|
141
|
+
if char in S.SEPARATORS:
|
|
142
|
+
self.fillstate = FillState.TAB
|
|
143
|
+
self.index = self.valid_choices.index(self.buf + fill)
|
|
144
|
+
break
|
|
145
|
+
elif (key in [S.RIGHT_WORD, S.CUSTOM_RIGHTWORD]) and \
|
|
146
|
+
self.pos < len(self.buf):
|
|
138
147
|
while (self.pos < len(self.buf)) and self.is_sep(self.pos):
|
|
139
148
|
self.move_right()
|
|
140
149
|
while (self.pos < len(self.buf)) and not self.is_sep(self.pos):
|
|
@@ -151,6 +160,12 @@ class ShellLineEditor: # pragma: nocover
|
|
|
151
160
|
write(S.RETURN)
|
|
152
161
|
return self.buf
|
|
153
162
|
|
|
163
|
+
def write_key(self, key):
|
|
164
|
+
write(S.BOLD + key + self.buf[self.pos:] +
|
|
165
|
+
('\b' * (len(self.buf) - self.pos)) + S.RESET)
|
|
166
|
+
self.buf = self.buf[:self.pos] + key + self.buf[self.pos:]
|
|
167
|
+
self.pos += 1
|
|
168
|
+
|
|
154
169
|
def is_sep(self, pos):
|
|
155
170
|
return (self.buf[pos] in S.SEPARATORS)
|
|
156
171
|
|
wizlib/ui/shell_ui.py
CHANGED
|
@@ -4,6 +4,7 @@ import sys
|
|
|
4
4
|
from wizlib.ui import UI, Chooser, Emphasis
|
|
5
5
|
from wizlib.ui.shell.line_editor import ShellLineEditor
|
|
6
6
|
from wizlib.ui.shell import S
|
|
7
|
+
from wizlib.ui.text_wrapper import StreamingTextWrapper
|
|
7
8
|
import wizlib.io
|
|
8
9
|
|
|
9
10
|
COLOR = {
|
|
@@ -23,10 +24,35 @@ class ShellUI(UI):
|
|
|
23
24
|
|
|
24
25
|
name = "shell"
|
|
25
26
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
def __init__(self):
|
|
28
|
+
super().__init__()
|
|
29
|
+
self._wrapper = None
|
|
30
|
+
|
|
31
|
+
def send(self, value: str = '', emphasis: Emphasis = Emphasis.GENERAL,
|
|
32
|
+
newline: bool = True, wrap: int = 0):
|
|
33
|
+
"""Output some text
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
value: Text to output
|
|
37
|
+
emphasis: Color/emphasis style
|
|
38
|
+
newline: Whether to append a newline
|
|
39
|
+
wrap: Column width to wrap at (0 = no wrapping)
|
|
40
|
+
"""
|
|
41
|
+
if wrap > 0:
|
|
42
|
+
# Initialize wrapper if needed or if width changed
|
|
43
|
+
if self._wrapper is None or self._wrapper.width != wrap:
|
|
44
|
+
self._wrapper = StreamingTextWrapper(
|
|
45
|
+
width=wrap, output_stream=sys.stderr)
|
|
46
|
+
|
|
47
|
+
# Use streaming wrapper
|
|
48
|
+
self._wrapper.write_streaming(value, COLOR[emphasis])
|
|
49
|
+
if newline:
|
|
50
|
+
self._wrapper.write_newline()
|
|
51
|
+
else:
|
|
52
|
+
# Original behavior - no wrapping
|
|
53
|
+
end = '\n' if newline else ''
|
|
54
|
+
sys.stderr.write(COLOR[emphasis] + value + S.RESET + end)
|
|
55
|
+
sys.stderr.flush()
|
|
30
56
|
|
|
31
57
|
def ask(self, value: str):
|
|
32
58
|
"""Prompt for input"""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Streaming text wrapper that provides word wrapping with backspace correction
|
|
3
|
+
for a typewriter-like streaming experience.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from wizlib.ui import Emphasis
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StreamingTextWrapper:
|
|
12
|
+
"""Handles streaming text output with word wrapping that uses backspace
|
|
13
|
+
to correct words that would overflow the line boundary.
|
|
14
|
+
|
|
15
|
+
Provides a typewriter-like streaming experience where characters are
|
|
16
|
+
written immediately, but words are moved to the next line if they
|
|
17
|
+
would overflow.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, width: int = 80, output_stream=None):
|
|
21
|
+
"""Initialize the wrapper.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
width: Column width to wrap at
|
|
25
|
+
output_stream: Stream to write to (defaults to sys.stderr)
|
|
26
|
+
"""
|
|
27
|
+
self.width = width
|
|
28
|
+
self.output_stream = output_stream or sys.stderr
|
|
29
|
+
self._current_col = 0
|
|
30
|
+
self._word_buffer = [] # Buffer for current word being streamed
|
|
31
|
+
|
|
32
|
+
def _write_char(self, char: str, color_code: str = ''):
|
|
33
|
+
"""Write a single character with optional color."""
|
|
34
|
+
if color_code:
|
|
35
|
+
self.output_stream.write(color_code + char + '\033[0m')
|
|
36
|
+
else:
|
|
37
|
+
self.output_stream.write(char)
|
|
38
|
+
self.output_stream.flush()
|
|
39
|
+
|
|
40
|
+
def _backspace_buffer(self):
|
|
41
|
+
"""Backspace over the current word buffer."""
|
|
42
|
+
for _ in range(len(self._word_buffer)):
|
|
43
|
+
self.output_stream.write('\b \b') # backspace, space, backspace
|
|
44
|
+
self.output_stream.flush()
|
|
45
|
+
|
|
46
|
+
def write_streaming(self, text: str, color_code: str = ''):
|
|
47
|
+
"""Write text with streaming word wrapping.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
text: Text to output
|
|
51
|
+
color_code: ANSI color code to apply
|
|
52
|
+
"""
|
|
53
|
+
for char in text:
|
|
54
|
+
if char == '\n':
|
|
55
|
+
# Explicit newline - flush buffer and reset
|
|
56
|
+
self._word_buffer = []
|
|
57
|
+
self._write_char(char)
|
|
58
|
+
self._current_col = 0
|
|
59
|
+
elif char in ' \t':
|
|
60
|
+
# Word boundary - write the space and clear buffer
|
|
61
|
+
self._word_buffer = []
|
|
62
|
+
self._write_char(char, color_code)
|
|
63
|
+
self._current_col += 1
|
|
64
|
+
else:
|
|
65
|
+
# Regular character - add to buffer and write immediately
|
|
66
|
+
self._word_buffer.append(char)
|
|
67
|
+
self._write_char(char, color_code)
|
|
68
|
+
self._current_col += 1
|
|
69
|
+
|
|
70
|
+
# Check if this character pushed us over the width
|
|
71
|
+
if self._current_col > self.width:
|
|
72
|
+
# Only backspace and wrap if the word can fit on a new line
|
|
73
|
+
# If the word itself is longer than width, let it continue
|
|
74
|
+
if len(self._word_buffer) <= self.width:
|
|
75
|
+
# We've gone over - backspace the whole current word
|
|
76
|
+
self._backspace_buffer()
|
|
77
|
+
self._current_col -= len(self._word_buffer)
|
|
78
|
+
|
|
79
|
+
# Write newline
|
|
80
|
+
self._write_char('\n')
|
|
81
|
+
self._current_col = 0
|
|
82
|
+
|
|
83
|
+
# Rewrite word on new line
|
|
84
|
+
for c in self._word_buffer:
|
|
85
|
+
self._write_char(c, color_code)
|
|
86
|
+
self._current_col += 1
|
|
87
|
+
|
|
88
|
+
def write_newline(self):
|
|
89
|
+
"""Write a newline and reset position."""
|
|
90
|
+
self.output_stream.write('\n')
|
|
91
|
+
self.output_stream.flush()
|
|
92
|
+
self._current_col = 0
|
|
93
|
+
self._word_buffer = []
|
|
94
|
+
|
|
95
|
+
def reset_position(self):
|
|
96
|
+
"""Reset the column position (useful for explicit positioning)."""
|
|
97
|
+
self._current_col = 0
|
|
98
|
+
self._word_buffer = []
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def current_column(self):
|
|
102
|
+
"""Get the current column position."""
|
|
103
|
+
return self._current_col
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wizlib
|
|
3
|
+
Version: 3.4.0
|
|
4
|
+
Summary: Framework for flexible and powerful command-line applications
|
|
5
|
+
License: MIT
|
|
6
|
+
Author: Steampunk Wizard
|
|
7
|
+
Author-email: wizlib@steampunkwizard.ca
|
|
8
|
+
Requires-Python: >=3.11,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: PyYAML (>=6.0.1,<7.0.0)
|
|
16
|
+
Requires-Dist: readchar (>=4.0.5,<5.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# WizLib
|
|
21
|
+
|
|
22
|
+
Build configurable CLI tools easily in Python (a framework)
|
|
23
|
+
|
|
24
|
+
<a style="font-weight: bold; font-size: 1.2em;" href="https://wizlib.steamwiz.io">Documentation on SteamWiz.io</a>
|
|
25
|
+
|
|
26
|
+
<a style="font-weight: 300; font-size: 0.8em;" href="https://www.flaticon.com/free-icons/wizard" title="wizard icons">Wizard icon by Freepik-Flaticon</a>
|
|
27
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
wizlib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
wizlib/app.py,sha256=ke0NbaYC6ArmdtLTySl6VIGIGicxTsIuzW9wag6b7rU,4180
|
|
3
|
+
wizlib/class_family.py,sha256=tORSVAaPeWTQMcz2DX-MQClj1GQR3vCmkALPXxHa_pk,4506
|
|
4
|
+
wizlib/command.py,sha256=NO1558EYuXxfkpSmX6ljjzae8n8g4w6yytZKTJtigvo,1708
|
|
5
|
+
wizlib/config_handler.py,sha256=jKQV-6iP3V3XLx-swLWUmuAaMHsFp9DN_dd6dMeaBhk,4301
|
|
6
|
+
wizlib/error.py,sha256=ypwdMOYhtgKWd48ccfOX8idmCXmm-Skwx3gkPwqJB3c,46
|
|
7
|
+
wizlib/handler.py,sha256=Oz80aPhDyeY9tdppZ1dvtN-19JU5ydEDVW6jtppVoD4,446
|
|
8
|
+
wizlib/io.py,sha256=MWzqqXdmWmOsLs7n2KTcDodNd5YbfHMl5WuVoqsRm0Y,875
|
|
9
|
+
wizlib/parser.py,sha256=yLHV0fENeApFomCRWa3I6sB1x4lk1ag4vKejWVsic64,1550
|
|
10
|
+
wizlib/stream_handler.py,sha256=M8uZkzMFYCEmobq3Ji9S0Z-9fYA8v997oMyax110pAc,963
|
|
11
|
+
wizlib/super_wrapper.py,sha256=msitlfFfEwnrskzTtQBEY975sh9TQPicdLVo67imuqU,315
|
|
12
|
+
wizlib/test_case.py,sha256=-hhrkXST465PTr7CZfztjFD5OXoW60HeFRxV5WHsdN0,1250
|
|
13
|
+
wizlib/ui/__init__.py,sha256=ve_p_g4aBujh4jIJMgKkJ6cE5PT0aeY5AgRlneDswGg,4241
|
|
14
|
+
wizlib/ui/shell/__init__.py,sha256=zT_GjUpKRWAbZ_0rwbHL580vlaKLMesTDdvDApiuKDU,1154
|
|
15
|
+
wizlib/ui/shell/line_editor.py,sha256=l3iPtmYsavBGnQXoV-UHMWfzUqOfsNID6-ySpTF3SIc,8163
|
|
16
|
+
wizlib/ui/shell_ui.py,sha256=zsqmfL5LTnH_yqfqHUmBKhkZdb5cjRWL96vmfTrAn3U,2855
|
|
17
|
+
wizlib/ui/text_wrapper.py,sha256=WYL59E9cMRv0gI2Yhrc5GJTvstRZO86uYUD7lmDiwjM,3812
|
|
18
|
+
wizlib/ui_handler.py,sha256=JoZadtw9DKAtGvHKP3_BJF2NaYqmcQYNdsY4PeRnOjg,634
|
|
19
|
+
wizlib-3.4.0.dist-info/METADATA,sha256=h-cK4RnxEsEfWip8YnSD7Q14FIBN43fswcO6tUuT6u8,995
|
|
20
|
+
wizlib-3.4.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
21
|
+
wizlib-3.4.0.dist-info/RECORD,,
|
wizlib-3.2.0.dist-info/METADATA
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: wizlib
|
|
3
|
-
Version: 3.2.0
|
|
4
|
-
Summary: Framework for flexible and powerful command-line applications
|
|
5
|
-
License: MIT
|
|
6
|
-
Author: Steampunk Wizard
|
|
7
|
-
Author-email: wizlib@steampunkwizard.ca
|
|
8
|
-
Requires-Python: >=3.11,<3.12
|
|
9
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
-
Classifier: Programming Language :: Python :: 3
|
|
11
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
-
Requires-Dist: PyYAML (>=6.0.1,<7.0.0)
|
|
13
|
-
Requires-Dist: gnureadline (>=8.1.2,<9.0.0) ; sys_platform == "darwin"
|
|
14
|
-
Requires-Dist: myst-parser (>=2.0.0,<3.0.0)
|
|
15
|
-
Requires-Dist: pyreadline3 (>=3.4.1,<4.0.0) ; sys_platform == "win32"
|
|
16
|
-
Requires-Dist: readchar (>=4.0.5,<5.0.0)
|
|
17
|
-
Description-Content-Type: text/markdown
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# WizLib
|
|
21
|
-
|
|
22
|
-
A framework for command-line devops and personal productivity tools
|
|
23
|
-
|
|
24
|
-
WizLib wraps the built-in ArgumentParser with a set of functions, classes, and conventions that enables:
|
|
25
|
-
|
|
26
|
-
- Easy addition of loosely coupled new subcommands
|
|
27
|
-
- Unified access to configuration files with a normalized approach
|
|
28
|
-
- Standardized use of stdin, stdout, and stderr
|
|
29
|
-
- Plugin-type system for handling alternate UIs (such as curses or even a web UI)
|
|
30
|
-
- Usable completion support for user input
|
|
31
|
-
- Abstracts some of the argparse complexity
|
|
32
|
-
- Applies conventions to application code structure
|
|
33
|
-
- Supports test-driven development and CICD
|
|
34
|
-
|
|
35
|
-
## Getting started
|
|
36
|
-
|
|
37
|
-
- [Home page](https://wizlib.ca) with documentation
|
|
38
|
-
- [Sample project](https://gitlab.com/wizlib/sample) to get started
|
|
39
|
-
- [Related projects](https://gitlab.com/wizlib) for real-world examples
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
Logo by [Freepik](https://www.freepik.com/?_gl=1*1y9rvc9*test_ga*Mjc1MTIzODYxLjE2ODA3OTczNTg.*test_ga_523JXC6VL7*MTY4MDc5NzM1OC4xLjEuMTY4MDc5NzQxNS4zLjAuMA..*fp_ga*Mjc1MTIzODYxLjE2ODA3OTczNTg.*fp_ga_1ZY8468CQB*MTY4MDc5NzM1OC4xLjEuMTY4MDc5NzQxNS4zLjAuMA..)
|
|
44
|
-
|
|
45
|
-
|
wizlib-3.2.0.dist-info/RECORD
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
wizlib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
wizlib/app.py,sha256=ke0NbaYC6ArmdtLTySl6VIGIGicxTsIuzW9wag6b7rU,4180
|
|
3
|
-
wizlib/class_family.py,sha256=tORSVAaPeWTQMcz2DX-MQClj1GQR3vCmkALPXxHa_pk,4506
|
|
4
|
-
wizlib/command.py,sha256=NO1558EYuXxfkpSmX6ljjzae8n8g4w6yytZKTJtigvo,1708
|
|
5
|
-
wizlib/config_handler.py,sha256=hoDavSMiGM_7PAHI8XIwC8nxPWOZDk302ryyjluoLGg,2588
|
|
6
|
-
wizlib/error.py,sha256=ypwdMOYhtgKWd48ccfOX8idmCXmm-Skwx3gkPwqJB3c,46
|
|
7
|
-
wizlib/handler.py,sha256=Oz80aPhDyeY9tdppZ1dvtN-19JU5ydEDVW6jtppVoD4,446
|
|
8
|
-
wizlib/io.py,sha256=vZ1pIMtPFUZxbOv10HFDezEmlPgwPlUVP6RPwtfM9ec,506
|
|
9
|
-
wizlib/parser.py,sha256=yLHV0fENeApFomCRWa3I6sB1x4lk1ag4vKejWVsic64,1550
|
|
10
|
-
wizlib/stream_handler.py,sha256=7y1ckUoqTXxaCFI-XEixdBFZQZDxOv2jEEwtjWHZsUA,1066
|
|
11
|
-
wizlib/super_wrapper.py,sha256=msitlfFfEwnrskzTtQBEY975sh9TQPicdLVo67imuqU,315
|
|
12
|
-
wizlib/test_case.py,sha256=T5cX7yWy94hf8JuherDeBCH9Gve5tx2x_PZOte4udC0,1017
|
|
13
|
-
wizlib/ui/__init__.py,sha256=ve_p_g4aBujh4jIJMgKkJ6cE5PT0aeY5AgRlneDswGg,4241
|
|
14
|
-
wizlib/ui/shell/__init__.py,sha256=sPrYe4bG_Xf7Nwssx_dqXVk9jeyYBFUjh4oLdlSOeRY,943
|
|
15
|
-
wizlib/ui/shell/line_editor.py,sha256=vXXsCS_i4ZCjP0su2X9W_yD1CS6MiazPAtxpiaXQ6Jc,7413
|
|
16
|
-
wizlib/ui/shell_ui.py,sha256=jre7E_5vP_SceNH7GYenfXZpFf4h9Sbh9cWZZycTYk8,1911
|
|
17
|
-
wizlib/ui_handler.py,sha256=JoZadtw9DKAtGvHKP3_BJF2NaYqmcQYNdsY4PeRnOjg,634
|
|
18
|
-
wizlib-3.2.0.dist-info/METADATA,sha256=rKb5cRkQEpv_YjanZXOcPoGyDAz_86TjB67UNor42IM,1779
|
|
19
|
-
wizlib-3.2.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
20
|
-
wizlib-3.2.0.dist-info/RECORD,,
|