Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-Flask-WTF for 
openSUSE:Factory checked in at 2026-04-25 21:38:06
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-Flask-WTF (Old)
 and      /work/SRC/openSUSE:Factory/.python-Flask-WTF.new.11940 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-Flask-WTF"

Sat Apr 25 21:38:06 2026 rev:15 rq:1349214 version:1.3.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-Flask-WTF/python-Flask-WTF.changes        
2024-11-14 16:10:48.064039988 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-Flask-WTF.new.11940/python-Flask-WTF.changes 
    2026-04-25 21:43:02.219671996 +0200
@@ -1,0 +2,22 @@
+Sat Apr 25 10:21:44 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 1.3.0:
+  * Don't read the whole uploaded files to know their size.
+  * Stop support for Python 3.9. Start support for Python 3.14.
+  * Migrate the project to uv. :pr:`649`
+  * Allow setting a nonce on
+    :class:`~flask_wtf.recaptcha.RecaptchaField` (string or zero-
+    argument callable) for nonce-based Content Security Policies.
+    :pr:`312`
+  * Add csrf_meta_tag() helper and WTF_CSRF_META_NAME setting to
+    render the CSRF token as an HTML <meta> tag.
+  * Forward keyword arguments passed to the reCAPTCHA widget as
+    HTML attributes on the captcha <div>, with the field id used
+    as a default id. :pr:`353`
+  * Add apply_exemptions parameter to
+    :meth:`~flask_wtf.csrf.CSRFProtect.protect` so @csrf.exempt
+    keeps working when validation is triggered manually.
+    :pr:`419`
+  * Add RECAPTCHA_ENABLED setting. :pr:`509`
+
+-------------------------------------------------------------------

Old:
----
  flask_wtf-1.2.2.tar.gz

New:
----
  flask_wtf-1.3.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-Flask-WTF.spec ++++++
--- /var/tmp/diff_new_pack.zvUD6r/_old  2026-04-25 21:43:02.727692722 +0200
+++ /var/tmp/diff_new_pack.zvUD6r/_new  2026-04-25 21:43:02.751693702 +0200
@@ -1,7 +1,7 @@
 #
 # spec file for package python-Flask-WTF
 #
-# Copyright (c) 2024 SUSE LLC
+# Copyright (c) 2026 SUSE LLC and contributors
 #
 # All modifications and additions to the file contributed by third parties
 # remain the property of their copyright owners, unless otherwise agreed
@@ -19,16 +19,15 @@
 %bcond_without     test
 %{?sle15_python_module_pythons}
 Name:           python-Flask-WTF
-Version:        1.2.2
+Version:        1.3.0
 Release:        0
 Summary:        WTForms support for Flask
 License:        BSD-3-Clause
 URL:            https://github.com/lepture/flask-wtf
 Source:         
https://files.pythonhosted.org/packages/source/F/Flask-WTF/flask_wtf-%{version}.tar.gz
-BuildRequires:  %{python_module base >= 3.8}
+BuildRequires:  %{python_module base >= 3.10}
 BuildRequires:  %{python_module hatchling}
 BuildRequires:  %{python_module pip}
-BuildRequires:  %{python_module setuptools}
 BuildRequires:  %{python_module wheel}
 BuildRequires:  fdupes
 BuildRequires:  python-rpm-macros

