├── tests ├── fields │ ├── __init__.py │ ├── test_search_field.py │ ├── test_string_field.py │ ├── test_passive_hidden_field.py │ ├── test_integer_field.py │ ├── test_read_only_fields.py │ ├── test_date_field.py │ ├── test_date_time_field.py │ ├── test_date_time_local_field.py │ ├── test_decimal_field.py │ ├── test_json_field.py │ ├── test_select_multiple_field.py │ ├── test_color_field.py │ ├── test_int_interval_field.py │ ├── test_time_field.py │ ├── test_select_field.py │ └── test_split_date_time_field.py ├── test_chain_validator.py ├── test_widgets.py ├── test_read_only_function.py ├── test_time_range_validator.py ├── test_date_range_validator.py ├── test_if_validator.py ├── __init__.py └── test_email_validator.py ├── docs ├── requirements.in ├── requirements.txt ├── make.bat ├── Makefile ├── index.rst └── conf.py ├── setup.cfg ├── MANIFEST.in ├── wtforms_components ├── fields │ ├── passive_hidden.py │ ├── json_field.py │ ├── color.py │ ├── time.py │ ├── __init__.py │ ├── select_multiple.py │ ├── html5.py │ ├── interval.py │ ├── split_date_time.py │ ├── select.py │ └── ajax.py ├── __init__.py ├── validators.py └── widgets.py ├── .readthedocs.yaml ├── pyproject.toml ├── .gitignore ├── .github └── workflows │ ├── docs.yml │ ├── lint.yml │ └── test.yml ├── tox.ini ├── README.rst ├── LICENSE ├── setup.py └── CHANGES.rst /tests/fields/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | furo 2 | sphinx 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE 6 | -------------------------------------------------------------------------------- /tests/fields/test_search_field.py: -------------------------------------------------------------------------------- 1 | from tests import FieldTestCase 2 | from wtforms_components import SearchField 3 | 4 | 5 | class TestSearchField(FieldTestCase): 6 | field_class = SearchField 7 | -------------------------------------------------------------------------------- /tests/fields/test_string_field.py: -------------------------------------------------------------------------------- 1 | from tests import FieldTestCase 2 | from wtforms_components import StringField 3 | 4 | 5 | class TestStringField(FieldTestCase): 6 | field_class = StringField 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst LICENSE README.rst 2 | recursive-include tests * 3 | recursive-exclude tests *.pyc 4 | recursive-include docs * 5 | recursive-exclude docs *.pyc 6 | prune docs/_build 7 | exclude docs/_themes/.git 8 | -------------------------------------------------------------------------------- /wtforms_components/fields/passive_hidden.py: -------------------------------------------------------------------------------- 1 | from wtforms.fields import HiddenField 2 | 3 | 4 | class PassiveHiddenField(HiddenField): 5 | """ 6 | HiddenField that does not populate obj values. 7 | """ 8 | 9 | def populate_obj(self, obj, name): 10 | pass 11 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-24.04 5 | tools: 6 | python: "3.12" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | fail_on_warning: true 11 | 12 | python: 13 | install: 14 | - method: pip 15 | path: . 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | target-version = "py39" 3 | 4 | [tool.ruff.format] 5 | docstring-code-format = true 6 | 7 | [tool.ruff.lint] 8 | select = [ 9 | "C90", # mccabe 10 | "E", # pycodestyle errors 11 | "F", # Pyflakes 12 | "I", # isort 13 | "UP", # pyupgrade 14 | "W", # pycodestyle warnings 15 | ] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | 23 | #Translations 24 | *.mo 25 | 26 | #Mr Developer 27 | .mr.developer.cfg 28 | 29 | # Built docs 30 | docs/_build/ 31 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | docs: 9 | name: Docs 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.12" 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install --upgrade tox 22 | 23 | - name: Build documentation 24 | run: tox -e docs 25 | -------------------------------------------------------------------------------- /tests/fields/test_passive_hidden_field.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms_test import FormTestCase 3 | 4 | from tests import MultiDict 5 | from wtforms_components import PassiveHiddenField 6 | 7 | 8 | class TestPassiveHiddenField(FormTestCase): 9 | def test_does_not_populate_obj_values(self): 10 | class MyForm(Form): 11 | id = PassiveHiddenField() 12 | 13 | class A: 14 | id = None 15 | 16 | form = MyForm(MultiDict({"id": 12})) 17 | a = A() 18 | form.populate_obj(a) 19 | assert a.id is None 20 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | name: Lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: 3.12 17 | 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install .[test] 22 | 23 | - name: Run linting 24 | run: | 25 | ruff check . 26 | ruff format --check 27 | -------------------------------------------------------------------------------- /tests/test_chain_validator.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms.fields import StringField 3 | from wtforms.validators import DataRequired, Email 4 | from wtforms_test import FormTestCase 5 | 6 | from tests import MultiDict 7 | from wtforms_components import Chain 8 | 9 | 10 | class TestChainValidator(FormTestCase): 11 | def test_validates_whole_chain(self): 12 | class MyForm(Form): 13 | email = StringField(validators=[Chain([DataRequired(), Email()])]) 14 | 15 | form = MyForm(MultiDict({"name": ""})) 16 | form.validate() 17 | assert "email" in form.errors 18 | -------------------------------------------------------------------------------- /tests/fields/test_integer_field.py: -------------------------------------------------------------------------------- 1 | from wtforms.validators import NumberRange 2 | 3 | from tests import FieldTestCase, MultiDict 4 | from wtforms_components import IntegerField 5 | 6 | 7 | class TestIntegerField(FieldTestCase): 8 | field_class = IntegerField 9 | 10 | def test_assigns_min_and_max(self): 11 | form_class = self.init_form(validators=[NumberRange(min=2, max=10)]) 12 | form = form_class(MultiDict(test_field=3)) 13 | assert str(form.test_field) == ( 14 | '' 16 | ) 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = 8 | {py39,py310,py311,py312}-{wtforms31,wtforms32} 9 | docs 10 | 11 | [testenv] 12 | commands = 13 | pytest {posargs} 14 | deps = 15 | .[test] 16 | wtforms31: WTForms>=3.1,<3.2 17 | wtforms32: WTForms>=3.2,<3.3 18 | 19 | [testenv:docs] 20 | basepython = py312 21 | deps = -r docs/requirements.txt 22 | commands = sphinx-build docs docs/_build --fail-on-warning 23 | -------------------------------------------------------------------------------- /tests/fields/test_read_only_fields.py: -------------------------------------------------------------------------------- 1 | from wtforms.fields import BooleanField 2 | 3 | from tests import MultiDict, SimpleFieldTestCase 4 | from wtforms_components import read_only 5 | 6 | 7 | class TestReadOnlyCheckboxField(SimpleFieldTestCase): 8 | field_class = BooleanField 9 | 10 | def test_has_readonly_and_disabled_attributes_in_html(self): 11 | form_class = self.init_form() 12 | form = form_class(MultiDict(test_field="y")) 13 | read_only(form.test_field) 14 | assert ( 15 | '' 17 | ) in str(form.test_field) 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | WTForms-Components 2 | ================== 3 | 4 | |Version Status| |Downloads| 5 | 6 | Additional fields, validators and widgets for WTForms. 7 | 8 | 9 | Resources 10 | --------- 11 | 12 | - `Documentation `_ 13 | - `Issue Tracker `_ 14 | - `Code `_ 15 | 16 | .. |Version Status| image:: https://img.shields.io/pypi/v/WTForms-Components.svg 17 | :target: https://crate.io/packages/WTForms-Components/ 18 | .. |Downloads| image:: https://img.shields.io/pypi/dm/WTForms-Components.svg 19 | :target: https://crate.io/packages/WTForms-Components/ 20 | -------------------------------------------------------------------------------- /tests/fields/test_date_field.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from tests import FieldTestCase, MultiDict 4 | from wtforms_components import DateField, DateRange 5 | 6 | 7 | class TestDateField(FieldTestCase): 8 | field_class = DateField 9 | 10 | def test_assigns_min_and_max(self): 11 | form_class = self.init_form( 12 | validators=[DateRange(min=datetime(2000, 1, 1), max=datetime(2000, 10, 10))] 13 | ) 14 | form = form_class(MultiDict(test_field="2000-2-2")) 15 | assert str(form.test_field) == ( 16 | '' 18 | ) 19 | -------------------------------------------------------------------------------- /tests/fields/test_date_time_field.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from tests import FieldTestCase, MultiDict 4 | from wtforms_components import DateRange, DateTimeField 5 | 6 | 7 | class TestDateTimeField(FieldTestCase): 8 | field_class = DateTimeField 9 | 10 | def test_assigns_min_and_max(self): 11 | form_class = self.init_form( 12 | validators=[DateRange(min=datetime(2000, 1, 1), max=datetime(2000, 10, 10))] 13 | ) 14 | form = form_class(MultiDict(test_field="2000-2-2")) 15 | assert str(form.test_field) == ( 16 | '' 19 | ) 20 | -------------------------------------------------------------------------------- /tests/fields/test_date_time_local_field.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from tests import FieldTestCase, MultiDict 4 | from wtforms_components import DateRange, DateTimeLocalField 5 | 6 | 7 | class TestDateTimeLocalField(FieldTestCase): 8 | field_class = DateTimeLocalField 9 | 10 | def test_assigns_min_and_max(self): 11 | form_class = self.init_form( 12 | validators=[DateRange(min=datetime(2000, 1, 1), max=datetime(2000, 10, 10))] 13 | ) 14 | form = form_class(MultiDict(test_field="2000-2-2")) 15 | assert str(form.test_field) == ( 16 | '' 19 | ) 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | name: Python ${{ matrix.python }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - python: "3.12" 16 | - python: "3.11" 17 | - python: "3.10" 18 | - python: "3.9" 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install --upgrade tox 31 | 32 | - name: Run tests 33 | env: 34 | TOXENV: py-wtforms31, py-wtforms32 35 | run: tox 36 | -------------------------------------------------------------------------------- /wtforms_components/fields/json_field.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from wtforms import fields, widgets 4 | 5 | 6 | class JSONField(fields.StringField): 7 | """ 8 | A text field which stores a `json`. 9 | """ 10 | 11 | widget = widgets.TextArea() 12 | 13 | def _value(self): 14 | return json.dumps(self.data) if self.data else "" 15 | 16 | def process_formdata(self, valuelist): 17 | if valuelist: 18 | try: 19 | self.data = json.loads(valuelist[0]) 20 | except ValueError: 21 | self.data = None 22 | raise ValueError("This field contains invalid JSON") 23 | else: 24 | self.data = None 25 | 26 | def pre_validate(self, form): 27 | if self.data: 28 | try: 29 | json.dumps(self.data) 30 | except TypeError: 31 | self.data = None 32 | raise ValueError("This field contains invalid JSON") 33 | -------------------------------------------------------------------------------- /tests/fields/test_decimal_field.py: -------------------------------------------------------------------------------- 1 | from wtforms.validators import NumberRange 2 | 3 | from tests import FieldTestCase, MultiDict 4 | from wtforms_components import DecimalField 5 | from wtforms_components.widgets import NumberInput 6 | 7 | 8 | class TestDecimalField(FieldTestCase): 9 | field_class = DecimalField 10 | 11 | def test_assigns_min_and_max(self): 12 | form_class = self.init_form(validators=[NumberRange(min=2, max=10)]) 13 | form = form_class(MultiDict(test_field=3)) 14 | assert str(form.test_field) == ( 15 | '' 17 | ) 18 | 19 | def test_assigns_step(self): 20 | form_class = self.init_form(widget=NumberInput(step="0.1")) 21 | form = form_class(MultiDict(test_field=3)) 22 | assert str(form.test_field) == ( 23 | '' 25 | ) 26 | -------------------------------------------------------------------------------- /tests/fields/test_json_field.py: -------------------------------------------------------------------------------- 1 | from tests import MultiDict, SimpleFieldTestCase 2 | from wtforms_components import JSONField 3 | 4 | 5 | class TestJSONField(SimpleFieldTestCase): 6 | field_class = JSONField 7 | 8 | def setup_method(self, method): 9 | self.valid_jsons = [ 10 | '{"a": {"b": true, "c": "lv", "d": 3}, "e": {"f": {"g": [85]}}}' 11 | ] 12 | self.invalid_jsons = [ 13 | '{"a": {"b": bzz, "c": "lv", "d": 3}, "e": {"f": {"g": [85]}}}' 14 | ] 15 | 16 | def test_valid_times(self): 17 | form_class = self.init_form() 18 | for time_ in self.valid_jsons: 19 | form = form_class(MultiDict(test_field=time_)) 20 | form.validate() 21 | assert len(form.errors) == 0 22 | 23 | def test_invalid_times(self): 24 | form_class = self.init_form() 25 | for time_ in self.invalid_jsons: 26 | form = form_class(MultiDict(test_field=time_)) 27 | form.validate() 28 | assert len(form.errors["test_field"]) == 1 29 | -------------------------------------------------------------------------------- /wtforms_components/fields/color.py: -------------------------------------------------------------------------------- 1 | from ..widgets import ColorInput 2 | from .html5 import StringField 3 | 4 | 5 | class ColorField(StringField): 6 | """ 7 | A string field representing a Color object from python colour package. 8 | 9 | .. _colours: 10 | https://github.com/vaab/colour 11 | 12 | Represents an ````. 13 | """ 14 | 15 | widget = ColorInput() 16 | 17 | error_msg = "Not a valid color." 18 | 19 | def _value(self): 20 | if self.raw_data: 21 | return self.raw_data[0] 22 | if self.data: 23 | return str(self.data) 24 | else: 25 | return "" 26 | 27 | def process_formdata(self, valuelist): 28 | from colour import Color 29 | 30 | if valuelist: 31 | if valuelist[0] == "" or valuelist[0] == "": 32 | self.data = None 33 | else: 34 | try: 35 | self.data = Color(valuelist[0]) 36 | except AttributeError: 37 | self.data = None 38 | raise ValueError(self.gettext(self.error_msg)) 39 | -------------------------------------------------------------------------------- /wtforms_components/fields/time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import time 3 | 4 | from wtforms.fields import Field 5 | 6 | from ..widgets import TimeInput 7 | 8 | 9 | class TimeField(Field): 10 | """ 11 | A text field which stores a `datetime.time` matching a format. 12 | """ 13 | 14 | widget = TimeInput() 15 | error_msg = "Not a valid time." 16 | 17 | def __init__(self, label=None, validators=None, format="%H:%M", **kwargs): 18 | super().__init__(label, validators, **kwargs) 19 | self.format = format 20 | 21 | def _value(self): 22 | if self.raw_data: 23 | return " ".join(self.raw_data) 24 | elif self.data is not None: 25 | return self.data.strftime(self.format) 26 | else: 27 | return "" 28 | 29 | def process_formdata(self, valuelist): 30 | if valuelist: 31 | time_str = " ".join(valuelist) 32 | try: 33 | self.data = datetime.time(*time.strptime(time_str, self.format)[3:6]) 34 | except ValueError: 35 | self.data = None 36 | raise ValueError(self.gettext(self.error_msg)) 37 | -------------------------------------------------------------------------------- /tests/test_widgets.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms.fields import SelectField 3 | from wtforms_test import FormTestCase 4 | 5 | from wtforms_components import SelectWidget 6 | 7 | 8 | class Dummy: 9 | fruits = None 10 | 11 | 12 | class TestSelectWidgetWithNativeSelect(FormTestCase): 13 | choices = ( 14 | ("apple", "Apple"), 15 | ("peach", "Peach"), 16 | ("pear", "Pear"), 17 | ("cucumber", "Cucumber"), 18 | ("potato", "Potato"), 19 | ("tomato", "Tomato"), 20 | ) 21 | 22 | def init_form(self, **kwargs): 23 | class TestForm(Form): 24 | fruit = SelectField(widget=SelectWidget(), **kwargs) 25 | 26 | self.form_class = TestForm 27 | return self.form_class 28 | 29 | def test_option_selected(self): 30 | form_class = self.init_form(choices=self.choices) 31 | 32 | obj = Dummy() 33 | obj.fruit = "peach" 34 | form = form_class(obj=obj) 35 | assert '' in str(form.fruit) 36 | 37 | def test_default_value(self): 38 | form_class = self.init_form(choices=self.choices, default="pear") 39 | form = form_class() 40 | assert '' in str(form.fruit) 41 | -------------------------------------------------------------------------------- /tests/test_read_only_function.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms.fields import StringField 3 | from wtforms_test import FormTestCase 4 | 5 | from tests import MultiDict 6 | from wtforms_components import read_only 7 | 8 | 9 | class TestReadOnlyFunction(FormTestCase): 10 | def test_prevents_value_changing(self): 11 | class MyForm(Form): 12 | name = StringField(default="") 13 | 14 | form = MyForm() 15 | read_only(form.name) 16 | form.process(MultiDict({"name": "New value"})) 17 | assert form.name.data == "" 18 | 19 | def test_preserves_previous_value(self): 20 | class MyForm(Form): 21 | name = StringField() 22 | 23 | form = MyForm() 24 | form.name.data = "Previous value" 25 | read_only(form.name) 26 | form.process(MultiDict({"name": "New value"})) 27 | assert form.name.data == "Previous value" 28 | 29 | def test_prevents_value_population(self): 30 | class MyForm(Form): 31 | name = StringField() 32 | 33 | class MyModel: 34 | pass 35 | 36 | form = MyForm() 37 | model = MyModel() 38 | form.name.data = "Existing value" 39 | read_only(form.name) 40 | form.populate_obj(model) 41 | assert not hasattr(model, "name") 42 | -------------------------------------------------------------------------------- /wtforms_components/fields/__init__.py: -------------------------------------------------------------------------------- 1 | from .ajax import AjaxField 2 | from .color import ColorField 3 | from .html5 import ( 4 | DateField, 5 | DateTimeField, 6 | DateTimeLocalField, 7 | DecimalField, 8 | DecimalSliderField, 9 | EmailField, 10 | IntegerField, 11 | IntegerSliderField, 12 | SearchField, 13 | StringField, 14 | ) 15 | from .interval import ( 16 | DateIntervalField, 17 | DateTimeIntervalField, 18 | DecimalIntervalField, 19 | FloatIntervalField, 20 | IntIntervalField, 21 | ) 22 | from .json_field import JSONField 23 | from .passive_hidden import PassiveHiddenField 24 | from .select import SelectField 25 | from .select_multiple import SelectMultipleField 26 | from .split_date_time import SplitDateTimeField 27 | from .time import TimeField 28 | 29 | __all__ = ( 30 | AjaxField, 31 | ColorField, 32 | DateField, 33 | DateIntervalField, 34 | DateTimeField, 35 | DateTimeIntervalField, 36 | DateTimeLocalField, 37 | DecimalField, 38 | DecimalIntervalField, 39 | DecimalSliderField, 40 | EmailField, 41 | FloatIntervalField, 42 | IntegerField, 43 | IntegerSliderField, 44 | IntIntervalField, 45 | JSONField, 46 | PassiveHiddenField, 47 | SearchField, 48 | SelectField, 49 | SelectMultipleField, 50 | SplitDateTimeField, 51 | StringField, 52 | TimeField, 53 | ) 54 | -------------------------------------------------------------------------------- /tests/test_time_range_validator.py: -------------------------------------------------------------------------------- 1 | from datetime import time 2 | 3 | from wtforms import Form 4 | from wtforms_test import FormTestCase 5 | 6 | from tests import MultiDict 7 | from wtforms_components import TimeField, TimeRange 8 | 9 | 10 | class TestTimeRangeValidator(FormTestCase): 11 | def init_form(self, **kwargs): 12 | class ModelTestForm(Form): 13 | time = TimeField(validators=[TimeRange(**kwargs)]) 14 | 15 | self.form_class = ModelTestForm 16 | return self.form_class 17 | 18 | def test_time_greater_than_validator(self): 19 | form_class = self.init_form(min=time(12)) 20 | form = form_class(MultiDict(time="11:12")) 21 | form.validate() 22 | error_msg = "Time must be greater than 12:00." 23 | assert form.errors["time"] == [error_msg] 24 | 25 | def test_time_less_than_validator(self): 26 | form_class = self.init_form(max=time(13, 30)) 27 | form = form_class(MultiDict(time="13:40")) 28 | form.validate() 29 | error_msg = "Time must be less than 13:30." 30 | assert form.errors["time"] == [error_msg] 31 | 32 | def test_time_between_validator(self): 33 | form_class = self.init_form(min=time(12), max=time(13)) 34 | form = form_class(MultiDict(time="14:30")) 35 | form.validate() 36 | error_msg = "Time must be between 12:00 and 13:00." 37 | assert form.errors["time"] == [error_msg] 38 | -------------------------------------------------------------------------------- /tests/fields/test_select_multiple_field.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms_test import FormTestCase 3 | 4 | from tests import MultiDict 5 | from wtforms_components import SelectMultipleField 6 | 7 | 8 | class Dummy: 9 | fruits = [] 10 | 11 | 12 | class TestSelectMultipleField(FormTestCase): 13 | choices = ( 14 | ("Fruits", (("apple", "Apple"), ("peach", "Peach"), ("pear", "Pear"))), 15 | ( 16 | "Vegetables", 17 | ( 18 | ("cucumber", "Cucumber"), 19 | ("potato", "Potato"), 20 | ("tomato", "Tomato"), 21 | ), 22 | ), 23 | ) 24 | 25 | def init_form(self, **kwargs): 26 | class TestForm(Form): 27 | fruits = SelectMultipleField(**kwargs) 28 | 29 | self.form_class = TestForm 30 | return self.form_class 31 | 32 | def test_understands_nested_choices(self): 33 | form_class = self.init_form(choices=self.choices) 34 | form = form_class(MultiDict([("fruits", "apple"), ("fruits", "invalid")])) 35 | form.validate() 36 | 37 | assert form.errors == { 38 | "fruits": ["'invalid' is not a valid choice for this field"] 39 | } 40 | 41 | def test_option_selected(self): 42 | form_class = self.init_form(choices=self.choices) 43 | 44 | obj = Dummy() 45 | obj.fruits = ["peach"] 46 | form = form_class(obj=obj) 47 | assert '' in str(form.fruits) 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Konsta Vesterinen, Janne Vanhala 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * The names of the contributors may not be used to endorse or promote products 16 | derived from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, 22 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 23 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE 26 | OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 27 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /wtforms_components/fields/select_multiple.py: -------------------------------------------------------------------------------- 1 | from wtforms.validators import ValidationError 2 | 3 | from ..widgets import SelectWidget 4 | from .select import SelectField 5 | 6 | 7 | class SelectMultipleField(SelectField): 8 | """ 9 | No different from a normal select field, except this one can take (and 10 | validate) multiple choices. You'll need to specify the HTML `rows` 11 | attribute to the select field when rendering. 12 | """ 13 | 14 | widget = SelectWidget(multiple=True) 15 | 16 | def process_data(self, value): 17 | try: 18 | self.data = list(self.coerce(v) for v in value) 19 | except (ValueError, TypeError): 20 | self.data = None 21 | 22 | def process_formdata(self, valuelist): 23 | try: 24 | self.data = list(self.coerce(x) for x in valuelist) 25 | except ValueError: 26 | raise ValueError( 27 | self.gettext( 28 | "Invalid choice(s): one or more data inputs " "could not be coerced" 29 | ) 30 | ) 31 | 32 | def pre_validate(self, form): 33 | if self.data: 34 | values = self.choice_values 35 | for value in self.data: 36 | if value not in values: 37 | raise ValidationError( 38 | self.gettext( 39 | "'%(value)s' is not a valid" " choice for this field" 40 | ) 41 | % dict(value=value) 42 | ) 43 | -------------------------------------------------------------------------------- /tests/test_date_range_validator.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from wtforms import Form 4 | from wtforms.fields import DateField 5 | from wtforms_test import FormTestCase 6 | 7 | from tests import MultiDict 8 | from wtforms_components import DateRange 9 | 10 | 11 | class TestDateRangeValidator(FormTestCase): 12 | def init_form(self, **kwargs): 13 | class ModelTestForm(Form): 14 | date = DateField(validators=[DateRange(**kwargs)]) 15 | 16 | self.form_class = ModelTestForm 17 | return self.form_class 18 | 19 | def test_date_greater_than_validator(self): 20 | form_class = self.init_form(min=date(1990, 1, 1)) 21 | form = form_class(MultiDict(date="1980-1-1")) 22 | form.validate() 23 | error_msg = "Date must be equal to or later than 1990-01-01." 24 | assert form.errors["date"] == [error_msg] 25 | 26 | def test_date_less_than_validator(self): 27 | form_class = self.init_form(max=date(1990, 1, 1)) 28 | form = form_class(MultiDict(date="1991-1-1")) 29 | form.validate() 30 | error_msg = "Date must be equal to or earlier than 1990-01-01." 31 | assert form.errors["date"] == [error_msg] 32 | 33 | def test_date_between_validator(self): 34 | form_class = self.init_form(min=date(1990, 1, 1), max=date(1991, 1, 1)) 35 | form = form_class(MultiDict(date="1989-1-1")) 36 | form.validate() 37 | error_msg = "Date must be between 1990-01-01 and 1991-01-01." 38 | assert form.errors["date"] == [error_msg] 39 | -------------------------------------------------------------------------------- /wtforms_components/fields/html5.py: -------------------------------------------------------------------------------- 1 | from wtforms.fields import ( 2 | DateField, 3 | DateTimeField, 4 | DecimalField, 5 | DecimalRangeField, 6 | IntegerField, 7 | IntegerRangeField, 8 | SearchField, 9 | ) 10 | from wtforms.fields import StringField as _StringField 11 | 12 | from ..widgets import ( 13 | DateInput, 14 | DateTimeInput, 15 | DateTimeLocalInput, 16 | EmailInput, 17 | NumberInput, 18 | RangeInput, 19 | SearchInput, 20 | TextInput, 21 | ) 22 | 23 | 24 | class EmailField(_StringField): 25 | widget = EmailInput() 26 | 27 | 28 | class IntegerField(IntegerField): 29 | widget = NumberInput(step="1") 30 | 31 | 32 | class DecimalField(DecimalField): 33 | widget = NumberInput(step="any") 34 | 35 | 36 | class DateTimeLocalField(DateTimeField): 37 | def __init__( 38 | self, label=None, validators=None, format="%Y-%m-%dT%H:%M:%S", **kwargs 39 | ): 40 | super().__init__(label, validators, format, **kwargs) 41 | 42 | widget = DateTimeLocalInput() 43 | 44 | 45 | class DateTimeField(DateTimeField): 46 | widget = DateTimeInput() 47 | 48 | 49 | class DateField(DateField): 50 | widget = DateInput() 51 | 52 | 53 | class IntegerSliderField(IntegerRangeField): 54 | widget = RangeInput(step="1") 55 | 56 | 57 | class DecimalSliderField(DecimalRangeField): 58 | widget = RangeInput(step="any") 59 | 60 | 61 | class SearchField(SearchField): 62 | widget = SearchInput() 63 | 64 | 65 | class StringField(_StringField): 66 | widget = TextInput() 67 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.9 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | alabaster==0.7.16 8 | # via sphinx 9 | babel==2.16.0 10 | # via sphinx 11 | beautifulsoup4==4.12.3 12 | # via furo 13 | certifi==2024.8.30 14 | # via requests 15 | charset-normalizer==3.4.0 16 | # via requests 17 | docutils==0.21.2 18 | # via sphinx 19 | furo==2024.8.6 20 | # via -r requirements.in 21 | idna==3.10 22 | # via requests 23 | imagesize==1.4.1 24 | # via sphinx 25 | importlib-metadata==8.5.0 26 | # via sphinx 27 | jinja2==3.1.6 28 | # via sphinx 29 | markupsafe==3.0.2 30 | # via jinja2 31 | packaging==24.2 32 | # via sphinx 33 | pygments==2.18.0 34 | # via 35 | # furo 36 | # sphinx 37 | requests==2.32.4 38 | # via sphinx 39 | snowballstemmer==2.2.0 40 | # via sphinx 41 | soupsieve==2.6 42 | # via beautifulsoup4 43 | sphinx==7.4.7 44 | # via 45 | # -r requirements.in 46 | # furo 47 | # sphinx-basic-ng 48 | sphinx-basic-ng==1.0.0b2 49 | # via furo 50 | sphinxcontrib-applehelp==2.0.0 51 | # via sphinx 52 | sphinxcontrib-devhelp==2.0.0 53 | # via sphinx 54 | sphinxcontrib-htmlhelp==2.1.0 55 | # via sphinx 56 | sphinxcontrib-jsmath==1.0.1 57 | # via sphinx 58 | sphinxcontrib-qthelp==2.0.0 59 | # via sphinx 60 | sphinxcontrib-serializinghtml==2.0.0 61 | # via sphinx 62 | tomli==2.1.0 63 | # via sphinx 64 | urllib3==2.6.0 65 | # via requests 66 | zipp==3.21.0 67 | # via importlib-metadata 68 | -------------------------------------------------------------------------------- /tests/fields/test_color_field.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms_test import FormTestCase 3 | 4 | from tests import MultiDict 5 | from wtforms_components import ColorField 6 | 7 | 8 | class TestColorField(FormTestCase): 9 | def setup_method(self, method): 10 | self.valid_colors = [ 11 | "#222222", 12 | "cyan", 13 | ] 14 | self.invalid_colors = [ 15 | "abc", 16 | "#123123123", 17 | ] 18 | 19 | def init_form(self, **kwargs): 20 | class TestForm(Form): 21 | color = ColorField(**kwargs) 22 | 23 | self.form_class = TestForm 24 | return self.form_class 25 | 26 | def test_valid_colors(self): 27 | form_class = self.init_form() 28 | for color in self.valid_colors: 29 | form = form_class(MultiDict(color=color)) 30 | form.validate() 31 | assert len(form.errors) == 0 32 | 33 | def test_invalid_number_ranges(self): 34 | form_class = self.init_form() 35 | for color in self.invalid_colors: 36 | form = form_class(MultiDict(color=color)) 37 | form.validate() 38 | assert len(form.errors["color"]) == 1 39 | 40 | def test_field_rendering_when_validation_fails(self): 41 | form_class = self.init_form() 42 | form = form_class(MultiDict(color="invalid")) 43 | form.validate() 44 | assert 'value="invalid"' in str(form.color) 45 | 46 | def test_converts_empty_strings_to_none(self): 47 | form_class = self.init_form() 48 | form = form_class(MultiDict(color="")) 49 | assert form.data == {"color": None} 50 | -------------------------------------------------------------------------------- /tests/test_if_validator.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms.fields import StringField 3 | from wtforms.validators import DataRequired 4 | from wtforms_test import FormTestCase 5 | 6 | from tests import MultiDict 7 | from wtforms_components import If 8 | 9 | 10 | class TestIfValidator(FormTestCase): 11 | def test_only_validates_if_condition_returns_true(self): 12 | class MyForm(Form): 13 | name = StringField( 14 | validators=[ 15 | If( 16 | lambda form, field: False, 17 | DataRequired(), 18 | ) 19 | ] 20 | ) 21 | 22 | form = MyForm(MultiDict({"name": ""})) 23 | form.validate() 24 | assert not form.errors 25 | 26 | def test_encapsulates_given_validator(self): 27 | class MyForm(Form): 28 | name = StringField( 29 | validators=[ 30 | If( 31 | lambda form, field: True, 32 | DataRequired(), 33 | ) 34 | ] 35 | ) 36 | 37 | form = MyForm(MultiDict({"name": ""})) 38 | form.validate() 39 | assert "name" in form.errors 40 | 41 | def test_supports_custom_error_messages(self): 42 | class MyForm(Form): 43 | name = StringField( 44 | validators=[ 45 | If( 46 | lambda form, field: True, 47 | DataRequired(), 48 | message="Validation failed.", 49 | ) 50 | ] 51 | ) 52 | 53 | form = MyForm(MultiDict({"name": ""})) 54 | form.validate() 55 | assert form.errors["name"] == ["Validation failed."] 56 | -------------------------------------------------------------------------------- /wtforms_components/fields/interval.py: -------------------------------------------------------------------------------- 1 | from intervals import ( 2 | DateInterval, 3 | DateTimeInterval, 4 | DecimalInterval, 5 | FloatInterval, 6 | IntervalException, 7 | IntInterval, 8 | ) 9 | 10 | from .html5 import StringField 11 | 12 | 13 | class IntervalField(StringField): 14 | """ 15 | A string field representing an interval object from 16 | `intervals`_. 17 | 18 | .. _intervals: 19 | https://github.com/kvesteri/intervals 20 | """ 21 | 22 | def _value(self): 23 | if self.raw_data: 24 | return self.raw_data[0] 25 | if self.data: 26 | return self.data.hyphenized 27 | else: 28 | return "" 29 | 30 | def process_formdata(self, valuelist): 31 | if valuelist: 32 | if valuelist[0] == "" or valuelist[0] == "": 33 | self.data = None 34 | else: 35 | try: 36 | self.data = self.interval_class.from_string(valuelist[0]) 37 | except IntervalException: 38 | self.data = None 39 | raise ValueError(self.gettext(self.error_msg)) 40 | 41 | 42 | class DecimalIntervalField(IntervalField): 43 | error_msg = "Not a valid decimal range value" 44 | interval_class = DecimalInterval 45 | 46 | 47 | class FloatIntervalField(IntervalField): 48 | error_msg = "Not a valid float range value" 49 | interval_class = FloatInterval 50 | 51 | 52 | class IntIntervalField(IntervalField): 53 | error_msg = "Not a valid int range value" 54 | interval_class = IntInterval 55 | 56 | 57 | class DateIntervalField(IntervalField): 58 | error_msg = "Not a valid date range value" 59 | interval_class = DateInterval 60 | 61 | 62 | class DateTimeIntervalField(IntervalField): 63 | error_msg = "Not a valid datetime range value" 64 | interval_class = DateTimeInterval 65 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms.validators import DataRequired 3 | from wtforms_test import FormTestCase 4 | 5 | 6 | class MultiDict(dict): 7 | def getlist(self, key): 8 | return [self[key]] 9 | 10 | 11 | class SimpleFieldTestCase(FormTestCase): 12 | field_class = None 13 | 14 | def init_form(self, **kwargs): 15 | class TestForm(Form): 16 | test_field = self.field_class(**kwargs) 17 | 18 | self.form_class = TestForm 19 | return self.form_class 20 | 21 | 22 | class FieldTestCase(SimpleFieldTestCase): 23 | def test_assigns_required_from_validator(self): 24 | form_class = self.init_form(validators=[DataRequired()]) 25 | form = form_class() 26 | assert ('' 40 | ) 41 | 42 | def test_renders_input_time_at_midnight(self): 43 | form_class = self.init_form() 44 | form = form_class(MultiDict(test_field="00:00")) 45 | assert str(form.test_field) == ( 46 | '' 47 | ) 48 | 49 | 50 | class TestTimeFieldWithSeconds(TestTimeField): 51 | def setup_method(self, method): 52 | self.valid_times = ["00:00:00", "11:11:11", "12:15:17"] 53 | self.invalid_times = [ 54 | "00:61", 55 | "01:01:61", 56 | "unknown", 57 | ] 58 | 59 | def init_form(self, **kwargs): 60 | kwargs["format"] = "%H:%M:%S" 61 | return super().init_form(**kwargs) 62 | -------------------------------------------------------------------------------- /wtforms_components/fields/split_date_time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from wtforms import Form 4 | from wtforms.fields import FormField 5 | from wtforms.utils import unset_value 6 | 7 | from .html5 import DateField 8 | from .time import TimeField 9 | 10 | 11 | class Date: 12 | date = None 13 | time = None 14 | 15 | 16 | class SplitDateTimeField(FormField): 17 | def __init__(self, label=None, validators=None, separator="-", **kwargs): 18 | FormField.__init__( 19 | self, 20 | datetime_form(kwargs.pop("datetime_form", {})), 21 | label=label, 22 | validators=validators, 23 | separator=separator, 24 | **kwargs, 25 | ) 26 | 27 | def process(self, formdata, data=unset_value, extra_filters=None): 28 | if data is unset_value: 29 | try: 30 | data = self.default() 31 | except TypeError: 32 | data = self.default 33 | if data: 34 | obj = Date() 35 | obj.date = data.date() 36 | obj.time = data.time() 37 | else: 38 | obj = None 39 | 40 | kwargs = dict() 41 | if extra_filters is not None: 42 | # do not enforce extra_filters=None injection to wtforms<3 43 | kwargs["extra_filters"] = extra_filters 44 | FormField.process(self, formdata, data=obj, **kwargs) 45 | 46 | def populate_obj(self, obj, name): 47 | if hasattr(obj, name): 48 | date = self.date.data 49 | hours, minutes = self.time.data.hour, self.time.data.minute 50 | setattr( 51 | obj, 52 | name, 53 | datetime.datetime(date.year, date.month, date.day, hours, minutes), 54 | ) 55 | 56 | 57 | def datetime_form(options): 58 | options.setdefault("date", {}) 59 | options.setdefault("time", {}) 60 | options["date"].setdefault("label", "Date") 61 | options["time"].setdefault("label", "Time") 62 | base_form = options.get("base_form", Form) 63 | 64 | class DateTimeForm(base_form): 65 | date = DateField(**options["date"]) 66 | time = TimeField(**options["time"]) 67 | 68 | return DateTimeForm 69 | -------------------------------------------------------------------------------- /wtforms_components/fields/select.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | 3 | from wtforms import SelectField as _SelectField 4 | from wtforms.validators import ValidationError 5 | 6 | from ..widgets import SelectWidget 7 | 8 | 9 | class SelectField(_SelectField): 10 | """ 11 | Add support of ``optgroup``'s' to default WTForms' ``SelectField`` class. 12 | 13 | So, next choices would be supported as well:: 14 | 15 | ( 16 | ( 17 | "Fruits", 18 | ( 19 | ("apple", "Apple"), 20 | ("peach", "Peach"), 21 | ("pear", "Pear"), 22 | ), 23 | ), 24 | ( 25 | "Vegetables", 26 | ( 27 | ("cucumber", "Cucumber"), 28 | ("potato", "Potato"), 29 | ("tomato", "Tomato"), 30 | ), 31 | ), 32 | ) 33 | 34 | Also supports lazy choices (callables that return an iterable) 35 | """ 36 | 37 | widget = SelectWidget() 38 | 39 | def __init__(self, *args, **kwargs): 40 | choices = kwargs.pop("choices", None) 41 | if callable(choices): 42 | super().__init__(*args, **kwargs) 43 | self.choices = copy(choices) 44 | else: 45 | super().__init__(*args, choices=choices, **kwargs) 46 | 47 | def iter_choices(self): 48 | """ 49 | We should update how choices are iter to make sure that value from 50 | internal list or tuple should be selected. 51 | """ 52 | for value, label in self.concrete_choices: 53 | yield (value, label, (self.coerce, self.data), {}) 54 | 55 | @property 56 | def concrete_choices(self): 57 | if callable(self.choices): 58 | return self.choices() 59 | return self.choices 60 | 61 | @property 62 | def choice_values(self): 63 | values = [] 64 | for value, label in self.concrete_choices: 65 | if isinstance(label, (list, tuple)): 66 | for subvalue, sublabel in label: 67 | values.append(subvalue) 68 | else: 69 | values.append(value) 70 | return values 71 | 72 | def pre_validate(self, form): 73 | """ 74 | Don't forget to validate also values from embedded lists. 75 | """ 76 | values = self.choice_values 77 | if (self.data is None and "" in values) or self.data in values: 78 | return True 79 | 80 | raise ValidationError(self.gettext("Not a valid choice")) 81 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | WTForms-Components 3 | ------------------ 4 | 5 | Additional fields, validators and widgets for WTForms. 6 | """ 7 | 8 | import os 9 | import re 10 | 11 | from setuptools import setup 12 | 13 | HERE = os.path.dirname(os.path.abspath(__file__)) 14 | 15 | 16 | def get_version(): 17 | filename = os.path.join(HERE, "wtforms_components", "__init__.py") 18 | with open(filename) as f: 19 | contents = f.read() 20 | pattern = r'^__version__ = "(.*?)"$' 21 | return re.search(pattern, contents, re.MULTILINE).group(1) 22 | 23 | 24 | extras_require = { 25 | "test": [ 26 | "pytest>=2.2.3", 27 | "flexmock>=0.9.7", 28 | "ruff==0.7.3", 29 | "WTForms-Test>=0.1.1", 30 | ], 31 | "color": ["colour>=0.0.4"], 32 | "ipaddress": [], 33 | "timezone": ["python-dateutil"], 34 | } 35 | 36 | 37 | # Add all optional dependencies to testing requirements. 38 | for name, requirements in extras_require.items(): 39 | if name != "test": 40 | extras_require["test"] += requirements 41 | 42 | 43 | setup( 44 | name="WTForms-Components", 45 | version=get_version(), 46 | url="https://github.com/kvesteri/wtforms-components", 47 | license="BSD", 48 | author="Konsta Vesterinen", 49 | author_email="konsta@fastmonkeys.com", 50 | description="Additional fields, validators and widgets for WTForms.", 51 | long_description=__doc__, 52 | packages=["wtforms_components", "wtforms_components.fields"], 53 | zip_safe=False, 54 | include_package_data=True, 55 | platforms="any", 56 | install_requires=[ 57 | "WTForms>=3.1.0", 58 | "validators>=0.21", 59 | "intervals>=0.6.0", 60 | "MarkupSafe>=1.0.0", 61 | ], 62 | extras_require=extras_require, 63 | classifiers=[ 64 | "Environment :: Web Environment", 65 | "Intended Audience :: Developers", 66 | "License :: OSI Approved :: BSD License", 67 | "Operating System :: OS Independent", 68 | "Programming Language :: Python", 69 | "Programming Language :: Python :: 3", 70 | "Programming Language :: Python :: 3.9", 71 | "Programming Language :: Python :: 3.10", 72 | "Programming Language :: Python :: 3.11", 73 | "Programming Language :: Python :: 3.12", 74 | "Programming Language :: Python :: 3 :: Only", 75 | "Programming Language :: Python :: Implementation :: CPython", 76 | "Programming Language :: Python :: Implementation :: PyPy", 77 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 78 | "Topic :: Software Development :: Libraries :: Python Modules", 79 | ], 80 | python_requires=">=3.9", 81 | ) 82 | -------------------------------------------------------------------------------- /tests/test_email_validator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from wtforms.validators import ValidationError 3 | 4 | from wtforms_components import Email 5 | 6 | 7 | class DummyTranslations: 8 | def gettext(self, string): 9 | return string 10 | 11 | def ngettext(self, singular, plural, n): 12 | if n == 1: 13 | return singular 14 | 15 | return plural 16 | 17 | 18 | class DummyForm(dict): 19 | pass 20 | 21 | 22 | class DummyField: 23 | _translations = DummyTranslations() 24 | 25 | def __init__(self, data, errors=(), raw_data=None): 26 | self.data = data 27 | self.errors = list(errors) 28 | self.raw_data = raw_data 29 | 30 | def gettext(self, string): 31 | return self._translations.gettext(string) 32 | 33 | def ngettext(self, singular, plural, n): 34 | return self._translations.ngettext(singular, plural, n) 35 | 36 | 37 | class TestEmailValidator: 38 | def setup_method(self, method): 39 | self.form = DummyForm() 40 | 41 | @pytest.mark.parametrize( 42 | "email", 43 | [ 44 | "email@here.com", 45 | "weirder-email@here.and.there.com", 46 | "example@valid-----hyphens.com", 47 | "example@valid-with-hyphens.com", 48 | "test@domain.with.idn.tld.उदाहरण.परीक्षा", 49 | '"\\\011"@here.com', 50 | ], 51 | ) 52 | def test_returns_none_on_valid_email(self, email): 53 | validate_email = Email() 54 | validate_email(self.form, DummyField(email)) 55 | 56 | @pytest.mark.parametrize( 57 | ("email",), 58 | [ 59 | (None,), 60 | ("",), 61 | ("abc",), 62 | ("abc@",), 63 | ("abc@bar",), 64 | ("a @x.cz",), 65 | ("abc@.com",), 66 | ("something@@somewhere.com",), 67 | ("email@127.0.0.1",), 68 | ("example@invalid-.com",), 69 | ("example@-invalid.com",), 70 | ("example@inv-.alid-.com",), 71 | ("example@inv-.-alid.com",), 72 | # Quoted-string format (CR not allowed) 73 | ('"\\\012"@here.com',), 74 | ], 75 | ) 76 | def test_raises_validationerror_on_invalid_email(self, email): 77 | validate_email = Email() 78 | with pytest.raises(ValidationError): 79 | validate_email(self.form, DummyField(email)) 80 | 81 | def test_default_validation_error_message(self): 82 | validate_email = Email() 83 | try: 84 | validate_email(self.form, DummyField("@@@")) 85 | assert False, "No validation error thrown." 86 | except ValidationError as e: 87 | assert str(e) == "Invalid email address." 88 | -------------------------------------------------------------------------------- /wtforms_components/fields/ajax.py: -------------------------------------------------------------------------------- 1 | import operator 2 | 3 | from wtforms import fields, widgets 4 | from wtforms.validators import ValidationError 5 | 6 | anyjson = None 7 | try: 8 | import anyjson 9 | except ImportError: 10 | pass 11 | 12 | 13 | class ImproperlyConfigured(Exception): 14 | pass 15 | 16 | 17 | class AjaxField(fields.Field): 18 | widget = widgets.HiddenInput() 19 | 20 | def __init__( 21 | self, 22 | label=None, 23 | validators=None, 24 | data_url=None, 25 | get_object=None, 26 | get_pk=None, 27 | coerce=int, 28 | get_label=None, 29 | allow_blank=False, 30 | blank_text="", 31 | **kwargs, 32 | ): 33 | super().__init__(label, validators, **kwargs) 34 | 35 | if anyjson is None: 36 | raise ImproperlyConfigured( 37 | "AjaxField requires anyjson extension to be installed." 38 | ) 39 | 40 | if data_url is None: 41 | raise Exception("data_url must be given") 42 | 43 | self.get_pk = get_pk 44 | 45 | if get_label is None: 46 | self.get_label = lambda x: x 47 | elif isinstance(get_label, str): 48 | self.get_label = operator.attrgetter(get_label) 49 | else: 50 | self.get_label = get_label 51 | 52 | self.coerce = coerce 53 | self.data_url = data_url 54 | self.get_object = get_object 55 | self.allow_blank = allow_blank 56 | self.blank_text = blank_text 57 | 58 | @property 59 | def data(self): 60 | if self._formdata is not None: 61 | try: 62 | pk = self.coerce(self._formdata) 63 | except ValueError: 64 | self.data = None 65 | else: 66 | self.data = self.get_object(pk) 67 | return self._data 68 | 69 | @data.setter 70 | def data(self, data): 71 | self._data = data 72 | self._formdata = None 73 | 74 | def process_formdata(self, valuelist): 75 | if valuelist: 76 | if self.allow_blank and not valuelist[0]: 77 | self.data = None 78 | else: 79 | self._data = None 80 | self._formdata = valuelist[0] 81 | 82 | def pre_validate(self, form): 83 | if self.data is None: 84 | if self._formdata or not self.allow_blank: 85 | raise ValidationError("Not a valid choice") 86 | 87 | def __call__(self, **kwargs): 88 | kwargs.setdefault("data-allow-clear", anyjson.serialize(self.allow_blank)) 89 | kwargs.setdefault("data-placeholder", self.blank_text) 90 | kwargs.setdefault("data-url", self.data_url) 91 | if self.data is not None: 92 | kwargs.setdefault("data-initial-label", self.get_label(self.data)) 93 | kwargs.setdefault("value", self.get_pk(self.data)) 94 | else: 95 | kwargs.setdefault("value", "") 96 | return super().__call__(**kwargs) 97 | -------------------------------------------------------------------------------- /tests/fields/test_select_field.py: -------------------------------------------------------------------------------- 1 | from wtforms import Form 2 | from wtforms_test import FormTestCase 3 | 4 | from tests import MultiDict 5 | from wtforms_components import SelectField 6 | 7 | 8 | class Dummy: 9 | fruits = [] 10 | 11 | 12 | class TestSelectField(FormTestCase): 13 | choices = ( 14 | ("Fruits", (("apple", "Apple"), ("peach", "Peach"), ("pear", "Pear"))), 15 | ( 16 | "Vegetables", 17 | ( 18 | ("cucumber", "Cucumber"), 19 | ("potato", "Potato"), 20 | ("tomato", "Tomato"), 21 | ), 22 | ), 23 | ) 24 | 25 | def init_form(self, **kwargs): 26 | class TestForm(Form): 27 | fruit = SelectField(**kwargs) 28 | 29 | self.form_class = TestForm 30 | return self.form_class 31 | 32 | def test_understands_nested_choices(self): 33 | form_class = self.init_form(choices=self.choices) 34 | form = form_class(MultiDict([("fruit", "invalid")])) 35 | form.validate() 36 | 37 | assert len(form.errors["fruit"]) == 1 38 | 39 | def test_understands_mixing_of_choice_types(self): 40 | choices = ( 41 | ("Fruits", (("apple", "Apple"), ("peach", "Peach"), ("pear", "Pear"))), 42 | ("cucumber", "Cucumber"), 43 | ) 44 | 45 | form_class = self.init_form(choices=choices) 46 | form = form_class(MultiDict([("fruit", "cucumber")])) 47 | form.validate() 48 | assert len(form.errors) == 0 49 | 50 | def test_understands_callables_as_choices(self): 51 | form_class = self.init_form(choices=lambda: []) 52 | form = form_class(MultiDict([("fruit", "invalid")])) 53 | form.validate() 54 | 55 | assert len(form.errors["fruit"]) == 1 56 | 57 | def test_option_selected(self): 58 | form_class = self.init_form(choices=self.choices) 59 | 60 | obj = Dummy() 61 | obj.fruit = "peach" 62 | form = form_class(obj=obj) 63 | assert '' in str(form.fruit) 64 | 65 | def test_nested_option_selected_by_field_default_value(self): 66 | form_class = self.init_form(choices=self.choices, default="pear") 67 | form = form_class() 68 | assert '' in str(form.fruit) 69 | 70 | def test_option_selected_by_field_default_value(self): 71 | choices = [("apple", "Apple"), ("peach", "Peach"), ("pear", "Pear")] 72 | form_class = self.init_form(choices=choices, default="pear") 73 | form = form_class() 74 | assert '' in str(form.fruit) 75 | 76 | def test_callable_option_selected_by_field_default_value(self): 77 | def choices(): 78 | return [("apple", "Apple"), ("peach", "Peach"), ("pear", "Pear")] 79 | 80 | form_class = self.init_form(choices=choices, default="pear") 81 | form = form_class() 82 | assert '' in str(form.fruit) 83 | 84 | def test_data_coercion(self): 85 | choices = ( 86 | ("Fruits", ((0, "Apple"), (1, "Peach"), (2, "Pear"))), 87 | (3, "Cucumber"), 88 | ) 89 | 90 | form_class = self.init_form(choices=choices, coerce=int) 91 | form = form_class(MultiDict([("fruit", "1")])) 92 | form.validate() 93 | assert not form.errors 94 | -------------------------------------------------------------------------------- /tests/fields/test_split_date_time_field.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime, time 2 | 3 | from wtforms import Form 4 | from wtforms.validators import DataRequired 5 | 6 | from tests import MultiDict, SimpleFieldTestCase 7 | from wtforms_components.fields import SplitDateTimeField 8 | 9 | 10 | class Obj: 11 | test_field = None 12 | 13 | 14 | class TestSplitDateTimeField(SimpleFieldTestCase): 15 | field_class = SplitDateTimeField 16 | 17 | def test_assigns_required_to_date(self): 18 | form_class = self.init_form( 19 | datetime_form={"date": {"validators": [DataRequired()]}} 20 | ) 21 | form = form_class() 22 | assert str(form.test_field.date) == ( 23 | '' 25 | ) 26 | 27 | def test_renders_date_field(self): 28 | form_class = self.init_form() 29 | form = form_class() 30 | assert str(form.test_field.date) == ( 31 | '' 33 | ) 34 | 35 | def test_assigns_required_to_time(self): 36 | form_class = self.init_form( 37 | datetime_form={"time": {"validators": [DataRequired()]}} 38 | ) 39 | form = form_class() 40 | assert str(form.test_field.time) == ( 41 | '' 43 | ) 44 | 45 | def test_renders_time_field(self): 46 | form_class = self.init_form() 47 | form = form_class() 48 | assert str(form.test_field.time) == ( 49 | '' 51 | ) 52 | 53 | def test_processes_values(self): 54 | form_class = self.init_form() 55 | form = form_class( 56 | MultiDict( 57 | { 58 | "test_field-date": "2000-3-2", 59 | "test_field-time": "19:10", 60 | } 61 | ) 62 | ) 63 | assert form.test_field.data["date"] == date(2000, 3, 2) 64 | assert form.test_field.data["time"] == time(19, 10) 65 | 66 | def test_populates_object(self): 67 | form_class = self.init_form() 68 | form = form_class( 69 | MultiDict( 70 | { 71 | "test_field-date": "2000-3-2", 72 | "test_field-time": "19:10", 73 | } 74 | ) 75 | ) 76 | obj = Obj() 77 | form.populate_obj(obj) 78 | assert obj.test_field == datetime(2000, 3, 2, 19, 10) 79 | 80 | def test_processes_values_when_format_is_set(self): 81 | form_class = self.init_form( 82 | datetime_form={ 83 | "date": {"format": "%d.%m.%Y"}, 84 | "time": {"format": "%H.%M"}, 85 | } 86 | ) 87 | form = form_class( 88 | MultiDict( 89 | { 90 | "test_field-date": "2.3.2000", 91 | "test_field-time": "19.10", 92 | } 93 | ) 94 | ) 95 | assert form.test_field.data["date"] == date(2000, 3, 2) 96 | assert form.test_field.data["time"] == time(19, 10) 97 | 98 | def test_default_base_form(self): 99 | form_class = self.init_form() 100 | form = form_class() 101 | assert form.test_field.form.__class__.__bases__ == (Form,) 102 | 103 | def test_custom_base_form(self): 104 | class A(Form): 105 | pass 106 | 107 | form_class = self.init_form(datetime_form={"base_form": A}) 108 | form = form_class() 109 | assert form.test_field.form.__class__.__bases__ == (A,) 110 | 111 | def test_custom_base_form_with_two_instances(self): 112 | class A(Form): 113 | pass 114 | 115 | form_class = self.init_form(datetime_form={"base_form": A}) 116 | form = form_class() 117 | form2 = form_class() 118 | assert form.test_field.form.__class__.__bases__ == (A,) 119 | assert form2.test_field.form.__class__.__bases__ == (A,) 120 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\WTForms-Components.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\WTForms-Components.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/WTForms-Components.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/WTForms-Components.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/WTForms-Components" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/WTForms-Components" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /wtforms_components/validators.py: -------------------------------------------------------------------------------- 1 | from validators import email 2 | from wtforms import ValidationError 3 | from wtforms.validators import StopValidation 4 | 5 | 6 | class ControlStructure: 7 | """ 8 | Base object for validator control structures 9 | """ 10 | 11 | message = None 12 | 13 | def reraise(self, exc): 14 | if not self.message: 15 | raise exc 16 | else: 17 | raise type(exc)(self.message) 18 | 19 | 20 | class Chain(ControlStructure): 21 | """ 22 | Represents a chain of validators, useful when using multiple validators 23 | with If control structure. 24 | 25 | :param validators: 26 | list of validator objects 27 | :param message: 28 | custom validation error message, if this message is set and some of the 29 | child validators raise a ValidationError, an exception is being raised 30 | again with this custom message. 31 | """ 32 | 33 | def __init__(self, validators, message=None): 34 | self.validators = validators 35 | if message: 36 | self.message = message 37 | 38 | def __call__(self, form, field): 39 | for validator in self.validators: 40 | try: 41 | validator(form, field) 42 | except ValidationError as exc: 43 | self.reraise(exc) 44 | except StopValidation as exc: 45 | self.reraise(exc) 46 | 47 | 48 | class If(ControlStructure): 49 | """ 50 | Conditional validator. 51 | 52 | :param condition: callable which takes two arguments form and field 53 | :param validator: encapsulated validator, this validator is validated 54 | only if given condition returns true 55 | :param message: custom message, which overrides child validator's 56 | validation error message 57 | """ 58 | 59 | def __init__(self, condition, validator, message=None): 60 | self.condition = condition 61 | self.validator = validator 62 | 63 | if message: 64 | self.message = message 65 | 66 | def __call__(self, form, field): 67 | if self.condition(form, field): 68 | try: 69 | self.validator(form, field) 70 | except ValidationError as exc: 71 | self.reraise(exc) 72 | except StopValidation as exc: 73 | self.reraise(exc) 74 | 75 | 76 | class BaseDateTimeRange: 77 | def __init__(self, min=None, max=None, format="%H:%M", message=None): 78 | self.min = min 79 | self.max = max 80 | self.format = format 81 | self.message = message 82 | 83 | def __call__(self, form, field): 84 | data = field.data 85 | min_ = self.min() if callable(self.min) else self.min 86 | max_ = self.max() if callable(self.max) else self.max 87 | if ( 88 | data is None 89 | or (min_ is not None and data < min_) 90 | or (max_ is not None and data > max_) 91 | ): 92 | if self.message is None: 93 | if max_ is None: 94 | self.message = field.gettext(self.greater_than_msg) 95 | elif min_ is None: 96 | self.message = field.gettext(self.less_than_msg) 97 | else: 98 | self.message = field.gettext(self.between_msg) 99 | 100 | raise ValidationError( 101 | self.message 102 | % dict( 103 | field_label=field.label, 104 | min=min_.strftime(self.format) if min_ else "", 105 | max=max_.strftime(self.format) if max_ else "", 106 | ) 107 | ) 108 | 109 | 110 | class TimeRange(BaseDateTimeRange): 111 | """ 112 | Same as wtforms.validators.NumberRange but validates date. 113 | 114 | :param min: 115 | The minimum required value of the time. If not provided, minimum 116 | value will not be checked. 117 | :param max: 118 | The maximum value of the time. If not provided, maximum value 119 | will not be checked. 120 | :param message: 121 | Error message to raise in case of a validation error. Can be 122 | interpolated using `%(min)s` and `%(max)s` if desired. Useful defaults 123 | are provided depending on the existence of min and max. 124 | """ 125 | 126 | greater_than_msg = "Time must be greater than %(min)s." 127 | 128 | less_than_msg = "Time must be less than %(max)s." 129 | 130 | between_msg = "Time must be between %(min)s and %(max)s." 131 | 132 | def __init__(self, min=None, max=None, format="%H:%M", message=None): 133 | super().__init__(min=min, max=max, format=format, message=message) 134 | 135 | 136 | class DateRange(BaseDateTimeRange): 137 | """ 138 | Same as wtforms.validators.NumberRange but validates date. 139 | 140 | :param min: 141 | The minimum required value of the date. If not provided, minimum 142 | value will not be checked. 143 | :param max: 144 | The maximum value of the date. If not provided, maximum value 145 | will not be checked. 146 | :param message: 147 | Error message to raise in case of a validation error. Can be 148 | interpolated using `%(min)s` and `%(max)s` if desired. Useful defaults 149 | are provided depending on the existence of min and max. 150 | """ 151 | 152 | greater_than_msg = "Date must be equal to or later than %(min)s." 153 | 154 | less_than_msg = "Date must be equal to or earlier than %(max)s." 155 | 156 | between_msg = "Date must be between %(min)s and %(max)s." 157 | 158 | def __init__(self, min=None, max=None, format="%Y-%m-%d", message=None): 159 | super().__init__(min=min, max=max, format=format, message=message) 160 | 161 | 162 | class Email: 163 | """ 164 | Validates an email address. 165 | This validator is is stricter than the standard email 166 | validator included in WTForms. 167 | 168 | :param message: 169 | Error message to raise in case of a validation error. 170 | """ 171 | 172 | def __init__(self, message=None): 173 | self.message = message 174 | 175 | def __call__(self, form, field): 176 | if not email(field.data): 177 | message = self.message 178 | if message is None: 179 | message = field.gettext("Invalid email address.") 180 | raise ValidationError(message) 181 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | Here you can see the full list of changes between each WTForms-Components 5 | release. 6 | 7 | 8 | 0.11.0 (2024-11-15) 9 | ^^^^^^^^^^^^^^^^^^^ 10 | 11 | - Drop ``whitelist`` argument from ``email`` validator (#75, pull request courtesy tvuotila) 12 | - Add support for Python 3.9, 3.10, 3.11 and 3.12. 13 | - Drop support for Python 3.8 or older. 14 | - Add support for WTForms 3.2. The minimum supported WTForms version is now 3.1.0. 15 | - Remove ``email_validator`` and ``six`` dependencies. 16 | - Remove ``_compat`` and ``utils`` modules. 17 | - Remove ``try..except`` from email validator import. 18 | 19 | 20 | 0.10.5 (2021-01-17) 21 | ^^^^^^^^^^^^^^^^^^^ 22 | 23 | - Added WTForms 3.0 support (#71, pull request courtesy of jpic) 24 | 25 | 26 | 0.10.4 (2019-04-01) 27 | ^^^^^^^^^^^^^^^^^^^ 28 | 29 | - Added JSONField (#65, pull request courtesy fedExpress) 30 | 31 | 32 | 0.10.3 (2017-03-03) 33 | ^^^^^^^^^^^^^^^^^^^ 34 | 35 | - Made SelectWidget backwards compatible (#52, pull request courtesy tvuotila) 36 | 37 | 38 | 0.10.2 (2016-12-05) 39 | ^^^^^^^^^^^^^^^^^^^ 40 | 41 | - Made read_only also add disabled attribute (#51, pull request courtesy quantus) 42 | 43 | 44 | 0.10.1 (2016-11-22) 45 | ^^^^^^^^^^^^^^^^^^^ 46 | 47 | - Added seconds support for TimeField (#48, pull request courtesy tvuotila) 48 | 49 | 50 | 0.10.0 (2016-01-28) 51 | ^^^^^^^^^^^^^^^^^^^ 52 | 53 | - Moved GroupedQuerySelectField to WTForms-Alchemy 54 | - Moved PhoneNumber to WTForms-Alchemy 55 | - Moved WeekdaysField to WTForms-Alchemy 56 | - Moved Unique validator to WTForms-Alchemy 57 | - Remove AJAXField dependency on SQLAlchemy-Utils 58 | - Added PyPy support 59 | - Fixed IntervalFields to work with intervals 0.6.0 60 | - Updated intervals requirement to 0.6.0 61 | 62 | 63 | 0.9.9 (2016-01-10) 64 | ^^^^^^^^^^^^^^^^^^ 65 | 66 | - Added sane error handling with Unique validator when Form is missing ``_obj`` attribute (#34) 67 | 68 | 69 | 0.9.8 (2015-09-28) 70 | ^^^^^^^^^^^^^^^^^^ 71 | 72 | - Added isort and flake8 checks to ensure all code is PEP8 compliant 73 | - Fields marked read only are no longer processed and populated 74 | 75 | 76 | 0.9.7 (2014-12-22) 77 | ^^^^^^^^^^^^^^^^^^ 78 | 79 | - Added blank_value option for GroupedQuerySelectField 80 | - Add py34 to test matrix 81 | - Fixed midnight handling for TimeField 82 | 83 | 84 | 0.9.6 (2014-09-04) 85 | ^^^^^^^^^^^^^^^^^^ 86 | 87 | - Fixed Unique validator session checking (#19). 88 | 89 | 90 | 0.9.5 (2014-07-31) 91 | ^^^^^^^^^^^^^^^^^^ 92 | 93 | - Fixed base_form option in SplitDateTimeField getting lost if form is initialized more than once. 94 | 95 | 96 | 0.9.4 (2014-07-29) 97 | ^^^^^^^^^^^^^^^^^^ 98 | 99 | - Added base_form option to SplitDateTimeField 100 | 101 | 102 | 0.9.3 (2014-05-15) 103 | ^^^^^^^^^^^^^^^^^^ 104 | 105 | - Fixed InstrumentedAttribute support for Unique validator, issue #13 106 | - Removed CountryField (now part of WTForms-Alchemy) 107 | 108 | 109 | 0.9.2 (2014-03-26) 110 | ^^^^^^^^^^^^^^^^^^ 111 | 112 | - Added WeekDaysField 113 | 114 | 115 | 0.9.1 (2014-01-27) 116 | ^^^^^^^^^^^^^^^^^^ 117 | 118 | - Added new unique validator 119 | 120 | 121 | 0.9.0 (2014-01-14) 122 | ^^^^^^^^^^^^^^^^^^ 123 | 124 | - Deprecated NumberRangeField 125 | - Added IntIntervalField, FloatIntervalField, DecimalIntervalField, DateIntervalField, DateTimeIntervalField 126 | - Updated SQLAlchemy-Utils dependency to version 0.23.0 127 | 128 | 129 | 0.8.3 (2014-01-05) 130 | ^^^^^^^^^^^^^^^^^^ 131 | 132 | - Updated SQLAlchemy-Utils dependency to version 0.22.1 133 | 134 | 135 | 0.8.2 (2013-12-12) 136 | ^^^^^^^^^^^^^^^^^^ 137 | 138 | - Add default validation message for Email validator 139 | 140 | 141 | 0.8.1 (2013-11-30) 142 | ^^^^^^^^^^^^^^^^^^ 143 | 144 | - Fix import error with new versions of ``validators`` package. 145 | - Added initial WTForms 2.0 support 146 | 147 | 148 | 0.8.0 (2013-10-11) 149 | ^^^^^^^^^^^^^^^^^^ 150 | 151 | - Added Python 3 support 152 | 153 | 154 | 0.7.1 (2013-09-07) 155 | ^^^^^^^^^^^^^^^^^^ 156 | 157 | - Added AjaxField 158 | 159 | 160 | 0.7.0 (2013-08-09) 161 | ^^^^^^^^^^^^^^^^^^ 162 | 163 | - Added GroupedQuerySelectField 164 | 165 | 166 | 0.6.6 (2013-07-31) 167 | ^^^^^^^^^^^^^^^^^^ 168 | 169 | - Added HTML5 compatible basic parameters (disabled, required, autofocus and readonly) for all widgets 170 | 171 | 172 | 0.6.5 (2013-07-30) 173 | ^^^^^^^^^^^^^^^^^^ 174 | 175 | - Added step rendering for NumberInput and RangeInput widgets 176 | 177 | 178 | 0.6.4 (2013-07-22) 179 | ^^^^^^^^^^^^^^^^^^ 180 | 181 | - Packages colour and phonenumbers are now lazy imported 182 | 183 | 184 | 0.6.3 (2013-05-24) 185 | ^^^^^^^^^^^^^^^^^^ 186 | 187 | - Added EmailField to main import 188 | - Added SearchField, IntegerSliderField, DecimalSliderField 189 | 190 | 191 | 0.6.2 (2013-05-24) 192 | ^^^^^^^^^^^^^^^^^^ 193 | 194 | - Added TimeInput, URLInput, ColorInput and TelInput 195 | - Added TimeRange validator 196 | 197 | 198 | 0.6.1 (2013-05-23) 199 | ^^^^^^^^^^^^^^^^^^ 200 | 201 | - Added required flag for NumberInput, DateInput, DateTimeInput 202 | and DateTimeLocalInput whenever associated field has a DataRequired validator. 203 | 204 | 205 | 0.6.0 (2013-05-23) 206 | ^^^^^^^^^^^^^^^^^^ 207 | 208 | - IntegerField and DecimalField which create HTML5 compatible min and max 209 | attributes based on attached NumberRange validators 210 | - DateField, DateTimeField and DateTimeLocalField classes which create HTML5 211 | compatible min and max attributes based on attached NumberRange validators 212 | 213 | 214 | 0.5.5 (2013-05-07) 215 | ^^^^^^^^^^^^^^^^^^ 216 | 217 | - Made TimeField use HTML5 TimeInput 218 | - Made PhoneNumberField use HTML5 TelInput 219 | - Made ColorField use HTML5 ColorInput 220 | - Updated WTForms dependency to 1.0.4 221 | 222 | 223 | 0.5.4 (2013-04-29) 224 | ^^^^^^^^^^^^^^^^^^ 225 | 226 | - Added ColorField 227 | 228 | 229 | 0.5.3 (2013-04-26) 230 | ^^^^^^^^^^^^^^^^^^ 231 | 232 | - Added read_only field marker function 233 | 234 | 235 | 0.5.2 (2013-04-12) 236 | ^^^^^^^^^^^^^^^^^^ 237 | 238 | - Added tests for TimeField 239 | - Added TimeField to main module import 240 | 241 | 242 | 0.5.1 (2013-04-12) 243 | ^^^^^^^^^^^^^^^^^^ 244 | 245 | - Added PassiveHiddenField 246 | 247 | 248 | 0.5.0 (2013-04-04) 249 | ^^^^^^^^^^^^^^^^^^ 250 | 251 | - Added Email validator 252 | - Fixed empty string handling with NumberRange fields 253 | 254 | 255 | 0.4.6 (2013-03-29) 256 | ^^^^^^^^^^^^^^^^^^ 257 | 258 | - Fixed Unique validator when using Form constructor obj parameter 259 | - Updated docs 260 | 261 | 262 | 0.4.5 (2013-03-27) 263 | ^^^^^^^^^^^^^^^^^^ 264 | 265 | - Fixed PhoneNumberField field rendering when validation fails 266 | 267 | 268 | 0.4.4 (2013-03-26) 269 | ^^^^^^^^^^^^^^^^^^ 270 | 271 | - Fixed NumberRangeField field rendering when validation fails 272 | 273 | 274 | 0.4.3 (2013-03-26) 275 | ^^^^^^^^^^^^^^^^^^ 276 | 277 | - Fixed NumberRangeField widget rendering 278 | 279 | 280 | 0.4.2 (2013-03-26) 281 | ^^^^^^^^^^^^^^^^^^ 282 | 283 | - Removed NumberRangeInput 284 | 285 | 286 | 0.4.1 (2013-03-26) 287 | ^^^^^^^^^^^^^^^^^^ 288 | 289 | - Changed empty phone number to be passed as None 290 | 291 | 292 | 0.4.0 (2013-03-26) 293 | ^^^^^^^^^^^^^^^^^^ 294 | 295 | - Added NumberRangeField 296 | 297 | 298 | 0.3.0 (2013-03-26) 299 | ^^^^^^^^^^^^^^^^^^ 300 | 301 | - Changed to use SQLAlchemy-Utils PhoneNumber class 302 | 303 | 304 | 0.2.0 (2013-03-20) 305 | ^^^^^^^^^^^^^^^^^^ 306 | 307 | - Added PhoneNumberField 308 | 309 | 310 | 0.1.0 (2013-03-15) 311 | ^^^^^^^^^^^^^^^^^^ 312 | 313 | - Initial public release 314 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | WTForms-Components 2 | ================== 3 | 4 | WTForms-Components provides various additional fields, validators and widgets 5 | for WTForms. 6 | 7 | Fields 8 | ====== 9 | 10 | 11 | WTForms derived HTML5 Fields 12 | ----------------------------- 13 | 14 | WTForms-Components provides enhanced versions of WTForms HTML5 fields. These fields support 15 | HTML5 compatible min and max validators. WTForms-Components is smart enough to automatically 16 | attach HTML5 min and max validators based on field's NumberRange and DateRange validators. 17 | 18 | Example: 19 | :: 20 | 21 | 22 | from wtforms import Form 23 | from wtforms_components import DateTimeField 24 | from werkzeug.datastructures import MultiDict 25 | 26 | 27 | class TestForm(Form): 28 | test_field = DateTimeField( 29 | 'Date', 30 | validators=[DateRange( 31 | min=datetime(2000, 1, 1), 32 | max=datetime(2000, 10, 10) 33 | )] 34 | ) 35 | 36 | 37 | form = TestForm(MultiDict(test_field='2000-2-2')) 38 | form.test_field 39 | 40 | # ' 41 | 42 | 43 | Same applies to IntegerField: 44 | :: 45 | 46 | 47 | from wtforms import Form 48 | from wtforms_components import IntegerField 49 | from werkzeug.datastructures import MultiDict 50 | 51 | 52 | class TestForm(Form): 53 | test_field = IntegerField( 54 | 'Date', 55 | validators=[NumberRange( 56 | min=1, 57 | max=4 58 | )] 59 | ) 60 | 61 | 62 | form = TestForm(MultiDict(test_field='3')) 63 | form.test_field 64 | 65 | # ' 66 | 67 | 68 | 69 | SelectField & SelectMultipleField 70 | --------------------------------- 71 | 72 | WTForms-Components provides enhanced versions of WTForms SelectFields. Both WTForms-Components 73 | SelectField and SelectMultipleField support the following enhancements: 74 | 75 | - Ability to generate `optgroup`_ elements. 76 | - ``choices`` can be a callable, which allows for dynamic choices. With the plain version of WTForms this has to be added manually, after instantiation of the form. 77 | 78 | .. _`optgroup`: 79 | https://developer.mozilla.org/en-US/docs/Web/HTML/Element/optgroup 80 | 81 | PhoneNumberField 82 | ---------------- 83 | 84 | Older versions of WTForms-Components had a PhoneNumberField. As of version 0.10.0 this field has now been moved to `WTForms-Alchemy`_. 85 | 86 | .. _WTForms-Alchemy: 87 | https://github.com/kvesteri/wtforms-alchemy 88 | 89 | 90 | ColorField 91 | ---------- 92 | 93 | ColorField is a string field representing a Color object from `colour`_ package. 94 | 95 | .. _colour: 96 | https://github.com/vaab/colour 97 | 98 | Example: 99 | :: 100 | 101 | 102 | from wtforms import Form 103 | from wtforms_components import ColorField 104 | 105 | class DocumentForm(Form): 106 | background_color = ColorField() 107 | 108 | 109 | 110 | NumberRangeField 111 | ---------------- 112 | 113 | NumberRangeField is a string field representing a NumberRange object from 114 | `SQLAlchemy-Utils`_. 115 | 116 | .. _SQLAlchemy-Utils: 117 | https://github.com/kvesteri/sqlalchemy-utils 118 | 119 | Example: 120 | :: 121 | 122 | 123 | from wtforms import Form 124 | from wtforms_components import NumberRangeField 125 | 126 | class EventForm(Form): 127 | estimated_participants = NumberRangeField('Estimated participants') 128 | 129 | 130 | 131 | PassiveHiddenField 132 | ------------------ 133 | 134 | PassiveHiddenField acts just like normal wtforms.fields.HiddenField except it 135 | doesn't populate object values with populate_obj function. 136 | 137 | Example: 138 | :: 139 | 140 | 141 | from wtforms import Form, TextField 142 | from wtforms_components import PassiveHiddenField 143 | 144 | class EventForm(Form): 145 | id = PassiveHiddenField() 146 | name = TextField('Name') 147 | 148 | 149 | TimeField 150 | --------- 151 | 152 | TimeField is a string field which stores a `datetime.time` matching a format. 153 | :: 154 | 155 | 156 | from wtforms import Form, DateField 157 | from wtforms_components import TimeField 158 | 159 | class EventForm(Form): 160 | start_date = DateField('Start date') 161 | start_time = TimeField('Start time') 162 | 163 | 164 | Read-only fields 165 | ---------------- 166 | 167 | WTForms-Components provides a convenient function for making fields read-only. 168 | 169 | In the following example we define a form where name field is defined as read-only. 170 | :: 171 | 172 | 173 | from wtforms import Form, DateField, TextField 174 | from wtforms_components import TimeField, read_only 175 | 176 | class EventForm(Form): 177 | name = TextField('Name') 178 | start_date = DateField('Start date') 179 | start_time = TimeField('Start time') 180 | 181 | def __init__(self, *args, **kwargs): 182 | super(EventForm, self).__init__(*args, **kwargs) 183 | read_only(self.name) 184 | 185 | 186 | Validators 187 | ========== 188 | 189 | DateRange validator 190 | ------------------- 191 | 192 | The DateRange validator is essentially the same as wtforms.validators.NumberRange validator but validates 193 | dates. 194 | 195 | In the following example we define a start_time and a start_date field, which do not accept dates in the past. :: 196 | 197 | from datetime import datetime, date 198 | from wtforms import Form 199 | from wtforms.fields import DateField 200 | from wtforms_components import DateRange 201 | 202 | class EventForm(Form): 203 | start_time = DateField( 204 | validators=[DateRange(min=datetime.now())] 205 | ) 206 | start_date = DateField( 207 | validators=[DateRange(min=date.today())] 208 | ) 209 | 210 | 211 | Email validator 212 | --------------- 213 | 214 | Validates an email address. This validator is based on Django's email validator and is stricter than the standard email validator included in WTForms. 215 | 216 | Example: 217 | :: 218 | 219 | 220 | from wtforms import Form 221 | from wtforms.fields import TextField 222 | from wtforms_components import Email 223 | 224 | class UserForm(Form): 225 | email = TextField( 226 | validators=[Email()] 227 | ) 228 | 229 | 230 | If validator 231 | ------------ 232 | 233 | The If validator provides means for having conditional validations. In the following example we only 234 | validate field email if field user_id is provided. 235 | :: 236 | 237 | 238 | from wtforms import Form 239 | from wtforms.fields import IntegerField, TextField 240 | from wtforms_components import If 241 | 242 | class SomeForm(Form): 243 | user_id = IntegerField() 244 | email = TextField(validators=[ 245 | If(lambda form, field: form.user_id.data, Email()) 246 | ]) 247 | 248 | 249 | Chain validator 250 | --------------- 251 | 252 | 253 | Chain validator chains validators together. Chain validator can be combined with If validator 254 | to provide nested conditional validations. 255 | :: 256 | 257 | 258 | from wtforms import Form 259 | from wtforms.fields import IntegerField, TextField 260 | from wtforms_components import If 261 | 262 | class SomeForm(Form): 263 | user_id = IntegerField() 264 | email = TextField(validators=[ 265 | If( 266 | lambda form, field: form.user_id.data, 267 | Chain(DataRequired(), Email()) 268 | ) 269 | ]) 270 | 271 | 272 | Unique Validator 273 | ---------------- 274 | 275 | Unique validator provides convenient way for checking the unicity of given field in database. 276 | 277 | As of WTForms-Components version 0.10.0 the Unique validator has been moved to WTForms-Alchemy due to its SQLAlchemy dependency. 278 | -------------------------------------------------------------------------------- /wtforms_components/widgets.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | 3 | from markupsafe import Markup, escape 4 | from wtforms.validators import DataRequired, NumberRange 5 | from wtforms.widgets import Input, html_params 6 | from wtforms.widgets import Select as _Select 7 | 8 | from .validators import DateRange, TimeRange 9 | 10 | 11 | def min_max(field, validator_class): 12 | """ 13 | Returns maximum minimum and minimum maximum value for given validator class 14 | of given field. 15 | 16 | :param field: WTForms Field object 17 | :param validator_class: WTForms Validator class 18 | 19 | Example:: 20 | 21 | 22 | class MyForm(Form): 23 | some_integer_field = IntegerField( 24 | validators=[Length(min=3, max=6), Length(min=4, max=7)] 25 | ) 26 | 27 | 28 | form = MyForm() 29 | 30 | min_max(form.some_integer_field, Length) 31 | # {'min': 4, 'max': 6} 32 | """ 33 | min_values = [] 34 | max_values = [] 35 | for validator in field.validators: 36 | if isinstance(validator, validator_class): 37 | if validator.min is not None: 38 | min_values.append(validator.min) 39 | if validator.max is not None: 40 | max_values.append(validator.max) 41 | 42 | data = {} 43 | if min_values: 44 | data["min"] = max(min_values) 45 | if max_values: 46 | data["max"] = min(max_values) 47 | return data 48 | 49 | 50 | def has_validator(field, validator_class): 51 | """ 52 | Returns whether or not given field has an instance of given validator class 53 | in the validators property. 54 | 55 | :param field: WTForms Field object 56 | :param validator_class: WTForms Validator class 57 | """ 58 | return any( 59 | [isinstance(validator, validator_class) for validator in field.validators] 60 | ) 61 | 62 | 63 | class HTML5Input(Input): 64 | validation_attrs = ["required", "disabled"] 65 | 66 | def __init__(self, **kwargs): 67 | self.options = kwargs 68 | 69 | def __call__(self, field, **kwargs): 70 | if has_validator(field, DataRequired): 71 | kwargs.setdefault("required", True) 72 | 73 | for key, value in self.range_validators(field).items(): 74 | kwargs.setdefault(key, value) 75 | 76 | if hasattr(field, "widget_options"): 77 | for key, value in self.field.widget_options: 78 | kwargs.setdefault(key, value) 79 | 80 | options_copy = copy(self.options) 81 | options_copy.update(kwargs) 82 | return super().__call__(field, **options_copy) 83 | 84 | def range_validators(self, field): 85 | return {} 86 | 87 | 88 | class BaseDateTimeInput(HTML5Input): 89 | """ 90 | Base class for TimeInput, DateTimeLocalInput, DateTimeInput and 91 | DateInput widgets 92 | """ 93 | 94 | range_validator_class = DateRange 95 | 96 | def range_validators(self, field): 97 | data = min_max(field, self.range_validator_class) 98 | if "min" in data: 99 | data["min"] = data["min"].strftime(self.format) 100 | if "max" in data: 101 | data["max"] = data["max"].strftime(self.format) 102 | return data 103 | 104 | 105 | class TextInput(HTML5Input): 106 | input_type = "text" 107 | 108 | 109 | class SearchInput(HTML5Input): 110 | """ 111 | Renders an input with type "search". 112 | """ 113 | 114 | input_type = "search" 115 | 116 | 117 | class MonthInput(HTML5Input): 118 | """ 119 | Renders an input with type "month". 120 | """ 121 | 122 | input_type = "month" 123 | 124 | 125 | class WeekInput(HTML5Input): 126 | """ 127 | Renders an input with type "week". 128 | """ 129 | 130 | input_type = "week" 131 | 132 | 133 | class RangeInput(HTML5Input): 134 | """ 135 | Renders an input with type "range". 136 | """ 137 | 138 | input_type = "range" 139 | 140 | 141 | class URLInput(HTML5Input): 142 | """ 143 | Renders an input with type "url". 144 | """ 145 | 146 | input_type = "url" 147 | 148 | 149 | class ColorInput(HTML5Input): 150 | """ 151 | Renders an input with type "color". 152 | """ 153 | 154 | input_type = "color" 155 | 156 | 157 | class TelInput(HTML5Input): 158 | """ 159 | Renders an input with type "tel". 160 | """ 161 | 162 | input_type = "tel" 163 | 164 | 165 | class EmailInput(HTML5Input): 166 | """ 167 | Renders an input with type "email". 168 | """ 169 | 170 | input_type = "email" 171 | 172 | 173 | class TimeInput(BaseDateTimeInput): 174 | """ 175 | Renders an input with type "time". 176 | 177 | Adds min and max html5 field parameters based on field's TimeRange 178 | validator. 179 | """ 180 | 181 | input_type = "time" 182 | range_validator_class = TimeRange 183 | format = "%H:%M:%S" 184 | 185 | 186 | class DateTimeLocalInput(BaseDateTimeInput): 187 | """ 188 | Renders an input with type "datetime-local". 189 | 190 | Adds min and max html5 field parameters based on field's DateRange 191 | validator. 192 | """ 193 | 194 | input_type = "datetime-local" 195 | format = "%Y-%m-%dT%H:%M:%S" 196 | 197 | 198 | class DateTimeInput(BaseDateTimeInput): 199 | """ 200 | Renders an input with type "datetime". 201 | 202 | Adds min and max html5 field parameters based on field's DateRange 203 | validator. 204 | """ 205 | 206 | input_type = "datetime" 207 | format = "%Y-%m-%dT%H:%M:%SZ" 208 | 209 | 210 | class DateInput(BaseDateTimeInput): 211 | """ 212 | Renders an input with type "date". 213 | 214 | Adds min and max html5 field parameters based on field's DateRange 215 | validator. 216 | """ 217 | 218 | input_type = "date" 219 | format = "%Y-%m-%d" 220 | 221 | 222 | class NumberInput(HTML5Input): 223 | """ 224 | Renders an input with type "number". 225 | 226 | Adds min and max html5 field parameters based on field's NumberRange 227 | validator. 228 | """ 229 | 230 | input_type = "number" 231 | range_validator_class = NumberRange 232 | 233 | def range_validators(self, field): 234 | return min_max(field, self.range_validator_class) 235 | 236 | 237 | class ReadOnlyWidgetProxy: 238 | def __init__(self, widget): 239 | self.widget = widget 240 | 241 | def __getattr__(self, name): 242 | return getattr(self.widget, name) 243 | 244 | def __call__(self, field, **kwargs): 245 | kwargs.setdefault("readonly", True) 246 | # Some html elements also need disabled attribute to achieve the 247 | # expected UI behaviour. 248 | kwargs.setdefault("disabled", True) 249 | return self.widget(field, **kwargs) 250 | 251 | 252 | class SelectWidget(_Select): 253 | """ 254 | Add support of choices with ``optgroup`` to the ``Select`` widget. 255 | """ 256 | 257 | @classmethod 258 | def render_optgroup(cls, value, label, mixed): 259 | children = [] 260 | 261 | for item_value, item_label in label: 262 | item_html = cls.render_option(item_value, item_label, mixed) 263 | children.append(item_html) 264 | 265 | html = '%s' 266 | data = (escape(str(value)), "\n".join(children)) 267 | return Markup(html % data) 268 | 269 | @classmethod 270 | def render_option(cls, value, label, mixed): 271 | """ 272 | Render option as HTML tag, but not forget to wrap options into 273 | ``optgroup`` tag if ``label`` var is ``list`` or ``tuple``. 274 | """ 275 | if isinstance(label, (list, tuple)): 276 | return cls.render_optgroup(value, label, mixed) 277 | 278 | try: 279 | coerce_func, data = mixed 280 | except TypeError: 281 | selected = mixed 282 | else: 283 | if isinstance(data, list) or isinstance(data, tuple): 284 | selected = coerce_func(value) in data 285 | else: 286 | selected = coerce_func(value) == data 287 | 288 | options = {"value": value} 289 | 290 | if selected: 291 | options["selected"] = True 292 | 293 | html = "" 294 | data = (html_params(**options), escape(str(label))) 295 | 296 | return Markup(html % data) 297 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # WTForms-Components documentation build configuration file, created by 3 | # sphinx-quickstart on Fri Mar 15 10:35:33 2013. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import pathlib 14 | import re 15 | 16 | 17 | def get_version(): 18 | path = pathlib.Path(__file__).parent.parent / "wtforms_components" / "__init__.py" 19 | contents = path.read_text() 20 | pattern = r'^__version__ = "(.*?)"$' 21 | return re.search(pattern, contents, re.MULTILINE).group(1) 22 | 23 | 24 | # If extensions (or modules to document with autodoc) are in another directory, 25 | # add these directories to sys.path here. If the directory is relative to the 26 | # documentation root, use os.path.abspath to make it absolute, like shown here. 27 | # sys.path.insert(0, os.path.abspath('.')) 28 | 29 | # -- General configuration ----------------------------------------------------- 30 | 31 | # If your documentation needs a minimal Sphinx version, state it here. 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be extensions 35 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 36 | extensions = [ 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.doctest", 39 | "sphinx.ext.intersphinx", 40 | "sphinx.ext.todo", 41 | "sphinx.ext.coverage", 42 | "sphinx.ext.ifconfig", 43 | "sphinx.ext.viewcode", 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ["_templates"] 48 | 49 | # The suffix of source filenames. 50 | source_suffix = ".rst" 51 | 52 | # The encoding of source files. 53 | # source_encoding = 'utf-8-sig' 54 | 55 | # The master toctree document. 56 | master_doc = "index" 57 | 58 | # General information about the project. 59 | project = "WTForms-Components" 60 | copyright = "2013, Konsta Vesterinen, Janne Vanhala, Vesa Uimonen" 61 | 62 | # The version info for the project you're documenting, acts as replacement for 63 | # |version| and |release|, also used in various other places throughout the 64 | # built documents. 65 | # 66 | # The short X.Y version. 67 | version = get_version() 68 | # The full version, including alpha/beta/rc tags. 69 | release = version 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | # language = None 74 | 75 | # There are two options for replacing |today|: either, you set today to some 76 | # non-false value, then it is used: 77 | # today = '' 78 | # Else, today_fmt is used as the format for a strftime call. 79 | # today_fmt = '%B %d, %Y' 80 | 81 | # List of patterns, relative to source directory, that match files and 82 | # directories to ignore when looking for source files. 83 | exclude_patterns = ["_build"] 84 | 85 | # The reST default role (used for this markup: `text`) to use for all documents. 86 | # default_role = None 87 | 88 | # If true, '()' will be appended to :func: etc. cross-reference text. 89 | # add_function_parentheses = True 90 | 91 | # If true, the current module name will be prepended to all description 92 | # unit titles (such as .. function::). 93 | # add_module_names = True 94 | 95 | # If true, sectionauthor and moduleauthor directives will be shown in the 96 | # output. They are ignored by default. 97 | # show_authors = False 98 | 99 | # The name of the Pygments (syntax highlighting) style to use. 100 | pygments_style = "sphinx" 101 | 102 | # A list of ignored prefixes for module index sorting. 103 | # modindex_common_prefix = [] 104 | 105 | 106 | # -- Options for HTML output --------------------------------------------------- 107 | 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = "furo" 111 | 112 | # Theme options are theme-specific and customize the look and feel of a theme 113 | # further. For a list of options available for each theme, see the 114 | # documentation. 115 | # html_theme_options = {} 116 | 117 | # Add any paths that contain custom themes here, relative to this directory. 118 | # html_theme_path = [] 119 | 120 | # The name for this set of Sphinx documents. If None, it defaults to 121 | # " v documentation". 122 | # html_title = None 123 | 124 | # A shorter title for the navigation bar. Default is the same as html_title. 125 | # html_short_title = None 126 | 127 | # The name of an image file (relative to this directory) to place at the top 128 | # of the sidebar. 129 | # html_logo = None 130 | 131 | # The name of an image file (within the static path) to use as favicon of the 132 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 133 | # pixels large. 134 | # html_favicon = None 135 | 136 | # Add any paths that contain custom static files (such as style sheets) here, 137 | # relative to this directory. They are copied after the builtin static files, 138 | # so a file named "default.css" will overwrite the builtin "default.css". 139 | # html_static_path = ["_static"] 140 | 141 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 142 | # using the given strftime format. 143 | # html_last_updated_fmt = '%b %d, %Y' 144 | 145 | # If true, SmartyPants will be used to convert quotes and dashes to 146 | # typographically correct entities. 147 | # html_use_smartypants = True 148 | 149 | # Custom sidebar templates, maps document names to template names. 150 | # html_sidebars = {} 151 | 152 | # Additional templates that should be rendered to pages, maps page names to 153 | # template names. 154 | # html_additional_pages = {} 155 | 156 | # If false, no module index is generated. 157 | # html_domain_indices = True 158 | 159 | # If false, no index is generated. 160 | # html_use_index = True 161 | 162 | # If true, the index is split into individual pages for each letter. 163 | # html_split_index = False 164 | 165 | # If true, links to the reST sources are added to the pages. 166 | # html_show_sourcelink = True 167 | 168 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 169 | # html_show_sphinx = True 170 | 171 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 172 | # html_show_copyright = True 173 | 174 | # If true, an OpenSearch description file will be output, and all pages will 175 | # contain a tag referring to it. The value of this option must be the 176 | # base URL from which the finished HTML is served. 177 | # html_use_opensearch = '' 178 | 179 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 180 | # html_file_suffix = None 181 | 182 | # Output file base name for HTML help builder. 183 | htmlhelp_basename = "WTForms-Componentsdoc" 184 | 185 | 186 | # -- Options for LaTeX output -------------------------------------------------- 187 | 188 | latex_elements = { 189 | # The paper size ('letterpaper' or 'a4paper'). 190 | #'papersize': 'letterpaper', 191 | # The font size ('10pt', '11pt' or '12pt'). 192 | #'pointsize': '10pt', 193 | # Additional stuff for the LaTeX preamble. 194 | #'preamble': '', 195 | } 196 | 197 | # Grouping the document tree into LaTeX files. List of tuples 198 | # (source start file, target name, title, author, documentclass [howto/manual]). 199 | latex_documents = [ 200 | ( 201 | "index", 202 | "WTForms-Components.tex", 203 | "WTForms-Components Documentation", 204 | "Konsta Vesterinen, Janne Vanhala, Vesa Uimonen", 205 | "manual", 206 | ), 207 | ] 208 | 209 | # The name of an image file (relative to this directory) to place at the top of 210 | # the title page. 211 | # latex_logo = None 212 | 213 | # For "manual" documents, if this is true, then toplevel headings are parts, 214 | # not chapters. 215 | # latex_use_parts = False 216 | 217 | # If true, show page references after internal links. 218 | # latex_show_pagerefs = False 219 | 220 | # If true, show URL addresses after external links. 221 | # latex_show_urls = False 222 | 223 | # Documents to append as an appendix to all manuals. 224 | # latex_appendices = [] 225 | 226 | # If false, no module index is generated. 227 | # latex_domain_indices = True 228 | 229 | 230 | # -- Options for manual page output -------------------------------------------- 231 | 232 | # One entry per manual page. List of tuples 233 | # (source start file, name, description, authors, manual section). 234 | man_pages = [ 235 | ( 236 | "index", 237 | "wtforms-components", 238 | "WTForms-Components Documentation", 239 | ["Konsta Vesterinen, Janne Vanhala, Vesa Uimonen"], 240 | 1, 241 | ) 242 | ] 243 | 244 | # If true, show URL addresses after external links. 245 | # man_show_urls = False 246 | 247 | 248 | # -- Options for Texinfo output ------------------------------------------------ 249 | 250 | # Grouping the document tree into Texinfo files. List of tuples 251 | # (source start file, target name, title, author, 252 | # dir menu entry, description, category) 253 | texinfo_documents = [ 254 | ( 255 | "index", 256 | "WTForms-Components", 257 | "WTForms-Components Documentation", 258 | "Konsta Vesterinen, Janne Vanhala, Vesa Uimonen", 259 | "WTForms-Components", 260 | "One line description of project.", 261 | "Miscellaneous", 262 | ), 263 | ] 264 | 265 | # Documents to append as an appendix to all manuals. 266 | # texinfo_appendices = [] 267 | 268 | # If false, no module index is generated. 269 | # texinfo_domain_indices = True 270 | 271 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 272 | # texinfo_show_urls = 'footnote' 273 | 274 | 275 | # Example configuration for intersphinx: refer to the Python standard library. 276 | intersphinx_mapping = {"python": ("http://docs.python.org/", None)} 277 | --------------------------------------------------------------------------------