wizlib 0.0.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.
Potentially problematic release.
This version of wizlib might be problematic. Click here for more details.
- wizlib/__init__.py +0 -0
- wizlib/app.py +85 -0
- wizlib/class_family.py +120 -0
- wizlib/command.py +45 -0
- wizlib/config_handler.py +87 -0
- wizlib/error.py +2 -0
- wizlib/handler.py +14 -0
- wizlib/input_handler.py +26 -0
- wizlib/parser.py +54 -0
- wizlib/rlinput.py +71 -0
- wizlib/super_wrapper.py +8 -0
- wizlib-0.0.0.dist-info/METADATA +141 -0
- wizlib-0.0.0.dist-info/RECORD +14 -0
- wizlib-0.0.0.dist-info/WHEEL +4 -0
wizlib/__init__.py
ADDED
|
File without changes
|
wizlib/app.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from wizlib.class_family import ClassFamily
|
|
7
|
+
from wizlib.command import WizHelpCommand
|
|
8
|
+
from wizlib.super_wrapper import SuperWrapper
|
|
9
|
+
from wizlib.parser import WizParser
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
RED = '\033[91m'
|
|
13
|
+
RESET = '\033[0m'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WizApp:
|
|
17
|
+
"""Root of all WizLib-based CLI applications. Subclass it. Can be
|
|
18
|
+
instantiated and then run multiple commands."""
|
|
19
|
+
|
|
20
|
+
base_command = None
|
|
21
|
+
name = ''
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def main(cls): # pragma: nocover
|
|
25
|
+
"""Call this from a __main__ entrypoint"""
|
|
26
|
+
cls.run(*sys.argv[1:], debug=os.getenv('DEBUG'))
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def run(cls, *args, debug=False):
|
|
30
|
+
"""Call this from a Python entrypoint"""
|
|
31
|
+
try:
|
|
32
|
+
cls.initialize()
|
|
33
|
+
app = cls(*args)
|
|
34
|
+
# if app.ready:
|
|
35
|
+
command = app.first_command
|
|
36
|
+
result = command.execute()
|
|
37
|
+
if result:
|
|
38
|
+
print(result, file=sys.stdout, end='')
|
|
39
|
+
if sys.stdout.isatty(): # pragma: nocover
|
|
40
|
+
print()
|
|
41
|
+
if command.status:
|
|
42
|
+
print(command.status, file=sys.stderr)
|
|
43
|
+
except Exception as error:
|
|
44
|
+
if debug:
|
|
45
|
+
raise error
|
|
46
|
+
else:
|
|
47
|
+
print(f"\n{RED}{type(error).__name__}: " +
|
|
48
|
+
f"{error}{RESET}\n", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def initialize(cls):
|
|
53
|
+
"""Set up the app class to parse arguments"""
|
|
54
|
+
cls.parser = WizParser(
|
|
55
|
+
prog=cls.name,
|
|
56
|
+
exit_on_error=False)
|
|
57
|
+
for handler in cls.base_command.handlers:
|
|
58
|
+
cls.parser.add_argument(
|
|
59
|
+
f"--{handler.name}",
|
|
60
|
+
f"-{handler.name[0]}",
|
|
61
|
+
type=handler.named(cls.name),
|
|
62
|
+
default='')
|
|
63
|
+
subparsers = cls.parser.add_subparsers(dest='command')
|
|
64
|
+
for command in cls.base_command.family_members('name'):
|
|
65
|
+
key = command.get_member_attr('key')
|
|
66
|
+
aliases = [key] if key else []
|
|
67
|
+
subparser = subparsers.add_parser(command.name, aliases=aliases)
|
|
68
|
+
command.add_args(subparser)
|
|
69
|
+
|
|
70
|
+
def __init__(self, *args):
|
|
71
|
+
args = args if args else [self.base_command.default]
|
|
72
|
+
self.vals = vars(self.parser.parse_args(args))
|
|
73
|
+
self.first_command = self.get_command(**self.vals)
|
|
74
|
+
|
|
75
|
+
def get_command(self, **vals):
|
|
76
|
+
"""Run a single command"""
|
|
77
|
+
if 'help' in vals:
|
|
78
|
+
return WizHelpCommand(**vals)
|
|
79
|
+
else:
|
|
80
|
+
command_name = vals.pop('command')
|
|
81
|
+
command_class = self.base_command.family_member(
|
|
82
|
+
'name', command_name)
|
|
83
|
+
if not command_class:
|
|
84
|
+
raise Exception(f"Unknown command {command_name}")
|
|
85
|
+
return command_class(**vals)
|
wizlib/class_family.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
from inspect import getmodule
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from re import match
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ClassFamily:
|
|
8
|
+
|
|
9
|
+
# Return an attribute of this specific class, not inherited.
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def get_member_attr(self, attribute):
|
|
13
|
+
if attribute in self.__dict__:
|
|
14
|
+
return self.__dict__[attribute]
|
|
15
|
+
|
|
16
|
+
# True or False: This class has all the attributes provided. Return True if
|
|
17
|
+
# no attributes provided. Use __subclasses__ so we only look at direct
|
|
18
|
+
# descendents, and __dict__ so we only look at attributes defined here (not
|
|
19
|
+
# inherited).
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def has_member_attrs(self, *attributes):
|
|
23
|
+
|
|
24
|
+
# Do I have all the attributes?
|
|
25
|
+
matches = [a in self.__dict__ for a in attributes]
|
|
26
|
+
|
|
27
|
+
# Python's all() function returns True if the list is empty. So we kill
|
|
28
|
+
# two birds with one stone here - get a hit if there are no attributes,
|
|
29
|
+
# and avoid the empty-matches problem.
|
|
30
|
+
return all(matches + list(attributes))
|
|
31
|
+
|
|
32
|
+
# Return a set of myself and all my descendents that have certain
|
|
33
|
+
# attributes. If no attribute names are provided, return the entire family.
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def family_members(self, *attributes):
|
|
37
|
+
|
|
38
|
+
# Put myself into a set if I qualify
|
|
39
|
+
hits = {self} if self.has_member_attrs(*attributes) else set()
|
|
40
|
+
|
|
41
|
+
# Go through my subclasses
|
|
42
|
+
for kid in self.family_children():
|
|
43
|
+
|
|
44
|
+
# Call the same function on each subclass
|
|
45
|
+
hits |= kid.family_members(*attributes)
|
|
46
|
+
|
|
47
|
+
# Send it back
|
|
48
|
+
return hits
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def family_attrs(self, attribute):
|
|
52
|
+
"""
|
|
53
|
+
Return a set of all the values of a specific attribute that exist in
|
|
54
|
+
the family. The set avoids repetition of values in the result.
|
|
55
|
+
"""
|
|
56
|
+
# Put myself into a set if I qualify
|
|
57
|
+
if self.has_member_attrs(attribute):
|
|
58
|
+
values = {self.get_member_attr(attribute)}
|
|
59
|
+
else:
|
|
60
|
+
values = set()
|
|
61
|
+
|
|
62
|
+
# Go through my subclasses
|
|
63
|
+
for kid in self.family_children():
|
|
64
|
+
|
|
65
|
+
# Call the same function on each subclass
|
|
66
|
+
values |= kid.family_attrs(attribute)
|
|
67
|
+
|
|
68
|
+
# Send it back
|
|
69
|
+
return values
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def family_member(self, attribute, value):
|
|
73
|
+
"""Return a specific family member that has a specified value of an
|
|
74
|
+
attribute. Assume that there is only one, so return it as soon as it's
|
|
75
|
+
found."""
|
|
76
|
+
|
|
77
|
+
# See if I qualify, and if so, return myself. Use __dict__ rather than
|
|
78
|
+
# hasattr() and getattr() to refer only to myself, rather than
|
|
79
|
+
# inherited attributes.
|
|
80
|
+
if attribute in self.__dict__:
|
|
81
|
+
if self.__dict__[attribute] == value:
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
# Go through the subclasses, doing the same. The return statement will
|
|
85
|
+
# exit the loop, so we get out as soon as we find one, without having
|
|
86
|
+
# to evaluate others.
|
|
87
|
+
for kid in self.family_children():
|
|
88
|
+
found = kid.family_member(attribute, value)
|
|
89
|
+
if found:
|
|
90
|
+
return found
|
|
91
|
+
|
|
92
|
+
# Import the whole family, so they show up as subclasses. Then return this
|
|
93
|
+
# classes subclasses. This obviates the need to import everything at
|
|
94
|
+
# initialization. It happens when needed, and only once. Family members
|
|
95
|
+
# must be in modules (files) in the same directory as the parent (me).
|
|
96
|
+
# Furthermore, all the files in that directory will be imported. The only
|
|
97
|
+
# exception is a file with the same name as myself, to avoid circular
|
|
98
|
+
# imports. This is a private method, intended to be called when needed.
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def family_children(self):
|
|
102
|
+
|
|
103
|
+
# We only need to import the family once
|
|
104
|
+
if not hasattr(self, '_family_imported'):
|
|
105
|
+
|
|
106
|
+
# Find the location of the directory and the name of the module
|
|
107
|
+
module = getmodule(self)
|
|
108
|
+
directory = Path(module.__file__).parent
|
|
109
|
+
module_name = module.__package__
|
|
110
|
+
|
|
111
|
+
# Go through the directory and import everything into the module
|
|
112
|
+
for import_file in directory.iterdir():
|
|
113
|
+
if match(r'^[^_].*\.py$', import_file.name):
|
|
114
|
+
import_module(f'{module_name}.{import_file.stem}')
|
|
115
|
+
|
|
116
|
+
# Prevent it from needing to run again
|
|
117
|
+
self._family_imported = True
|
|
118
|
+
|
|
119
|
+
# Return the subclasses (children)
|
|
120
|
+
return self.__subclasses__()
|
wizlib/command.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from argparse import ArgumentParser
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from wizlib.class_family import ClassFamily
|
|
5
|
+
from wizlib.config_handler import ConfigHandler
|
|
6
|
+
from wizlib.input_handler import InputHandler
|
|
7
|
+
from wizlib.super_wrapper import SuperWrapper
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WizCommand(ClassFamily, SuperWrapper):
|
|
11
|
+
"""Define all the args you want, but stdin always works."""
|
|
12
|
+
|
|
13
|
+
status = ''
|
|
14
|
+
handlers = []
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def add_args(self, parser):
|
|
18
|
+
"""Add arguments to the command's parser - override this.
|
|
19
|
+
Add global arguments in the base class. Not wrapped."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def __init__(self, **vals):
|
|
23
|
+
for key in vals:
|
|
24
|
+
setattr(self, key, vals[key])
|
|
25
|
+
for handler in self.handlers:
|
|
26
|
+
if handler.name not in vals:
|
|
27
|
+
setattr(self, handler.name, handler())
|
|
28
|
+
|
|
29
|
+
def handle_vals(self):
|
|
30
|
+
"""Clean up vals, calculate any, ask through UI, etc. - override
|
|
31
|
+
this and call super().handle_vals()."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
def execute(self, method, *args, **kwargs):
|
|
35
|
+
"""Actually perform the command - override and wrap this via
|
|
36
|
+
SuperWrapper"""
|
|
37
|
+
self.handle_vals()
|
|
38
|
+
result = method(self, *args, **kwargs)
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WizHelpCommand(WizCommand):
|
|
43
|
+
|
|
44
|
+
def execute(self):
|
|
45
|
+
return self.help
|
wizlib/config_handler.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from argparse import Namespace
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from yaml import load
|
|
8
|
+
from yaml import Loader
|
|
9
|
+
from wizlib.handler import Handler
|
|
10
|
+
|
|
11
|
+
from wizlib.error import ConfigHandlerError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigHandler(Handler):
|
|
15
|
+
"""
|
|
16
|
+
Handle app-level configuration, where settings could come from specific
|
|
17
|
+
settings (such as from argparse), environment variables, or a YAML file.
|
|
18
|
+
Within the Python code, config keys are underscore-separated all-lower.
|
|
19
|
+
|
|
20
|
+
A ConfigHandler returns null in the case of a missing value, assuming that
|
|
21
|
+
commands can handle their own null cases.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name = 'config'
|
|
25
|
+
|
|
26
|
+
def __init__(self, value=None):
|
|
27
|
+
self.file = value
|
|
28
|
+
self.cache = {}
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def yaml(self):
|
|
32
|
+
if hasattr(self, '_yaml'):
|
|
33
|
+
return self._yaml
|
|
34
|
+
path = None
|
|
35
|
+
if self.file:
|
|
36
|
+
path = Path(self.file)
|
|
37
|
+
elif self.appname:
|
|
38
|
+
localpath = Path.cwd() / f".{self.appname}.yml"
|
|
39
|
+
homepath = Path.home() / f".{self.appname}.yml"
|
|
40
|
+
if (envvar := self.env(self.appname + '-config')):
|
|
41
|
+
path = Path(envvar)
|
|
42
|
+
elif (localpath.is_file()):
|
|
43
|
+
path = localpath
|
|
44
|
+
elif (homepath.is_file()):
|
|
45
|
+
path = homepath
|
|
46
|
+
if path:
|
|
47
|
+
with open(path) as file:
|
|
48
|
+
self._yaml = load(file, Loader=Loader)
|
|
49
|
+
return self._yaml
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def env(name):
|
|
53
|
+
if (envvar := name.upper().replace('-', '_')) in os.environ:
|
|
54
|
+
return os.environ[envvar]
|
|
55
|
+
|
|
56
|
+
def get(self, key: str):
|
|
57
|
+
"""Return the value for the requested config entry"""
|
|
58
|
+
|
|
59
|
+
# If we already found the value, return it
|
|
60
|
+
if key in self.cache:
|
|
61
|
+
return self.cache[key]
|
|
62
|
+
|
|
63
|
+
# Environment variables take precedence
|
|
64
|
+
if (result := self.env(key)):
|
|
65
|
+
self.cache[key] = result
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
# Otherwise look at the YAML
|
|
69
|
+
if (yaml := self.yaml):
|
|
70
|
+
split = key.split('-')
|
|
71
|
+
while (val := split.pop(0)) and (val in yaml):
|
|
72
|
+
yaml = yaml[val] if val in yaml else None
|
|
73
|
+
if not split:
|
|
74
|
+
self.cache[key] = yaml
|
|
75
|
+
return yaml
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def fake(cls, **vals):
|
|
79
|
+
"""Return a fake ConfigHandler with forced values, for testing"""
|
|
80
|
+
handler = cls()
|
|
81
|
+
|
|
82
|
+
def fake_env(name):
|
|
83
|
+
key = name.replace('-', '_')
|
|
84
|
+
if key in vals:
|
|
85
|
+
return vals[key]
|
|
86
|
+
handler.env = fake_env
|
|
87
|
+
return handler
|
wizlib/error.py
ADDED
wizlib/handler.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from argparse import Action
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Handler:
|
|
5
|
+
"""Base class for handlers"""
|
|
6
|
+
|
|
7
|
+
appname = None
|
|
8
|
+
|
|
9
|
+
@classmethod
|
|
10
|
+
def named(cls, name):
|
|
11
|
+
"""Subclass of the handler that holds the app name as a closure"""
|
|
12
|
+
class NamedHandler(cls):
|
|
13
|
+
appname = name
|
|
14
|
+
return NamedHandler
|
wizlib/input_handler.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from wizlib.handler import Handler
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InputHandler(Handler):
|
|
8
|
+
|
|
9
|
+
name = 'input'
|
|
10
|
+
text: str = ''
|
|
11
|
+
|
|
12
|
+
def __init__(self, file=None, stdin=True):
|
|
13
|
+
if file:
|
|
14
|
+
self.text = Path(file).read_text()
|
|
15
|
+
elif stdin and (not sys.stdin.isatty()):
|
|
16
|
+
self.text = sys.stdin.read()
|
|
17
|
+
|
|
18
|
+
def __str__(self):
|
|
19
|
+
return self.text
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def fake(cls, value):
|
|
23
|
+
"""Return a fake InputHandler with forced values, for testing"""
|
|
24
|
+
handler = cls(stdin=False)
|
|
25
|
+
handler.text = value
|
|
26
|
+
return handler
|
wizlib/parser.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""A drop-in replacement for ArgumentParser that always raises exceptions
|
|
2
|
+
for argument errors (including unrecognized arguments) and returns help
|
|
3
|
+
messages in a 'help' item in the resulting namespace. Useful especially for
|
|
4
|
+
REPLs."""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from argparse import ArgumentParser
|
|
8
|
+
from argparse import ArgumentError
|
|
9
|
+
from argparse import Action
|
|
10
|
+
from argparse import SUPPRESS
|
|
11
|
+
from contextlib import redirect_stdout
|
|
12
|
+
from io import StringIO
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WizHelpAction(Action):
|
|
18
|
+
|
|
19
|
+
def __init__(self,
|
|
20
|
+
option_strings,
|
|
21
|
+
dest=SUPPRESS,
|
|
22
|
+
default=SUPPRESS,
|
|
23
|
+
help=None):
|
|
24
|
+
super(WizHelpAction, self).__init__(
|
|
25
|
+
option_strings=option_strings,
|
|
26
|
+
dest=dest,
|
|
27
|
+
default=default,
|
|
28
|
+
nargs=0,
|
|
29
|
+
help=help)
|
|
30
|
+
|
|
31
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
32
|
+
with redirect_stdout(output := StringIO()):
|
|
33
|
+
parser.print_help()
|
|
34
|
+
output.seek(0)
|
|
35
|
+
setattr(namespace, self.dest, output.read())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class WizArgumentError(ArgumentError):
|
|
39
|
+
|
|
40
|
+
def __init__(self, message):
|
|
41
|
+
self.argument_name = None
|
|
42
|
+
self.message = message
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class WizParser(ArgumentParser):
|
|
46
|
+
|
|
47
|
+
def __init__(self, **vals):
|
|
48
|
+
vals['exit_on_error'] = False
|
|
49
|
+
vals['add_help'] = False
|
|
50
|
+
super().__init__(**vals)
|
|
51
|
+
self.add_argument('--help', '-h', action=WizHelpAction)
|
|
52
|
+
|
|
53
|
+
def error(self, *args, **vals):
|
|
54
|
+
raise WizArgumentError(str(args[0]))
|
wizlib/rlinput.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
try: # pragma: nocover
|
|
2
|
+
import gnureadline as readline
|
|
3
|
+
except ImportError: # pragma: nocover
|
|
4
|
+
import readline
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Use tab for completion
|
|
9
|
+
readline.parse_and_bind('tab: complete')
|
|
10
|
+
|
|
11
|
+
# Only a space separates words
|
|
12
|
+
readline.set_completer_delims(' ')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Readline defaults to complete with local filenames. Definitely not what we
|
|
16
|
+
# want.
|
|
17
|
+
|
|
18
|
+
def null_complete(text, start): # pragma: nocover
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
readline.set_completer(null_complete)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def rlinput(prompt: str = "", default: str = "",
|
|
26
|
+
options: list = None): # pragma: nocover
|
|
27
|
+
"""
|
|
28
|
+
Get input with preset default and/or tab completion of options
|
|
29
|
+
|
|
30
|
+
Parameters:
|
|
31
|
+
|
|
32
|
+
prompt:str - string to output before requesting input, same as in the
|
|
33
|
+
input() function
|
|
34
|
+
|
|
35
|
+
default:str - value with which to prepopulate the response, can be cleared
|
|
36
|
+
with ctrl-a, ctrl-k
|
|
37
|
+
|
|
38
|
+
options:list - list of choices for tab completion
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Clean out the options
|
|
42
|
+
options = [o.strip() + " " for o in options] if options else []
|
|
43
|
+
|
|
44
|
+
# Create the completer using the options
|
|
45
|
+
def complete(text, state):
|
|
46
|
+
results = [x for x in options if x.startswith(text)] + [None]
|
|
47
|
+
return results[state]
|
|
48
|
+
readline.set_completer(complete)
|
|
49
|
+
|
|
50
|
+
# Insert the default when we launch
|
|
51
|
+
def start():
|
|
52
|
+
readline.insert_text(default)
|
|
53
|
+
readline.set_startup_hook(start)
|
|
54
|
+
|
|
55
|
+
# Actually perform the input
|
|
56
|
+
try:
|
|
57
|
+
value = input(prompt)
|
|
58
|
+
finally:
|
|
59
|
+
readline.set_startup_hook()
|
|
60
|
+
readline.set_completer(null_complete)
|
|
61
|
+
return value.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def tryit(): # pragma: nocover
|
|
65
|
+
"""Quick and dirty tester function"""
|
|
66
|
+
return rlinput(prompt='>',
|
|
67
|
+
options=['a-b', 'a-c', 'b-a'])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == '__main__':
|
|
71
|
+
tryit()
|
wizlib/super_wrapper.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: wizlib
|
|
3
|
+
Version: 0.0.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
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# WizLib
|
|
19
|
+
|
|
20
|
+
Framework for flexible and powerful command-line applications
|
|
21
|
+
|
|
22
|
+
## ClassFamily
|
|
23
|
+
|
|
24
|
+
A class family is a set of class definitions that use single inheritance
|
|
25
|
+
(each subclass inherits from only one parent) and often multiple inheritance
|
|
26
|
+
(subclasses can inherit from subclasses). So it's a hierarchy of classes,
|
|
27
|
+
with one super-parent (termed the "atriarch") at the top.
|
|
28
|
+
|
|
29
|
+
We offer a way for members of the family to declare themselves simply by
|
|
30
|
+
living in the right package location. Then those classes can be instantiated
|
|
31
|
+
using keys or names, without having to be specifically called. The members
|
|
32
|
+
act independently of each other.
|
|
33
|
+
|
|
34
|
+
What we get, after importing everything and loading it all, is essentially a
|
|
35
|
+
little database of classes, where class-level properties become keys for
|
|
36
|
+
looking up member classes. So, for example, we can have a family of commands,
|
|
37
|
+
and use a command string to look up the right command.
|
|
38
|
+
|
|
39
|
+
Ultimately, the atriarch of the family -- the class at the top of the
|
|
40
|
+
hierarchy -- holds the database, actually a list, in the property called
|
|
41
|
+
"family". So that class can be queried to find appropriate family member
|
|
42
|
+
classes or instances thereof.
|
|
43
|
+
|
|
44
|
+
This utility provides functions for importing family members, loading the
|
|
45
|
+
"families" property of the super-parent, and querying the family.
|
|
46
|
+
|
|
47
|
+
In the process of loading and querying the class family, we need to *avoid*
|
|
48
|
+
inheritance of attributes. There might be abstract intermediary classes that
|
|
49
|
+
don't want to play. So we use `__dict__` to ensure we're only seeing the
|
|
50
|
+
atttributes that are defined on that specific class.
|
|
51
|
+
|
|
52
|
+
## SuperWrapper
|
|
53
|
+
|
|
54
|
+
Provide a decorator to wrap a method so that it's called within the inherited
|
|
55
|
+
version of that method.
|
|
56
|
+
|
|
57
|
+
Example of use:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
class Parent(SuperWrapper):
|
|
61
|
+
def execute(self, method, *args, **kwargs):
|
|
62
|
+
print(f"Parent execute before")
|
|
63
|
+
method(self, *args, **kwargs)
|
|
64
|
+
print(f"Parent execute after")
|
|
65
|
+
|
|
66
|
+
class InBetween(Parent):
|
|
67
|
+
@Parent.wrap
|
|
68
|
+
def execute(self, method, *args, **kwargs):
|
|
69
|
+
print(f"IB execute before")
|
|
70
|
+
method(self, *args, **kwargs)
|
|
71
|
+
print(f"IB execute after")
|
|
72
|
+
|
|
73
|
+
class NewChild(InBetween):
|
|
74
|
+
@InBetween.wrap
|
|
75
|
+
def execute(self, name):
|
|
76
|
+
print(f"Hello {name}")
|
|
77
|
+
|
|
78
|
+
c = NewChild()
|
|
79
|
+
c.execute("Jane")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Note that for a method to be "wrappable" it must take the form shown above, and explicitly call the method that's handed into it. So strictly, this is different from regular inheritance, where the parent class method has the same signature as the child class method.
|
|
83
|
+
|
|
84
|
+
## RLInput
|
|
85
|
+
|
|
86
|
+
Python supports the GNU readline approach, which enables tab completion, key mappings, and history with the `input()` function. But the documentation is cryptic, and the implementation differs between Linux and MacOS. RLInput makes it easy.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from wizlib.rlinput import rlinput
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It's just a function, with up to three parameters:
|
|
93
|
+
|
|
94
|
+
- `intro:str=""` - The intro or prompt, same as in the `input()` function.
|
|
95
|
+
- `default:str=""` - If provided, the text will be inserted into the buffer at the start, with the cursor at the end of the buffer. So that becomes the default, that must be overridden by the user if they want different input.
|
|
96
|
+
- `options:list=[]` - A list of options for tab completion. This assumes the options are choices for the entire entry; it's not context-dependent within the buffer.
|
|
97
|
+
|
|
98
|
+
Emacs keys are enabled by default; I'm able to use the arrow keys on my Mac so you should too. I made one change to the keyboard mappings, which is the Ctrl-A, instead of just moving the cursor to the beginning of the line, wipes the entire buffer. So to wipe out the default value and type or tab something new, just hit Ctrl-A.
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
## The WizApp framework
|
|
102
|
+
|
|
103
|
+
_Docs in progress - might be out of date_
|
|
104
|
+
|
|
105
|
+
Commands automatically handle input via stdin for non-tty inputs such as pipes. Some details:
|
|
106
|
+
|
|
107
|
+
- The argument name is `input`
|
|
108
|
+
- Therefore `input` is a reserved name for arguments
|
|
109
|
+
- Optionally use the `--input` or `-i` command line argument to pass in the same information
|
|
110
|
+
- Reading from stdin in tty cases (i.e. in the terminal) would still have to happen in the command itself.
|
|
111
|
+
|
|
112
|
+
### ConfigHandler
|
|
113
|
+
|
|
114
|
+
Enables easy configuration across multiple levels. Tries each of the following approaches in order until one finds the required config option
|
|
115
|
+
|
|
116
|
+
- First look for attributes of the instance (subclass of ConfigHandler) itself (e.g. `gitlab_host`)
|
|
117
|
+
- Then look for a specific env variable for that config setting in all caps, e.g. `GITLAB_HOST`
|
|
118
|
+
- If those both fail, then look for a YAML configuration file:
|
|
119
|
+
- First identified with a `--config` / `-c` option on the command line
|
|
120
|
+
- Then with a path in the `APPNAME_CONFIG` environment variable - note all caps
|
|
121
|
+
- Then look in the local working directory for `.appname.yml`
|
|
122
|
+
- Then look for `~/.appname.yml` in the user's home directory
|
|
123
|
+
|
|
124
|
+
Config files are in YAML, and look something like this:
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
gitlab:
|
|
128
|
+
host: gitlab.com
|
|
129
|
+
local:
|
|
130
|
+
root: $HOME/git
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Note that nested labels in the config map to hyphenated command line options.
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
Logo by [Freepik](https://www.freepik.com/?_gl=1*1y9rvc9*test_ga*Mjc1MTIzODYxLjE2ODA3OTczNTg.*test_ga_523JXC6VL7*MTY4MDc5NzM1OC4xLjEuMTY4MDc5NzQxNS4zLjAuMA..*fp_ga*Mjc1MTIzODYxLjE2ODA3OTczNTg.*fp_ga_1ZY8468CQB*MTY4MDc5NzM1OC4xLjEuMTY4MDc5NzQxNS4zLjAuMA..)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
wizlib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
wizlib/app.py,sha256=7emrVV077IXk7PGQeFz1jne0jpDCPyrJXrZfaf2Ya54,2822
|
|
3
|
+
wizlib/class_family.py,sha256=EX1ZZmrmC2M-PV_iorHXCWqETE2thrwQE45RtHxojbs,4424
|
|
4
|
+
wizlib/command.py,sha256=GQlXP8dOJHXUGDhjV3VYnOmHLtbEc4TMAGogLUqy2U8,1304
|
|
5
|
+
wizlib/config_handler.py,sha256=alHpJzFY39XXDv94W4fLwda6hjruySADAJD944rE3HU,2639
|
|
6
|
+
wizlib/error.py,sha256=ypwdMOYhtgKWd48ccfOX8idmCXmm-Skwx3gkPwqJB3c,46
|
|
7
|
+
wizlib/handler.py,sha256=vUrlMIER019Sz17yeg95Cs6u9Vo5u24qf0q7p9HEWec,306
|
|
8
|
+
wizlib/input_handler.py,sha256=RmoZA_FlQ_1qeDEV9YZSU8Zw933pMPEb4yOZLPUGnBA,597
|
|
9
|
+
wizlib/parser.py,sha256=O34azN4ttVfwwAsza0hujxGxDpzc4xUEVAf26DXJS5g,1505
|
|
10
|
+
wizlib/rlinput.py,sha256=l00Pa3rxNeY6LJgz8Aws_rTKoEchw33fuL8yqHF9_-o,1754
|
|
11
|
+
wizlib/super_wrapper.py,sha256=F834ytHqA7zegTD1ezk_uxlF9PLygh84wReuiqcI7BI,272
|
|
12
|
+
wizlib-0.0.0.dist-info/METADATA,sha256=Sz-Eh8FH_CgeAMbZ1FDNLUDHRrsrs-E5MfXT8RNOAog,5965
|
|
13
|
+
wizlib-0.0.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
14
|
+
wizlib-0.0.0.dist-info/RECORD,,
|