diff --git a/.gitignore b/.gitignore index 5ff10bf..8f1c027 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,66 @@ +# vim +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +*.un~ +Session.vim +.netrwhist *~ -*.py[cdo] + + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ diff --git a/setup.py b/setup.py index 71e40fe..ce3918c 100755 --- a/setup.py +++ b/setup.py @@ -1,39 +1,50 @@ #!/usr/bin/env python import os + try: from setuptools import setup, find_packages except ImportError: from distutils.core import setup, find_packages -VERSION = '0.0.7' +VERSION = "0.0.7" PATH = os.path.dirname(os.path.abspath(__file__)) try: - LONG_DESC = '\n===='+open(os.path.join(PATH, 'README.rst'), 'r').read().split('====', 1)[-1] -except IOError: #happens when using tox - LONG_DESC = '' + LONG_DESC = ( + "\n====" + + open(os.path.join(PATH, "README.rst"), "r").read().split("====", 1)[-1] + ) +except IOError: # happens when using tox + LONG_DESC = "" -setup(name='wtforms-jsonschema', - version=VERSION, - description="wtforms-jsonschema converts WTForms into JSON Schema compatibile representations", - long_description=LONG_DESC, - classifiers=[ - 'Programming Language :: Python', - 'Environment :: Web Environment', - 'Operating System :: OS Independent', - 'Natural Language :: English', - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', - ], - keywords='wtforms json schema', - author = 'Jason Kraus', - author_email = 'zbyte64@gmail.com', - maintainer = 'Jason Kraus', - maintainer_email = 'zbyte64@gmail.com', - url='http://github.com/zbyte64/wtforms-jsonschema', - license='New BSD License', - packages=find_packages(exclude=['tests']), - include_package_data = True, - zip_safe = False, - ) +setup( + name="wtforms-jsonschema", + version=VERSION, + description=( + "wtforms-jsonschema converts WTForms into JSON Schema " + "compatibile representations" + ), + long_description=LONG_DESC, + classifiers=[ + "Programming Language :: Python", + "Environment :: Web Environment", + "Operating System :: OS Independent", + "Natural Language :: English", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + ], + keywords="wtforms json schema", + author="Jason Kraus", + author_email="zbyte64@gmail.com", + maintainer="Jason Kraus", + maintainer_email="zbyte64@gmail.com", + url="http://github.com/zbyte64/wtforms-jsonschema", + license="New BSD License", + packages=find_packages(exclude=["tests"]), + include_package_data=True, + zip_safe=False, + install_requires=[ + "wtforms>=3", + ], +) diff --git a/wtforms_jsonschema/jsonschema.py b/wtforms_jsonschema/jsonschema.py index 1f4f518..6c01aad 100644 --- a/wtforms_jsonschema/jsonschema.py +++ b/wtforms_jsonschema/jsonschema.py @@ -1,114 +1,207 @@ +import copy from collections import OrderedDict +import wtforms +from wtforms.validators import Length + def pretty_name(name): """Converts 'first_name' to 'First name'""" if not name: - return u'' - return name.replace('_', ' ').capitalize() + return "" + return name.replace("_", " ").capitalize() -class WTFormToJSONSchema(object): - DEFAULT_CONVERSIONS = { - 'URLField': { - 'type': 'string', - 'format': 'uri', - }, - 'URIField': { - 'type': 'string', - 'format': 'uri', - }, - 'URIFileField': { - 'type': 'string', - 'format': 'uri', - 'ux-widget': 'file-select', #not part of spec but flags behavior - }, - 'FileField': { - 'type': 'string', - 'format': 'uri', - 'ux-widget': 'file-select', #not part of spec but flags behavior - }, - 'DateField': { - 'type': 'string', - 'format': 'date', - }, - 'DateTimeField': { - 'type': 'string', - 'format': 'datetime', - }, - 'DecimalField': { - 'type': 'number', - }, - 'IntegerField': { - 'type': 'integer', - }, - 'BooleanField': { - 'type': 'boolean', - }, - 'StringField': { - 'type': 'string', - }, - 'SearchField': { - 'type': 'string', - }, - 'TelField': { - 'type': 'string', - 'format': 'phone', - }, - 'EmailField': { - 'type': 'string', - 'format': 'email', - }, - 'DateTimeLocalField': { - 'type': 'string', - 'format': 'datetime', - }, - 'ColorField': { - 'type': 'string', - 'format': 'color', - }, - #TODO min/max - 'DecimalRangeField': { - 'type': 'number', - }, - 'IntegerRangeField': { - 'type': 'integer', +_DEFAULT_CONVERSIONS = {} +_INPUT_TYPE_MAP = {} +try: + import wtforms_components + + _DEFAULT_CONVERSIONS[wtforms_components.ColorField] = { + "type": "string", + "format": "color", + "form": { + "type": "color", }, } + # TODO min/max + _DEFAULT_CONVERSIONS[wtforms_components.DecimalRangeField] = { + "type": "number", + } + _DEFAULT_CONVERSIONS[wtforms_components.IntegerRangeField] = { + "type": "integer", + } + _INPUT_TYPE_MAP["color"] = wtforms_components.ColorField + +except ImportError: + pass + + +class WTFormToJSONSchema: + + DEFAULT_CONVERSIONS = OrderedDict( + [ + ( + wtforms.fields.URLField, + { + "type": "string", + "format": "uri", + "form": { + "type": "url", + }, + }, + ), + ( + wtforms.fields.FileField, + { + "type": "string", + "format": "uri", + "form": { + "type": "file", + }, + }, + ), + ( + wtforms.fields.DateField, + { + "type": "string", + "format": "date", + "form": { + "type": "date", + }, + }, + ), + ( + wtforms.fields.DateTimeField, + { + "type": "string", + "format": "datetime", + "form": { + "type": "datetime", + }, + }, + ), + ( + wtforms.fields.DecimalField, + { + "type": "number", + "form": { + "type": "number", + "step": "any", + }, + }, + ), + ( + wtforms.fields.IntegerField, + { + "type": "integer", + "form": { + "type": "number", + "min": "1", + "step": "1", + }, + }, + ), + ( + wtforms.fields.BooleanField, + { + "type": "boolean", + "form": {}, + }, + ), + ( + wtforms.fields.PasswordField, + { + "type": "string", + "form": { + "type": "password", + }, + }, + ), + ( + wtforms.fields.SearchField, + { + "type": "string", + "form": { + "type": "search", + }, + }, + ), + ( + wtforms.fields.TelField, + { + "type": "string", + "format": "phone", + "form": { + "type": "tel", + }, + }, + ), + ( + wtforms.fields.EmailField, + { + "type": "string", + "format": "email", + "form": { + "type": "email", + }, + }, + ), + ( + wtforms.fields.DateTimeLocalField, + { + "type": "string", + "format": "datetime", + "form": { + "type": "datetime-local", + }, + }, + ), + ( + wtforms.fields.StringField, + { + "type": "string", + "form": { + "type": "text", + }, + }, + ), + ] + ) + INPUT_TYPE_MAP = { - 'text': 'StringField', - 'checkbox': 'BooleanField', - 'color': 'ColorField', - 'tel': 'TelField', + "text": wtforms.fields.StringField, + "password": wtforms.fields.PasswordField, + "checkbox": wtforms.fields.BooleanField, + "tel": wtforms.fields.TelField, } - def __init__(self, conversions=None, include_array_item_titles=True, - include_array_title=True): + def __init__( + self, conversions=None, include_array_item_titles=True, include_array_title=True + ): self.conversions = conversions or self.DEFAULT_CONVERSIONS self.include_array_item_titles = include_array_item_titles self.include_array_title = include_array_title def convert_form(self, form, json_schema=None, forms_seen=None, path=None): - if forms_seen is None: - forms_seen = dict() - if path is None: - path = [] - if json_schema is None: - json_schema = { - #'title':dockit_schema._meta - #'description' - 'type': 'object', - 'properties': OrderedDict(), - } + forms_seen = forms_seen or dict() + path = path or [] + json_schema = json_schema or { + "type": "object", + "schema": { + "properties": OrderedDict(), + }, + "form": [], + } key = id(form) if key in forms_seen: - json_schema['$ref'] = '#'+'/'.join(forms_seen[key]) - json_schema.pop('properties', None) + json_schema["$ref"] = "#" + "/".join(forms_seen[key]) + json_schema.pop("properties", None) return json_schema forms_seen[key] = path - #_unbound_fields preserves order, _fields does not - if hasattr(form, '_unbound_fields'): + # _unbound_fields preserves order, _fields does not + if hasattr(form, "_unbound_fields"): if form._unbound_fields is None: form = form() fields = [name for name, ufield in form._unbound_fields] @@ -118,52 +211,95 @@ def convert_form(self, form, json_schema=None, forms_seen=None, path=None): if name not in form._fields: continue field = form._fields[name] - json_schema['properties'][name] = \ + json_schema["schema"]["properties"][name], form_obj = ( self.convert_formfield(name, field, json_schema, forms_seen, path) + ) + if form_obj is None: + form_obj = {"key": name} + else: + form_obj["key"] = name + json_schema["form"].append(form_obj) + return json_schema + def _find_conversion_class(self, cls): + if self.conversions.get(cls): + return cls + else: + for klass in self.conversions.keys(): + if issubclass(cls, klass): + return klass + raise KeyError(cls) + + def _find_conversion(self, field, name): + cls = field.__class__ + try: + klass = self._find_conversion_class(cls) + return copy.deepcopy(self.conversions.get(klass)) + except (KeyError, TypeError) as exc: + raise NotImplementedError( + "Unsupported field {name}: {field!r}".format(name=name, field=field) + ) from exc def convert_formfield(self, name, field, json_schema, forms_seen, path): widget = field.widget path = path + [name] target_def = { - 'title': field.label.text, - 'description': field.description, + "title": field.label.text, + "description": field.description, } if field.flags.required: - target_def['required'] = True - json_schema.setdefault('required', list()) - json_schema['required'].append(name) - ftype = type(field).__name__ - if hasattr(self, 'convert_%s' % ftype): - return getattr(self, 'convert_%s' % ftype)(name, field, json_schema) - params = self.conversions.get(ftype) - if params is not None: - target_def.update(params) - elif ftype == 'FormField': + target_def["required"] = True + json_schema.setdefault("required", []) + json_schema["required"].append(name) + for validator in field.validators: + if isinstance(validator, Length): + if validator.min != -1: + target_def["minLength"] = validator.min + if validator.max != -1: + target_def["maxLength"] = validator.max + if hasattr(self, "convert_%s" % field.__class__.__name__): + func = getattr(self, "convert_%s" % field.__class__.__name__) + return func(name, field, json_schema) + + params = self._find_conversion(field, name) + + form = params.pop("form", None) + target_def.update(params) + + if isinstance(field, wtforms.fields.FormField): key = id(field.form_class) if key in forms_seen: - return {"$ref": "#"+"/".join(forms_seen[key])} + return {"$ref": "#" + "/".join(forms_seen[key])} forms_seen[key] = path - target_def.update(self.convert_form(field.form_class(obj=getattr(field, '_obj', None)), None, forms_seen, path)) - elif ftype == 'FieldList': + target_def.update( + self.convert_form( + field.form_class(obj=getattr(field, "_obj", None)), + None, + forms_seen, + path, + ) + ) + elif isinstance(field, wtforms.fields.FieldList): if not self.include_array_title: - target_def.pop('title') - target_def.pop('description') - target_def['type'] = 'array' - subfield = field.unbound_field.bind(getattr(field, '_obj', None), name) - target_def['items'] = self.convert_formfield(name, subfield, json_schema, forms_seen, path) + target_def.pop("title") + target_def.pop("description") + target_def["type"] = "array" + subfield = field.unbound_field.bind(getattr(field, "_obj", None), name) + target_def["items"] = self.convert_formfield( + name, subfield, json_schema, forms_seen, path + ) if not self.include_array_item_titles: - target_def['items'].pop('title', None) - target_def['items'].pop('description', None) - elif hasattr(widget, 'input_type'): - it = self.INPUT_TYPE_MAP.get(widget.input_type, 'StringField') - if hasattr(self, 'convert_%s' % it): - return getattr(self, 'convert_%s' % it)(name, field, json_schema) + target_def["items"].pop("title", None) + target_def["items"].pop("description", None) + elif hasattr(widget, "input_type"): + it = self.INPUT_TYPE_MAP.get(widget.input_type, wtforms.fields.StringField) + if hasattr(self, "convert_%s" % it): + return getattr(self, "convert_%s" % it)(name, field, json_schema) target_def.update(self.conversions[it]) else: - target_def['type'] = 'string' - return target_def + target_def["type"] = "string" + return target_def, form def convert_SelectField(self, name, field, json_schema): values = list() @@ -174,24 +310,30 @@ def convert_SelectField(self, name, field, json_schema): values.append(val) target_def = { - 'title': field.label.text, - 'description': field.description, - 'enum': values, - 'ux-widget-choices': list(field.choices), + "title": field.label.text, + "description": field.description, + "enum": values, + "ux-widget-choices": list(field.choices), } if field.flags.required: - target_def['required'] = True - return target_def + target_def["required"] = True + return target_def, { + "type": "select", + "enum": values, + } def convert_RadioField(self, name, field, json_schema): target_def = { - 'title': field.label.text, - 'description': field.description, - 'enum': [x for x, y in field.choices], - 'ux-widget': 'radio', - 'ux-widget-choices': list(field.choices), + "title": field.label.text, + "description": field.description, + "enum": [x for x, y in field.choices], + "ux-widget": "radio", + "ux-widget-choices": list(field.choices), } if field.flags.required: - target_def['required'] = True + target_def["required"] = True return target_def + +WTFormToJSONSchema.DEFAULT_CONVERSIONS.update(_DEFAULT_CONVERSIONS) +WTFormToJSONSchema.INPUT_TYPE_MAP.update(_INPUT_TYPE_MAP)