Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bug fixes and cleanups #16

Merged
merged 6 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion ox_ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"""


VERSION = '0.3.6'
VERSION = '0.3.7'
68 changes: 60 additions & 8 deletions ox_ui/core/c2f.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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):
"""
Expand All @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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:
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions ox_ui/core/c2g.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion ox_ui/core/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
48 changes: 6 additions & 42 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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