Repository: wtforms/wtforms (python)

Issue:
html_params and class and class_
Hello,

I have a form created with model_form of flask_mongoengine.wtf

```python
    field_args = {
      'bio': {'render_kw':{'class_':"summernote"}},
    }
    exclude = ['created_at','created_by']
    form = model_form(Author,exclude=exclude,field_args=field_args)
```

I have a HTML 
```jinja
 {{ field(class='form-control', **kwargs) }}
```

for which the TextArea widget correctly outputs 
```jinja
<textarea class="form-control" class="summernote" id="bio" name="bio">text</textarea>
```

However all browsers are removing the extra class tag. It would be good to rewrite the html_params so that multiple class tags are combined into one. 

**expected output:**
```jinja
<textarea class="form-control summernote" id="bio" name="bio">text</textarea>
```

I am using 

Package | Version
--------  | --------
Flask | 1.0.2
Flask-WTF |   0.14.2
flask-mongoengine | 0.9.5
WTForms |  2.2.1

Thanks,


Pull request description:
Normalize render attribute names consistently
Fixes #449 

The *cleaning* of `for_`, `class_` and `class__` parameter names is done in `DefaultMeta.render_field` (instead of `html_params`), so `Field.__call__` arguments can override `render_kw`.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index a33ed92..50f43a2 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -31,6 +31,9 @@ Unreleased
     *extra_filters* parameter. :issue:`128` :pr:`592`
 -   Fields can be passed the ``name`` argument to use a HTML name
     different than their Python name. :issue:`205`, :pr:`601`
+-   Render attribute names like ``for_`` and ``class_`` are normalized
+    consistently so later values override those specified earlier.
+    :issue:`449`, :pr:`596`
 
 
 Version 2.3.1
diff --git a/src/wtforms/meta.py b/src/wtforms/meta.py
index bea8df9..710ab58 100644
--- a/src/wtforms/meta.py
+++ b/src/wtforms/meta.py
@@ -1,5 +1,6 @@
 from wtforms import i18n
 from wtforms.utils import WebobInputWrapper
+from wtforms.widgets.core import clean_key
 
 
 class DefaultMeta:
@@ -53,8 +54,12 @@ class DefaultMeta:
 
         The default implementation calls ``field.widget(field, **render_kw)``
         """
+
+        render_kw = {clean_key(k): v for k, v in render_kw.items()}
+
         other_kw = getattr(field, "render_kw", None)
         if other_kw is not None:
+            other_kw = {clean_key(k): v for k, v in other_kw.items()}
             render_kw = dict(other_kw, **render_kw)
         return field.widget(field, **render_kw)
 
diff --git a/src/wtforms/widgets/core.py b/src/wtforms/widgets/core.py
index 5be3f40..3e2a2eb 100644
--- a/src/wtforms/widgets/core.py
+++ b/src/wtforms/widgets/core.py
@@ -17,6 +17,13 @@ __all__ = (
 )
 
 
+def clean_key(key):
+    key = key.rstrip("_")
+    if key.startswith("data_") or key.startswith("aria_"):
+        key = key.replace("_", "-")
+    return key
+
+
 def html_params(**kwargs):
     """
     Generate HTML attribute syntax from inputted keyword arguments.
@@ -53,10 +60,7 @@ def html_params(**kwargs):
     """
     params = []
     for k, v in sorted(kwargs.items()):
-        if k in ("class_", "class__", "for_"):
-            k = k[:-1]
-        elif k.startswith("data_") or k.startswith("aria_"):
-            k = k.replace("_", "-")
+        k = clean_key(k)
         if v is True:
             params.append(k)
         elif v is False:


Test patch:
diff --git a/tests/test_fields.py b/tests/test_fields.py
index a82a3f9..f36ce71 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -266,6 +266,42 @@ class TestField:
             'type="text" value="hello">'
         )
 
+    def test_render_special(self):
+        class F(Form):
+            s = StringField(render_kw={"class_": "foo"})
+
+        assert F().s() == '<input class="foo" id="s" name="s" type="text" value="">'
+        assert (
+            F().s(**{"class": "bar"})
+            == '<input class="bar" id="s" name="s" type="text" value="">'
+        )
+        assert (
+            F().s(**{"class_": "bar"})
+            == '<input class="bar" id="s" name="s" type="text" value="">'
+        )
+
+        class G(Form):
+            s = StringField(render_kw={"class__": "foo"})
+
+        assert G().s() == '<input class="foo" id="s" name="s" type="text" value="">'
+        assert (
+            G().s(**{"class__": "bar"})
+            == '<input class="bar" id="s" name="s" type="text" value="">'
+        )
+
+        class H(Form):
+            s = StringField(render_kw={"for_": "foo"})
+
+        assert H().s() == '<input for="foo" id="s" name="s" type="text" value="">'
+        assert (
+            H().s(**{"for": "bar"})
+            == '<input for="bar" id="s" name="s" type="text" value="">'
+        )
+        assert (
+            H().s(**{"for_": "bar"})
+            == '<input for="bar" id="s" name="s" type="text" value="">'
+        )
+
     def test_select_field_copies_choices(self):
         class F(Form):
             items = SelectField(choices=[])
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
index 11d724c..9bedec6 100644
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -21,7 +21,7 @@ class TestHTMLParams:
     def test_basic(self):
         assert html_params(foo=9, k="wuuu") == 'foo="9" k="wuuu"'
         assert html_params(class_="foo") == 'class="foo"'
-        assert html_params(class__="foo") == 'class_="foo"'
+        assert html_params(class__="foo") == 'class="foo"'
         assert html_params(for_="foo") == 'for="foo"'
         assert html_params(readonly=False, foo=9) == 'foo="9"'
         assert (