++++++ flask_wtf-1.2.2.tar.gz -> flask_wtf-1.3.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/.hgignore 
new/flask_wtf-1.3.0/.hgignore
--- old/flask_wtf-1.2.2/.hgignore       1970-01-01 01:00:00.000000000 +0100
+++ new/flask_wtf-1.3.0/.hgignore       2026-04-25 21:43:02.859698108 +0200
@@ -0,0 +1 @@
+symbolic link to .dotfiles/mercurial/.hgignore
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/CONTRIBUTING.rst 
new/flask_wtf-1.3.0/CONTRIBUTING.rst
--- old/flask_wtf-1.2.2/CONTRIBUTING.rst        1970-01-01 01:00:00.000000000 
+0100
+++ new/flask_wtf-1.3.0/CONTRIBUTING.rst        2020-02-02 01:00:00.000000000 
+0100
@@ -0,0 +1,218 @@
+How to contribute to Flask-WTF
+==============================
+
+Thank you for considering contributing to Flask-WTF!
+
+
+Support questions
+-----------------
+
+Please don't use the issue tracker for this. The issue tracker is a
+tool to address bugs and feature requests in Flask-WTF itself. Use one of
+the following resources for questions about using Flask-WTF or issues
+with your own code:
+
+-   The ``#get-help`` channel on our Discord chat:
+    https://discord.gg/pallets
+-   The mailing list [email protected] for long term discussion or larger
+    issues.
+-   Ask on `Stack Overflow`_. Search with Google first using:
+    ``site:stackoverflow.com flask-wtf {search term, exception message, etc.}``
+
+.. _Stack Overflow: 
https://stackoverflow.com/questions/tagged/flask-wtf?tab=Frequent
+
+
+Reporting issues
+----------------
+
+Include the following information in your post:
+
+-   Describe what you expected to happen.
+-   If possible, include a `minimal reproducible example`_ to help us
+    identify the issue. This also helps check that the issue is not with
+    your own code.
+-   Describe what actually happened. Include the full traceback if there
+    was an exception.
+-   List your Python, Flask-WTF, and WTForms versions. If possible, check if 
this
+    issue is already fixed in the latest releases or the latest code in
+    the repository.
+
+.. _minimal reproducible example: 
https://stackoverflow.com/help/minimal-reproducible-example
+
+
+Submitting patches
+------------------
+
+If there is not an open issue for what you want to submit, prefer
+opening one for discussion before working on a PR. You can work on any
+issue that doesn't have an open PR linked to it or a maintainer assigned
+to it. These show up in the sidebar. No need to ask if you can work on
+an issue that interests you.
+
+Include the following in your patch:
+
+-   Use `Ruff`_ to format your code. This and other tools will run
+    automatically if you install `pre-commit`_ using the instructions
+    below.
+-   Include tests if your patch adds or changes code. Make sure the test
+    fails without your patch.
+-   Update any relevant docs pages and docstrings. Docs pages and
+    docstrings should be wrapped at 72 characters.
+-   Add an entry in ``CHANGES.rst``. Use the same style as other
+    entries. Also include ``.. versionchanged::`` inline changelogs in
+    relevant docstrings.
+
+.. _Ruff: https://docs.astral.sh/ruff/
+.. _pre-commit: https://pre-commit.com
+
+
+First time setup
+~~~~~~~~~~~~~~~~
+
+-   Download and install the `latest version of git`_.
+-   Configure git with your `username`_ and `email`_.
+
+    .. code-block:: console
+
+        $ git config --global user.name 'your name'
+        $ git config --global user.email 'your email'
+
+-   Make sure you have a `GitHub account`_.
+-   Fork Flask-WTF to your GitHub account by clicking the `Fork`_ button.
+-   `Clone`_ the main repository locally.
+
+    .. code-block:: console
+
+        $ git clone https://github.com/pallets-eco/flask-wtf
+        $ cd flask-wtf
+
+-   Add your fork as a remote to push your work to. Replace
+    ``{username}`` with your username. This names the remote "fork", the
+    default WTForms remote is "origin".
+
+    .. code-block:: console
+
+        $ git remote add fork https://github.com/{username}/flask-wtf
+
+-   Install `uv`_ if you don't have it already.
+
+    .. code-block:: console
+
+        $ curl -LsSf https://astral.sh/uv/install.sh | sh
+
+    On Windows, see the `uv installation docs`_.
+
+-   Sync the project dependencies with uv. This will create a virtual
+    environment and install all development dependencies.
+
+    .. code-block:: console
+
+        $ uv sync --all-groups
+
+-   (Optional) Install the pre-commit hooks.
+
+    .. code-block:: console
+
+        $ uv run pre-commit install
+
+.. _latest version of git: https://git-scm.com/downloads
+.. _username: 
https://docs.github.com/en/github/using-git/setting-your-username-in-git
+.. _email: 
https://docs.github.com/en/github/setting-up-and-managing-your-github-user-account/setting-your-commit-email-address
+.. _GitHub account: https://github.com/join
+.. _Fork: https://github.com/pallets-eco/flask-wtf/fork
+.. _Clone: 
https://docs.github.com/en/github/getting-started-with-github/fork-a-repo#step-2-create-a-local-clone-of-your-fork
+.. _uv: https://docs.astral.sh/uv/
+.. _uv installation docs: 
https://docs.astral.sh/uv/getting-started/installation/
+
+
+Start coding
+~~~~~~~~~~~~
+
+-   Create a branch to identify the issue you would like to work on. If
+    you're submitting a bug or documentation fix, branch off of the
+    latest ".x" branch.
+
+    .. code-block:: console
+
+        $ git fetch origin
+        $ git checkout -b your-branch-name origin/1.0.x
+
+    If you're submitting a feature addition or change, branch off of the
+    "main" branch.
+
+    .. code-block:: console
+
+        $ git fetch origin
+        $ git checkout -b your-branch-name origin/main
+
+-   Using your favorite editor, make your changes,
+    `committing as you go`_.
+-   Include tests that cover any code changes you make. Make sure the
+    test fails without your patch. Run the tests as described below.
+-   Push your commits to your fork on GitHub and
+    `create a pull request`_. Link to the issue being addressed with
+    ``fixes #123`` in the pull request.
+
+    .. code-block:: console
+
+        $ git push --set-upstream fork your-branch-name
+
+.. _committing as you go: 
https://dont-be-afraid-to-commit.readthedocs.io/en/latest/git/commandlinegit.html#commit-your-changes
+.. _create a pull request: 
https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request
+
+
+Running the tests
+~~~~~~~~~~~~~~~~~
+
+Run the basic test suite with pytest.
+
+.. code-block:: console
+
+    $ uv run pytest
+
+This runs the tests for the current environment, which is usually
+sufficient. CI will run the full suite when you submit your pull
+request. You can run the full test suite with tox if you don't want to
+wait.
+
+.. code-block:: console
+
+    $ uv run tox
+
+
+Running test coverage
+~~~~~~~~~~~~~~~~~~~~~
+
+Generating a report of lines that do not have test coverage can indicate
+where to start contributing. Run ``pytest`` using ``coverage`` and
+generate a report.
+
+.. code-block:: console
+
+    $ uv run coverage run -m pytest
+    $ uv run coverage html
+
+Open ``htmlcov/index.html`` in your browser to explore the report.
+
+Read more about `coverage <https://coverage.readthedocs.io>`__.
+
+
+Building the docs
+~~~~~~~~~~~~~~~~~
+
+Build the docs in the ``docs`` directory using Sphinx.
+
+.. code-block:: console
+
+    $ uv run tox -e docs
+
+Or build manually:
+
+.. code-block:: console
+
+    $ cd docs
+    $ uv run make html
+
+Open ``_build/html/index.html`` in your browser to view the docs.
+
+Read more about `Sphinx <https://www.sphinx-doc.org/en/stable/>`__.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/PKG-INFO new/flask_wtf-1.3.0/PKG-INFO
--- old/flask_wtf-1.2.2/PKG-INFO        2024-10-20 22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/PKG-INFO        2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
-Metadata-Version: 2.3
+Metadata-Version: 2.4
 Name: Flask-WTF
-Version: 1.2.2
+Version: 1.3.0
 Summary: Form rendering, validation, and CSRF protection for Flask with 
WTForms.
 Project-URL: Documentation, https://flask-wtf.readthedocs.io/
 Project-URL: Changes, https://flask-wtf.readthedocs.io/changes/
@@ -47,7 +47,7 @@
 Classifier: Topic :: Internet :: WWW/HTTP :: WSGI
 Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
 Classifier: Topic :: Software Development :: Libraries :: Application 
Frameworks
-Requires-Python: >=3.9
+Requires-Python: >=3.10
 Requires-Dist: flask
 Requires-Dist: itsdangerous
 Requires-Dist: wtforms
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/docs/api.rst 
new/flask_wtf-1.3.0/docs/api.rst
--- old/flask_wtf-1.2.2/docs/api.rst    2024-10-20 22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/docs/api.rst    2020-02-02 01:00:00.000000000 +0100
@@ -39,3 +39,5 @@
 .. autofunction:: generate_csrf
 
 .. autofunction:: validate_csrf
+
+.. autofunction:: csrf_meta_tag
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/docs/changes.rst 
new/flask_wtf-1.3.0/docs/changes.rst
--- old/flask_wtf-1.2.2/docs/changes.rst        2024-10-20 22:27:21.000000000 
+0200
+++ new/flask_wtf-1.3.0/docs/changes.rst        2020-02-02 01:00:00.000000000 
+0100
@@ -1,6 +1,27 @@
 Changes
 =======
 
+Version 1.3.0
+-------------
+
+Released 2026-04-23
+
+- Don't read the whole uploaded files to know their size. :pr:`635`
+- Stop support for Python 3.9. Start support for Python 3.14. :pr:`648`
+- Migrate the project to uv. :pr:`649`
+- Allow setting a ``nonce`` on :class:`~flask_wtf.recaptcha.RecaptchaField`
+  (string or zero-argument callable) for nonce-based Content Security
+  Policies. :pr:`312`
+- Add ``csrf_meta_tag()`` helper and ``WTF_CSRF_META_NAME`` setting to render
+  the CSRF token as an HTML ``<meta>`` tag.
+- Forward keyword arguments passed to the reCAPTCHA widget as HTML attributes
+  on the captcha ``<div>``, with the field id used as a default ``id``.
+  :pr:`353`
+- Add ``apply_exemptions`` parameter to
+  :meth:`~flask_wtf.csrf.CSRFProtect.protect` so ``@csrf.exempt`` keeps working
+  when validation is triggered manually. :pr:`419`
+- Add ``RECAPTCHA_ENABLED`` setting. :pr:`509`
+
 Version 1.2.2
 -------------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/docs/config.rst 
