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 "<script>" 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 "<script>" 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