Browse Source

wip

1.0.7-wip
Chris 4 years ago
parent
commit
323719c885
23 changed files with 1149 additions and 87 deletions
  1. 49
      docs/Filterable-Dropdown-Guide.md
  2. 99
      docs/releases/1.0.7-release-notes.md
  3. 1
      gooey/__init__.py
  4. 16
      gooey/gui/components/filtering/prefix_filter.py
  5. 2
      gooey/gui/components/footer.py
  6. 0
      gooey/gui/components/options/__init__.py
  7. 42
      gooey/gui/components/options/layout.py
  8. 291
      gooey/gui/components/options/options.py
  9. 127
      gooey/gui/components/options/validation.py
  10. 199
      gooey/gui/components/options/validators.py
  11. 2
      gooey/gui/components/widgets/__init__.py
  12. 21
      gooey/gui/components/widgets/bases.py
  13. 5
      gooey/gui/components/widgets/core/chooser.py
  14. 3
      gooey/gui/components/widgets/core/text_input.py
  15. 50
      gooey/gui/components/widgets/numeric_fields.py
  16. 74
      gooey/gui/components/widgets/options.py
  17. 28
      gooey/gui/components/widgets/slider.py
  18. 6
      gooey/gui/components/widgets/textfield.py
  19. 33
      gooey/python_bindings/argparse_to_json.py
  20. 89
      gooey/tests/test_numeric_inputs.py
  21. 54
      gooey/tests/test_options.py
  22. 40
      gooey/tests/test_slider.py
  23. 5
      gooey/util/functional.py

49
docs/Filterable-Dropdown-Guide.md

@ -0,0 +1,49 @@
# Customizing FilterableDropdown's search behavior
Out of the box, FilterableDropdown does a very simple 'startswith' style lookup to find candidates which match the user's input. However, this behavior can be customized using GooeyOptions to support all kinds of filtering strategies.
For each example, we'll be starting with the following sample program. This uses just 4 choices to keep the different options easy to follow. However, `FilterableDropdown` is fully virtualized and can be used with 10s of thousands of choices.
```python
from gooey import Gooey, GooeyParser, PrefixTokenizers
choices = [
'Afghanistan Kabul',
'Albania Tirana',
'Japan Kyoto',
'Japan Tokyo'
]
@Gooey(program_name='FilterableDropdown Demo', poll_external_updates=True)
def main():
parser = GooeyParser(description="Example of the Filterable Dropdown")
parser.add_argument(
"-a",
"--myargument",
metavar='Country',
help='Search for a country',
choices=choices,
widget='FilterableDropdown',
gooey_options={
'label_color': (255, 100, 100),
'placeholder': 'Start typing to view suggestions'
})
args = parser.parse_args()
print(args)
```
## Combining results
## Suffix Trees

99
docs/releases/1.0.7-release-notes.md

@ -0,0 +1,99 @@
## Gooey 1.0.6 Released!
Quality of Life improvements for Gooey Options.
Goal is to enable more IDE auto-completion help as well as more REPL driven usefulness via help() and docstrings.
Gooey now exposes a top-level set of public gooey_option data constructors.
```python
from gooey import options
parser.add_argument(
'--foo',
help='Some foo thing',
widget='FilterableDropdown',
gooey_options=options.FilterableDropdown(
placeholder='Search for a Foo',
search_strategy=options.PrefixSearchStrategy(
ignore_case=True
)
))
```
Note that these are _just_ helpers for generating the right data shapes. They're still generating plain data behind the scenes and thus all existing gooey_options code remains 100% compatible.
**Better Docs:**
Which is to say, documentation which actually exists rather than _not_ exist. You can inspect the docs live in the REPL or by hopping to the symbol in editors which support such things.
```
>>> from gooey import options
>>> help(options.RadioGroup)
Help on function FileChooser in module __main__:
FileChooser(wildcard=None, default_dir=None, default_file=None, message=None, **layout_options)
:param wildcard: Sets the wildcard, which can contain multiple file types, for
example: "BMP files (.bmp)|.bmp|GIF files (.gif)|.gif"
:param message: Sets the message that will be displayed on the dialog.
:param default_dir: The default directory selected when the dialog spawns
:param default_file: The default filename used in the dialog
Layout Options:
---------------
Color options can be passed either as a hex string ('#ff0000') or as
a collection of RGB values (e.g. `[255, 0, 0]` or `(255, 0, 0)`)
:param label_color: The foreground color of the label text
:param label_bg_color: The background color of the label text.
:param help_color: The foreground color of the help text.
:param help_bg_color: The background color of the help text.
:param error_color: The foreground color of the error text (when visible).
:param error_bg_color: The background color of the error text (when visible).
:param show_label: Toggles whether or not to display the label text
:param show_help: Toggles whether or not to display the help text
:param visible: Hides the entire widget when False. Note: the widget
is still present in the UI and will still send along any
default values that have been provided in code. This option
is here for when you want to hide certain advanced / dangerous
inputs from your GUI users.
:param full_width: This is a layout hint for this widget. When True the widget
will fill the entire available space within a given row.
Otherwise, it will be sized based on the column rules
provided elsewhere.
```
Previously, Gooey Options have been an opaque map. While great of openness / extenisibility, it's pretty terrible from a "what does this actually take..?" perspective.
Ideally, and eventually, we'll be able to completely type these options to increase visibility / usability even more. However, for backwards compatibility reasons, Gooey will continue to be sans types for the time being.
### Gooey Options: placeholder
Widgets with text inputs now all accept a `placeholder` Gooey option.
```python
add_argument('--foo', widget='TextField', gooey_options=options.TextField(
placeholder='Type some text here!'
)
```
### New Widget: IntegerField
### New Widget: DecimalField
### New Widget: Slider
### New Validator option: RegexValidator

1
gooey/__init__.py