new/flask_wtf-1.3.0/docs/config.rst
--- old/flask_wtf-1.2.2/docs/config.rst 2024-10-20 22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/docs/config.rst 2020-02-02 01:00:00.000000000 +0100
@@ -16,6 +16,9 @@
 ``WTF_CSRF_HEADERS``       HTTP headers to search for CSRF token when it is not
                            provided in the form. Default is
                            ``['X-CSRFToken', 'X-CSRF-Token']``.
+``WTF_CSRF_META_NAME``     Value of the ``name`` attribute rendered by
+                           :func:`~flask_wtf.csrf.csrf_meta_tag`. Default is
+                           ``csrf-token``.
 ``WTF_CSRF_TIME_LIMIT``    Max age in seconds for CSRF tokens. Default is
                            ``3600``. If set to ``None``, the CSRF token is 
valid
                            for the life of the session.
@@ -40,6 +43,9 @@
 ``RECAPTCHA_PUBLIC_KEY``    **required** A public key.
 ``RECAPTCHA_PRIVATE_KEY``   **required** A private key.
                             https://www.google.com/recaptcha/admin
+``RECAPTCHA_ENABLED``       Set to ``False`` to disable recaptcha widgets
+                            and always validate recaptcha fields as
+                            valid. Default is ``True``.
 ``RECAPTCHA_PARAMETERS``    **optional** A dict of configuration options.
 ``RECAPTCHA_HTML``          **optional** Override default HTML template
                             for Recaptcha.
@@ -60,6 +66,16 @@
 
 =========================== ==============================================
 
+Per-instance HTML attributes can also be passed when rendering the field.
+Any keyword argument given to the widget is forwarded to the captcha
+``<div>``, following the standard WTForms naming convention (``class_``
+becomes ``class``, ``data_foo`` becomes ``data-foo``, ``aria_label``
+becomes ``aria-label``). Kwargs take precedence over ``RECAPTCHA_DIV_CLASS``
+and ``RECAPTCHA_DATA_ATTRS``. The ``id`` attribute defaults to the field
+id and can be overridden the same way::
+
+    {{ form.recaptcha(class_="my-captcha", data_theme="dark", 
aria_label="Captcha") }}
+
 Logging
 -------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/docs/csrf.rst 
new/flask_wtf-1.3.0/docs/csrf.rst
--- old/flask_wtf-1.2.2/docs/csrf.rst   2024-10-20 22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/docs/csrf.rst   2020-02-02 01:00:00.000000000 +0100
@@ -61,33 +61,56 @@
         <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
     </form>
 
+The ``name`` attribute must match the ``WTF_CSRF_FIELD_NAME`` setting
+(``'csrf_token'`` by default). Otherwise the request fails with a
+``Bad Request: CSRF token missing`` error. Change the setting if you need a
+different field name.
+
+HTML Meta Tag
+-------------
+
+For JavaScript clients, the recommended way to expose the token to the page is
+to render it in a ``<meta>`` tag in the document ``<head>``. This is the
+convention used by Rails and recommended by the
+`OWASP CSRF prevention cheat sheet`_.
+
+.. sourcecode:: html+jinja
+
+    <head>
+        {{ csrf_meta_tag() }}
+    </head>
+
+This renders ``<meta name="csrf-token" content="...">``. The attribute name is
+configurable via the ``WTF_CSRF_META_NAME`` setting, or per-call with the
+``name`` argument: ``{{ csrf_meta_tag(name="authenticity-token") }}``.
+
+.. _OWASP CSRF prevention cheat sheet: 
https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#storing-the-csrf-token-value-in-the-dom
+
 JavaScript Requests
 -------------------
 
-When sending an AJAX request, add the ``X-CSRFToken`` header to it.
-For example, in jQuery you can configure all requests to send the token.
+When sending an AJAX request, read the token from the meta tag and send it in
+the ``X-CSRFToken`` header. This pattern is compatible with a strict
+``Content-Security-Policy`` since no inline script is required.
 
-.. sourcecode:: html+jinja
+Using ``fetch``:
 
-    <script type="text/javascript">
-        var csrf_token = "{{ csrf_token() }}";
+.. sourcecode:: javascript
 
-        $.ajaxSetup({
-            beforeSend: function(xhr, settings) {
-                if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && 
!this.crossDomain) {
-                    xhr.setRequestHeader("X-CSRFToken", csrf_token);
-                }
-            }
-        });
-    </script>
+    const token = document.querySelector('meta[name="csrf-token"]').content;
 
-In Axios you can set the header for all requests with 
``axios.defaults.headers.common``.
+    fetch("/api/resource", {
+        method: "POST",
+        headers: { "X-CSRFToken": token, "Content-Type": "application/json" },
+        body: JSON.stringify(data),
+    });
 
-.. sourcecode:: html+jinja
+Using Axios, configure the default header once at startup:
 
-    <script type="text/javascript">
-        axios.defaults.headers.common["X-CSRFToken"] = "{{ csrf_token() }}";
-    </script>
+.. sourcecode:: javascript
+
+    axios.defaults.headers.common["X-CSRFToken"] =
+        document.querySelector('meta[name="csrf-token"]').content;
 
 Customize the error response
 ----------------------------
@@ -128,3 +151,11 @@
     def check_csrf():
         if not is_oauth(request):
             csrf.protect()
+
+Pass ``apply_exemptions=True`` to keep ``@csrf.exempt`` working in this mode.
+The call will skip validation for views and blueprints marked as exempt. ::
+
+    @app.before_request
+    def check_csrf():
+        if not is_oauth(request):
+            csrf.protect(apply_exemptions=True)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/docs/form.rst 
new/flask_wtf-1.3.0/docs/form.rst
--- old/flask_wtf-1.2.2/docs/form.rst   2024-10-20 22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/docs/form.rst   2020-02-02 01:00:00.000000000 +0100
@@ -171,6 +171,22 @@
 For your convenience, when testing your application, if ``app.testing`` is 
``True``, the recaptcha
 field will always be valid.
 
