diff --git a/docs/Filterable-Dropdown-Guide.md b/docs/Filterable-Dropdown-Guide.md new file mode 100644 index 0000000..2486e46 --- /dev/null +++ b/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 + + + + + diff --git a/docs/releases/1.0.7-release-notes.md b/docs/releases/1.0.7-release-notes.md new file mode 100644 index 0000000..b38f1ed --- /dev/null +++ b/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 + + + + + \ No newline at end of file diff --git a/gooey/__init__.py b/gooey/__init__.py index e8507c5..ec213db 100644 --- a/gooey/__init__.py +++ b/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.python_bindings import constants from gooey.gui.components.filtering.prefix_filter import PrefixTokenizers +import gooey.gui.components.options.options as options __version__ = '1.0.6' diff --git a/gooey/gui/components/filtering/prefix_filter.py b/gooey/gui/components/filtering/prefix_filter.py index 6813523..391f53e 100644 --- a/gooey/gui/components/filtering/prefix_filter.py +++ b/gooey/gui/components/filtering/prefix_filter.py @@ -2,28 +2,32 @@ import re import pygtrie as trie from functools import reduce +from typing import NewType __ALL__ = ('PrefixTokenizers', 'PrefixSearch') +Tokenizer = NewType('Tokenizer', str) class PrefixTokenizers: # This string here is just an arbitrary long string so that # 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 - WORDS = r'\s' + WORDS: Tokenizer = r'\s' @classmethod - def REGEX(cls, expression): + def REGEX(cls, expression) ->Tokenizer: return expression - +class OperatorType: + AND = 'AND' + OR = 'OR' class SearchOptions: 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, operator='AND', index_suffix= False, diff --git a/gooey/gui/components/footer.py b/gooey/gui/components/footer.py index adfec4b..a219d21 100644 --- a/gooey/gui/components/footer.py +++ b/gooey/gui/components/footer.py @@ -18,7 +18,7 @@ class Footer(wx.Panel): self.buildSpec = buildSpec self.SetMinSize((30, 53)) - self.SetDoubleBuffered(True) + self.SetDoubleBuffered(False) # components self.cancel_button = None self.start_button = None diff --git a/gooey/gui/components/options/__init__.py b/gooey/gui/components/options/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gooey/gui/components/options/layout.py b/gooey/gui/components/options/layout.py new file mode 100644 index 0000000..ab674e2 --- /dev/null +++ b/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 diff --git a/gooey/gui/components/options/options.py b/gooey/gui/components/options/options.py new file mode 100644 index 0000000..dd6be3f --- /dev/null +++ b/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} + diff --git a/gooey/gui/components/options/validation.py b/gooey/gui/components/options/validation.py new file mode 100644 index 0000000..c49fc28 --- /dev/null +++ b/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))) diff --git a/gooey/gui/components/options/validators.py b/gooey/gui/components/options/validators.py new file mode 100644 index 0000000..d33bd27 --- /dev/null +++ b/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) + + diff --git a/gooey/gui/components/widgets/__init__.py b/gooey/gui/components/widgets/__init__.py index 2710107..7916c81 100644 --- a/gooey/gui/components/widgets/__init__.py +++ b/gooey/gui/components/widgets/__init__.py @@ -12,3 +12,5 @@ from .counter import Counter from .radio_group import RadioGroup from .choosers import * from .dropdown_filterable import FilterableDropdown +from .numeric_fields import IntegerField, DecimalField +from .slider import Slider diff --git a/gooey/gui/components/widgets/bases.py b/gooey/gui/components/widgets/bases.py index c1f535e..cc22671 100644 --- a/gooey/gui/components/widgets/bases.py +++ b/gooey/gui/components/widgets/bases.py @@ -1,3 +1,4 @@ +import re from functools import reduce import wx @@ -28,6 +29,9 @@ class BaseWidget(wx.Panel): def setValue(self, value): raise NotImplementedError + def setPlaceholder(self, value): + raise NotImplementedError + def receiveChange(self, *args, **kwargs): raise NotImplementedError @@ -75,6 +79,10 @@ class TextContainer(BaseWidget): # Checking for None instead of truthiness means False-evaluaded defaults can be used. if self._meta['default'] is not None: self.setValue(self._meta['default']) + + if self._options.get('placeholder'): + self.setPlaceholder(self._options.get('placeholder')) + self.onComponentInitialized() def onComponentInitialized(self): @@ -153,9 +161,13 @@ class TextContainer(BaseWidget): def getValue(self): + regexFunc = lambda x: bool(re.match(userValidator, x)) + userValidator = getin(self._options, ['validator', 'test'], 'True') 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) value = self.getWidgetValue() @@ -173,6 +185,10 @@ class TextContainer(BaseWidget): def setValue(self, value): self.widget.SetValue(value) + def setPlaceholder(self, value): + if getattr(self.widget, 'SetHint', None): + self.widget.SetHint(value) + def setErrorString(self, message): self.error.SetLabel(message) self.error.Wrap(self.Size.width) @@ -203,6 +219,9 @@ class BaseChooser(TextContainer): def setValue(self, value): self.widget.setValue(value) + def setPlaceholder(self, value): + self.widget.SetHint(value) + def getWidgetValue(self): return self.widget.getValue() diff --git a/gooey/gui/components/widgets/core/chooser.py b/gooey/gui/components/widgets/core/chooser.py index 1135dce..2335e06 100644 --- a/gooey/gui/components/widgets/core/chooser.py +++ b/gooey/gui/components/widgets/core/chooser.py @@ -22,7 +22,7 @@ class Chooser(wx.Panel): TODO: oh, young me. DRY != Good Abstraction 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? All this could be simplified to make the data flow more apparent. @@ -73,6 +73,9 @@ class Chooser(wx.Panel): def setValue(self, value): self.widget.setValue(value) + def SetHint(self, value): + self.widget.SetHint(value) + def getValue(self): return self.widget.getValue() diff --git a/gooey/gui/components/widgets/core/text_input.py b/gooey/gui/components/widgets/core/text_input.py index b161f7f..e97c575 100644 --- a/gooey/gui/components/widgets/core/text_input.py +++ b/gooey/gui/components/widgets/core/text_input.py @@ -32,6 +32,9 @@ class TextInput(wx.Panel): def getValue(self): return self.widget.GetValue() + def SetHint(self, value): + self.widget.SetHint(value) + def SetDropTarget(self, target): self.widget.SetDropTarget(target) diff --git a/gooey/gui/components/widgets/numeric_fields.py b/gooey/gui/components/widgets/numeric_fields.py new file mode 100644 index 0000000..c932ca0 --- /dev/null +++ b/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) + + diff --git a/gooey/gui/components/widgets/options.py b/gooey/gui/components/widgets/options.py deleted file mode 100644 index d11ce82..0000000 --- a/gooey/gui/components/widgets/options.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/gooey/gui/components/widgets/slider.py b/gooey/gui/components/widgets/slider.py new file mode 100644 index 0000000..f64abf6 --- /dev/null +++ b/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) + + diff --git a/gooey/gui/components/widgets/textfield.py b/gooey/gui/components/widgets/textfield.py index b286bfb..07f6713 100644 --- a/gooey/gui/components/widgets/textfield.py +++ b/gooey/gui/components/widgets/textfield.py @@ -6,6 +6,9 @@ from gooey.gui.pubsub import pub from gooey.util.functional import getin +def public_constructor(**layout_options): + pass + class TextField(TextContainer): widget_class = TextInput @@ -15,6 +18,9 @@ class TextField(TextContainer): def setValue(self, value): self.widget.setValue(str(value)) + def setPlaceholder(self, value): + self.widget.SetHint(value) + def formatOutput(self, metatdata, value): return formatters.general(metatdata, value) diff --git a/gooey/python_bindings/argparse_to_json.py b/gooey/python_bindings/argparse_to_json.py index 5e6ad44..b51d439 100644 --- a/gooey/python_bindings/argparse_to_json.py +++ b/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.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 = ( 'FileChooser', @@ -38,7 +39,10 @@ VALID_WIDGETS = ( 'Textarea', 'PasswordField', 'Listbox', - 'FilterableDropdown' + 'FilterableDropdown', + 'IntegerField', + 'DecimalField', + 'Slider' ) @@ -419,6 +423,7 @@ def action_to_json(action, widget, options): base = merge(item_default, { 'validator': { + 'type': 'ExpressionValidator', 'test': validator, 'message': error_msg }, @@ -428,6 +433,9 @@ def action_to_json(action, widget, options): if default == argparse.SUPPRESS: default = None + final_options = merge(base, options.get(action.dest) or {}) + validate_gooey_options(action, widget, final_options) + return { 'id': action.option_strings[0] if action.option_strings else action.dest, 'type': widget, @@ -443,11 +451,30 @@ def action_to_json(action, widget, options): 'default': default, '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): diff --git a/gooey/tests/test_numeric_inputs.py b/gooey/tests/test_numeric_inputs.py new file mode 100644 index 0000000..322c85f --- /dev/null +++ b/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() \ No newline at end of file diff --git a/gooey/tests/test_options.py b/gooey/tests/test_options.py new file mode 100644 index 0000000..5438093 --- /dev/null +++ b/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) \ No newline at end of file diff --git a/gooey/tests/test_slider.py b/gooey/tests/test_slider.py new file mode 100644 index 0000000..2a974f9 --- /dev/null +++ b/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() \ No newline at end of file diff --git a/gooey/util/functional.py b/gooey/util/functional.py index 91ba956..27fbb61 100644 --- a/gooey/util/functional.py +++ b/gooey/util/functional.py @@ -72,7 +72,10 @@ def zipmap(keys, vals): def compact(coll): """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):