@ -4,4 +4,5 @@ from gooey.python_bindings.gooey_parser import GooeyParser
from gooey.gui.util.freeze import localResourcePath as local_resource_path from gooey.gui.util.freeze import localResourcePath as local_resource_path
from gooey.python_bindings import constants from gooey.python_bindings import constants
from gooey.gui.components.filtering.prefix_filter import PrefixTokenizers from gooey.gui.components.filtering.prefix_filter import PrefixTokenizers
import gooey.gui.components.options.options as options
__version__ = '1.0.6' __version__ = '1.0.6'

16
gooey/gui/components/filtering/prefix_filter.py

@ -2,28 +2,32 @@ import re
import pygtrie as trie import pygtrie as trie
from functools import reduce from functools import reduce
from typing import NewType
__ALL__ = ('PrefixTokenizers', 'PrefixSearch') __ALL__ = ('PrefixTokenizers', 'PrefixSearch')
Tokenizer = NewType('Tokenizer', str)
class PrefixTokenizers: class PrefixTokenizers:
# This string here is just an arbitrary long string so that # This string here is just an arbitrary long string so that
# re.split finds no matches and returns the entire phrase # re.split finds no matches and returns the entire phrase
ENTIRE_PHRASE = '::gooey/tokenization/entire-phrase'
ENTIRE_PHRASE: Tokenizer = '::gooey/tokenization/entire-phrase'
# \s == any whitespace character # \s == any whitespace character
WORDS = r'\s'
WORDS: Tokenizer = r'\s'
@classmethod @classmethod
def REGEX(cls, expression):
def REGEX(cls, expression) ->Tokenizer:
return expression return expression
class OperatorType:
AND = 'AND'
OR = 'OR'
class SearchOptions: class SearchOptions:
def __init__(self, def __init__(self,
choice_tokenizer=PrefixTokenizers.ENTIRE_PHRASE,
input_tokenizer=PrefixTokenizers.ENTIRE_PHRASE,
choice_tokenizer: Tokenizer=PrefixTokenizers.ENTIRE_PHRASE,
input_tokenizer: Tokenizer=PrefixTokenizers.ENTIRE_PHRASE,
ignore_case=True, ignore_case=True,
operator='AND', operator='AND',
index_suffix= False, index_suffix= False,

2
gooey/gui/components/footer.py

@ -18,7 +18,7 @@ class Footer(wx.Panel):
self.buildSpec = buildSpec self.buildSpec = buildSpec
self.SetMinSize((30, 53)) self.SetMinSize((30, 53))
self.SetDoubleBuffered(True)
self.SetDoubleBuffered(False)
# components # components
self.cancel_button = None self.cancel_button = None
self.start_button = None self.start_button = None

0
gooey/gui/components/options/__init__.py

42
gooey/gui/components/options/layout.py

@ -0,0 +1,42 @@
from pyrsistent import pmap
from gooey.gui.components.options.validation import validate_color, _unit
layout_validators = {
'label_color': validate_color,
'label_bg_color': validate_color,
'help_color': validate_color,
'help_bg_color': validate_color,
'error_color': validate_color,
'error_bg_color': validate_color,
# 'show_label': validate_bool,
# 'show_help': validate_bool,
# 'visible': validate_bool,
# 'full_width': validate_bool
}
def layout_options(label_color=None,
label_bg_color=None,
help_color=None,
help_bg_color=None,
error_color=None,
error_bg_color=None,
show_label=True,
show_help=True,
visible=True,
full_width=False):
options = pmap({k:v for k,v in locals().items() if v is not None})
failures = {}
for k,v in options.items():
validator = layout_validators.get(k, _unit)
err, success = validator(v)
if err:
failures[k] = err
if failures:
raise ValueError(failures)
return options

291
gooey/gui/components/options/options.py

@ -0,0 +1,291 @@
from gooey.gui.components.filtering.prefix_filter import PrefixTokenizers
def _include_layout_docs(f):
"""
Combines the layout_options docsstring with the
wrapped function's doc string.
"""
f.__doc__ = (f.__doc__ or '') + LayoutOptions.__doc__
return f
def _include_chooser_msg_wildcard_docs(f):
"""
Combines the basic Chooser options (wildard, message) docsstring
with the wrapped function's doc string.
"""
_doc = """:param wildcard: Sets the wildcard, which can contain multiple file types, for
example: "BMP files (.bmp)|.bmp|GIF files (.gif)|.gif"
:param message: Sets the message that will be displayed on the dialog.
"""
f.__doc__ = (f.__doc__ or '') + _doc
return f
def _include_choose_dir_file_docs(f):
"""
Combines the basic Chooser options (wildard, message) docsstring
with the wrapped function's doc string.
"""
_doc = """:param default_dir: The default directory selected when the dialog spawns
:param default_file: The default filename used in the dialog
"""
f.__doc__ = (f.__doc__ or '') + _doc
return f
def LayoutOptions(label_color=None,
label_bg_color=None,
help_color=None,
help_bg_color=None,
error_color=None,
error_bg_color=None,
show_label=True,
show_help=True,
visible=True,
full_width=False):
"""
Layout Options:
---------------
Color options can be passed either as a hex string ('#ff0000') or as
a collection of RGB values (e.g. `[255, 0, 0]` or `(255, 0, 0)`)
:param label_color: The foreground color of the label text
:param label_bg_color: The background color of the label text.
:param help_color: The foreground color of the help text.
:param help_bg_color: The background color of the help text.
:param error_color: The foreground color of the error text (when visible).
:param error_bg_color: The background color of the error text (when visible).
:param show_label: Toggles whether or not to display the label text
:param show_help: Toggles whether or not to display the help text
:param visible: Hides the entire widget when False. Note: the widget
is still present in the UI and will still send along any
default values that have been provided in code. This option
is here for when you want to hide certain advanced / dangerous
inputs from your GUI users.
:param full_width: This is a layout hint for this widget. When True the widget
will fill the entire available space within a given row.
Otherwise, it will be sized based on the column rules
provided elsewhere.
"""
return _clean(locals())
@_include_layout_docs
def TextField(validator=None, **layout_options):
return _clean(locals())
@_include_layout_docs
def PasswordField(validator=None, **layout_options):
return _clean(locals())
@_include_layout_docs
def IntegerField(validator=None, min=0, max=100, increment=1, **layout_options):
"""
:param min: The minimum value allowed
:param max: The maximum value allowed
:param increment: The step size of the spinner
"""
return _clean(locals())
@_include_layout_docs
def DecimalField(validator=None,
min=0.0,
max=1.0,
increment=0.01,
precision=2,
**layout_options):
"""
:param min: The minimum value allowed
:param max: The maximum value allowed
:param increment: The step size of the spinner
:param precision: The precision of the decimal (0-20)
"""
return _clean(locals())
@_include_layout_docs
def TextArea(height=None, readonly=False, validator=None, **layout_options):
"""
:param height: The height of the TextArea.
:param readonly: Controls whether or not user's may modify the contents
"""
return _clean(locals())
@_include_layout_docs
def RichTextConsole(**layout_options):
return _clean(locals())
@_include_layout_docs
def ListBox(height=None, **layout_options):
"""
:param height: The height of the ListBox
"""
return _clean(locals())
# TODO: what are this guy's layout options..?
def MutexGroup(initial_selection=None, title=None, **layout_options):
"""
:param initial_selection: The index of the option which should be initially selected.
:param title: Adds the supplied title above the RadioGroup options (when present)
"""
return _clean(locals())
@_include_layout_docs
def Dropdown(**layout_options):
return _clean(locals())
@_include_layout_docs
def Counter(**layout_options):
return _clean(locals())
@_include_layout_docs
def CheckBox(**layout_options):
return _clean(locals())
@_include_layout_docs
def BlockCheckBox(checkbox_label=None, **layout_options):
return _clean(locals())
@_include_layout_docs
def FilterableDropdown(placeholder=None,
empty_message=None,
max_size=80,
search_strategy=None,
**layout_options):
"""
:param placeholder: Text to display when the user has provided no input
:param empty_message: Text to display if the user's query doesn't match anything
:param max_size: maximum height of the dropdown
:param search_strategy: see: PrefixSearchStrategy
"""
return _clean(locals())
def PrefixSearchStrategy(
choice_tokenizer=PrefixTokenizers.WORDS,
input_tokenizer=PrefixTokenizers.REGEX('\s'),
ignore_case=True,
operator='AND',
index_suffix=False):
"""
:param choice_tokenizer: See: PrefixTokenizers - sets the tokenization strategy
for the `choices`
:param input_tokenizer: See: PrefixTokenizers sets how the users's `input` get tokenized.
:param ignore_case: Controls whether or not to honor case while searching
:param operator: see: `OperatorType` - controls whether or not individual
search tokens
get `AND`ed or `OR`d together when evaluating a match.
:param index_suffix: When enabled, generates a suffix-tree to enable efficient
partial-matching against any of the tokens.
"""
return {**_clean(locals()), 'type': 'PrefixFilter'}
@_include_layout_docs
@_include_choose_dir_file_docs
@_include_chooser_msg_wildcard_docs
def FileChooser(wildcard=None,
default_dir=None,
default_file=None,
message=None,
**layout_options):
return _clean(locals())
@_include_layout_docs
@_include_chooser_msg_wildcard_docs
def DirectoryChooser(wildcard=None,
default_path=None,
message=None,
**layout_options):
"""
:param default_path: The default path selected when the dialog spawns
"""
return _clean(locals())
@_include_layout_docs
@_include_choose_dir_file_docs
@_include_chooser_msg_wildcard_docs
def FileSaver(wildcard=None,
default_dir=None,
default_file=None,
message=None,
**layout_options):
return _clean(locals())
@_include_layout_docs
@_include_choose_dir_file_docs
@_include_chooser_msg_wildcard_docs
def MultiFileSaver(wildcard=None,
default_dir=None,
default_file=None,
message=None,
**layout_options):
return _clean(locals())
def ExpressionValidator(test=None, message=None):
"""
Creates the data for a basic expression validator.
Your test function can be made up of any valid Python expression.
It receives the variable user_input as an argument against which to
perform its validation. Note that all values coming from Gooey
are in the form of a string, so you'll have to cast as needed
in order to perform your validation.
"""
return {**_clean(locals()), 'type': 'ExpressionValidator'}
def RegexValidator(test=None, message=None):
"""
Creates the data for a basic RegexValidator.
:param test: the regex expression. This should be the expression
directly (i.e. `test='\d+'`). Gooey will test
that the user's input satisfies this expression.
:param message: The message to display if the input doesn't match
the regex
"""
return {**_clean(locals()), 'type': 'RegexValidator'}
def ArgumentGroup(show_border=False,
show_underline=True,
label_color=None,
columns=None,
margin_top=None):
"""
:param show_border: When True a labeled border will surround all widgets added to this group.
:param show_underline: Controls whether or not to display the underline when using the default border style
:param label_color: The foreground color for the group name
:param columns: Controls the number of widgets on each row
:param margin_top: specifies the top margin in pixels for this group
"""
return _clean(locals())
def _clean(options):
cleaned = {k: v for k, v in options.items()
if v is not None and k is not "layout_options"}
return {**options.get('layout_options', {}), **cleaned}

127
gooey/gui/components/options/validation.py

@ -0,0 +1,127 @@
from textwrap import dedent
from pyrsistent import m, pmap, v
import re
def validate_and_raise(validators, some_map):
err, success = validate(validators, some_map)
if err:
raise ValueError(err)
return success
def validate(validators, some_map):
failures = {}
for k, v in some_map.items():
validator = validators.get(k, _unit)
err, success = validator(v)
if err:
failures[k] = err
if failures:
return [[failures], None]
return [None, some_map]
def _unit(value):
return [None, value]
def is_tuple_or_list(value):
return isinstance(value, list) or isinstance(value, tuple)
def is_str(value):
return isinstance(value, str)
def validate_str_or_coll(value):
error_msg = dedent('''
Colors must be either a hex string or collection of RGB values.
e.g.
Hex string: #fff0ce
RGB Collection: [0, 255, 128] or (0, 255, 128)
''')
return ([None, value]
if is_str(value) or is_tuple_or_list(value)
else [[error_msg], None])
def validate_color(value):
def _validate(value):
if isinstance(value, str):
return isfoo(value)
else:
return validate_rgb(value)
return _flatmap(_validate, validate_str_or_coll(value))
def validate_rgb_vals(rgb_coll):
failures = []
for val, channel in zip(rgb_coll, 'RGB'):
err, success = is_uint8(val)
if err:
failures += ['{} value: {}'.format(channel, msg) for msg in err]
return [failures, None] if failures else [None, rgb_coll]
def is_uint8(value):
return _flatmap(is0to255, isInt(value))
def validate_rgb(value):
return _flatmap(validate_rgb_vals, three_channels(value))
def three_channels(value):
return ([None, value]
if len(value) == 3
else [['Colors in an RGB collection should be of the form [R,G,B] or (R,G,B)'], None])
def isfoo(value: str):
return ([None, value]
if re.match('^#[\dABCDEF]{6}$', value, flags=2)
else [['Invalid hexadecimal format. Expected: "#FFFFFF"'], None])
def is0to255(value: int):
return ([None, value]
if 0 <= value <= 255
else [['Colors myst be 0-255'], None])
def isInt(value):
return ([None, value]
if isinstance(value, int)
else [['Invalid RGB value. Expected type int'], None])
def _or(f, g):
def inner(value):
err1, val1 = f(value)
err2, val2 = g(value)
if val1 and val2:
return [None, val1]
elif err1 and err2:
return [err1 + err2, None]
elif err1:
return [err1, None]
else:
return err2
return inner
def _and(f, g):
def inner(value):
return _flatmap(f, g(value))
return inner
def _flatmap(f, v):
err, value = v
if err:
return v
else:
return f(value)
if __name__ == '__main__':
print(validate_color((1,'ergerg',1234)))
print(validate_color(1234))
print(validate_color(123.234))
print(validate_color('123.234'))
print(validate_color('FFFAAA'))
print(validate_color('#FFFAAA'))
print(validate_color([]))
print(validate_color(()))
print(validate_color((1,2)))
print(validate_color((1, 2, 1234)))

199
gooey/gui/components/options/validators.py

@ -0,0 +1,199 @@
import re
from functools import wraps
from gooey.gui.components.filtering.prefix_filter import OperatorType
class SuperBool(object):
"""
A boolean which keeps with it the rationale
for when it is false.
"""
def __init__(self, value, rationale):
self.value = value
self.rationale = rationale
def __bool__(self):
return self.value
__nonzero__ = __bool__
def __str__(self):
return str(self.value)
def lift(f):
"""
Lifts a basic predicate to the SuperBool type
stealing the docstring as the rationale message.
This is largely just goofing around and experimenting
since it's a private internal API.
"""
@wraps(f)
def inner(value):
result = f(value)
return SuperBool(result, f.__doc__) if not isinstance(result, SuperBool) else result
return inner
@lift
def is_tuple_or_list(value):
"""Must be either a list or tuple"""
return isinstance(value, list) or isinstance(value, tuple)
@lift
def is_str(value):
"""Must be of type `str`"""
return isinstance(value, str)
@lift
def is_str_or_coll(value):
"""
Colors must be either a hex string or collection of RGB values.
e.g.
Hex string: #fff0ce
RGB Collection: [0, 255, 128] or (0, 255, 128)
"""
return bool(is_str(value)) or bool(is_tuple_or_list(value))
@lift
def has_valid_channel_values(rgb_coll):
"""Colors in an RGB collection must all be in the range 0-255"""
return all([is_0to255(c) and is_int(c) for c in rgb_coll])
@lift
def is_three_channeled(value):
"""Missing channels! Colors in an RGB collection should be of the form [R,G,B] or (R,G,B)"""
return len(value) == 3
@lift
def is_hex_string(value: str):
"""Invalid hexadecimal format. Expected: "#FFFFFF" """
return isinstance(value, str) and bool(re.match('^#[\dABCDEF]{6}$', value, flags=2))
@lift
def is_bool(value):
"""Must be of type Boolean"""
return isinstance(value, bool)
@lift
def non_empty_string(value):
"""Must be a non-empty non-blank string"""
return value and bool(value.strip())
@lift
def is_tokenization_operator(value):
"""Operator must be a valid OperatorType i.e. one of: (AND, OR)"""
return value in (OperatorType.AND, OperatorType.OR)
@lift
def is_tokenizer(value):
"""Tokenizers must be valid Regular expressions. see: options.PrefixTokenizers"""
return bool(non_empty_string(value))
@lift
def is_int(value):
"""Invalid type. Expected `int`"""
return isinstance(value, int)
@lift
def is_0to255(value):
"""RGB values must be in the range 0 - 255 (inclusive)"""
return 0 <= value <= 255
def is_0to20(value):
"""Precision values must be in the range 0 - 20 (inclusive)"""
return 0 <= value <= 20
@lift
def is_valid_color(value):
"""Must be either a valid hex string or RGB list"""
if is_str(value):
return is_hex_string(value)
elif is_tuple_or_list(value):
return (is_tuple_or_list(value)
and is_three_channeled(value)
and has_valid_channel_values(value))
else:
return is_str_or_coll(value)
validators = {
'label_color': is_valid_color,
'label_bg_color': is_valid_color,
'help_color': is_valid_color,
'help_bg_color': is_valid_color,
'error_color': is_valid_color,
'error_bg_color': is_valid_color,
'show_label': is_bool,
'show_help': is_bool,
'visible': is_bool,
'full_width': is_bool,
'height': is_int,
'readonly': is_bool,
'initial_selection': is_int,
'title': non_empty_string,
'checkbox_label': non_empty_string,
'placeholder': non_empty_string,
'empty_message': non_empty_string,
'max_size': is_int,
'choice_tokenizer': is_tokenizer,
'input_tokenizer': is_tokenizer,
'ignore_case': is_bool,
'operator': is_tokenization_operator,
'index_suffix': is_bool,
'wildcard': non_empty_string,
'default_dir': non_empty_string,
'default_file': non_empty_string,
'default_path': non_empty_string,
'message': non_empty_string,
'precision': is_0to20
}
def collect_errors(predicates, m):
return {
k:predicates[k](v).rationale
for k,v in m.items()
if k in predicates and not predicates[k](v)}
def validate(pred, value):
result = pred(value)
if not result:
raise ValueError(result.rationale)
if __name__ == '__main__':
# print(validateColor((1, 'ergerg', 1234)))
# print(validateColor(1234))
# print(validateColor(123.234))
# print(validateColor('123.234'))
# print(validateColor('FFFAAA'))
# print(validateColor('#FFFAAA'))
# print(validateColor([]))
# print(validateColor(()))
# print(validateColor((1, 2)))
# print(validateColor((1, 2, 1234)))
# print(is_lifted(lift(is_int)))
# print(is_lifted(is_int))
# print(OR(is_poop, is_int)('poop'))
# print(AND(is_poop, is_lower, is_lower)('pooP'))
# print(OR(is_poop, is_int))
# print(is_lifted(OR(is_poop, is_int)))
# print(validate(is_valid_color, [255, 255, 256]))
# print(is_valid_color('#fff000'))
# print(is_valid_color([255, 244, 256]))
print(non_empty_string('asdf') and non_empty_string('asdf'))
# validate(is_valid_color, 1234)

2
gooey/gui/components/widgets/__init__.py

@ -12,3 +12,5 @@ from .counter import Counter
from .radio_group import RadioGroup from .radio_group import RadioGroup
from .choosers import * from .choosers import *
from .dropdown_filterable import FilterableDropdown from .dropdown_filterable import FilterableDropdown
from .numeric_fields import IntegerField, DecimalField
from .slider import Slider

21
gooey/gui/components/widgets/bases.py

@ -1,3 +1,4 @@
import re
from functools import reduce from functools import reduce
import wx import wx
@ -28,6 +29,9 @@ class BaseWidget(wx.Panel):
def setValue(self, value): def setValue(self, value):
raise NotImplementedError raise NotImplementedError
def setPlaceholder(self, value):
raise NotImplementedError
def receiveChange(self, *args, **kwargs): def receiveChange(self, *args, **kwargs):
raise NotImplementedError raise NotImplementedError
@ -75,6 +79,10 @@ class TextContainer(BaseWidget):
# Checking for None instead of truthiness means False-evaluaded defaults can be used. # Checking for None instead of truthiness means False-evaluaded defaults can be used.
if self._meta['default'] is not None: if self._meta['default'] is not None:
self.setValue(self._meta['default']) self.setValue(self._meta['default'])
if self._options.get('placeholder'):
self.setPlaceholder(self._options.get('placeholder'))
self.onComponentInitialized() self.onComponentInitialized()
def onComponentInitialized(self): def onComponentInitialized(self):
@ -153,9 +161,13 @@ class TextContainer(BaseWidget):
def getValue(self): def getValue(self):
regexFunc = lambda x: bool(re.match(userValidator, x))
userValidator = getin(self._options, ['validator', 'test'], 'True') userValidator = getin(self._options, ['validator', 'test'], 'True')
message = getin(self._options, ['validator', 'message'], '') message = getin(self._options, ['validator', 'message'], '')
testFunc = eval('lambda user_input: bool(%s)' % userValidator)
testFunc = regexFunc \
if getin(self._options, ['validator', 'type'], None) == 'RegexValidator'\
else eval('lambda user_input: bool(%s)' % userValidator)
satisfies = testFunc if self._meta['required'] else ifPresent(testFunc) satisfies = testFunc if self._meta['required'] else ifPresent(testFunc)
value = self.getWidgetValue() value = self.getWidgetValue()
@ -173,6 +185,10 @@ class TextContainer(BaseWidget):
def setValue(self, value): def setValue(self, value):
self.widget.SetValue(value) self.widget.SetValue(value)
def setPlaceholder(self, value):
if getattr(self.widget, 'SetHint', None):
self.widget.SetHint(value)
def setErrorString(self, message): def setErrorString(self, message):
self.error.SetLabel(message) self.error.SetLabel(message)
self.error.Wrap(self.Size.width) self.error.Wrap(self.Size.width)
@ -203,6 +219,9 @@ class BaseChooser(TextContainer):
def setValue(self, value): def setValue(self, value):
self.widget.setValue(value) self.widget.setValue(value)
def setPlaceholder(self, value):
self.widget.SetHint(value)
def getWidgetValue(self): def getWidgetValue(self):
return self.widget.getValue() return self.widget.getValue()

5
gooey/gui/components/widgets/core/chooser.py

@ -22,7 +22,7 @@ class Chooser(wx.Panel):
TODO: oh, young me. DRY != Good Abstraction TODO: oh, young me. DRY != Good Abstraction
TODO: this is another weird inheritance hierarchy that's hard TODO: this is another weird inheritance hierarchy that's hard
to follow. Why do subclasses rather into, not their parent
to follow. Why do subclasses reach into, not their parent
class, but their _physical_ UI parent to grab the Gooey Options? class, but their _physical_ UI parent to grab the Gooey Options?
All this could be simplified to make the data flow All this could be simplified to make the data flow
more apparent. more apparent.
@ -73,6 +73,9 @@ class Chooser(wx.Panel):
def setValue(self, value): def setValue(self, value):
self.widget.setValue(value) self.widget.setValue(value)
def SetHint(self, value):
self.widget.SetHint(value)
def getValue(self): def getValue(self):
return self.widget.getValue() return self.widget.getValue()

3
gooey/gui/components/widgets/core/text_input.py

@ -32,6 +32,9 @@ class TextInput(wx.Panel):
def getValue(self): def getValue(self):
return self.widget.GetValue() return self.widget.GetValue()
def SetHint(self, value):
self.widget.SetHint(value)
def SetDropTarget(self, target): def SetDropTarget(self, target):
self.widget.SetDropTarget(target) self.widget.SetDropTarget(target)

50
gooey/gui/components/widgets/numeric_fields.py

@ -0,0 +1,50 @@
import wx
from gooey.gui import formatters
from gooey.gui.components.widgets.bases import TextContainer
class IntegerField(TextContainer):
"""
An integer input field
"""
widget_class = wx.SpinCtrl
def getWidget(self, *args, **options):
widget = self.widget_class(self,
value='',
min=self._options.get('min', 0),
max=self._options.get('max', 100))
return widget
def getWidgetValue(self):
return self.widget.GetValue()
def setValue(self, value):
self.widget.SetValue(int(value))
def formatOutput(self, metatdata, value):
# casting to string so that the generic formatter
# doesn't treat 0 as false/None
return formatters.general(metatdata, str(value))
class DecimalField(IntegerField):
"""
A decimal input field
"""
widget_class = wx.SpinCtrlDouble
def getWidget(self, *args, **options):
widget = self.widget_class(self,
value='',
min=self._options.get('min', 0),
max=self._options.get('max', 100),
inc=self._options.get('increment', 0.01))
widget.SetDigits(self._options.get('precision', widget.GetDigits()))
return widget
def setValue(self, value):
self.widget.SetValue(value)

74
gooey/gui/components/widgets/options.py

@ -1,74 +0,0 @@
import pygtrie as trie
from fuzzywuzzy import process
class BasicDisplayOptions(object):
pass
countries = ["Abkhazia -> Abkhazia", "Afghanistan",
"Albania", "Algeria", "Andorra", "Angola", "Antigua and Barbuda", "Argentina",
"Armenia", "Artsakh -> Artsakh", "Australia", "Austria", "Azerbaijan", "Bahamas, The",
"Bahrain", "Bangladesh", "Barbados", "Belarus", "Belgium", "Belize", "Benin", "Bhutan",
"Bolivia", "Bosnia and Herzegovina", "Botswana", "Brazil", "Brunei", "Bulgaria",
"Burkina Faso[j]", "Burma -> Myanmar", "Burundi", "Cambodia", "Cameroon", "Canada[k]",
"Cape Verde", "Central African Republic", "Chad", "Chile", "China",
"China, Republic of -> Taiwan", "Colombia", "Comoros",
"Congo, Democratic Republic of the[p]", "Congo, Republic of the",
"Cook Islands -> Cook Islands", "Costa Rica", "Croatia", "Cuba", "Cyprus",
"Czech Republic[r]", "Democratic People's Republic of Korea -> Korea, North",
"Democratic Republic of the Congo -> Congo, Democratic Republic of the", "Denmark",
"Djibouti", "Dominica", "Dominican Republic", "East Timor", "Ecuador", "Egypt",
"El Salvador", "Equatorial Guinea", "Eritrea", "Estonia", "Eswatini -> Swaziland",
"Ethiopia", "Fiji", "Finland", "France", "Gabon", "Gambia, The", "Georgia", "Germany",
"Ghana", "Greece", "Grenada", "Guatemala", "Guinea", "Guinea", "Guyana", "Haiti",
"Holy See -> Vatican City", "Honduras", "Hungary", "Iceland[v]", "India", "Indonesia",
"Iran", "Iraq", "Ireland", "Israel", "Italy", "Ivory Coast", "Jamaica", "Japan",
"Jordan", "Kazakhstan", "Kenya", "Kiribati", "Korea, North", "Korea, South",
"Kosovo -> Kosovo", "Kuwait", "Kyrgyzstan", "Laos", "Latvia", "Lebanon", "Lesotho",
"Liberia", "Libya", "Liechtenstein", "Lithuania", "Luxembourg", "Madagascar", "Malawi",
"Malaysia", "Maldives", "Mali", "Malta", "Marshall Islands", "Mauritania", "Mauritius",
"Mexico", "Micronesia", "Moldova", "Monaco", "Mongolia", "Montenegro", "Morocco",
"Mozambique", "Myanmar", "Nagorno", "Namibia", "Nauru", "Nepal", "Netherlands",
"New Zealand", "Nicaragua", "Niger", "Nigeria", "Niue -> Niue",
"Northern Cyprus -> Northern Cyprus", "North Korea -> Korea, North", "North Macedonia",
"Norway", "Oman", "Pakistan", "Palau", "Palestine", "Panama", "Papua New Guinea",
"Paraguay", "Peru", "Philippines", "Poland", "Portugal",
"Pridnestrovie -> Transnistria", "Qatar", "Republic of Korea -> Korea, South",
"Republic of the Congo -> Congo, Republic of the", "Romania", "Russia", "Rwanda",
"Sahrawi Arab Democratic Republic -> Sahrawi Arab Democratic Republic",
"Saint Kitts and Nevis", "Saint Lucia", "Saint Vincent and the Grenadines", "Samoa",
"San Marino", "São Tomé and Príncipe", "Saudi Arabia", "Senegal", "Serbia",
"Seychelles", "Sierra Leone", "Singapore", "Slovakia", "Slovenia", "Solomon Islands",
"Somalia", "Somaliland -> Somaliland", "South Africa", "South Korea -> Korea, South",
"South Ossetia -> South Ossetia", "South Sudan", "Spain", "Sri Lanka", "Sudan",
"Sudan, South -> South Sudan", "Suriname", "Swaziland", "Sweden", "Switzerland",
"Syria", "Taiwan (Republic of China) -> Taiwan", "Tajikistan", "Tanzania", "Thailand",
"The Bahamas -> Bahamas, The", "The Gambia -> Gambia, The", "Timor", "Togo", "Tonga",
"Transnistria -> Transnistria", "Trinidad and Tobago", "Tunisia", "Turkey",
"Turkmenistan", "Tuvalu", "Uganda", "Ukraine", "United Arab Emirates",
"United Kingdom", "United States", "Uruguay", "Uzbekistan", "Vanuatu", "Vatican City",
"Venezuela", "Vietnam", "Yemen", "Zambia", "Zimbabwe",
"↑ UN member states and observer states ↑", "", "↓ Other states ↓", "Abkhazia",
"Artsakh", "Cook Islands", "Kosovo", "Niue", "Northern Cyprus",
"Sahrawi Arab Democratic Republic", "Somaliland", "South Ossetia", "Taiwan",
"Transnistria", "↑ Other states ↑"]
us_states = ["Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado",
"Connecticut", "Delaware", "Florida", "Georgia", "Hawaii", "Idaho", "Illinois",
"Indiana", "Iowa", "Kansas", "Kentucky[E]", "Louisiana", "Maine", "Maryland",
"Massachusetts", "Michigan", "Minnesota", "Mississippi", "Missouri", "Montana",
"Nebraska", "Nevada", "New Hampshire", "New Jersey", "New Mexico", "New York",
"North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania",
"Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah",
"Vermont", "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming"]
x = trie.Trie()
output = {}
for country in countries:
for i in country.split():
if not x.has_key(i):
x[i] = []
x[i].append(country)
a = 10

28
gooey/gui/components/widgets/slider.py

@ -0,0 +1,28 @@
import wx
from gooey.gui import formatters
from gooey.gui.components.widgets.bases import TextContainer
class Slider(TextContainer):
"""
An integer input field
"""
widget_class = wx.Slider
def getWidget(self, *args, **options):
widget = self.widget_class(self,
minValue=self._options.get('min', 0),
maxValue=self._options.get('max', 100),
style=wx.SL_MIN_MAX_LABELS | wx.SL_VALUE_LABEL)
return widget
def getWidgetValue(self):
return self.widget.GetValue()
def setValue(self, value):
self.widget.SetValue(value)
def formatOutput(self, metatdata, value):
return formatters.general(metatdata, value)

6
gooey/gui/components/widgets/textfield.py

@ -6,6 +6,9 @@ from gooey.gui.pubsub import pub
from gooey.util.functional import getin from gooey.util.functional import getin
def public_constructor(**layout_options):
pass
class TextField(TextContainer): class TextField(TextContainer):
widget_class = TextInput widget_class = TextInput
@ -15,6 +18,9 @@ class TextField(TextContainer):
def setValue(self, value): def setValue(self, value):
self.widget.setValue(str(value)) self.widget.setValue(str(value))
def setPlaceholder(self, value):
self.widget.SetHint(value)
def formatOutput(self, metatdata, value): def formatOutput(self, metatdata, value):
return formatters.general(metatdata, value) return formatters.general(metatdata, value)

33
gooey/python_bindings/argparse_to_json.py

@ -19,7 +19,8 @@ from uuid import uuid4
from gooey.python_bindings.gooey_parser import GooeyParser from gooey.python_bindings.gooey_parser import GooeyParser
from gooey.util.functional import merge, getin, identity, assoc from gooey.util.functional import merge, getin, identity, assoc
from gooey.gui.components.options.validators import validators
from gooey.gui.components.options.validators import collect_errors
VALID_WIDGETS = ( VALID_WIDGETS = (
'FileChooser', 'FileChooser',
@ -38,7 +39,10 @@ VALID_WIDGETS = (
'Textarea', 'Textarea',
'PasswordField', 'PasswordField',
'Listbox', 'Listbox',
'FilterableDropdown'
'FilterableDropdown',
'IntegerField',
'DecimalField',
'Slider'
) )
@ -419,6 +423,7 @@ def action_to_json(action, widget, options):
base = merge(item_default, { base = merge(item_default, {
'validator': { 'validator': {
'type': 'ExpressionValidator',
'test': validator, 'test': validator,
'message': error_msg 'message': error_msg
}, },
@ -428,6 +433,9 @@ def action_to_json(action, widget, options):
if default == argparse.SUPPRESS: if default == argparse.SUPPRESS:
default = None default = None
final_options = merge(base, options.get(action.dest) or {})
validate_gooey_options(action, widget, final_options)
return { return {
'id': action.option_strings[0] if action.option_strings else action.dest, 'id': action.option_strings[0] if action.option_strings else action.dest,
'type': widget, 'type': widget,
@ -443,11 +451,30 @@ def action_to_json(action, widget, options):
'default': default, 'default': default,
'dest': action.dest, 'dest': action.dest,
}, },
'options': merge(base, options.get(action.dest) or {})
'options': final_options
} }
def validate_gooey_options(action, widget, options):
"""Very basic field validation / sanity checking for
the time being.
Future plans are to assert against the options and actions together
to facilitate checking that certain options like `initial_selection` in
RadioGroups map to a value which actually exists (rather than exploding
at runtime with an unhelpful error)
Additional problems with the current approach is that no feedback is given
as to WHERE the issue took place (in terms of stacktrace). Which means we should
probably explode in GooeyParser proper rather than trying to collect all the errors here.
It's not super ideal in that the user will need to run multiple times to
see all the issues, but, ultimately probably less annoying that trying to
debug which `gooey_option` key had an issue in a large program.
"""
errors = collect_errors(validators, options)
if errors:
from pprint import pformat
raise ValueError(str(action.dest) + str(pformat(errors)))
def choose_cli_type(action): def choose_cli_type(action):

89
gooey/tests/test_numeric_inputs.py

@ -0,0 +1,89 @@
import unittest
from random import randint
from unittest.mock import patch
from tests.harness import instrumentGooey
from gooey import GooeyParser
from gooey.tests import *
class TestNumbericInputs(unittest.TestCase):
def makeParser(self, **kwargs):
parser = GooeyParser(description='description')
parser.add_argument('--input', **kwargs)
return parser
def testDefault(self):
cases = [
[{'widget': 'IntegerField'}, 0],
[{'default': 0, 'widget': 'IntegerField'}, 0],
[{'default': 10, 'widget': 'IntegerField'}, 10],
[{'default': 76, 'widget': 'IntegerField'}, 76],
# note that WX caps the value
# unless explicitly widened via gooey_options
[{'default': 81234, 'widget': 'IntegerField'}, 100],
# here we set the max to something higher than
# the default and all works as expected.
# this is a TODO for validation
[{'default': 81234, 'widget': 'IntegerField', 'gooey_options': {'max': 99999}}, 81234],
[{'widget': 'DecimalField'}, 0],
[{'default': 0, 'widget': 'DecimalField'}, 0],
[{'default': 81234, 'widget': 'DecimalField'}, 100],
[{'default': 81234, 'widget': 'DecimalField', 'gooey_options': {'max': 99999}}, 81234],
]
for inputs, expected in cases:
with self.subTest(inputs):
parser = self.makeParser(**inputs)
with instrumentGooey(parser) as (app, gooeyApp):
input = gooeyApp.configs[0].reifiedWidgets[0]
self.assertEqual(input.getValue()['rawValue'], expected)
def testGooeyOptions(self):
cases = [
{'widget': 'DecimalField', 'gooey_options': {'min': -100, 'max': 1234, 'increment': 1.240}},
{'widget': 'DecimalField', 'gooey_options': {'min': 1234, 'max': 3456, 'increment': 2.2}},
{'widget': 'IntegerField', 'gooey_options': {'min': -100, 'max': 1234}},
{'widget': 'IntegerField', 'gooey_options': {'min': 1234, 'max': 3456}}
];
using = {
'min': lambda widget: widget.GetMin(),
'max': lambda widget: widget.GetMax(),
'increment': lambda widget: widget.GetIncrement(),
}
for case in cases:
with self.subTest(case):
parser = self.makeParser(**case)
with instrumentGooey(parser) as (app, gooeyApp):
wxWidget = gooeyApp.configs[0].reifiedWidgets[0].widget
for option, value in case['gooey_options'].items():
self.assertEqual(using[option](wxWidget), value)
def testZerosAreReturned(self):
"""
Originally the formatter was dropping '0' due to
it being interpreted as falsey
"""
parser = self.makeParser(widget='IntegerField')
with instrumentGooey(parser) as (app, gooeyApp):
field = gooeyApp.configs[0].reifiedWidgets[0]
result = field.getValue()
self.assertEqual(result['rawValue'], 0)
self.assertIsNotNone(result['cmd'])
def testNoLossOfPrecision(self):
parser = self.makeParser(widget='DecimalField', default=12.23534, gooey_options={'precision': 20})
with instrumentGooey(parser) as (app, gooeyApp):
field = gooeyApp.configs[0].reifiedWidgets[0]
result = field.getValue()
self.assertEqual(result['rawValue'], 12.23534)
self.assertIsNotNone(result['cmd'])
if __name__ == '__main__':
unittest.main()

54
gooey/tests/test_options.py

@ -0,0 +1,54 @@
import unittest
from gooey.gui.components.options import options
class TestPrefixFilter(unittest.TestCase):
def test_doc_schenanigans(self):
"""Sanity check that my docstring wrappers all behave as expected"""
@options._include_layout_docs
def no_self_docstring():
pass
@options._include_layout_docs
def yes_self_docstring():
"""sup"""
pass
# gets attached to functions even if they don't have a docstring
self.assertIn(options.LayoutOptions.__doc__, no_self_docstring.__doc__)
# gets attached to the *end* of existing doc strings
self.assertTrue(yes_self_docstring.__doc__.startswith('sup'))
self.assertIn(options.LayoutOptions.__doc__, yes_self_docstring.__doc__)
def test_clean_method(self):
"""
_clean should drop any keys with None values
and flatten the layout_option kwargs to the root level
"""
result = options._clean({'a': None, 'b': 123, 'c': 0})
self.assertEqual(result, {'b': 123, 'c': 0})
result = options._clean({'root_level': 123, 'layout_options': {
'nested': 'hello',
'another': 1234
}})
self.assertEqual(result, {'root_level': 123, 'nested': 'hello', 'another': 1234})
def test_only_provided_arguments_included(self):
"""
More sanity checking that the internal use of locals()
does the Right Thing
"""
option = options.LayoutOptions(label_color='#ffffff')
self.assertIn('label_color', option)
option = options.LayoutOptions()
self.assertNotIn('label_color', option)
option = options.TextField(label_color='#ffffff')
self.assertIn('label_color', option)
option = options.TextField()
self.assertNotIn('label_color', option)

40
gooey/tests/test_slider.py

@ -0,0 +1,40 @@
import unittest
from unittest.mock import patch
from tests.harness import instrumentGooey
from gooey import GooeyParser
from gooey.tests import *
class TestGooeySlider(unittest.TestCase):
def makeParser(self, **kwargs):
parser = GooeyParser(description='description')
parser.add_argument('--slider', widget="Slider", **kwargs)
return parser
def testSliderDefault(self):
cases = [
[{}, 0],
[{'default': 0}, 0],
[{'default': 10}, 10],
[{'default': 76}, 76],
# note that WX caps the value
# unless explicitly widened via gooey_options
[{'default': 81234}, 100],
# here we set the max to something higher than
# the default and all works as expected.
# this is a TODO for validation
[{'default': 81234, 'gooey_options': {'max': 99999}}, 81234],
]
for inputs, expected in cases:
with self.subTest(inputs):
parser = self.makeParser(**inputs)
with instrumentGooey(parser) as (app, gooeyApp):
slider = gooeyApp.configs[0].reifiedWidgets[0]
self.assertEqual(slider.getValue()['rawValue'], expected)
if __name__ == '__main__':
unittest.main()

5
gooey/util/functional.py

@ -72,7 +72,10 @@ def zipmap(keys, vals):
def compact(coll): def compact(coll):
"""Returns a new list with all falsy values removed""" """Returns a new list with all falsy values removed"""
return list(filter(None, coll))
if isinstance(coll, dict):
return {k:v for k,v in coll.items() if v is not None}
else:
return list(filter(None, coll))
def ifPresent(f): def ifPresent(f):

Loading…
Cancel
Save