+In development environment or when you are offline you can disable all
+recaptcha fields::
+
+    RECAPTCHA_ENABLED = False
+
+If your site uses a nonce-based Content Security Policy, pass a ``nonce``
+to :class:`RecaptchaField` so the generated ``<script>`` tag carries the
+matching ``nonce`` attribute. Because the nonce typically changes on every
+request, pass a zero-argument callable rather than a string so the value
+is resolved at render time::
+
+    from flask import g
+
+    class SignupForm(FlaskForm):
+        recaptcha = RecaptchaField(nonce=lambda: g.csp_nonce)
+
 And it can be easily setup in the templates:
 
 .. sourcecode:: html+jinja
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/examples/babel/app.py 
new/flask_wtf-1.3.0/examples/babel/app.py
--- old/flask_wtf-1.2.2/examples/babel/app.py   1970-01-01 01:00:00.000000000 
+0100
+++ new/flask_wtf-1.3.0/examples/babel/app.py   2020-02-02 01:00:00.000000000 
+0100
@@ -0,0 +1,51 @@
+from flask import Flask
+from flask import render_template
+from flask import request
+from flask_babel import Babel
+from flask_babel import lazy_gettext as _
+from wtforms import StringField
+from wtforms.validators import DataRequired
+
+from flask_wtf import FlaskForm
+
+
+class BabelForm(FlaskForm):
+    name = StringField(_("Name"), validators=[DataRequired()])
+
+
+DEBUG = True
+SECRET_KEY = "secret"
+WTF_I18N_ENABLED = True
+
+
+def get_locale():
+    """how to get the locale is defined by you.
+
+    Match by the Accept Language header::
+
+        match = app.config.get('BABEL_SUPPORTED_LOCALES', ['en', 'zh'])
+        default = app.config.get('BABEL_DEFAULT_LOCALES', 'en')
+        return request.accept_languages.best_match(match, default)
+    """
+    # this is a demo case, we use url to get locale
+    code = request.args.get("lang", "en")
+    return code
+
+
+app = Flask(__name__)
+app.config.from_object(__name__)
+
+# config babel
+babel = Babel(app, locale_selector=get_locale)
+
+
[email protected]("/", methods=("GET", "POST"))
+def index():
+    form = BabelForm()
+    if form.validate_on_submit():
+        pass
+    return render_template("index.html", form=form)
+
+
+if __name__ == "__main__":
+    app.run()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/examples/babel/templates/index.html 
new/flask_wtf-1.3.0/examples/babel/templates/index.html
--- old/flask_wtf-1.2.2/examples/babel/templates/index.html     1970-01-01 
01:00:00.000000000 +0100
+++ new/flask_wtf-1.3.0/examples/babel/templates/index.html     2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+
+<html>
+  <body>
+    <p><a href="?lang=zh">zh</a> <a href="?lang=en">en</a></p>
+    <form method="POST">
+      {{ form.hidden_tag() }}
+      {% for error in form.name.errors %}
+      <p>{{ error }}</p>
+      {% endfor %}
+      <p>
+        {{ form.name.label }} {{ form.name() }}
+      </p>
+      <p>
+        <input type="submit" value="Submit">
+      </p>
+    </form>
+  </body>
+</html>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/examples/recaptcha/app.py 
new/flask_wtf-1.3.0/examples/recaptcha/app.py
--- old/flask_wtf-1.2.2/examples/recaptcha/app.py       1970-01-01 
01:00:00.000000000 +0100
+++ new/flask_wtf-1.3.0/examples/recaptcha/app.py       2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,51 @@
+from flask import flash
+from flask import Flask
+from flask import redirect
+from flask import render_template
+from flask import session
+from flask import url_for
+from wtforms import TextAreaField
+from wtforms.validators import DataRequired
+
+from flask_wtf import FlaskForm
+from flask_wtf.recaptcha import RecaptchaField
+
+DEBUG = True
+SECRET_KEY = "secret"
+
+# keys for localhost. Change as appropriate.
+
+RECAPTCHA_PUBLIC_KEY = "6LeYIbsSAAAAACRPIllxA7wvXjIE411PfdB2gt2J"
+RECAPTCHA_PRIVATE_KEY = "6LeYIbsSAAAAAJezaIq3Ft_hSTo0YtyeFG-JgRtu"
+
+app = Flask(__name__)
+app.config.from_object(__name__)
+
+
+class CommentForm(FlaskForm):
+    comment = TextAreaField("Comment", validators=[DataRequired()])
+    recaptcha = RecaptchaField()
+
+
[email protected]("/")
+def index(form=None):
+    if form is None:
+        form = CommentForm()
+    comments = session.get("comments", [])
+    return render_template("index.html", comments=comments, form=form)
+
+
[email protected]("/add/", methods=("POST",))
+def add_comment():
+    form = CommentForm()
+    if form.validate_on_submit():
+        comments = session.pop("comments", [])
+        comments.append(form.comment.data)
+        session["comments"] = comments
+        flash("You have added a new comment")
+        return redirect(url_for("index"))
+    return index(form)
+
+
+if __name__ == "__main__":
+    app.run()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/flask_wtf-1.2.2/examples/recaptcha/templates/index.html 
new/flask_wtf-1.3.0/examples/recaptcha/templates/index.html
--- old/flask_wtf-1.2.2/examples/recaptcha/templates/index.html 1970-01-01 
01:00:00.000000000 +0100
+++ new/flask_wtf-1.3.0/examples/recaptcha/templates/index.html 2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,23 @@
+<html>
+    <body>
+        {% for comment in comments %}
+        <p>{{ comment }}</p>
+        {% endfor %}
+        <form method="POST" action="{{ url_for('add_comment') }}">
+            {{ form.csrf_token }}
+            <p>
+                {{ form.comment.label }}<br>
+                {{ form.comment(rows=5, cols=40) }}
+            </p>
+            <p>
+                {% for error in form.recaptcha.errors %}
+                    {{ error }}
+                {% endfor %}
+                {{ form.recaptcha }}
+            </p>
+            <p>
+                <input type="submit" value="Add comment">
+            </p>
+        </form>
+    </body>
+</html>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/examples/uploadr/app.py 
new/flask_wtf-1.3.0/examples/uploadr/app.py
--- old/flask_wtf-1.2.2/examples/uploadr/app.py 1970-01-01 01:00:00.000000000 
+0100
+++ new/flask_wtf-1.3.0/examples/uploadr/app.py 2020-02-02 01:00:00.000000000 
+0100
@@ -0,0 +1,37 @@
+from flask import Flask
+from flask import render_template
+from wtforms import FieldList
+
+from flask_wtf import FlaskForm
+from flask_wtf.file import FileField
+
+
+class FileUploadForm(FlaskForm):
+    uploads = FieldList(FileField())
+
+
+DEBUG = True
+SECRET_KEY = "secret"
+
+app = Flask(__name__)
+app.config.from_object(__name__)
+
+
[email protected]("/", methods=("GET", "POST"))
+def index():
+    form = FileUploadForm()
+
+    for _ in range(5):
+        form.uploads.append_entry()
+
+    filedata = []
+
+    if form.validate_on_submit():
+        for upload in form.uploads.entries:
+            filedata.append(upload)
+
+    return render_template("index.html", form=form, filedata=filedata)
+
+
+if __name__ == "__main__":
+    app.run()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/flask_wtf-1.2.2/examples/uploadr/templates/index.html 
new/flask_wtf-1.3.0/examples/uploadr/templates/index.html
--- old/flask_wtf-1.2.2/examples/uploadr/templates/index.html   1970-01-01 
01:00:00.000000000 +0100
+++ new/flask_wtf-1.3.0/examples/uploadr/templates/index.html   2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+
+<html>
+    <body>
+        {% for upload in filedata %}
+        <h3>{{ upload.data.filename }}</h3>
+        {% endfor %}
+        <form method="POST" enctype="multipart/form-data">
+            {{ form.errors }}
+            {{ form.hidden_tag() }}
+            {% for upload in form.uploads.entries %}
+            <p>
+            {{ upload }}
+            </p>
+            {% endfor %}
+            <p>
+                <input type="submit" value="Submit">
+            </p>
+        </form>
+    </body>
+</html>
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/pyproject.toml 
new/flask_wtf-1.3.0/pyproject.toml
--- old/flask_wtf-1.2.2/pyproject.toml  2024-10-20 22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/pyproject.toml  2020-02-02 01:00:00.000000000 +0100
@@ -16,7 +16,7 @@
     "Topic :: Internet :: WWW/HTTP :: WSGI :: Application",
     "Topic :: Software Development :: Libraries :: Application Frameworks",
 ]
