diff --git a/README.rst b/README.rst index bc33cf7..40a6a39 100644 --- a/README.rst +++ b/README.rst @@ -2,3 +2,66 @@ Introduction ============ The ``ox_ui`` package provides tools for writing user interfaces. + +For example, ``ox_ui`` lets you take a command defined using the +``click`` library and convert it to something you can run in a Flask web +server as discussed in the `Click to WTForms <#click-to-wtforms>`__ +section. + +Click to WTForms +================ + +The ``ox_ui`` package can convert a ``click`` command into a flask route +using the ``WTForms`` library. This can be convenient both so that you +have a command line interface (CLI) for your functions in addition to a +web interface and also because sometimes it is quicker and easier to +define the CLI interface and auto-generate the web interface. + +Imagine you define a function called ``hello_cmd`` via something like: + +.. code:: python + + @click.command() + @click.option('--count', default=1, type=int, help='how many times to say it') + @click.option('--text', default='hi', type=str, help='what to say') + def hello_cmd(count, text): + 'say hello' + + result = [] + for i in range(count): + result.append(text) + + return '\n'.join(result) + +You can import ``c2f`` from ``ox_ui.core`` and use it to convert your +``hello_cmd`` into a flask route via something like: + +.. code:: python + + from flask import Flask + from ox_ui.core import c2f + + APP = Flask(__name__) + + @APP.route('/hello', methods=('GET', 'POST')) + def hello(): + fcmd = c2f.ClickToWTF(hello_cmd) + result = fcmd.handle_request() + return result + +Once you start your flask web server, you will then have a route that +introspects ``hello_cmd``, creates a web form using the ``WTForms`` +library and handles the command. + +See examples in the ``tests`` directory for more details. + +Other Utilities +=============== + +A few additional utilites are provided in the +``ox_ui/core/decorators.py`` module including a ``watched`` decorator to +log the start/end of functions, a ``setup_flask_watch`` function which +applies the ``watched`` decorator to allow your routes using the +``before_request`` and ``teardown_request`` hooks in flask, and a +``LockFile`` context decorator for easily adding lock files to any +function or context. diff --git a/ox_ui/__init__.py b/ox_ui/__init__.py index 1ec3aa5..9398399 100644 --- a/ox_ui/__init__.py +++ b/ox_ui/__init__.py @@ -2,4 +2,4 @@ """ -VERSION = '0.3.6' +VERSION = '0.3.7' diff --git a/ox_ui/core/c2f.py b/ox_ui/core/c2f.py index c461c5f..dde2148 100644 --- a/ox_ui/core/c2f.py +++ b/ox_ui/core/c2f.py @@ -18,7 +18,7 @@ from wtforms import widgets from wtforms import ( StringField, IntegerField, FileField, Field, PasswordField, SelectField, - BooleanField) + BooleanField, HiddenField, FloatField) from wtforms.validators import DataRequired from dateutil.parser import parse, ParserError @@ -58,6 +58,15 @@ def ensure_open( opened.close() +class RequestResult(typing.NamedTuple): + """Result of handle_request method of ClickToWTF class. + """ + + finished: bool + form: typing.Any + output: typing.Any + + class FileResCallback: """ to replace the deprecated FileResponseTweak @@ -154,7 +163,11 @@ def __init__(self, label=None, validators=None, def _value(self): if self.raw_data: return ' '.join(self.raw_data) - return self.data and self.data.strftime(self.formats[0]) or '' + if self.data: + if isinstance(self.data, str): + return self.data + return self.data.strftime(self.formats[0]) + return '' def process_formdata(self, valuelist): if valuelist: @@ -172,7 +185,9 @@ def process_formdata(self, valuelist): class ClickToWTF: def __init__(self, clickCmd, skip_opt_re=None, tweaks: list = None, - fill_get_from_url: bool = False): + fill_get_from_url: bool = False, + hidden_overrides = None, + require_re = None): """Initializer. :param clickCmd: A click command to turn into a form. @@ -185,6 +200,15 @@ def __init__(self, clickCmd, skip_opt_re=None, tweaks: list = None, to fill out arguments we look in the URL params to override standard defaults. + :param hidden_overrides: Optional dictionary with string keys + indicating fields to hide in WTForm and + values indicating the value to provide. + For example, if you provide something like + {'user': 'anon'} then the option 'user' in + `clickCmd` will not be visibile in the + WTForm but contain a hidden value set to + `'anon'`. This is useful to force certain + values in the web form. """ self.clickCmd = clickCmd self.rawTemplate = None @@ -193,6 +217,9 @@ def __init__(self, clickCmd, skip_opt_re=None, tweaks: list = None, self.tweaks = tweaks if tweaks else [] self.fill_get_from_url = fill_get_from_url self.gobbled_opts = {} + self.hidden_overrides = dict( + hidden_overrides) if hidden_overrides else {} + self.require_re = dict(require_re) if require_re else {} def click_cmd_params(self): """ @@ -219,6 +246,9 @@ class ClickForm(FlaskForm): if request.method == 'GET' and self.fill_get_from_url: self.populate_form_from_url(ClickForm) + for name, value in self.hidden_overrides.items(): + setattr(ClickForm, name, HiddenField(name, default=value)) + return ClickForm def populate_form_from_url(self, ClickForm): @@ -284,6 +314,8 @@ def click_opt_to_wtf_field(cls, opt): if opt.type == types.INT: return IntegerField(opt.name, **kwargs) + if opt.type == types.FLOAT: + return FloatField(opt.name, **kwargs) if opt.type == types.BOOL: return BooleanField(opt.name, **kwargs) if opt.type == types.STRING: @@ -316,6 +348,23 @@ def handle_choice_type(opt, **kwargs): return SelectField(opt.name, choices=[ (n, n) for n in opt.type.choices], **kwargs) + def _verify_kwargs(self, kwargs): + for name, value in self.hidden_overrides.items(): + actual = kwargs.get(name, None) + regexp = self.require_re.get(name, None) + if regexp is not None: + pass # we will validate later when we check require_re + elif actual != value: + # Someone may have manually tried to circumvent override + raise ValueError(f'Field {name} must match default {value}' + f' (got {actual}).') + for name, value in self.require_re.items(): + actual = kwargs.get(name, '') + if not re.compile(value).match(actual): + raise ValueError( + f'Field {name} does not match regexp {value}' + f' (got {actual}).') + def process(self, form): defaults = {opt.name: opt.default for opt in self.clickCmd.params} kwargs = {} @@ -326,7 +375,7 @@ def process(self, form): else: kwargs[opt.name] = value kwargs = {**defaults, **kwargs} - + self._verify_kwargs(kwargs) self.pad_kwargs(kwargs) wrapped_cmd = getattr(self.clickCmd.callback, '__wrapped__', None) if wrapped_cmd: @@ -384,10 +433,13 @@ def simple_render(self, form, **data): result = rtemplate.render(form=form, **data) return result - def handle_request(self): + def handle_request(self, verbose=False): form = self.form() if form.validate_on_submit(): - result = self.process(form) + result = RequestResult(True, form, self.process(form)) + else: + logging.debug('Showing form either because invalid or first view') + result = RequestResult(False, form, self.show_form(form=form)) + if verbose: return result - logging.debug('Showing form either because invalid or first view') - return self.show_form(form=form) + return result.output diff --git a/ox_ui/core/c2g.py b/ox_ui/core/c2g.py index a6dce1b..21bf6eb 100644 --- a/ox_ui/core/c2g.py +++ b/ox_ui/core/c2g.py @@ -99,6 +99,14 @@ def make_int_field(self, opt): opt.name, description=opt.help, default=opt.default, type=int) + def make_float_field(self, opt): + "Make a float field." + + logging.debug('Making field for %s', self) + return GenericField( + opt.name, description=opt.help, default=opt.default, + type=float) + def make_bool_field(self, opt): "Make a bool field." @@ -151,6 +159,8 @@ def click_opt_to_field(self, opt): field = self.make_dt_field(opt) elif isinstance(opt.type, (types.File, types.Path)): field = self.make_file_field(opt) + elif opt.type == types.FLOAT: + field = self.make_float_field(opt) else: raise TypeError(f'Cannot represent click type {opt.type}') return field diff --git a/ox_ui/core/decorators.py b/ox_ui/core/decorators.py index 5bbfbe7..1ea7efc 100644 --- a/ox_ui/core/decorators.py +++ b/ox_ui/core/decorators.py @@ -70,7 +70,8 @@ def _end_watch(w_data, str_result): cmd_time = time.time() - w_data['start'] w_data.update(watched='end', status='ok', w_run_time=cmd_time, w_result=str_result) - logging.info('watched_cmd_end: ok:%s', w_data['w_name'], extra=w_data) + logging.info('watched_cmd_end: ok:%s (%.4f s)', w_data['w_name'], + cmd_time, extra=w_data) return w_data diff --git a/requirements.txt b/requirements.txt index 6ba6584..2bf838e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,42 +1,6 @@ -astroid==2.11.1 -attrs==21.4.0 -certifi==2022.12.7 -charset-normalizer==2.0.12 -click==8.0.4 -dill==0.3.4 -execnet==1.9.0 -eyap==0.9.3 -flake8==4.0.1 -Flask==2.2.5 -Flask-WTF==1.0.0 -idna==3.3 -iniconfig==1.1.1 -isort==5.10.1 -itsdangerous==2.1.2 -Jinja2==3.1.0 -lazy-object-proxy==1.7.1 -MarkupSafe==2.1.1 -mccabe==0.6.1 -ox-herd==0.8.1 -ox-secrets==0.3.6 -packaging==21.3 -platformdirs==2.5.1 -pluggy==1.0.0 -py==1.11.0 -pycodestyle==2.8.0 -pyflakes==2.4.0 -pylint==2.13.0 -pyparsing==3.0.7 -pytest==7.1.1 -pytest-forked==1.4.0 -pytest-xdist==2.5.0 -python-dateutil==2.8.2 -requests==2.27.1 -six==1.16.0 -tomli==2.0.1 -typing-extensions==4.1.1 -urllib3==1.26.9 -Werkzeug==2.2.3 -wrapt==1.14.0 -WTForms==3.0.1 -xmltodict==0.13.0 +requests +python-dateutil +flask +flask-wtf +wrapt +click