-requires-python = ">=3.9"
+requires-python = ">=3.10"
 dependencies = [
     "Flask",
     "WTForms",
@@ -34,6 +34,26 @@
 [project.optional-dependencies]
 email = ["email_validator"]
 
+[dependency-groups]
+test = [
+    "pytest",
+]
+test-babel = [
+    "Flask-Babel",
+    "flask-reuploaded",
+]
+docs = [
+    "Pallets-Sphinx-Themes",
+    "Sphinx",
+    "sphinx-issues",
+    "sphinxcontrib-log-cabinet",
+]
+dev = [
+    "pre-commit",
+    "tox",
+    "tox-uv",
+]
+
 [build-system]
 requires = ["hatchling"]
 build-backend = "hatchling.build"
@@ -49,7 +69,9 @@
     "src/",
     "docs/",
     "tests/",
+    "examples/",
     "CHANGES.rst",
+    "CONTRIBUTING.rst",
     "tox.ini",
 ]
 exclude = [
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/src/flask_wtf/__init__.py 
new/flask_wtf-1.3.0/src/flask_wtf/__init__.py
--- old/flask_wtf-1.2.2/src/flask_wtf/__init__.py       2024-10-20 
22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/src/flask_wtf/__init__.py       2020-02-02 
01:00:00.000000000 +0100
@@ -5,7 +5,7 @@
 from .recaptcha import RecaptchaField
 from .recaptcha import RecaptchaWidget
 
-__version__ = "1.2.2"
+__version__ = "1.3.0"
 __all__ = [
     "CSRFProtect",
     "FlaskForm",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/src/flask_wtf/csrf.py 
new/flask_wtf-1.3.0/src/flask_wtf/csrf.py
--- old/flask_wtf-1.2.2/src/flask_wtf/csrf.py   2024-10-20 22:27:21.000000000 
+0200
+++ new/flask_wtf-1.3.0/src/flask_wtf/csrf.py   2020-02-02 01:00:00.000000000 
+0100
@@ -12,11 +12,13 @@
 from itsdangerous import BadData
 from itsdangerous import SignatureExpired
 from itsdangerous import URLSafeTimedSerializer
+from markupsafe import escape
+from markupsafe import Markup
 from werkzeug.exceptions import BadRequest
 from wtforms import ValidationError
 from wtforms.csrf.core import CSRF
 
-__all__ = ("generate_csrf", "validate_csrf", "CSRFProtect")
+__all__ = ("generate_csrf", "validate_csrf", "csrf_meta_tag", "CSRFProtect")
 logger = logging.getLogger(__name__)
 
 
@@ -115,6 +117,25 @@
         raise ValidationError("The CSRF tokens do not match.")
 
 
+def csrf_meta_tag(name=None, secret_key=None, token_key=None):
+    """Render an HTML ``<meta>`` tag carrying the CSRF token, following the
+    convention used by Rails and recommended by OWASP for SPA and AJAX clients.
+
+    Extract the token client-side with
+    ``document.querySelector('meta[name="csrf-token"]').content`` and send it
+    in the ``X-CSRFToken`` header of state-changing requests.
+
+    :param name: Value of the meta tag's ``name`` attribute. Default is
+        ``WTF_CSRF_META_NAME`` or ``'csrf-token'``.
+    :param secret_key: Forwarded to :func:`generate_csrf`.
+    :param token_key: Forwarded to :func:`generate_csrf`.
+    """
+
+    name = _get_config(name, "WTF_CSRF_META_NAME", "csrf-token")
+    token = generate_csrf(secret_key=secret_key, token_key=token_key)
+    return Markup(f'<meta name="{escape(name)}" content="{escape(token)}">')
+
+
 def _get_config(
     value, config_name, default=None, required=True, message="CSRF is not 
configured."
 ):
@@ -197,11 +218,15 @@
         )
         app.config.setdefault("WTF_CSRF_FIELD_NAME", "csrf_token")
         app.config.setdefault("WTF_CSRF_HEADERS", ["X-CSRFToken", 
"X-CSRF-Token"])
+        app.config.setdefault("WTF_CSRF_META_NAME", "csrf-token")
         app.config.setdefault("WTF_CSRF_TIME_LIMIT", 3600)
         app.config.setdefault("WTF_CSRF_SSL_STRICT", True)
 
         app.jinja_env.globals["csrf_token"] = generate_csrf
-        app.context_processor(lambda: {"csrf_token": generate_csrf})
+        app.jinja_env.globals["csrf_meta_tag"] = csrf_meta_tag
+        app.context_processor(
+            lambda: {"csrf_token": generate_csrf, "csrf_meta_tag": 
csrf_meta_tag}
+        )
 
         @app.before_request
         def csrf_protect():
@@ -211,22 +236,7 @@
             if not app.config["WTF_CSRF_CHECK_DEFAULT"]:
                 return
 
-            if request.method not in app.config["WTF_CSRF_METHODS"]:
-                return
-
-            if not request.endpoint:
-                return
-
-            if app.blueprints.get(request.blueprint) in 
self._exempt_blueprints:
-                return
-
-            view = app.view_functions.get(request.endpoint)
-            dest = f"{view.__module__}.{view.__name__}"
-
-            if dest in self._exempt_views:
-                return
-
-            self.protect()
+            self.protect(apply_exemptions=True)
 
     def _get_csrf_token(self):
         # find the token in the form data
@@ -253,7 +263,22 @@
 
         return None
 
-    def protect(self):
+    def protect(self, apply_exemptions=False):
+        """Validate CSRF on the current request.
+
+        When ``apply_exemptions`` is ``True``, views and blueprints marked with
+        :meth:`exempt` are skipped. This lets you combine a custom
+        ``before_request`` hook (or any manual call) with the declarative
+        ``@csrf.exempt`` decorator.
+        """
+
+        if apply_exemptions:
+            if not request.endpoint:
+                return
+
+            if self._is_exempt():
+                return
+
         if request.method not in current_app.config["WTF_CSRF_METHODS"]:
             return
 
@@ -274,6 +299,17 @@
 
         g.csrf_valid = True  # mark this request as CSRF valid
 
+    def _is_exempt(self):
+        if current_app.blueprints.get(request.blueprint) in 
self._exempt_blueprints:
+            return True
+
+        view = current_app.view_functions.get(request.endpoint)
+        if view is None:
+            return False
+
+        dest = f"{view.__module__}.{view.__name__}"
+        return dest in self._exempt_views
+
     def exempt(self, view):
         """Mark a view or blueprint to be excluded from CSRF protection.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/src/flask_wtf/file.py 
new/flask_wtf-1.3.0/src/flask_wtf/file.py
--- old/flask_wtf-1.2.2/src/flask_wtf/file.py   2024-10-20 22:27:21.000000000 
+0200
+++ new/flask_wtf-1.3.0/src/flask_wtf/file.py   2020-02-02 01:00:00.000000000 
+0100
@@ -1,4 +1,6 @@
 from collections import abc
+from io import BytesIO
+from io import SEEK_END
 
 from werkzeug.datastructures import FileStorage
 from wtforms import FileField as _FileField
@@ -129,8 +131,16 @@
             return
 
         for f in field_data:
-            file_size = len(f.read())
-            f.seek(0)  # reset cursor position to beginning of file
+            if isinstance(f.stream, BytesIO):
+                file_size = f.getbuffer().nbytes
+            elif f.seekable():
+                file_size = f.seek(0, SEEK_END)
+                f.seek(0)
+            else:
+                raise TypeError(
+                    f"File stream {type(f.stream).__name__} is not seekable. "
+                    "FileSize validator requires seekable streams."
+                )
 
             if (file_size < self.min_size) or (file_size > self.max_size):
                 # the file is too small or too big => validation failure
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/src/flask_wtf/recaptcha/fields.py 
new/flask_wtf-1.3.0/src/flask_wtf/recaptcha/fields.py
--- old/flask_wtf-1.2.2/src/flask_wtf/recaptcha/fields.py       2024-10-20 
22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/src/flask_wtf/recaptcha/fields.py       2020-02-02 
01:00:00.000000000 +0100
@@ -7,11 +7,23 @@
 
 
 class RecaptchaField(Field):
+    """reCAPTCHA field using :class:`.Recaptcha` as its default validator.
+
+    The default validator skips verification when ``current_app.testing`` is
+    ``True``, so tests don't need a real reCAPTCHA token.
+
+    When using a nonce-based Content Security Policy, pass ``nonce`` to
+    populate the ``nonce`` attribute of the generated ``<script>`` tag. A
+    zero-argument callable is accepted so the value can be resolved at
+    render time, e.g. ``nonce=lambda: g.csp_nonce``.
+    """
+
     widget = widgets.RecaptchaWidget()
 
     # error message if recaptcha validation fails
     recaptcha_error = None
 
-    def __init__(self, label="", validators=None, **kwargs):
+    def __init__(self, label="", validators=None, nonce=None, **kwargs):
         validators = validators or [Recaptcha()]
+        self.nonce = nonce
         super().__init__(label, validators, **kwargs)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/flask_wtf-1.2.2/src/flask_wtf/recaptcha/validators.py 
new/flask_wtf-1.3.0/src/flask_wtf/recaptcha/validators.py
--- old/flask_wtf-1.2.2/src/flask_wtf/recaptcha/validators.py   2024-10-20 
22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/src/flask_wtf/recaptcha/validators.py   2020-02-02 
01:00:00.000000000 +0100
@@ -19,7 +19,17 @@
 
 
 class Recaptcha:
-    """Validates a ReCaptcha."""
+    """Validates a ReCaptcha.
+
+    Verification is skipped and the field is considered valid whenever
+    ``current_app.testing`` is ``True`` or ``RECAPTCHA_ENABLED`` is
+    ``False``, so tests and offline development don't need a real
+    reCAPTCHA token.
+
+    .. versionchanged:: 1.3.0
+        Verification is also skipped when ``RECAPTCHA_ENABLED`` is
+        ``False``.
+    """
 
     def __init__(self, message=None):
         if message is None:
@@ -27,7 +37,7 @@
         self.message = message
 
     def __call__(self, form, field):
-        if current_app.testing:
+        if current_app.testing or not 
current_app.config.get("RECAPTCHA_ENABLED", True):
             return True
 
         if request.is_json:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/src/flask_wtf/recaptcha/widgets.py 
new/flask_wtf-1.3.0/src/flask_wtf/recaptcha/widgets.py
--- old/flask_wtf-1.2.2/src/flask_wtf/recaptcha/widgets.py      2024-10-20 
22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/src/flask_wtf/recaptcha/widgets.py      2020-02-02 
01:00:00.000000000 +0100
@@ -1,20 +1,18 @@
 from urllib.parse import urlencode
 
 from flask import current_app
+from markupsafe import escape
 from markupsafe import Markup
+from wtforms.widgets import html_params
 
 RECAPTCHA_SCRIPT_DEFAULT = "https://www.google.com/recaptcha/api.js";
 RECAPTCHA_DIV_CLASS_DEFAULT = "g-recaptcha"
-RECAPTCHA_TEMPLATE = """
-<script src='%s' async defer></script>
-<div class="%s" %s></div>
-"""
 
 __all__ = ["RecaptchaWidget"]
 
 
 class RecaptchaWidget:
-    def recaptcha_html(self, public_key):
+    def recaptcha_html(self, public_key, nonce=None, **kwargs):
         html = current_app.config.get("RECAPTCHA_HTML")
         if html:
             return Markup(html)
@@ -23,21 +21,38 @@
         if not script:
             script = RECAPTCHA_SCRIPT_DEFAULT
         if params:
-            script += "?" + urlencode(params)
-        attrs = current_app.config.get("RECAPTCHA_DATA_ATTRS", {})
-        attrs["sitekey"] = public_key
-        snippet = " ".join(f'data-{k}="{attrs[k]}"' for k in attrs)  # noqa: 
B028, B907
-        div_class = current_app.config.get("RECAPTCHA_DIV_CLASS")
-        if not div_class:
-            div_class = RECAPTCHA_DIV_CLASS_DEFAULT
-        return Markup(RECAPTCHA_TEMPLATE % (script, div_class, snippet))
+            script += f"?{urlencode(params)}"
+        if callable(nonce):
+            nonce = nonce()
+        nonce_attr = f' nonce="{escape(nonce)}"' if nonce else ""
+
+        kwargs.setdefault(
+            "class",
+            current_app.config.get("RECAPTCHA_DIV_CLASS")
+            or RECAPTCHA_DIV_CLASS_DEFAULT,
+        )
+
+        data_attrs = dict(current_app.config.get("RECAPTCHA_DATA_ATTRS", {}))
+        data_attrs["sitekey"] = public_key
+        for k, v in data_attrs.items():
+            kwargs.setdefault(f"data-{k}", v)
+
+        attributes = html_params(**kwargs)
+        return Markup(
+            f"\n<script src='{script}' async defer{nonce_attr}></script>\n"
+            f"<div {attributes}></div>\n"
+        )
 
     def __call__(self, field, error=None, **kwargs):
         """Returns the recaptcha input HTML."""
 
+        if not current_app.config.get("RECAPTCHA_ENABLED", True):
+            return Markup("<!-- recaptcha disabled -->")
+
         try:
             public_key = current_app.config["RECAPTCHA_PUBLIC_KEY"]
         except KeyError:
             raise RuntimeError("RECAPTCHA_PUBLIC_KEY config not set") from None
 
-        return self.recaptcha_html(public_key)
+        kwargs.setdefault("id", field.id)
+        return self.recaptcha_html(public_key, nonce=field.nonce, **kwargs)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/tests/test_csrf_extension.py 
new/flask_wtf-1.3.0/tests/test_csrf_extension.py
--- old/flask_wtf-1.2.2/tests/test_csrf_extension.py    2024-10-20 
22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/tests/test_csrf_extension.py    2020-02-02 
01:00:00.000000000 +0100
@@ -2,8 +2,10 @@
 from flask import Blueprint
 from flask import g
 from flask import render_template_string
+from flask import request
 
 from flask_wtf import FlaskForm
+from flask_wtf.csrf import csrf_meta_tag
 from flask_wtf.csrf import CSRFError
 from flask_wtf.csrf import CSRFProtect
 from flask_wtf.csrf import generate_csrf
@@ -35,6 +37,47 @@
     assert render_template_string("{{ csrf_token() }}") == token
 
 
+def test_csrf_meta_tag_default(req_ctx):
+    token = generate_csrf()
+    rendered = csrf_meta_tag()
+    assert rendered == f'<meta name="csrf-token" content="{token}">'
+
+
+def test_csrf_meta_tag_custom_name_param(req_ctx):
+    token = generate_csrf()
+    rendered = csrf_meta_tag(name="x-csrf")
+    assert rendered == f'<meta name="x-csrf" content="{token}">'
+
+
+def test_csrf_meta_tag_config(app, req_ctx):
+    app.config["WTF_CSRF_META_NAME"] = "authenticity-token"
+    token = generate_csrf()
+    rendered = csrf_meta_tag()
+    assert rendered == f'<meta name="authenticity-token" content="{token}">'
+
+
+def test_csrf_meta_tag_param_overrides_config(app, req_ctx):
+    app.config["WTF_CSRF_META_NAME"] = "from-config"
+    token = generate_csrf()
+    rendered = csrf_meta_tag(name="from-param")
+    assert rendered == f'<meta name="from-param" content="{token}">'
+
+
+def test_csrf_meta_tag_jinja(req_ctx):
+    token = generate_csrf()
+    assert (
+        render_template_string("{{ csrf_meta_tag() }}")
+        == f'<meta name="csrf-token" content="{token}">'
+    )
+
+
+def test_csrf_meta_tag_escapes_name(req_ctx):
+    token = generate_csrf()
+    rendered = csrf_meta_tag(name='"><script>alert(1)</script>')
+    assert "<script>" not in rendered
+    assert f'content="{token}"' in rendered
+
+
 def test_protect(app, client, app_ctx):
     response = client.post("/")
     assert response.status_code == 400
@@ -141,6 +184,40 @@
     assert response.status_code == 400
 
 
+def test_protect_apply_exemptions(app, csrf, client):
+    app.config["WTF_CSRF_CHECK_DEFAULT"] = False
+
+    @app.before_request
+    def custom():
+        if request.headers.get("Authorization", "").startswith("Bearer "):
+            return
+        csrf.protect(apply_exemptions=True)
+
+    @app.route("/api", methods=["POST"])
+    def api():
+        pass
+
+    @app.route("/webhook", methods=["POST"])
+    @csrf.exempt
+    def webhook():
+        pass
+
+    bp = Blueprint("public", __name__, url_prefix="/public")
+    csrf.exempt(bp)
+
+    @bp.route("/", methods=["POST"])
+    def public():
+        pass
+
+    app.register_blueprint(bp)
+
+    assert client.post("/api").status_code == 400
+    assert client.post("/api", headers={"Authorization": "Bearer 
x"}).status_code == 200
+    assert client.post("/webhook").status_code == 200
+    assert client.post("/public/").status_code == 200
+    assert client.post("/missing").status_code == 404
+
+
 def test_exempt_blueprint(app, csrf, client):
     bp = Blueprint("exempt", __name__, url_prefix="/exempt")
     csrf.exempt(bp)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/tests/test_file.py 
new/flask_wtf-1.3.0/tests/test_file.py
--- old/flask_wtf-1.2.2/tests/test_file.py      2024-10-20 22:27:21.000000000 
+0200
+++ new/flask_wtf-1.3.0/tests/test_file.py      2020-02-02 01:00:00.000000000 
+0100
@@ -1,3 +1,5 @@
+from io import BytesIO
+
 import pytest
 from werkzeug.datastructures import FileStorage
 from werkzeug.datastructures import ImmutableMultiDict
@@ -102,6 +104,51 @@
         assert f.validate()
 
 
+def test_file_size_bytesio_passes_validation(form):
+    form.file.kwargs["validators"] = [FileSize(max_size=100)]
+    contents = BytesIO(b"small")
+    f = form(file=FileStorage(contents, filename="bytes"))
+    assert f.validate()
+
+
+def test_file_size_bytesio_too_big_fails_validation(form):
+    form.file.kwargs["validators"] = [FileSize(max_size=100)]
+    contents = BytesIO(b"small" * 30)
+    f = form(file=FileStorage(contents, filename="bytes"))
+    assert not f.validate()
+    assert f.file.errors[0] == "File must be between 0 and 100 bytes."
+
+
+def test_file_size_seekable_file_passes_validation(form, tmp_path):
+    form.file.kwargs["validators"] = [FileSize(max_size=100)]
+    path = tmp_path / "test_seekable.txt"
+    path.write_bytes(b"test content")
+
+    with path.open("rb") as file:
+        file_storage = FileStorage(file, filename="test.txt")
+        f = form(file=file_storage)
+        assert f.validate()
+        assert file.tell() == 0
+
+
+def test_file_size_non_seekable_stream_raises_error(form):
+    class NonSeekableStream:
+        def __init__(self, data):
+            self._data = BytesIO(data)
+
+        def read(self, size=-1):
+            return self._data.read(size)
+
+        def seekable(self):
+            return False
+
+    form.file.kwargs["validators"] = [FileSize(max_size=100)]
+    stream = NonSeekableStream(b"test")
+    f = form(file=FileStorage(stream, filename="test.txt"))
+    with pytest.raises(TypeError, match="not seekable"):
+        f.validate()
+
+
 @pytest.mark.parametrize(
     "min_size, max_size, invalid_file_size", [(1, 100, 0), (0, 100, 101)]
 )
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/tests/test_recaptcha.py 
new/flask_wtf-1.3.0/tests/test_recaptcha.py
--- old/flask_wtf-1.2.2/tests/test_recaptcha.py 2024-10-20 22:27:21.000000000 
+0200
+++ new/flask_wtf-1.3.0/tests/test_recaptcha.py 2020-02-02 01:00:00.000000000 
+0100
@@ -15,6 +15,31 @@
     recaptcha = RecaptchaField()
 
 
+class RecaptchaNonceForm(FlaskForm):
+    class Meta:
+        csrf = False
+
+    recaptcha = RecaptchaField(nonce="foobar")
+
+
+class RecaptchaNonceCallableForm(FlaskForm):
+    """Form using a callable nonce, for per-request resolution tests."""
+
+    class Meta:
+        csrf = False
+
+    recaptcha = RecaptchaField(nonce=lambda: "dynamic-nonce")
+
+
+class RecaptchaNonceUnsafeForm(FlaskForm):
+    """Form with an HTML-unsafe nonce, to assert escaping."""
+
+    class Meta:
+        csrf = False
+
+    recaptcha = RecaptchaField(nonce='"><script>alert(1)</script>')
+
+
 @pytest.fixture
 def app(app):
     app.testing = False
@@ -59,6 +84,34 @@
     assert captcha_script in render
 
 
+def test_render_has_nonce():
+    f = RecaptchaNonceForm()
+    render = f.recaptcha()
+    assert 'nonce="foobar"' in render
+
+
+def test_render_without_nonce():
+    """Render must not include a nonce attribute when none is set."""
+    f = RecaptchaForm()
+    render = f.recaptcha()
+    assert "nonce=" not in render
+
+
+def test_render_nonce_callable():
+    """A callable nonce is resolved at render time."""
+    f = RecaptchaNonceCallableForm()
+    render = f.recaptcha()
+    assert 'nonce="dynamic-nonce"' in render
+
+
+def test_render_nonce_is_escaped():
+    """Nonce values are HTML-escaped to avoid attribute injection."""
+    f = RecaptchaNonceUnsafeForm()
+    render = f.recaptcha()
+    assert "<script>alert(1)</script>" not in render
+    assert "&lt;script&gt;" in render
+
+
 def test_render_custom_html(app):
     app.config["RECAPTCHA_HTML"] = "custom"
     f = RecaptchaForm()
@@ -84,6 +137,75 @@
     assert 'data-red="blue"' in render
 
 
+def test_render_default_id_from_field():
+    """The div ``id`` defaults to the field id."""
+    f = RecaptchaForm()
+    render = f.recaptcha()
+    assert f'id="{f.recaptcha.id}"' in render
+
+
+def test_render_custom_html_attrs():
+    """Arbitrary HTML attributes can be passed as kwargs to the widget."""
+    f = RecaptchaForm()
+    render = f.recaptcha(style="margin: 1em;", aria_label="captcha")
+    assert 'style="margin: 1em;"' in render
+    assert 'aria-label="captcha"' in render
+
+
+def test_render_kwargs_override_class(app):
+    """``class_`` kwarg takes precedence over ``RECAPTCHA_DIV_CLASS`` 
config."""
+    app.config["RECAPTCHA_DIV_CLASS"] = "from-config"
+    f = RecaptchaForm()
+    render = f.recaptcha(class_="from-kwargs")
+    assert 'class="from-kwargs"' in render
+    assert "from-config" not in render
+
+
+def test_render_kwargs_override_data_attr(app):
+    """``data-*`` kwargs take precedence over ``RECAPTCHA_DATA_ATTRS`` 
config."""
+    app.config["RECAPTCHA_DATA_ATTRS"] = {"theme": "light"}
+    f = RecaptchaForm()
+    render = f.recaptcha(data_theme="dark")
+    assert 'data-theme="dark"' in render
+    assert "light" not in render
+
+
+def test_render_kwargs_override_id():
+    """``id`` kwarg overrides the default field id."""
+    f = RecaptchaForm()
+    render = f.recaptcha(id="custom-id")
+    assert 'id="custom-id"' in render
+
+
+def test_render_kwargs_are_escaped():
+    """HTML attribute values from kwargs are escaped."""
+    f = RecaptchaForm()
+    render = f.recaptcha(title='"><script>alert(1)</script>')
+    assert "<script>alert(1)</script>" not in render
+    assert "&lt;script&gt;" in render
+
+
+def test_recaptcha_enabled_false_skips_render(app):
+    app.config["RECAPTCHA_ENABLED"] = False
+    f = RecaptchaForm()
+    render = f.recaptcha()
+    assert render == Markup("<!-- recaptcha disabled -->")
+
+
+def test_recaptcha_enabled_false_skips_validation(app):
+    app.config["RECAPTCHA_ENABLED"] = False
+    with app.test_request_context():
+        f = RecaptchaForm()
+        f.validate()
+        assert not f.recaptcha.errors
+
+
+def test_recaptcha_enabled_defaults_to_true(app):
+    f = RecaptchaForm()
+    render = f.recaptcha()
+    assert "g-recaptcha" in render
+
+
 def test_missing_response(app):
     with app.test_request_context():
         f = RecaptchaForm()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/flask_wtf-1.2.2/tox.ini new/flask_wtf-1.3.0/tox.ini
--- old/flask_wtf-1.2.2/tox.ini 2024-10-20 22:27:21.000000000 +0200
+++ new/flask_wtf-1.3.0/tox.ini 2020-02-02 01:00:00.000000000 +0100
@@ -1,26 +1,29 @@
 [tox]
 envlist =
-    py3{13,12,11,10,9},pypy3{10,9}
+    py3{14,13,12,11,10},pypy3{10}
     py-{no-babel}
     style
     docs
 
 [testenv]
-deps =
-    -r requirements/tests.txt
-    Flask-Babel
-    flask-reuploaded
+runner = uv-venv-lock-runner
+dependency_groups =
+    test
+    test-babel
 commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs}
 
 [testenv:py-no-babel]
-deps = -r requirements/tests.txt
+runner = uv-venv-lock-runner
+dependency_groups = test
 commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs}
 
 [testenv:style]
-deps = -r requirements/style.txt
+runner = uv-venv-lock-runner
+dependency_groups = dev
 skip_install = true
 commands = pre-commit run --all-files --show-diff-on-failure
 
 [testenv:docs]
-deps = -r requirements/docs.txt
+runner = uv-venv-lock-runner
+dependency_groups = docs
 commands = sphinx-build -W -b html -d {envtmpdir}/doctrees docs 
{envtmpdir}/html

Reply via email to