diff --git a/README.md b/README.md deleted file mode 100644 index e1ea7ac..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# packets14 - diff --git a/doc/Flask泛读报告.docx b/doc/Flask泛读报告.docx new file mode 100644 index 0000000..00cc69a Binary files /dev/null and b/doc/Flask泛读报告.docx differ diff --git a/src/demo_app.py b/src/demo_app.py new file mode 100644 index 0000000..2dc5857 --- /dev/null +++ b/src/demo_app.py @@ -0,0 +1,153 @@ +# Flask +from flask import Flask, render_template_string, request, redirect, url_for, session, jsonify, flash, g + +app = Flask(__name__) +app.secret_key = 'demo_secret_key_for_testing' + +# 模拟数据库 +users = {} +messages = [] + +# 首页模板 +HOME_TEMPLATE = ''' + + + + Flask 示例应用 + + + +

Flask 示例应用

+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + + {% if session.get('username') %} +

欢迎, {{ session['username'] }}! 退出

+ {% else %} +

登录 | 注册

+ {% endif %} + +

留言板

+
+ + +
+ +

所有留言:

+ {% for msg in messages %} +
{{ msg.user }}: {{ msg.content }}
+ {% endfor %} + +
+

API 信息 (JSON)

+ + +''' + +LOGIN_TEMPLATE = ''' + + +登录 + +

登录

+
+

+

+ +
+

返回首页

+ + +''' + +REGISTER_TEMPLATE = ''' + + +注册 + +

注册

+
+

+

+ +
+

返回首页

+ + +''' + +@app.before_request +def before_request(): + """请求前钩子 - 记录请求信息""" + g.request_start = 'Request started' + +@app.route('/') +def index(): + """首页路由""" + return render_template_string(HOME_TEMPLATE, messages=messages) + +@app.route('/login', methods=['GET', 'POST']) +def login(): + """登录路由""" + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + if username in users and users[username] == password: + session['username'] = username + flash('登录成功!') + return redirect(url_for('index')) + flash('用户名或密码错误!') + return render_template_string(LOGIN_TEMPLATE) + +@app.route('/register', methods=['GET', 'POST']) +def register(): + """注册路由""" + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + if username in users: + flash('用户名已存在!') + else: + users[username] = password + flash('注册成功,请登录!') + return redirect(url_for('login')) + return render_template_string(REGISTER_TEMPLATE) + +@app.route('/logout') +def logout(): + """退出登录""" + session.pop('username', None) + flash('已退出登录') + return redirect(url_for('index')) + +@app.route('/message', methods=['POST']) +def add_message(): + """添加留言""" + content = request.form.get('content', '') + username = session.get('username', '匿名用户') + if content: + messages.append({'user': username, 'content': content}) + return redirect(url_for('index')) + +@app.route('/api/info') +def api_info(): + """API 接口 - 返回 JSON 数据""" + return jsonify({ + 'app_name': 'Flask Demo', + 'version': '1.0', + 'total_users': len(users), + 'total_messages': len(messages) + }) + +if __name__ == '__main__': + print("Flask 示例应用") + print("运行命令: flask run 或 python demo_app.py") + print("访问地址: http://127.0.0.1:5000") + app.run(debug=True) diff --git a/src/flask-main/.devcontainer/devcontainer.json b/src/flask-main/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4519826 --- /dev/null +++ b/src/flask-main/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +{ + "name": "pallets/flask", + "image": "mcr.microsoft.com/devcontainers/python:3", + "customizations": { + "vscode": { + "settings": { + "python.defaultInterpreterPath": "${workspaceFolder}/.venv", + "python.terminal.activateEnvInCurrentTerminal": true, + "python.terminal.launchArgs": [ + "-X", + "dev" + ] + } + } + }, + "onCreateCommand": ".devcontainer/on-create-command.sh" +} diff --git a/src/flask-main/.devcontainer/on-create-command.sh b/src/flask-main/.devcontainer/on-create-command.sh new file mode 100644 index 0000000..eaebea6 --- /dev/null +++ b/src/flask-main/.devcontainer/on-create-command.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e +python3 -m venv --upgrade-deps .venv +. .venv/bin/activate +pip install -r requirements/dev.txt +pip install -e . +pre-commit install --install-hooks diff --git a/src/flask-main/.editorconfig b/src/flask-main/.editorconfig new file mode 100644 index 0000000..2ff985a --- /dev/null +++ b/src/flask-main/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 +max_line_length = 88 + +[*.{css,html,js,json,jsx,scss,ts,tsx,yaml,yml}] +indent_size = 2 diff --git a/src/flask-main/.gitignore b/src/flask-main/.gitignore new file mode 100644 index 0000000..8441e5a --- /dev/null +++ b/src/flask-main/.gitignore @@ -0,0 +1,8 @@ +.idea/ +.vscode/ +__pycache__/ +dist/ +.coverage* +htmlcov/ +.tox/ +docs/_build/ diff --git a/src/flask-main/.pre-commit-config.yaml b/src/flask-main/.pre-commit-config.yaml new file mode 100644 index 0000000..eeeee43 --- /dev/null +++ b/src/flask-main/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 488940d9de1b658fac229e34c521d75a6ea476f2 # frozen: v0.14.5 + hooks: + - id: ruff + - id: ruff-format + - repo: https://github.com/astral-sh/uv-pre-commit + rev: b6675a113e27a9b18f3d60c05794d62ca80c7ab5 # frozen: 0.9.9 + hooks: + - id: uv-lock + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 + hooks: + - id: check-merge-conflict + - id: debug-statements + - id: fix-byte-order-marker + - id: trailing-whitespace + - id: end-of-file-fixer diff --git a/src/flask-main/.readthedocs.yaml b/src/flask-main/.readthedocs.yaml new file mode 100644 index 0000000..acbd83f --- /dev/null +++ b/src/flask-main/.readthedocs.yaml @@ -0,0 +1,10 @@ +version: 2 +build: + os: ubuntu-24.04 + tools: + python: '3.13' + commands: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + - uv run --group docs sphinx-build -W -b dirhtml docs $READTHEDOCS_OUTPUT/html diff --git a/src/flask-main/CHANGES.rst b/src/flask-main/CHANGES.rst new file mode 100644 index 0000000..efd076e --- /dev/null +++ b/src/flask-main/CHANGES.rst @@ -0,0 +1,1644 @@ +Version 3.2.0 +------------- + +Unreleased + +- Drop support for Python 3.9. :pr:`5730` +- Remove previously deprecated code: ``__version__``. :pr:`5648` +- ``RequestContext`` has merged with ``AppContext``. ``RequestContext`` is now + a deprecated alias. If an app context is already pushed, it is not reused + when dispatching a request. This greatly simplifies the internal code for tracking + the active context. :issue:`5639` +- Many ``Flask`` methods involved in request dispatch now take the current + ``AppContext`` as the first parameter, instead of using the proxy objects. + If subclasses were overriding these methods, the old signature is detected, + shows a deprecation warning, and will continue to work during the + deprecation period. :issue:`5815` +- ``template_filter``, ``template_test``, and ``template_global`` decorators + can be used without parentheses. :issue:`5729` + + +Version 3.1.2 +------------- + +Released 2025-08-19 + +- ``stream_with_context`` does not fail inside async views. :issue:`5774` +- When using ``follow_redirects`` in the test client, the final state + of ``session`` is correct. :issue:`5786` +- Relax type hint for passing bytes IO to ``send_file``. :issue:`5776` + + +Version 3.1.1 +------------- + +Released 2025-05-13 + +- Fix signing key selection order when key rotation is enabled via + ``SECRET_KEY_FALLBACKS``. :ghsa:`4grg-w6v8-c28g` +- Fix type hint for ``cli_runner.invoke``. :issue:`5645` +- ``flask --help`` loads the app and plugins first to make sure all commands + are shown. :issue:`5673` +- Mark sans-io base class as being able to handle views that return + ``AsyncIterable``. This is not accurate for Flask, but makes typing easier + for Quart. :pr:`5659` + + +Version 3.1.0 +------------- + +Released 2024-11-13 + +- Drop support for Python 3.8. :pr:`5623` +- Update minimum dependency versions to latest feature releases. + Werkzeug >= 3.1, ItsDangerous >= 2.2, Blinker >= 1.9. :pr:`5624,5633` +- Provide a configuration option to control automatic option + responses. :pr:`5496` +- ``Flask.open_resource``/``open_instance_resource`` and + ``Blueprint.open_resource`` take an ``encoding`` parameter to use when + opening in text mode. It defaults to ``utf-8``. :issue:`5504` +- ``Request.max_content_length`` can be customized per-request instead of only + through the ``MAX_CONTENT_LENGTH`` config. Added + ``MAX_FORM_MEMORY_SIZE`` and ``MAX_FORM_PARTS`` config. Added documentation + about resource limits to the security page. :issue:`5625` +- Add support for the ``Partitioned`` cookie attribute (CHIPS), with the + ``SESSION_COOKIE_PARTITIONED`` config. :issue:`5472` +- ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files. + ``load_dotenv`` loads default files in addition to a path unless + ``load_defaults=False`` is passed. :issue:`5628` +- Support key rotation with the ``SECRET_KEY_FALLBACKS`` config, a list of old + secret keys that can still be used for unsigning. Extensions will need to + add support. :issue:`5621` +- Fix how setting ``host_matching=True`` or ``subdomain_matching=False`` + interacts with ``SERVER_NAME``. Setting ``SERVER_NAME`` no longer restricts + requests to only that domain. :issue:`5553` +- ``Request.trusted_hosts`` is checked during routing, and can be set through + the ``TRUSTED_HOSTS`` config. :issue:`5636` + + +Version 3.0.3 +------------- + +Released 2024-04-07 + +- The default ``hashlib.sha1`` may not be available in FIPS builds. Don't + access it at import time so the developer has time to change the default. + :issue:`5448` +- Don't initialize the ``cli`` attribute in the sansio scaffold, but rather in + the ``Flask`` concrete class. :pr:`5270` + + +Version 3.0.2 +------------- + +Released 2024-02-03 + +- Correct type for ``jinja_loader`` property. :issue:`5388` +- Fix error with ``--extra-files`` and ``--exclude-patterns`` CLI options. + :issue:`5391` + + +Version 3.0.1 +------------- + +Released 2024-01-18 + +- Correct type for ``path`` argument to ``send_file``. :issue:`5336` +- Fix a typo in an error message for the ``flask run --key`` option. :pr:`5344` +- Session data is untagged without relying on the built-in ``json.loads`` + ``object_hook``. This allows other JSON providers that don't implement that. + :issue:`5381` +- Address more type findings when using mypy strict mode. :pr:`5383` + + +Version 3.0.0 +------------- + +Released 2023-09-30 + +- Remove previously deprecated code. :pr:`5223` +- Deprecate the ``__version__`` attribute. Use feature detection, or + ``importlib.metadata.version("flask")``, instead. :issue:`5230` +- Restructure the code such that the Flask (app) and Blueprint + classes have Sans-IO bases. :pr:`5127` +- Allow self as an argument to url_for. :pr:`5264` +- Require Werkzeug >= 3.0.0. + + +Version 2.3.3 +------------- + +Released 2023-08-21 + +- Python 3.12 compatibility. +- Require Werkzeug >= 2.3.7. +- Use ``flit_core`` instead of ``setuptools`` as build backend. +- Refactor how an app's root and instance paths are determined. :issue:`5160` + + +Version 2.3.2 +------------- + +Released 2023-05-01 + +- Set ``Vary: Cookie`` header when the session is accessed, modified, or refreshed. +- Update Werkzeug requirement to >=2.3.3 to apply recent bug fixes. + :ghsa:`m2qf-hxjv-5gpq` + + +Version 2.3.1 +------------- + +Released 2023-04-25 + +- Restore deprecated ``from flask import Markup``. :issue:`5084` + + +Version 2.3.0 +------------- + +Released 2023-04-25 + +- Drop support for Python 3.7. :pr:`5072` +- Update minimum requirements to the latest versions: Werkzeug>=2.3.0, Jinja2>3.1.2, + itsdangerous>=2.1.2, click>=8.1.3. +- Remove previously deprecated code. :pr:`4995` + + - The ``push`` and ``pop`` methods of the deprecated ``_app_ctx_stack`` and + ``_request_ctx_stack`` objects are removed. ``top`` still exists to give + extensions more time to update, but it will be removed. + - The ``FLASK_ENV`` environment variable, ``ENV`` config key, and ``app.env`` + property are removed. + - The ``session_cookie_name``, ``send_file_max_age_default``, ``use_x_sendfile``, + ``propagate_exceptions``, and ``templates_auto_reload`` properties on ``app`` + are removed. + - The ``JSON_AS_ASCII``, ``JSON_SORT_KEYS``, ``JSONIFY_MIMETYPE``, and + ``JSONIFY_PRETTYPRINT_REGULAR`` config keys are removed. + - The ``app.before_first_request`` and ``bp.before_app_first_request`` decorators + are removed. + - ``json_encoder`` and ``json_decoder`` attributes on app and blueprint, and the + corresponding ``json.JSONEncoder`` and ``JSONDecoder`` classes, are removed. + - The ``json.htmlsafe_dumps`` and ``htmlsafe_dump`` functions are removed. + - Calling setup methods on blueprints after registration is an error instead of a + warning. :pr:`4997` + +- Importing ``escape`` and ``Markup`` from ``flask`` is deprecated. Import them + directly from ``markupsafe`` instead. :pr:`4996` +- The ``app.got_first_request`` property is deprecated. :pr:`4997` +- The ``locked_cached_property`` decorator is deprecated. Use a lock inside the + decorated function if locking is needed. :issue:`4993` +- Signals are always available. ``blinker>=1.6.2`` is a required dependency. The + ``signals_available`` attribute is deprecated. :issue:`5056` +- Signals support ``async`` subscriber functions. :pr:`5049` +- Remove uses of locks that could cause requests to block each other very briefly. + :issue:`4993` +- Use modern packaging metadata with ``pyproject.toml`` instead of ``setup.cfg``. + :pr:`4947` +- Ensure subdomains are applied with nested blueprints. :issue:`4834` +- ``config.from_file`` can use ``text=False`` to indicate that the parser wants a + binary file instead. :issue:`4989` +- If a blueprint is created with an empty name it raises a ``ValueError``. + :issue:`5010` +- ``SESSION_COOKIE_DOMAIN`` does not fall back to ``SERVER_NAME``. The default is not + to set the domain, which modern browsers interpret as an exact match rather than + a subdomain match. Warnings about ``localhost`` and IP addresses are also removed. + :issue:`5051` +- The ``routes`` command shows each rule's ``subdomain`` or ``host`` when domain + matching is in use. :issue:`5004` +- Use postponed evaluation of annotations. :pr:`5071` + + +Version 2.2.5 +------------- + +Released 2023-05-02 + +- Update for compatibility with Werkzeug 2.3.3. +- Set ``Vary: Cookie`` header when the session is accessed, modified, or refreshed. + + +Version 2.2.4 +------------- + +Released 2023-04-25 + +- Update for compatibility with Werkzeug 2.3. + + +Version 2.2.3 +------------- + +Released 2023-02-15 + +- Autoescape is enabled by default for ``.svg`` template files. :issue:`4831` +- Fix the type of ``template_folder`` to accept ``pathlib.Path``. :issue:`4892` +- Add ``--debug`` option to the ``flask run`` command. :issue:`4777` + + +Version 2.2.2 +------------- + +Released 2022-08-08 + +- Update Werkzeug dependency to >= 2.2.2. This includes fixes related + to the new faster router, header parsing, and the development + server. :pr:`4754` +- Fix the default value for ``app.env`` to be ``"production"``. This + attribute remains deprecated. :issue:`4740` + + +Version 2.2.1 +------------- + +Released 2022-08-03 + +- Setting or accessing ``json_encoder`` or ``json_decoder`` raises a + deprecation warning. :issue:`4732` + + +Version 2.2.0 +------------- + +Released 2022-08-01 + +- Remove previously deprecated code. :pr:`4667` + + - Old names for some ``send_file`` parameters have been removed. + ``download_name`` replaces ``attachment_filename``, ``max_age`` + replaces ``cache_timeout``, and ``etag`` replaces ``add_etags``. + Additionally, ``path`` replaces ``filename`` in + ``send_from_directory``. + - The ``RequestContext.g`` property returning ``AppContext.g`` is + removed. + +- Update Werkzeug dependency to >= 2.2. +- The app and request contexts are managed using Python context vars + directly rather than Werkzeug's ``LocalStack``. This should result + in better performance and memory use. :pr:`4682` + + - Extension maintainers, be aware that ``_app_ctx_stack.top`` + and ``_request_ctx_stack.top`` are deprecated. Store data on + ``g`` instead using a unique prefix, like + ``g._extension_name_attr``. + +- The ``FLASK_ENV`` environment variable and ``app.env`` attribute are + deprecated, removing the distinction between development and debug + mode. Debug mode should be controlled directly using the ``--debug`` + option or ``app.run(debug=True)``. :issue:`4714` +- Some attributes that proxied config keys on ``app`` are deprecated: + ``session_cookie_name``, ``send_file_max_age_default``, + ``use_x_sendfile``, ``propagate_exceptions``, and + ``templates_auto_reload``. Use the relevant config keys instead. + :issue:`4716` +- Add new customization points to the ``Flask`` app object for many + previously global behaviors. + + - ``flask.url_for`` will call ``app.url_for``. :issue:`4568` + - ``flask.abort`` will call ``app.aborter``. + ``Flask.aborter_class`` and ``Flask.make_aborter`` can be used + to customize this aborter. :issue:`4567` + - ``flask.redirect`` will call ``app.redirect``. :issue:`4569` + - ``flask.json`` is an instance of ``JSONProvider``. A different + provider can be set to use a different JSON library. + ``flask.jsonify`` will call ``app.json.response``, other + functions in ``flask.json`` will call corresponding functions in + ``app.json``. :pr:`4692` + +- JSON configuration is moved to attributes on the default + ``app.json`` provider. ``JSON_AS_ASCII``, ``JSON_SORT_KEYS``, + ``JSONIFY_MIMETYPE``, and ``JSONIFY_PRETTYPRINT_REGULAR`` are + deprecated. :pr:`4692` +- Setting custom ``json_encoder`` and ``json_decoder`` classes on the + app or a blueprint, and the corresponding ``json.JSONEncoder`` and + ``JSONDecoder`` classes, are deprecated. JSON behavior can now be + overridden using the ``app.json`` provider interface. :pr:`4692` +- ``json.htmlsafe_dumps`` and ``json.htmlsafe_dump`` are deprecated, + the function is built-in to Jinja now. :pr:`4692` +- Refactor ``register_error_handler`` to consolidate error checking. + Rewrite some error messages to be more consistent. :issue:`4559` +- Use Blueprint decorators and functions intended for setup after + registering the blueprint will show a warning. In the next version, + this will become an error just like the application setup methods. + :issue:`4571` +- ``before_first_request`` is deprecated. Run setup code when creating + the application instead. :issue:`4605` +- Added the ``View.init_every_request`` class attribute. If a view + subclass sets this to ``False``, the view will not create a new + instance on every request. :issue:`2520`. +- A ``flask.cli.FlaskGroup`` Click group can be nested as a + sub-command in a custom CLI. :issue:`3263` +- Add ``--app`` and ``--debug`` options to the ``flask`` CLI, instead + of requiring that they are set through environment variables. + :issue:`2836` +- Add ``--env-file`` option to the ``flask`` CLI. This allows + specifying a dotenv file to load in addition to ``.env`` and + ``.flaskenv``. :issue:`3108` +- It is no longer required to decorate custom CLI commands on + ``app.cli`` or ``blueprint.cli`` with ``@with_appcontext``, an app + context will already be active at that point. :issue:`2410` +- ``SessionInterface.get_expiration_time`` uses a timezone-aware + value. :pr:`4645` +- View functions can return generators directly instead of wrapping + them in a ``Response``. :pr:`4629` +- Add ``stream_template`` and ``stream_template_string`` functions to + render a template as a stream of pieces. :pr:`4629` +- A new implementation of context preservation during debugging and + testing. :pr:`4666` + + - ``request``, ``g``, and other context-locals point to the + correct data when running code in the interactive debugger + console. :issue:`2836` + - Teardown functions are always run at the end of the request, + even if the context is preserved. They are also run after the + preserved context is popped. + - ``stream_with_context`` preserves context separately from a + ``with client`` block. It will be cleaned up when + ``response.get_data()`` or ``response.close()`` is called. + +- Allow returning a list from a view function, to convert it to a + JSON response like a dict is. :issue:`4672` +- When type checking, allow ``TypedDict`` to be returned from view + functions. :pr:`4695` +- Remove the ``--eager-loading/--lazy-loading`` options from the + ``flask run`` command. The app is always eager loaded the first + time, then lazily loaded in the reloader. The reloader always prints + errors immediately but continues serving. Remove the internal + ``DispatchingApp`` middleware used by the previous implementation. + :issue:`4715` + + +Version 2.1.3 +------------- + +Released 2022-07-13 + +- Inline some optional imports that are only used for certain CLI + commands. :pr:`4606` +- Relax type annotation for ``after_request`` functions. :issue:`4600` +- ``instance_path`` for namespace packages uses the path closest to + the imported submodule. :issue:`4610` +- Clearer error message when ``render_template`` and + ``render_template_string`` are used outside an application context. + :pr:`4693` + + +Version 2.1.2 +------------- + +Released 2022-04-28 + +- Fix type annotation for ``json.loads``, it accepts str or bytes. + :issue:`4519` +- The ``--cert`` and ``--key`` options on ``flask run`` can be given + in either order. :issue:`4459` + + +Version 2.1.1 +------------- + +Released on 2022-03-30 + +- Set the minimum required version of importlib_metadata to 3.6.0, + which is required on Python < 3.10. :issue:`4502` + + +Version 2.1.0 +------------- + +Released 2022-03-28 + +- Drop support for Python 3.6. :pr:`4335` +- Update Click dependency to >= 8.0. :pr:`4008` +- Remove previously deprecated code. :pr:`4337` + + - The CLI does not pass ``script_info`` to app factory functions. + - ``config.from_json`` is replaced by + ``config.from_file(name, load=json.load)``. + - ``json`` functions no longer take an ``encoding`` parameter. + - ``safe_join`` is removed, use ``werkzeug.utils.safe_join`` + instead. + - ``total_seconds`` is removed, use ``timedelta.total_seconds`` + instead. + - The same blueprint cannot be registered with the same name. Use + ``name=`` when registering to specify a unique name. + - The test client's ``as_tuple`` parameter is removed. Use + ``response.request.environ`` instead. :pr:`4417` + +- Some parameters in ``send_file`` and ``send_from_directory`` were + renamed in 2.0. The deprecation period for the old names is extended + to 2.2. Be sure to test with deprecation warnings visible. + + - ``attachment_filename`` is renamed to ``download_name``. + - ``cache_timeout`` is renamed to ``max_age``. + - ``add_etags`` is renamed to ``etag``. + - ``filename`` is renamed to ``path``. + +- The ``RequestContext.g`` property is deprecated. Use ``g`` directly + or ``AppContext.g`` instead. :issue:`3898` +- ``copy_current_request_context`` can decorate async functions. + :pr:`4303` +- The CLI uses ``importlib.metadata`` instead of ``pkg_resources`` to + load command entry points. :issue:`4419` +- Overriding ``FlaskClient.open`` will not cause an error on redirect. + :issue:`3396` +- Add an ``--exclude-patterns`` option to the ``flask run`` CLI + command to specify patterns that will be ignored by the reloader. + :issue:`4188` +- When using lazy loading (the default with the debugger), the Click + context from the ``flask run`` command remains available in the + loader thread. :issue:`4460` +- Deleting the session cookie uses the ``httponly`` flag. + :issue:`4485` +- Relax typing for ``errorhandler`` to allow the user to use more + precise types and decorate the same function multiple times. + :issue:`4095, 4295, 4297` +- Fix typing for ``__exit__`` methods for better compatibility with + ``ExitStack``. :issue:`4474` +- From Werkzeug, for redirect responses the ``Location`` header URL + will remain relative, and exclude the scheme and domain, by default. + :pr:`4496` +- Add ``Config.from_prefixed_env()`` to load config values from + environment variables that start with ``FLASK_`` or another prefix. + This parses values as JSON by default, and allows setting keys in + nested dicts. :pr:`4479` + + +Version 2.0.3 +------------- + +Released 2022-02-14 + +- The test client's ``as_tuple`` parameter is deprecated and will be + removed in Werkzeug 2.1. It is now also deprecated in Flask, to be + removed in Flask 2.1, while remaining compatible with both in + 2.0.x. Use ``response.request.environ`` instead. :pr:`4341` +- Fix type annotation for ``errorhandler`` decorator. :issue:`4295` +- Revert a change to the CLI that caused it to hide ``ImportError`` + tracebacks when importing the application. :issue:`4307` +- ``app.json_encoder`` and ``json_decoder`` are only passed to + ``dumps`` and ``loads`` if they have custom behavior. This improves + performance, mainly on PyPy. :issue:`4349` +- Clearer error message when ``after_this_request`` is used outside a + request context. :issue:`4333` + + +Version 2.0.2 +------------- + +Released 2021-10-04 + +- Fix type annotation for ``teardown_*`` methods. :issue:`4093` +- Fix type annotation for ``before_request`` and ``before_app_request`` + decorators. :issue:`4104` +- Fixed the issue where typing requires template global + decorators to accept functions with no arguments. :issue:`4098` +- Support View and MethodView instances with async handlers. :issue:`4112` +- Enhance typing of ``app.errorhandler`` decorator. :issue:`4095` +- Fix registering a blueprint twice with differing names. :issue:`4124` +- Fix the type of ``static_folder`` to accept ``pathlib.Path``. + :issue:`4150` +- ``jsonify`` handles ``decimal.Decimal`` by encoding to ``str``. + :issue:`4157` +- Correctly handle raising deferred errors in CLI lazy loading. + :issue:`4096` +- The CLI loader handles ``**kwargs`` in a ``create_app`` function. + :issue:`4170` +- Fix the order of ``before_request`` and other callbacks that trigger + before the view returns. They are called from the app down to the + closest nested blueprint. :issue:`4229` + + +Version 2.0.1 +------------- + +Released 2021-05-21 + +- Re-add the ``filename`` parameter in ``send_from_directory``. The + ``filename`` parameter has been renamed to ``path``, the old name + is deprecated. :pr:`4019` +- Mark top-level names as exported so type checking understands + imports in user projects. :issue:`4024` +- Fix type annotation for ``g`` and inform mypy that it is a namespace + object that has arbitrary attributes. :issue:`4020` +- Fix some types that weren't available in Python 3.6.0. :issue:`4040` +- Improve typing for ``send_file``, ``send_from_directory``, and + ``get_send_file_max_age``. :issue:`4044`, :pr:`4026` +- Show an error when a blueprint name contains a dot. The ``.`` has + special meaning, it is used to separate (nested) blueprint names and + the endpoint name. :issue:`4041` +- Combine URL prefixes when nesting blueprints that were created with + a ``url_prefix`` value. :issue:`4037` +- Revert a change to the order that URL matching was done. The + URL is again matched after the session is loaded, so the session is + available in custom URL converters. :issue:`4053` +- Re-add deprecated ``Config.from_json``, which was accidentally + removed early. :issue:`4078` +- Improve typing for some functions using ``Callable`` in their type + signatures, focusing on decorator factories. :issue:`4060` +- Nested blueprints are registered with their dotted name. This allows + different blueprints with the same name to be nested at different + locations. :issue:`4069` +- ``register_blueprint`` takes a ``name`` option to change the + (pre-dotted) name the blueprint is registered with. This allows the + same blueprint to be registered multiple times with unique names for + ``url_for``. Registering the same blueprint with the same name + multiple times is deprecated. :issue:`1091` +- Improve typing for ``stream_with_context``. :issue:`4052` + + +Version 2.0.0 +------------- + +Released 2021-05-11 + +- Drop support for Python 2 and 3.5. +- Bump minimum versions of other Pallets projects: Werkzeug >= 2, + Jinja2 >= 3, MarkupSafe >= 2, ItsDangerous >= 2, Click >= 8. Be sure + to check the change logs for each project. For better compatibility + with other applications (e.g. Celery) that still require Click 7, + there is no hard dependency on Click 8 yet, but using Click 7 will + trigger a DeprecationWarning and Flask 2.1 will depend on Click 8. +- JSON support no longer uses simplejson. To use another JSON module, + override ``app.json_encoder`` and ``json_decoder``. :issue:`3555` +- The ``encoding`` option to JSON functions is deprecated. :pr:`3562` +- Passing ``script_info`` to app factory functions is deprecated. This + was not portable outside the ``flask`` command. Use + ``click.get_current_context().obj`` if it's needed. :issue:`3552` +- The CLI shows better error messages when the app failed to load + when looking up commands. :issue:`2741` +- Add ``SessionInterface.get_cookie_name`` to allow setting the + session cookie name dynamically. :pr:`3369` +- Add ``Config.from_file`` to load config using arbitrary file + loaders, such as ``toml.load`` or ``json.load``. + ``Config.from_json`` is deprecated in favor of this. :pr:`3398` +- The ``flask run`` command will only defer errors on reload. Errors + present during the initial call will cause the server to exit with + the traceback immediately. :issue:`3431` +- ``send_file`` raises a ``ValueError`` when passed an ``io`` object + in text mode. Previously, it would respond with 200 OK and an empty + file. :issue:`3358` +- When using ad-hoc certificates, check for the cryptography library + instead of PyOpenSSL. :pr:`3492` +- When specifying a factory function with ``FLASK_APP``, keyword + argument can be passed. :issue:`3553` +- When loading a ``.env`` or ``.flaskenv`` file, the current working + directory is no longer changed to the location of the file. + :pr:`3560` +- When returning a ``(response, headers)`` tuple from a view, the + headers replace rather than extend existing headers on the response. + For example, this allows setting the ``Content-Type`` for + ``jsonify()``. Use ``response.headers.extend()`` if extending is + desired. :issue:`3628` +- The ``Scaffold`` class provides a common API for the ``Flask`` and + ``Blueprint`` classes. ``Blueprint`` information is stored in + attributes just like ``Flask``, rather than opaque lambda functions. + This is intended to improve consistency and maintainability. + :issue:`3215` +- Include ``samesite`` and ``secure`` options when removing the + session cookie. :pr:`3726` +- Support passing a ``pathlib.Path`` to ``static_folder``. :pr:`3579` +- ``send_file`` and ``send_from_directory`` are wrappers around the + implementations in ``werkzeug.utils``. :pr:`3828` +- Some ``send_file`` parameters have been renamed, the old names are + deprecated. ``attachment_filename`` is renamed to ``download_name``. + ``cache_timeout`` is renamed to ``max_age``. ``add_etags`` is + renamed to ``etag``. :pr:`3828, 3883` +- ``send_file`` passes ``download_name`` even if + ``as_attachment=False`` by using ``Content-Disposition: inline``. + :pr:`3828` +- ``send_file`` sets ``conditional=True`` and ``max_age=None`` by + default. ``Cache-Control`` is set to ``no-cache`` if ``max_age`` is + not set, otherwise ``public``. This tells browsers to validate + conditional requests instead of using a timed cache. :pr:`3828` +- ``helpers.safe_join`` is deprecated. Use + ``werkzeug.utils.safe_join`` instead. :pr:`3828` +- The request context does route matching before opening the session. + This could allow a session interface to change behavior based on + ``request.endpoint``. :issue:`3776` +- Use Jinja's implementation of the ``|tojson`` filter. :issue:`3881` +- Add route decorators for common HTTP methods. For example, + ``@app.post("/login")`` is a shortcut for + ``@app.route("/login", methods=["POST"])``. :pr:`3907` +- Support async views, error handlers, before and after request, and + teardown functions. :pr:`3412` +- Support nesting blueprints. :issue:`593, 1548`, :pr:`3923` +- Set the default encoding to "UTF-8" when loading ``.env`` and + ``.flaskenv`` files to allow to use non-ASCII characters. :issue:`3931` +- ``flask shell`` sets up tab and history completion like the default + ``python`` shell if ``readline`` is installed. :issue:`3941` +- ``helpers.total_seconds()`` is deprecated. Use + ``timedelta.total_seconds()`` instead. :pr:`3962` +- Add type hinting. :pr:`3973`. + + +Version 1.1.4 +------------- + +Released 2021-05-13 + +- Update ``static_folder`` to use ``_compat.fspath`` instead of + ``os.fspath`` to continue supporting Python < 3.6 :issue:`4050` + + +Version 1.1.3 +------------- + +Released 2021-05-13 + +- Set maximum versions of Werkzeug, Jinja, Click, and ItsDangerous. + :issue:`4043` +- Re-add support for passing a ``pathlib.Path`` for ``static_folder``. + :pr:`3579` + + +Version 1.1.2 +------------- + +Released 2020-04-03 + +- Work around an issue when running the ``flask`` command with an + external debugger on Windows. :issue:`3297` +- The static route will not catch all URLs if the ``Flask`` + ``static_folder`` argument ends with a slash. :issue:`3452` + + +Version 1.1.1 +------------- + +Released 2019-07-08 + +- The ``flask.json_available`` flag was added back for compatibility + with some extensions. It will raise a deprecation warning when used, + and will be removed in version 2.0.0. :issue:`3288` + + +Version 1.1.0 +------------- + +Released 2019-07-04 + +- Bump minimum Werkzeug version to >= 0.15. +- Drop support for Python 3.4. +- Error handlers for ``InternalServerError`` or ``500`` will always be + passed an instance of ``InternalServerError``. If they are invoked + due to an unhandled exception, that original exception is now + available as ``e.original_exception`` rather than being passed + directly to the handler. The same is true if the handler is for the + base ``HTTPException``. This makes error handler behavior more + consistent. :pr:`3266` + + - ``Flask.finalize_request`` is called for all unhandled + exceptions even if there is no ``500`` error handler. + +- ``Flask.logger`` takes the same name as ``Flask.name`` (the value + passed as ``Flask(import_name)``. This reverts 1.0's behavior of + always logging to ``"flask.app"``, in order to support multiple apps + in the same process. A warning will be shown if old configuration is + detected that needs to be moved. :issue:`2866` +- ``RequestContext.copy`` includes the current session object in the + request context copy. This prevents ``session`` pointing to an + out-of-date object. :issue:`2935` +- Using built-in RequestContext, unprintable Unicode characters in + Host header will result in a HTTP 400 response and not HTTP 500 as + previously. :pr:`2994` +- ``send_file`` supports ``PathLike`` objects as described in + :pep:`519`, to support ``pathlib`` in Python 3. :pr:`3059` +- ``send_file`` supports ``BytesIO`` partial content. + :issue:`2957` +- ``open_resource`` accepts the "rt" file mode. This still does the + same thing as "r". :issue:`3163` +- The ``MethodView.methods`` attribute set in a base class is used by + subclasses. :issue:`3138` +- ``Flask.jinja_options`` is a ``dict`` instead of an + ``ImmutableDict`` to allow easier configuration. Changes must still + be made before creating the environment. :pr:`3190` +- Flask's ``JSONMixin`` for the request and response wrappers was + moved into Werkzeug. Use Werkzeug's version with Flask-specific + support. This bumps the Werkzeug dependency to >= 0.15. + :issue:`3125` +- The ``flask`` command entry point is simplified to take advantage + of Werkzeug 0.15's better reloader support. This bumps the Werkzeug + dependency to >= 0.15. :issue:`3022` +- Support ``static_url_path`` that ends with a forward slash. + :issue:`3134` +- Support empty ``static_folder`` without requiring setting an empty + ``static_url_path`` as well. :pr:`3124` +- ``jsonify`` supports ``dataclass`` objects. :pr:`3195` +- Allow customizing the ``Flask.url_map_class`` used for routing. + :pr:`3069` +- The development server port can be set to 0, which tells the OS to + pick an available port. :issue:`2926` +- The return value from ``cli.load_dotenv`` is more consistent with + the documentation. It will return ``False`` if python-dotenv is not + installed, or if the given path isn't a file. :issue:`2937` +- Signaling support has a stub for the ``connect_via`` method when + the Blinker library is not installed. :pr:`3208` +- Add an ``--extra-files`` option to the ``flask run`` CLI command to + specify extra files that will trigger the reloader on change. + :issue:`2897` +- Allow returning a dictionary from a view function. Similar to how + returning a string will produce a ``text/html`` response, returning + a dict will call ``jsonify`` to produce a ``application/json`` + response. :pr:`3111` +- Blueprints have a ``cli`` Click group like ``app.cli``. CLI commands + registered with a blueprint will be available as a group under the + ``flask`` command. :issue:`1357`. +- When using the test client as a context manager (``with client:``), + all preserved request contexts are popped when the block exits, + ensuring nested contexts are cleaned up correctly. :pr:`3157` +- Show a better error message when the view return type is not + supported. :issue:`3214` +- ``flask.testing.make_test_environ_builder()`` has been deprecated in + favour of a new class ``flask.testing.EnvironBuilder``. :pr:`3232` +- The ``flask run`` command no longer fails if Python is not built + with SSL support. Using the ``--cert`` option will show an + appropriate error message. :issue:`3211` +- URL matching now occurs after the request context is pushed, rather + than when it's created. This allows custom URL converters to access + the app and request contexts, such as to query a database for an id. + :issue:`3088` + + +Version 1.0.4 +------------- + +Released 2019-07-04 + +- The key information for ``BadRequestKeyError`` is no longer cleared + outside debug mode, so error handlers can still access it. This + requires upgrading to Werkzeug 0.15.5. :issue:`3249` +- ``send_file`` url quotes the ":" and "/" characters for more + compatible UTF-8 filename support in some browsers. :issue:`3074` +- Fixes for :pep:`451` import loaders and pytest 5.x. :issue:`3275` +- Show message about dotenv on stderr instead of stdout. :issue:`3285` + + +Version 1.0.3 +------------- + +Released 2019-05-17 + +- ``send_file`` encodes filenames as ASCII instead of Latin-1 + (ISO-8859-1). This fixes compatibility with Gunicorn, which is + stricter about header encodings than :pep:`3333`. :issue:`2766` +- Allow custom CLIs using ``FlaskGroup`` to set the debug flag without + it always being overwritten based on environment variables. + :pr:`2765` +- ``flask --version`` outputs Werkzeug's version and simplifies the + Python version. :pr:`2825` +- ``send_file`` handles an ``attachment_filename`` that is a native + Python 2 string (bytes) with UTF-8 coded bytes. :issue:`2933` +- A catch-all error handler registered for ``HTTPException`` will not + handle ``RoutingException``, which is used internally during + routing. This fixes the unexpected behavior that had been introduced + in 1.0. :pr:`2986` +- Passing the ``json`` argument to ``app.test_client`` does not + push/pop an extra app context. :issue:`2900` + + +Version 1.0.2 +------------- + +Released 2018-05-02 + +- Fix more backwards compatibility issues with merging slashes between + a blueprint prefix and route. :pr:`2748` +- Fix error with ``flask routes`` command when there are no routes. + :issue:`2751` + + +Version 1.0.1 +------------- + +Released 2018-04-29 + +- Fix registering partials (with no ``__name__``) as view functions. + :pr:`2730` +- Don't treat lists returned from view functions the same as tuples. + Only tuples are interpreted as response data. :issue:`2736` +- Extra slashes between a blueprint's ``url_prefix`` and a route URL + are merged. This fixes some backwards compatibility issues with the + change in 1.0. :issue:`2731`, :issue:`2742` +- Only trap ``BadRequestKeyError`` errors in debug mode, not all + ``BadRequest`` errors. This allows ``abort(400)`` to continue + working as expected. :issue:`2735` +- The ``FLASK_SKIP_DOTENV`` environment variable can be set to ``1`` + to skip automatically loading dotenv files. :issue:`2722` + + +Version 1.0 +----------- + +Released 2018-04-26 + +- Python 2.6 and 3.3 are no longer supported. +- Bump minimum dependency versions to the latest stable versions: + Werkzeug >= 0.14, Jinja >= 2.10, itsdangerous >= 0.24, Click >= 5.1. + :issue:`2586` +- Skip ``app.run`` when a Flask application is run from the command + line. This avoids some behavior that was confusing to debug. +- Change the default for ``JSONIFY_PRETTYPRINT_REGULAR`` to + ``False``. ``~json.jsonify`` returns a compact format by default, + and an indented format in debug mode. :pr:`2193` +- ``Flask.__init__`` accepts the ``host_matching`` argument and sets + it on ``Flask.url_map``. :issue:`1559` +- ``Flask.__init__`` accepts the ``static_host`` argument and passes + it as the ``host`` argument when defining the static route. + :issue:`1559` +- ``send_file`` supports Unicode in ``attachment_filename``. + :pr:`2223` +- Pass ``_scheme`` argument from ``url_for`` to + ``Flask.handle_url_build_error``. :pr:`2017` +- ``Flask.add_url_rule`` accepts the ``provide_automatic_options`` + argument to disable adding the ``OPTIONS`` method. :pr:`1489` +- ``MethodView`` subclasses inherit method handlers from base classes. + :pr:`1936` +- Errors caused while opening the session at the beginning of the + request are handled by the app's error handlers. :pr:`2254` +- Blueprints gained ``Blueprint.json_encoder`` and + ``Blueprint.json_decoder`` attributes to override the app's + encoder and decoder. :pr:`1898` +- ``Flask.make_response`` raises ``TypeError`` instead of + ``ValueError`` for bad response types. The error messages have been + improved to describe why the type is invalid. :pr:`2256` +- Add ``routes`` CLI command to output routes registered on the + application. :pr:`2259` +- Show warning when session cookie domain is a bare hostname or an IP + address, as these may not behave properly in some browsers, such as + Chrome. :pr:`2282` +- Allow IP address as exact session cookie domain. :pr:`2282` +- ``SESSION_COOKIE_DOMAIN`` is set if it is detected through + ``SERVER_NAME``. :pr:`2282` +- Auto-detect zero-argument app factory called ``create_app`` or + ``make_app`` from ``FLASK_APP``. :pr:`2297` +- Factory functions are not required to take a ``script_info`` + parameter to work with the ``flask`` command. If they take a single + parameter or a parameter named ``script_info``, the ``ScriptInfo`` + object will be passed. :pr:`2319` +- ``FLASK_APP`` can be set to an app factory, with arguments if + needed, for example ``FLASK_APP=myproject.app:create_app('dev')``. + :pr:`2326` +- ``FLASK_APP`` can point to local packages that are not installed in + editable mode, although ``pip install -e`` is still preferred. + :pr:`2414` +- The ``View`` class attribute + ``View.provide_automatic_options`` is set in ``View.as_view``, to be + detected by ``Flask.add_url_rule``. :pr:`2316` +- Error handling will try handlers registered for ``blueprint, code``, + ``app, code``, ``blueprint, exception``, ``app, exception``. + :pr:`2314` +- ``Cookie`` is added to the response's ``Vary`` header if the session + is accessed at all during the request (and not deleted). :pr:`2288` +- ``Flask.test_request_context`` accepts ``subdomain`` and + ``url_scheme`` arguments for use when building the base URL. + :pr:`1621` +- Set ``APPLICATION_ROOT`` to ``'/'`` by default. This was already the + implicit default when it was set to ``None``. +- ``TRAP_BAD_REQUEST_ERRORS`` is enabled by default in debug mode. + ``BadRequestKeyError`` has a message with the bad key in debug mode + instead of the generic bad request message. :pr:`2348` +- Allow registering new tags with ``TaggedJSONSerializer`` to support + storing other types in the session cookie. :pr:`2352` +- Only open the session if the request has not been pushed onto the + context stack yet. This allows ``stream_with_context`` generators to + access the same session that the containing view uses. :pr:`2354` +- Add ``json`` keyword argument for the test client request methods. + This will dump the given object as JSON and set the appropriate + content type. :pr:`2358` +- Extract JSON handling to a mixin applied to both the ``Request`` and + ``Response`` classes. This adds the ``Response.is_json`` and + ``Response.get_json`` methods to the response to make testing JSON + response much easier. :pr:`2358` +- Removed error handler caching because it caused unexpected results + for some exception inheritance hierarchies. Register handlers + explicitly for each exception if you want to avoid traversing the + MRO. :pr:`2362` +- Fix incorrect JSON encoding of aware, non-UTC datetimes. :pr:`2374` +- Template auto reloading will honor debug mode even if + ``Flask.jinja_env`` was already accessed. :pr:`2373` +- The following old deprecated code was removed. :issue:`2385` + + - ``flask.ext`` - import extensions directly by their name instead + of through the ``flask.ext`` namespace. For example, + ``import flask.ext.sqlalchemy`` becomes + ``import flask_sqlalchemy``. + - ``Flask.init_jinja_globals`` - extend + ``Flask.create_jinja_environment`` instead. + - ``Flask.error_handlers`` - tracked by + ``Flask.error_handler_spec``, use ``Flask.errorhandler`` + to register handlers. + - ``Flask.request_globals_class`` - use + ``Flask.app_ctx_globals_class`` instead. + - ``Flask.static_path`` - use ``Flask.static_url_path`` instead. + - ``Request.module`` - use ``Request.blueprint`` instead. + +- The ``Request.json`` property is no longer deprecated. :issue:`1421` +- Support passing a ``EnvironBuilder`` or ``dict`` to + ``test_client.open``. :pr:`2412` +- The ``flask`` command and ``Flask.run`` will load environment + variables from ``.env`` and ``.flaskenv`` files if python-dotenv is + installed. :pr:`2416` +- When passing a full URL to the test client, the scheme in the URL is + used instead of ``PREFERRED_URL_SCHEME``. :pr:`2430` +- ``Flask.logger`` has been simplified. ``LOGGER_NAME`` and + ``LOGGER_HANDLER_POLICY`` config was removed. The logger is always + named ``flask.app``. The level is only set on first access, it + doesn't check ``Flask.debug`` each time. Only one format is used, + not different ones depending on ``Flask.debug``. No handlers are + removed, and a handler is only added if no handlers are already + configured. :pr:`2436` +- Blueprint view function names may not contain dots. :pr:`2450` +- Fix a ``ValueError`` caused by invalid ``Range`` requests in some + cases. :issue:`2526` +- The development server uses threads by default. :pr:`2529` +- Loading config files with ``silent=True`` will ignore ``ENOTDIR`` + errors. :pr:`2581` +- Pass ``--cert`` and ``--key`` options to ``flask run`` to run the + development server over HTTPS. :pr:`2606` +- Added ``SESSION_COOKIE_SAMESITE`` to control the ``SameSite`` + attribute on the session cookie. :pr:`2607` +- Added ``Flask.test_cli_runner`` to create a Click runner that can + invoke Flask CLI commands for testing. :pr:`2636` +- Subdomain matching is disabled by default and setting + ``SERVER_NAME`` does not implicitly enable it. It can be enabled by + passing ``subdomain_matching=True`` to the ``Flask`` constructor. + :pr:`2635` +- A single trailing slash is stripped from the blueprint + ``url_prefix`` when it is registered with the app. :pr:`2629` +- ``Request.get_json`` doesn't cache the result if parsing fails when + ``silent`` is true. :issue:`2651` +- ``Request.get_json`` no longer accepts arbitrary encodings. Incoming + JSON should be encoded using UTF-8 per :rfc:`8259`, but Flask will + autodetect UTF-8, -16, or -32. :pr:`2691` +- Added ``MAX_COOKIE_SIZE`` and ``Response.max_cookie_size`` to + control when Werkzeug warns about large cookies that browsers may + ignore. :pr:`2693` +- Updated documentation theme to make docs look better in small + windows. :pr:`2709` +- Rewrote the tutorial docs and example project to take a more + structured approach to help new users avoid common pitfalls. + :pr:`2676` + + +Version 0.12.5 +-------------- + +Released 2020-02-10 + +- Pin Werkzeug to < 1.0.0. :issue:`3497` + + +Version 0.12.4 +-------------- + +Released 2018-04-29 + +- Repackage 0.12.3 to fix package layout issue. :issue:`2728` + + +Version 0.12.3 +-------------- + +Released 2018-04-26 + +- ``Request.get_json`` no longer accepts arbitrary encodings. + Incoming JSON should be encoded using UTF-8 per :rfc:`8259`, but + Flask will autodetect UTF-8, -16, or -32. :issue:`2692` +- Fix a Python warning about imports when using ``python -m flask``. + :issue:`2666` +- Fix a ``ValueError`` caused by invalid ``Range`` requests in some + cases. + + +Version 0.12.2 +-------------- + +Released 2017-05-16 + +- Fix a bug in ``safe_join`` on Windows. + + +Version 0.12.1 +-------------- + +Released 2017-03-31 + +- Prevent ``flask run`` from showing a ``NoAppException`` when an + ``ImportError`` occurs within the imported application module. +- Fix encoding behavior of ``app.config.from_pyfile`` for Python 3. + :issue:`2118` +- Use the ``SERVER_NAME`` config if it is present as default values + for ``app.run``. :issue:`2109`, :pr:`2152` +- Call ``ctx.auto_pop`` with the exception object instead of ``None``, + in the event that a ``BaseException`` such as ``KeyboardInterrupt`` + is raised in a request handler. + + +Version 0.12 +------------ + +Released 2016-12-21, codename Punsch + +- The cli command now responds to ``--version``. +- Mimetype guessing and ETag generation for file-like objects in + ``send_file`` has been removed. :issue:`104`, :pr`1849` +- Mimetype guessing in ``send_file`` now fails loudly and doesn't fall + back to ``application/octet-stream``. :pr:`1988` +- Make ``flask.safe_join`` able to join multiple paths like + ``os.path.join`` :pr:`1730` +- Revert a behavior change that made the dev server crash instead of + returning an Internal Server Error. :pr:`2006` +- Correctly invoke response handlers for both regular request + dispatching as well as error handlers. +- Disable logger propagation by default for the app logger. +- Add support for range requests in ``send_file``. +- ``app.test_client`` includes preset default environment, which can + now be directly set, instead of per ``client.get``. +- Fix crash when running under PyPy3. :pr:`1814` + + +Version 0.11.1 +-------------- + +Released 2016-06-07 + +- Fixed a bug that prevented ``FLASK_APP=foobar/__init__.py`` from + working. :pr:`1872` + + +Version 0.11 +------------ + +Released 2016-05-29, codename Absinthe + +- Added support to serializing top-level arrays to ``jsonify``. This + introduces a security risk in ancient browsers. +- Added before_render_template signal. +- Added ``**kwargs`` to ``Flask.test_client`` to support passing + additional keyword arguments to the constructor of + ``Flask.test_client_class``. +- Added ``SESSION_REFRESH_EACH_REQUEST`` config key that controls the + set-cookie behavior. If set to ``True`` a permanent session will be + refreshed each request and get their lifetime extended, if set to + ``False`` it will only be modified if the session actually modifies. + Non permanent sessions are not affected by this and will always + expire if the browser window closes. +- Made Flask support custom JSON mimetypes for incoming data. +- Added support for returning tuples in the form ``(response, + headers)`` from a view function. +- Added ``Config.from_json``. +- Added ``Flask.config_class``. +- Added ``Config.get_namespace``. +- Templates are no longer automatically reloaded outside of debug + mode. This can be configured with the new ``TEMPLATES_AUTO_RELOAD`` + config key. +- Added a workaround for a limitation in Python 3.3's namespace + loader. +- Added support for explicit root paths when using Python 3.3's + namespace packages. +- Added ``flask`` and the ``flask.cli`` module to start the + local debug server through the click CLI system. This is recommended + over the old ``flask.run()`` method as it works faster and more + reliable due to a different design and also replaces + ``Flask-Script``. +- Error handlers that match specific classes are now checked first, + thereby allowing catching exceptions that are subclasses of HTTP + exceptions (in ``werkzeug.exceptions``). This makes it possible for + an extension author to create exceptions that will by default result + in the HTTP error of their choosing, but may be caught with a custom + error handler if desired. +- Added ``Config.from_mapping``. +- Flask will now log by default even if debug is disabled. The log + format is now hardcoded but the default log handling can be disabled + through the ``LOGGER_HANDLER_POLICY`` configuration key. +- Removed deprecated module functionality. +- Added the ``EXPLAIN_TEMPLATE_LOADING`` config flag which when + enabled will instruct Flask to explain how it locates templates. + This should help users debug when the wrong templates are loaded. +- Enforce blueprint handling in the order they were registered for + template loading. +- Ported test suite to py.test. +- Deprecated ``request.json`` in favour of ``request.get_json()``. +- Add "pretty" and "compressed" separators definitions in jsonify() + method. Reduces JSON response size when + ``JSONIFY_PRETTYPRINT_REGULAR=False`` by removing unnecessary white + space included by default after separators. +- JSON responses are now terminated with a newline character, because + it is a convention that UNIX text files end with a newline and some + clients don't deal well when this newline is missing. :pr:`1262` +- The automatically provided ``OPTIONS`` method is now correctly + disabled if the user registered an overriding rule with the + lowercase-version ``options``. :issue:`1288` +- ``flask.json.jsonify`` now supports the ``datetime.date`` type. + :pr:`1326` +- Don't leak exception info of already caught exceptions to context + teardown handlers. :pr:`1393` +- Allow custom Jinja environment subclasses. :pr:`1422` +- Updated extension dev guidelines. +- ``flask.g`` now has ``pop()`` and ``setdefault`` methods. +- Turn on autoescape for ``flask.templating.render_template_string`` + by default. :pr:`1515` +- ``flask.ext`` is now deprecated. :pr:`1484` +- ``send_from_directory`` now raises BadRequest if the filename is + invalid on the server OS. :pr:`1763` +- Added the ``JSONIFY_MIMETYPE`` configuration variable. :pr:`1728` +- Exceptions during teardown handling will no longer leave bad + application contexts lingering around. +- Fixed broken ``test_appcontext_signals()`` test case. +- Raise an ``AttributeError`` in ``helpers.find_package`` with a + useful message explaining why it is raised when a :pep:`302` import + hook is used without an ``is_package()`` method. +- Fixed an issue causing exceptions raised before entering a request + or app context to be passed to teardown handlers. +- Fixed an issue with query parameters getting removed from requests + in the test client when absolute URLs were requested. +- Made ``@before_first_request`` into a decorator as intended. +- Fixed an etags bug when sending a file streams with a name. +- Fixed ``send_from_directory`` not expanding to the application root + path correctly. +- Changed logic of before first request handlers to flip the flag + after invoking. This will allow some uses that are potentially + dangerous but should probably be permitted. +- Fixed Python 3 bug when a handler from + ``app.url_build_error_handlers`` reraises the ``BuildError``. + + +Version 0.10.1 +-------------- + +Released 2013-06-14 + +- Fixed an issue where ``|tojson`` was not quoting single quotes which + made the filter not work properly in HTML attributes. Now it's + possible to use that filter in single quoted attributes. This should + make using that filter with angular.js easier. +- Added support for byte strings back to the session system. This + broke compatibility with the common case of people putting binary + data for token verification into the session. +- Fixed an issue where registering the same method twice for the same + endpoint would trigger an exception incorrectly. + + +Version 0.10 +------------ + +Released 2013-06-13, codename Limoncello + +- Changed default cookie serialization format from pickle to JSON to + limit the impact an attacker can do if the secret key leaks. +- Added ``template_test`` methods in addition to the already existing + ``template_filter`` method family. +- Added ``template_global`` methods in addition to the already + existing ``template_filter`` method family. +- Set the content-length header for x-sendfile. +- ``tojson`` filter now does not escape script blocks in HTML5 + parsers. +- ``tojson`` used in templates is now safe by default. This was + allowed due to the different escaping behavior. +- Flask will now raise an error if you attempt to register a new + function on an already used endpoint. +- Added wrapper module around simplejson and added default + serialization of datetime objects. This allows much easier + customization of how JSON is handled by Flask or any Flask + extension. +- Removed deprecated internal ``flask.session`` module alias. Use + ``flask.sessions`` instead to get the session module. This is not to + be confused with ``flask.session`` the session proxy. +- Templates can now be rendered without request context. The behavior + is slightly different as the ``request``, ``session`` and ``g`` + objects will not be available and blueprint's context processors are + not called. +- The config object is now available to the template as a real global + and not through a context processor which makes it available even in + imported templates by default. +- Added an option to generate non-ascii encoded JSON which should + result in less bytes being transmitted over the network. It's + disabled by default to not cause confusion with existing libraries + that might expect ``flask.json.dumps`` to return bytes by default. +- ``flask.g`` is now stored on the app context instead of the request + context. +- ``flask.g`` now gained a ``get()`` method for not erroring out on + non existing items. +- ``flask.g`` now can be used with the ``in`` operator to see what's + defined and it now is iterable and will yield all attributes stored. +- ``flask.Flask.request_globals_class`` got renamed to + ``flask.Flask.app_ctx_globals_class`` which is a better name to what + it does since 0.10. +- ``request``, ``session`` and ``g`` are now also added as proxies to + the template context which makes them available in imported + templates. One has to be very careful with those though because + usage outside of macros might cause caching. +- Flask will no longer invoke the wrong error handlers if a proxy + exception is passed through. +- Added a workaround for chrome's cookies in localhost not working as + intended with domain names. +- Changed logic for picking defaults for cookie values from sessions + to work better with Google Chrome. +- Added ``message_flashed`` signal that simplifies flashing testing. +- Added support for copying of request contexts for better working + with greenlets. +- Removed custom JSON HTTP exception subclasses. If you were relying + on them you can reintroduce them again yourself trivially. Using + them however is strongly discouraged as the interface was flawed. +- Python requirements changed: requiring Python 2.6 or 2.7 now to + prepare for Python 3.3 port. +- Changed how the teardown system is informed about exceptions. This + is now more reliable in case something handles an exception halfway + through the error handling process. +- Request context preservation in debug mode now keeps the exception + information around which means that teardown handlers are able to + distinguish error from success cases. +- Added the ``JSONIFY_PRETTYPRINT_REGULAR`` configuration variable. +- Flask now orders JSON keys by default to not trash HTTP caches due + to different hash seeds between different workers. +- Added ``appcontext_pushed`` and ``appcontext_popped`` signals. +- The builtin run method now takes the ``SERVER_NAME`` into account + when picking the default port to run on. +- Added ``flask.request.get_json()`` as a replacement for the old + ``flask.request.json`` property. + + +Version 0.9 +----------- + +Released 2012-07-01, codename Campari + +- The ``Request.on_json_loading_failed`` now returns a JSON formatted + response by default. +- The ``url_for`` function now can generate anchors to the generated + links. +- The ``url_for`` function now can also explicitly generate URL rules + specific to a given HTTP method. +- Logger now only returns the debug log setting if it was not set + explicitly. +- Unregister a circular dependency between the WSGI environment and + the request object when shutting down the request. This means that + environ ``werkzeug.request`` will be ``None`` after the response was + returned to the WSGI server but has the advantage that the garbage + collector is not needed on CPython to tear down the request unless + the user created circular dependencies themselves. +- Session is now stored after callbacks so that if the session payload + is stored in the session you can still modify it in an after request + callback. +- The ``Flask`` class will avoid importing the provided import name if + it can (the required first parameter), to benefit tools which build + Flask instances programmatically. The Flask class will fall back to + using import on systems with custom module hooks, e.g. Google App + Engine, or when the import name is inside a zip archive (usually an + egg) prior to Python 2.7. +- Blueprints now have a decorator to add custom template filters + application wide, ``Blueprint.app_template_filter``. +- The Flask and Blueprint classes now have a non-decorator method for + adding custom template filters application wide, + ``Flask.add_template_filter`` and + ``Blueprint.add_app_template_filter``. +- The ``get_flashed_messages`` function now allows rendering flashed + message categories in separate blocks, through a ``category_filter`` + argument. +- The ``Flask.run`` method now accepts ``None`` for ``host`` and + ``port`` arguments, using default values when ``None``. This allows + for calling run using configuration values, e.g. + ``app.run(app.config.get('MYHOST'), app.config.get('MYPORT'))``, + with proper behavior whether or not a config file is provided. +- The ``render_template`` method now accepts a either an iterable of + template names or a single template name. Previously, it only + accepted a single template name. On an iterable, the first template + found is rendered. +- Added ``Flask.app_context`` which works very similar to the request + context but only provides access to the current application. This + also adds support for URL generation without an active request + context. +- View functions can now return a tuple with the first instance being + an instance of ``Response``. This allows for returning + ``jsonify(error="error msg"), 400`` from a view function. +- ``Flask`` and ``Blueprint`` now provide a ``get_send_file_max_age`` + hook for subclasses to override behavior of serving static files + from Flask when using ``Flask.send_static_file`` (used for the + default static file handler) and ``helpers.send_file``. This hook is + provided a filename, which for example allows changing cache + controls by file extension. The default max-age for ``send_file`` + and static files can be configured through a new + ``SEND_FILE_MAX_AGE_DEFAULT`` configuration variable, which is used + in the default ``get_send_file_max_age`` implementation. +- Fixed an assumption in sessions implementation which could break + message flashing on sessions implementations which use external + storage. +- Changed the behavior of tuple return values from functions. They are + no longer arguments to the response object, they now have a defined + meaning. +- Added ``Flask.request_globals_class`` to allow a specific class to + be used on creation of the ``g`` instance of each request. +- Added ``required_methods`` attribute to view functions to force-add + methods on registration. +- Added ``flask.after_this_request``. +- Added ``flask.stream_with_context`` and the ability to push contexts + multiple times without producing unexpected behavior. + + +Version 0.8.1 +------------- + +Released 2012-07-01 + +- Fixed an issue with the undocumented ``flask.session`` module to not + work properly on Python 2.5. It should not be used but did cause + some problems for package managers. + + +Version 0.8 +----------- + +Released 2011-09-29, codename Rakija + +- Refactored session support into a session interface so that the + implementation of the sessions can be changed without having to + override the Flask class. +- Empty session cookies are now deleted properly automatically. +- View functions can now opt out of getting the automatic OPTIONS + implementation. +- HTTP exceptions and Bad Request errors can now be trapped so that + they show up normally in the traceback. +- Flask in debug mode is now detecting some common problems and tries + to warn you about them. +- Flask in debug mode will now complain with an assertion error if a + view was attached after the first request was handled. This gives + earlier feedback when users forget to import view code ahead of + time. +- Added the ability to register callbacks that are only triggered once + at the beginning of the first request with + ``Flask.before_first_request``. +- Malformed JSON data will now trigger a bad request HTTP exception + instead of a value error which usually would result in a 500 + internal server error if not handled. This is a backwards + incompatible change. +- Applications now not only have a root path where the resources and + modules are located but also an instance path which is the + designated place to drop files that are modified at runtime (uploads + etc.). Also this is conceptually only instance depending and outside + version control so it's the perfect place to put configuration files + etc. +- Added the ``APPLICATION_ROOT`` configuration variable. +- Implemented ``TestClient.session_transaction`` to easily modify + sessions from the test environment. +- Refactored test client internally. The ``APPLICATION_ROOT`` + configuration variable as well as ``SERVER_NAME`` are now properly + used by the test client as defaults. +- Added ``View.decorators`` to support simpler decorating of pluggable + (class-based) views. +- Fixed an issue where the test client if used with the "with" + statement did not trigger the execution of the teardown handlers. +- Added finer control over the session cookie parameters. +- HEAD requests to a method view now automatically dispatch to the + ``get`` method if no handler was implemented. +- Implemented the virtual ``flask.ext`` package to import extensions + from. +- The context preservation on exceptions is now an integral component + of Flask itself and no longer of the test client. This cleaned up + some internal logic and lowers the odds of runaway request contexts + in unittests. +- Fixed the Jinja environment's ``list_templates`` method not + returning the correct names when blueprints or modules were + involved. + + +Version 0.7.2 +------------- + +Released 2011-07-06 + +- Fixed an issue with URL processors not properly working on + blueprints. + + +Version 0.7.1 +------------- + +Released 2011-06-29 + +- Added missing future import that broke 2.5 compatibility. +- Fixed an infinite redirect issue with blueprints. + + +Version 0.7 +----------- + +Released 2011-06-28, codename Grappa + +- Added ``Flask.make_default_options_response`` which can be used by + subclasses to alter the default behavior for ``OPTIONS`` responses. +- Unbound locals now raise a proper ``RuntimeError`` instead of an + ``AttributeError``. +- Mimetype guessing and etag support based on file objects is now + deprecated for ``send_file`` because it was unreliable. Pass + filenames instead or attach your own etags and provide a proper + mimetype by hand. +- Static file handling for modules now requires the name of the static + folder to be supplied explicitly. The previous autodetection was not + reliable and caused issues on Google's App Engine. Until 1.0 the old + behavior will continue to work but issue dependency warnings. +- Fixed a problem for Flask to run on jython. +- Added a ``PROPAGATE_EXCEPTIONS`` configuration variable that can be + used to flip the setting of exception propagation which previously + was linked to ``DEBUG`` alone and is now linked to either ``DEBUG`` + or ``TESTING``. +- Flask no longer internally depends on rules being added through the + ``add_url_rule`` function and can now also accept regular werkzeug + rules added to the url map. +- Added an ``endpoint`` method to the flask application object which + allows one to register a callback to an arbitrary endpoint with a + decorator. +- Use Last-Modified for static file sending instead of Date which was + incorrectly introduced in 0.6. +- Added ``create_jinja_loader`` to override the loader creation + process. +- Implemented a silent flag for ``config.from_pyfile``. +- Added ``teardown_request`` decorator, for functions that should run + at the end of a request regardless of whether an exception occurred. + Also the behavior for ``after_request`` was changed. It's now no + longer executed when an exception is raised. +- Implemented ``has_request_context``. +- Deprecated ``init_jinja_globals``. Override the + ``Flask.create_jinja_environment`` method instead to achieve the + same functionality. +- Added ``safe_join``. +- The automatic JSON request data unpacking now looks at the charset + mimetype parameter. +- Don't modify the session on ``get_flashed_messages`` if there are no + messages in the session. +- ``before_request`` handlers are now able to abort requests with + errors. +- It is not possible to define user exception handlers. That way you + can provide custom error messages from a central hub for certain + errors that might occur during request processing (for instance + database connection errors, timeouts from remote resources etc.). +- Blueprints can provide blueprint specific error handlers. +- Implemented generic class-based views. + + +Version 0.6.1 +------------- + +Released 2010-12-31 + +- Fixed an issue where the default ``OPTIONS`` response was not + exposing all valid methods in the ``Allow`` header. +- Jinja template loading syntax now allows "./" in front of a + template load path. Previously this caused issues with module + setups. +- Fixed an issue where the subdomain setting for modules was ignored + for the static folder. +- Fixed a security problem that allowed clients to download arbitrary + files if the host server was a windows based operating system and + the client uses backslashes to escape the directory the files where + exposed from. + + +Version 0.6 +----------- + +Released 2010-07-27, codename Whisky + +- After request functions are now called in reverse order of + registration. +- OPTIONS is now automatically implemented by Flask unless the + application explicitly adds 'OPTIONS' as method to the URL rule. In + this case no automatic OPTIONS handling kicks in. +- Static rules are now even in place if there is no static folder for + the module. This was implemented to aid GAE which will remove the + static folder if it's part of a mapping in the .yml file. +- ``Flask.config`` is now available in the templates as ``config``. +- Context processors will no longer override values passed directly to + the render function. +- Added the ability to limit the incoming request data with the new + ``MAX_CONTENT_LENGTH`` configuration value. +- The endpoint for the ``Module.add_url_rule`` method is now optional + to be consistent with the function of the same name on the + application object. +- Added a ``make_response`` function that simplifies creating response + object instances in views. +- Added signalling support based on blinker. This feature is currently + optional and supposed to be used by extensions and applications. If + you want to use it, make sure to have ``blinker`` installed. +- Refactored the way URL adapters are created. This process is now + fully customizable with the ``Flask.create_url_adapter`` method. +- Modules can now register for a subdomain instead of just an URL + prefix. This makes it possible to bind a whole module to a + configurable subdomain. + + +Version 0.5.2 +------------- + +Released 2010-07-15 + +- Fixed another issue with loading templates from directories when + modules were used. + + +Version 0.5.1 +------------- + +Released 2010-07-06 + +- Fixes an issue with template loading from directories when modules + where used. + + +Version 0.5 +----------- + +Released 2010-07-06, codename Calvados + +- Fixed a bug with subdomains that was caused by the inability to + specify the server name. The server name can now be set with the + ``SERVER_NAME`` config key. This key is now also used to set the + session cookie cross-subdomain wide. +- Autoescaping is no longer active for all templates. Instead it is + only active for ``.html``, ``.htm``, ``.xml`` and ``.xhtml``. Inside + templates this behavior can be changed with the ``autoescape`` tag. +- Refactored Flask internally. It now consists of more than a single + file. +- ``send_file`` now emits etags and has the ability to do conditional + responses builtin. +- (temporarily) dropped support for zipped applications. This was a + rarely used feature and led to some confusing behavior. +- Added support for per-package template and static-file directories. +- Removed support for ``create_jinja_loader`` which is no longer used + in 0.5 due to the improved module support. +- Added a helper function to expose files from any directory. + + +Version 0.4 +----------- + +Released 2010-06-18, codename Rakia + +- Added the ability to register application wide error handlers from + modules. +- ``Flask.after_request`` handlers are now also invoked if the request + dies with an exception and an error handling page kicks in. +- Test client has not the ability to preserve the request context for + a little longer. This can also be used to trigger custom requests + that do not pop the request stack for testing. +- Because the Python standard library caches loggers, the name of the + logger is configurable now to better support unittests. +- Added ``TESTING`` switch that can activate unittesting helpers. +- The logger switches to ``DEBUG`` mode now if debug is enabled. + + +Version 0.3.1 +------------- + +Released 2010-05-28 + +- Fixed a error reporting bug with ``Config.from_envvar``. +- Removed some unused code. +- Release does no longer include development leftover files (.git + folder for themes, built documentation in zip and pdf file and some + .pyc files) + + +Version 0.3 +----------- + +Released 2010-05-28, codename Schnaps + +- Added support for categories for flashed messages. +- The application now configures a ``logging.Handler`` and will log + request handling exceptions to that logger when not in debug mode. + This makes it possible to receive mails on server errors for + example. +- Added support for context binding that does not require the use of + the with statement for playing in the console. +- The request context is now available within the with statement + making it possible to further push the request context or pop it. +- Added support for configurations. + + +Version 0.2 +----------- + +Released 2010-05-12, codename J?germeister + +- Various bugfixes +- Integrated JSON support +- Added ``get_template_attribute`` helper function. +- ``Flask.add_url_rule`` can now also register a view function. +- Refactored internal request dispatching. +- Server listens on 127.0.0.1 by default now to fix issues with + chrome. +- Added external URL support. +- Added support for ``send_file``. +- Module support and internal request handling refactoring to better + support pluggable applications. +- Sessions can be set to be permanent now on a per-session basis. +- Better error reporting on missing secret keys. +- Added support for Google Appengine. + + +Version 0.1 +----------- + +Released 2010-04-16 + +- First public preview release. diff --git a/src/flask-main/LICENSE.txt b/src/flask-main/LICENSE.txt new file mode 100644 index 0000000..9d227a0 --- /dev/null +++ b/src/flask-main/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 2010 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/flask-main/README.md b/src/flask-main/README.md new file mode 100644 index 0000000..64f56ca --- /dev/null +++ b/src/flask-main/README.md @@ -0,0 +1,53 @@ +
+ +# Flask + +Flask is a lightweight [WSGI] web application framework. It is designed +to make getting started quick and easy, with the ability to scale up to +complex applications. It began as a simple wrapper around [Werkzeug] +and [Jinja], and has become one of the most popular Python web +application frameworks. + +Flask offers suggestions, but doesn't enforce any dependencies or +project layout. It is up to the developer to choose the tools and +libraries they want to use. There are many extensions provided by the +community that make adding new functionality easy. + +[WSGI]: https://wsgi.readthedocs.io/ +[Werkzeug]: https://werkzeug.palletsprojects.com/ +[Jinja]: https://jinja.palletsprojects.com/ + +## A Simple Example + +```python +# save this as app.py +from flask import Flask + +app = Flask(__name__) + +@app.route("/") +def hello(): + return "Hello, World!" +``` + +``` +$ flask run + * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) +``` + +## Donate + +The Pallets organization develops and supports Flask and the libraries +it uses. In order to grow the community of contributors and users, and +allow the maintainers to devote more time to the projects, [please +donate today]. + +[please donate today]: https://palletsprojects.com/donate + +## Contributing + +See our [detailed contributing documentation][contrib] for many ways to +contribute, including reporting issues, requesting features, asking or answering +questions, and making PRs. + +[contrib]: https://palletsprojects.com/contributing/ diff --git a/src/flask-main/docs/Makefile b/src/flask-main/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/src/flask-main/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/src/flask-main/docs/_static/debugger.png b/src/flask-main/docs/_static/debugger.png new file mode 100644 index 0000000..7d4181f Binary files /dev/null and b/src/flask-main/docs/_static/debugger.png differ diff --git a/src/flask-main/docs/_static/flask-icon.svg b/src/flask-main/docs/_static/flask-icon.svg new file mode 100644 index 0000000..c802da9 --- /dev/null +++ b/src/flask-main/docs/_static/flask-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/flask-main/docs/_static/flask-logo.svg b/src/flask-main/docs/_static/flask-logo.svg new file mode 100644 index 0000000..c216b61 --- /dev/null +++ b/src/flask-main/docs/_static/flask-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/flask-main/docs/_static/flask-name.svg b/src/flask-main/docs/_static/flask-name.svg new file mode 100644 index 0000000..b46782d --- /dev/null +++ b/src/flask-main/docs/_static/flask-name.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/src/flask-main/docs/_static/pycharm-run-config.png b/src/flask-main/docs/_static/pycharm-run-config.png new file mode 100644 index 0000000..ad02554 Binary files /dev/null and b/src/flask-main/docs/_static/pycharm-run-config.png differ diff --git a/src/flask-main/docs/api.rst b/src/flask-main/docs/api.rst new file mode 100644 index 0000000..d3c517f --- /dev/null +++ b/src/flask-main/docs/api.rst @@ -0,0 +1,708 @@ +API +=== + +.. module:: flask + +This part of the documentation covers all the interfaces of Flask. For +parts where Flask depends on external libraries, we document the most +important right here and provide links to the canonical documentation. + + +Application Object +------------------ + +.. autoclass:: Flask + :members: + :inherited-members: + + +Blueprint Objects +----------------- + +.. autoclass:: Blueprint + :members: + :inherited-members: + +Incoming Request Data +--------------------- + +.. autoclass:: Request + :members: + :inherited-members: + :exclude-members: json_module + +.. data:: request + + A proxy to the request data for the current request, an instance of + :class:`.Request`. + + This is only available when a :doc:`request context ` is + active. + + This is a proxy. See :ref:`context-visibility` for more information. + + +Response Objects +---------------- + +.. autoclass:: flask.Response + :members: + :inherited-members: + :exclude-members: json_module + +Sessions +-------- + +If you have set :attr:`Flask.secret_key` (or configured it from +:data:`SECRET_KEY`) you can use sessions in Flask applications. A session makes +it possible to remember information from one request to another. The way Flask +does this is by using a signed cookie. The user can look at the session +contents, but can't modify it unless they know the secret key, so make sure to +set that to something complex and unguessable. + +To access the current session you can use the :data:`.session` proxy. + +.. data:: session + + A proxy to the session data for the current request, an instance of + :class:`.SessionMixin`. + + This is only available when a :doc:`request context ` is + active. + + This is a proxy. See :ref:`context-visibility` for more information. + + The session object works like a dict but tracks assignment and access to its + keys. It cannot track modifications to mutable values, you need to set + :attr:`~.SessionMixin.modified` manually when modifying a list, dict, etc. + + .. code-block:: python + + # appending to a list is not detected + session["numbers"].append(42) + # so mark it as modified yourself + session.modified = True + + The session is persisted across requests using a cookie. By default the + users's browser will clear the cookie when it is closed. Set + :attr:`~.SessionMixin.permanent` to ``True`` to persist the cookie for + :data:`PERMANENT_SESSION_LIFETIME`. + + +Session Interface +----------------- + +.. versionadded:: 0.8 + +The session interface provides a simple way to replace the session +implementation that Flask is using. + +.. currentmodule:: flask.sessions + +.. autoclass:: SessionInterface + :members: + +.. autoclass:: SecureCookieSessionInterface + :members: + +.. autoclass:: SecureCookieSession + :members: + +.. autoclass:: NullSession + :members: + +.. autoclass:: SessionMixin + :members: + +.. admonition:: Notice + + The :data:`PERMANENT_SESSION_LIFETIME` config can be an integer or ``timedelta``. + The :attr:`~flask.Flask.permanent_session_lifetime` attribute is always a + ``timedelta``. + + +Test Client +----------- + +.. currentmodule:: flask.testing + +.. autoclass:: FlaskClient + :members: + + +Test CLI Runner +--------------- + +.. currentmodule:: flask.testing + +.. autoclass:: FlaskCliRunner + :members: + + +Application Globals +------------------- + +.. currentmodule:: flask + +To share data that is valid for one request only from one function to +another, a global variable is not good enough because it would break in +threaded environments. Flask provides you with a special object that +ensures it is only valid for the active request and that will return +different values for each request. In a nutshell: it does the right +thing, like it does for :data:`.request` and :data:`.session`. + +.. data:: g + + A proxy to a namespace object used to store data during a single request or + app context. An instance of :attr:`.Flask.app_ctx_globals_class`, which + defaults to :class:`._AppCtxGlobals`. + + This is a good place to store resources during a request. For example, a + :meth:`~.Flask.before_request` function could load a user object from a + session id, then set ``g.user`` to be used in the view function. + + This is only available when an :doc:`app context ` is active. + + This is a proxy. See :ref:`context-visibility` for more information. + + .. versionchanged:: 0.10 + Bound to the application context instead of the request context. + +.. autoclass:: flask.ctx._AppCtxGlobals + :members: + + +Useful Functions and Classes +---------------------------- + +.. data:: current_app + + A proxy to the :class:`.Flask` application handling the current request or + other activity. + + This is useful to access the application without needing to import it, or if + it can't be imported, such as when using the application factory pattern or + in blueprints and extensions. + + This is only available when an :doc:`app context ` is active. + + This is a proxy. See :ref:`context-visibility` for more information. + +.. autofunction:: has_request_context + +.. autofunction:: copy_current_request_context + +.. autofunction:: has_app_context + +.. autofunction:: url_for + +.. autofunction:: abort + +.. autofunction:: redirect + +.. autofunction:: make_response + +.. autofunction:: after_this_request + +.. autofunction:: send_file + +.. autofunction:: send_from_directory + + +Message Flashing +---------------- + +.. autofunction:: flash + +.. autofunction:: get_flashed_messages + + +JSON Support +------------ + +.. module:: flask.json + +Flask uses Python's built-in :mod:`json` module for handling JSON by +default. The JSON implementation can be changed by assigning a different +provider to :attr:`flask.Flask.json_provider_class` or +:attr:`flask.Flask.json`. The functions provided by ``flask.json`` will +use methods on ``app.json`` if an app context is active. + +Jinja's ``|tojson`` filter is configured to use the app's JSON provider. +The filter marks the output with ``|safe``. Use it to render data inside +HTML `` + +.. autofunction:: jsonify + +.. autofunction:: dumps + +.. autofunction:: dump + +.. autofunction:: loads + +.. autofunction:: load + +.. autoclass:: flask.json.provider.JSONProvider + :members: + :member-order: bysource + +.. autoclass:: flask.json.provider.DefaultJSONProvider + :members: + :member-order: bysource + +.. automodule:: flask.json.tag + + +Template Rendering +------------------ + +.. currentmodule:: flask + +.. autofunction:: render_template + +.. autofunction:: render_template_string + +.. autofunction:: stream_template + +.. autofunction:: stream_template_string + +.. autofunction:: get_template_attribute + +Configuration +------------- + +.. autoclass:: Config + :members: + + +Stream Helpers +-------------- + +.. autofunction:: stream_with_context + +Useful Internals +---------------- + +.. autoclass:: flask.ctx.AppContext + :members: + +.. data:: flask.globals.app_ctx + + A proxy to the active :class:`.AppContext`. + + This is an internal object that is essential to how Flask handles requests. + Accessing this should not be needed in most cases. Most likely you want + :data:`.current_app`, :data:`.g`, :data:`.request`, and :data:`.session` instead. + + This is only available when a :doc:`request context ` is + active. + + This is a proxy. See :ref:`context-visibility` for more information. + +.. class:: flask.ctx.RequestContext + + .. deprecated:: 3.2 + Merged with :class:`AppContext`. This alias will be removed in Flask 4.0. + +.. data:: flask.globals.request_ctx + + .. deprecated:: 3.2 + Merged with :data:`.app_ctx`. This alias will be removed in Flask 4.0. + +.. autoclass:: flask.blueprints.BlueprintSetupState + :members: + +.. _core-signals-list: + +Signals +------- + +Signals are provided by the `Blinker`_ library. See :doc:`signals` for an introduction. + +.. _blinker: https://blinker.readthedocs.io/ + +.. data:: template_rendered + + This signal is sent when a template was successfully rendered. The + signal is invoked with the instance of the template as `template` + and the context as dictionary (named `context`). + + Example subscriber:: + + def log_template_renders(sender, template, context, **extra): + sender.logger.debug('Rendering template "%s" with context %s', + template.name or 'string template', + context) + + from flask import template_rendered + template_rendered.connect(log_template_renders, app) + +.. data:: flask.before_render_template + :noindex: + + This signal is sent before template rendering process. The + signal is invoked with the instance of the template as `template` + and the context as dictionary (named `context`). + + Example subscriber:: + + def log_template_renders(sender, template, context, **extra): + sender.logger.debug('Rendering template "%s" with context %s', + template.name or 'string template', + context) + + from flask import before_render_template + before_render_template.connect(log_template_renders, app) + +.. data:: request_started + + This signal is sent when the request context is set up, before + any request processing happens. Because the request context is already + bound, the subscriber can access the request with the standard global + proxies such as :class:`~flask.request`. + + Example subscriber:: + + def log_request(sender, **extra): + sender.logger.debug('Request context is set up') + + from flask import request_started + request_started.connect(log_request, app) + +.. data:: request_finished + + This signal is sent right before the response is sent to the client. + It is passed the response to be sent named `response`. + + Example subscriber:: + + def log_response(sender, response, **extra): + sender.logger.debug('Request context is about to close down. ' + 'Response: %s', response) + + from flask import request_finished + request_finished.connect(log_response, app) + +.. data:: got_request_exception + + This signal is sent when an unhandled exception happens during + request processing, including when debugging. The exception is + passed to the subscriber as ``exception``. + + This signal is not sent for + :exc:`~werkzeug.exceptions.HTTPException`, or other exceptions that + have error handlers registered, unless the exception was raised from + an error handler. + + This example shows how to do some extra logging if a theoretical + ``SecurityException`` was raised: + + .. code-block:: python + + from flask import got_request_exception + + def log_security_exception(sender, exception, **extra): + if not isinstance(exception, SecurityException): + return + + security_logger.exception( + f"SecurityException at {request.url!r}", + exc_info=exception, + ) + + got_request_exception.connect(log_security_exception, app) + +.. data:: request_tearing_down + + This signal is sent when the request is tearing down. This is always + called, even if an exception is caused. Currently functions listening + to this signal are called after the regular teardown handlers, but this + is not something you can rely on. + + Example subscriber:: + + def close_db_connection(sender, **extra): + session.close() + + from flask import request_tearing_down + request_tearing_down.connect(close_db_connection, app) + + As of Flask 0.9, this will also be passed an `exc` keyword argument + that has a reference to the exception that caused the teardown if + there was one. + +.. data:: appcontext_tearing_down + + This signal is sent when the app context is tearing down. This is always + called, even if an exception is caused. Currently functions listening + to this signal are called after the regular teardown handlers, but this + is not something you can rely on. + + Example subscriber:: + + def close_db_connection(sender, **extra): + session.close() + + from flask import appcontext_tearing_down + appcontext_tearing_down.connect(close_db_connection, app) + + This will also be passed an `exc` keyword argument that has a reference + to the exception that caused the teardown if there was one. + +.. data:: appcontext_pushed + + This signal is sent when an application context is pushed. The sender + is the application. This is usually useful for unittests in order to + temporarily hook in information. For instance it can be used to + set a resource early onto the `g` object. + + Example usage:: + + from contextlib import contextmanager + from flask import appcontext_pushed + + @contextmanager + def user_set(app, user): + def handler(sender, **kwargs): + g.user = user + with appcontext_pushed.connected_to(handler, app): + yield + + And in the testcode:: + + def test_user_me(self): + with user_set(app, 'john'): + c = app.test_client() + resp = c.get('/users/me') + assert resp.data == 'username=john' + + .. versionadded:: 0.10 + +.. data:: appcontext_popped + + This signal is sent when an application context is popped. The sender + is the application. This usually falls in line with the + :data:`appcontext_tearing_down` signal. + + .. versionadded:: 0.10 + +.. data:: message_flashed + + This signal is sent when the application is flashing a message. The + messages is sent as `message` keyword argument and the category as + `category`. + + Example subscriber:: + + recorded = [] + def record(sender, message, category, **extra): + recorded.append((message, category)) + + from flask import message_flashed + message_flashed.connect(record, app) + + .. versionadded:: 0.10 + + +Class-Based Views +----------------- + +.. versionadded:: 0.7 + +.. currentmodule:: None + +.. autoclass:: flask.views.View + :members: + +.. autoclass:: flask.views.MethodView + :members: + +.. _url-route-registrations: + +URL Route Registrations +----------------------- + +Generally there are three ways to define rules for the routing system: + +1. You can use the :meth:`flask.Flask.route` decorator. +2. You can use the :meth:`flask.Flask.add_url_rule` function. +3. You can directly access the underlying Werkzeug routing system + which is exposed as :attr:`flask.Flask.url_map`. + +Variable parts in the route can be specified with angular brackets +(``/user/``). By default a variable part in the URL accepts any +string without a slash however a different converter can be specified as +well by using ````. + +Variable parts are passed to the view function as keyword arguments. + +The following converters are available: + +=========== =============================================== +`string` accepts any text without a slash (the default) +`int` accepts integers +`float` like `int` but for floating point values +`path` like the default but also accepts slashes +`any` matches one of the items provided +`uuid` accepts UUID strings +=========== =============================================== + +Custom converters can be defined using :attr:`flask.Flask.url_map`. + +Here are some examples:: + + @app.route('/') + def index(): + pass + + @app.route('/') + def show_user(username): + pass + + @app.route('/post/') + def show_post(post_id): + pass + +An important detail to keep in mind is how Flask deals with trailing +slashes. The idea is to keep each URL unique so the following rules +apply: + +1. If a rule ends with a slash and is requested without a slash by the + user, the user is automatically redirected to the same page with a + trailing slash attached. +2. If a rule does not end with a trailing slash and the user requests the + page with a trailing slash, a 404 not found is raised. + +This is consistent with how web servers deal with static files. This +also makes it possible to use relative link targets safely. + +You can also define multiple rules for the same function. They have to be +unique however. Defaults can also be specified. Here for example is a +definition for a URL that accepts an optional page:: + + @app.route('/users/', defaults={'page': 1}) + @app.route('/users/page/') + def show_users(page): + pass + +This specifies that ``/users/`` will be the URL for page one and +``/users/page/N`` will be the URL for page ``N``. + +If a URL contains a default value, it will be redirected to its simpler +form with a 301 redirect. In the above example, ``/users/page/1`` will +be redirected to ``/users/``. If your route handles ``GET`` and ``POST`` +requests, make sure the default route only handles ``GET``, as redirects +can't preserve form data. :: + + @app.route('/region/', defaults={'id': 1}) + @app.route('/region/', methods=['GET', 'POST']) + def region(id): + pass + +Here are the parameters that :meth:`~flask.Flask.route` and +:meth:`~flask.Flask.add_url_rule` accept. The only difference is that +with the route parameter the view function is defined with the decorator +instead of the `view_func` parameter. + +=============== ========================================================== +`rule` the URL rule as string +`endpoint` the endpoint for the registered URL rule. Flask itself + assumes that the name of the view function is the name + of the endpoint if not explicitly stated. +`view_func` the function to call when serving a request to the + provided endpoint. If this is not provided one can + specify the function later by storing it in the + :attr:`~flask.Flask.view_functions` dictionary with the + endpoint as key. +`defaults` A dictionary with defaults for this rule. See the + example above for how defaults work. +`subdomain` specifies the rule for the subdomain in case subdomain + matching is in use. If not specified the default + subdomain is assumed. +`**options` the options to be forwarded to the underlying + :class:`~werkzeug.routing.Rule` object. A change to + Werkzeug is handling of method options. methods is a list + of methods this rule should be limited to (``GET``, ``POST`` + etc.). By default a rule just listens for ``GET`` (and + implicitly ``HEAD``). Starting with Flask 0.6, ``OPTIONS`` is + implicitly added and handled by the standard request + handling. They have to be specified as keyword arguments. +=============== ========================================================== + + +View Function Options +--------------------- + +For internal usage the view functions can have some attributes attached to +customize behavior the view function would normally not have control over. +The following attributes can be provided optionally to either override +some defaults to :meth:`~flask.Flask.add_url_rule` or general behavior: + +- `__name__`: The name of a function is by default used as endpoint. If + endpoint is provided explicitly this value is used. Additionally this + will be prefixed with the name of the blueprint by default which + cannot be customized from the function itself. + +- `methods`: If methods are not provided when the URL rule is added, + Flask will look on the view function object itself if a `methods` + attribute exists. If it does, it will pull the information for the + methods from there. + +- `provide_automatic_options`: if this attribute is set Flask will + either force enable or disable the automatic implementation of the + HTTP ``OPTIONS`` response. This can be useful when working with + decorators that want to customize the ``OPTIONS`` response on a per-view + basis. + +- `required_methods`: if this attribute is set, Flask will always add + these methods when registering a URL rule even if the methods were + explicitly overridden in the ``route()`` call. + +Full example:: + + def index(): + if request.method == 'OPTIONS': + # custom options handling here + ... + return 'Hello World!' + index.provide_automatic_options = False + index.methods = ['GET', 'OPTIONS'] + + app.add_url_rule('/', index) + +.. versionadded:: 0.8 + The `provide_automatic_options` functionality was added. + +Command Line Interface +---------------------- + +.. currentmodule:: flask.cli + +.. autoclass:: FlaskGroup + :members: + +.. autoclass:: AppGroup + :members: + +.. autoclass:: ScriptInfo + :members: + +.. autofunction:: load_dotenv + +.. autofunction:: with_appcontext + +.. autofunction:: pass_script_info + + Marks a function so that an instance of :class:`ScriptInfo` is passed + as first argument to the click callback. + +.. autodata:: run_command + +.. autodata:: shell_command diff --git a/src/flask-main/docs/appcontext.rst b/src/flask-main/docs/appcontext.rst new file mode 100644 index 0000000..81b73b4 --- /dev/null +++ b/src/flask-main/docs/appcontext.rst @@ -0,0 +1,186 @@ +The App and Request Context +=========================== + +The context keeps track of data and objects during a request, CLI command, or +other activity. Rather than passing this data around to every function, the +:data:`.current_app`, :data:`.g`, :data:`.request`, and :data:`.session` proxies +are accessed instead. + +When handling a request, the context is referred to as the "request context" +because it contains request data in addition to application data. Otherwise, +such as during a CLI command, it is referred to as the "app context". During an +app context, :data:`.current_app` and :data:`.g` are available, while during a +request context :data:`.request` and :data:`.session` are also available. + + +Purpose of the Context +---------------------- + +The context and proxies help solve two development issues: circular imports, and +passing around global data during a request. + +The :class:`.Flask` application object has attributes, such as +:attr:`~.Flask.config`, that are useful to access within views and other +functions. However, importing the ``app`` instance within the modules in your +project is prone to circular import issues. When using the +:doc:`app factory pattern ` or writing reusable +:doc:`blueprints ` or :doc:`extensions ` there won't +be an ``app`` instance to import at all. + +When the application handles a request, it creates a :class:`.Request` object. +Because a *worker* handles only one request at a time, the request data can be +considered global to that worker during that request. Passing it as an argument +through every function during the request becomes verbose and redundant. + +Flask solves these issues with the *active context* pattern. Rather than +importing an ``app`` directly, or having to pass it and the request through to +every single function, you import and access the proxies, which point to the +currently active application and request data. This is sometimes referred to +as "context local" data. + + +Context During Setup +-------------------- + +If you try to access :data:`.current_app`, :data:`.g`, or anything that uses it, +outside an app context, you'll get this error message: + +.. code-block:: pytb + + RuntimeError: Working outside of application context. + + Attempted to use functionality that expected a current application to be + set. To solve this, set up an app context using 'with app.app_context()'. + See the documentation on app context for more information. + +If you see that error while configuring your application, such as when +initializing an extension, you can push a context manually since you have direct +access to the ``app``. Use :meth:`.Flask.app_context` in a ``with`` block. + +.. code-block:: python + + def create_app(): + app = Flask(__name__) + + with app.app_context(): + init_db() + + return app + +If you see that error somewhere else in your code not related to setting up the +application, it most likely indicates that you should move that code into a view +function or CLI command. + + +Context During Testing +---------------------- + +See :doc:`/testing` for detailed information about managing the context during +tests. + +If you try to access :data:`.request`, :data:`.session`, or anything that uses +it, outside a request context, you'll get this error message: + +.. code-block:: pytb + + RuntimeError: Working outside of request context. + + Attempted to use functionality that expected an active HTTP request. See the + documentation on request context for more information. + +This will probably only happen during tests. If you see that error somewhere +else in your code not related to testing, it most likely indicates that you +should move that code into a view function. + +The primary way to solve this is to use :meth:`.Flask.test_client` to simulate +a full request. + +If you only want to unit test one function, rather than a full request, use +:meth:`.Flask.test_request_context` in a ``with`` block. + +.. code-block:: python + + def generate_report(year): + format = request.args.get("format") + ... + + with app.test_request_context( + "/make_report/2017", query_string={"format": "short"} + ): + generate_report() + + +.. _context-visibility: + +Visibility of the Context +------------------------- + +The context will have the same lifetime as an activity, such as a request, CLI +command, or ``with`` block. Various callbacks and signals registered with the +app will be run during the context. + +When a Flask application handles a request, it pushes a requet context +to set the active application and request data. When it handles a CLI command, +it pushes an app context to set the active application. When the activity ends, +it pops that context. Proxy objects like :data:`.request`, :data:`.session`, +:data:`.g`, and :data:`.current_app`, are accessible while the context is pushed +and active, and are not accessible after the context is popped. + +The context is unique to each thread (or other worker type). The proxies cannot +be passed to another worker, which has a different context space and will not +know about the active context in the parent's space. + +Besides being scoped to each worker, the proxy object has a separate type and +identity than the proxied real object. In some cases you'll need access to the +real object, rather than the proxy. Use the +:meth:`~.LocalProxy._get_current_object` method in those cases. + +.. code-block:: python + + app = current_app._get_current_object() + my_signal.send(app) + + +Lifecycle of the Context +------------------------ + +Flask dispatches a request in multiple stages which can affect the request, +response, and how errors are handled. See :doc:`/lifecycle` for a list of all +the steps, callbacks, and signals during each request. The following are the +steps directly related to the context. + +- The app context is pushed, the proxies are available. +- The :data:`.appcontext_pushed` signal is sent. +- The request is dispatched. +- Any :meth:`.Flask.teardown_request` decorated functions are called. +- The :data:`.request_tearing_down` signal is sent. +- Any :meth:`.Flask.teardown_appcontext` decorated functions are called. +- The :data:`.appcontext_tearing_down` signal is sent. +- The app context is popped, the proxies are no longer available. +- The :data:`.appcontext_popped` signal is sent. + +The teardown callbacks are called by the context when it is popped. They are +called even if there is an unhandled exception during dispatch. They may be +called multiple times in some test scenarios. This means there is no guarantee +that any other parts of the request dispatch have run. Be sure to write these +functions in a way that does not depend on other callbacks and will not fail. + + +How the Context Works +--------------------- + +Context locals are implemented using Python's :mod:`contextvars` and Werkzeug's +:class:`~werkzeug.local.LocalProxy`. Python's contextvars are a low level +structure to manage data local to a thread or coroutine. ``LocalProxy`` wraps +the contextvar so that access to any attributes and methods is forwarded to the +object stored in the contextvar. + +The context is tracked like a stack, with the active context at the top of the +stack. Flask manages pushing and popping contexts during requests, CLI commands, +testing, ``with`` blocks, etc. The proxies access attributes on the active +context. + +Because it is a stack, other contexts may be pushed to change the proxies during +an already active context. This is not a common pattern, but can be used in +advanced use cases. For example, a Flask application can be used as WSGI +middleware, calling another wrapped Flask app from a view. diff --git a/src/flask-main/docs/async-await.rst b/src/flask-main/docs/async-await.rst new file mode 100644 index 0000000..16b6194 --- /dev/null +++ b/src/flask-main/docs/async-await.rst @@ -0,0 +1,125 @@ +.. _async_await: + +Using ``async`` and ``await`` +============================= + +.. versionadded:: 2.0 + +Routes, error handlers, before request, after request, and teardown +functions can all be coroutine functions if Flask is installed with the +``async`` extra (``pip install flask[async]``). This allows views to be +defined with ``async def`` and use ``await``. + +.. code-block:: python + + @app.route("/get-data") + async def get_data(): + data = await async_db_query(...) + return jsonify(data) + +Pluggable class-based views also support handlers that are implemented as +coroutines. This applies to the :meth:`~flask.views.View.dispatch_request` +method in views that inherit from the :class:`flask.views.View` class, as +well as all the HTTP method handlers in views that inherit from the +:class:`flask.views.MethodView` class. + +.. admonition:: Using ``async`` with greenlet + + When using gevent or eventlet to serve an application or patch the + runtime, greenlet>=1.0 is required. When using PyPy, PyPy>=7.3.7 is + required. + + +Performance +----------- + +Async functions require an event loop to run. Flask, as a WSGI +application, uses one worker to handle one request/response cycle. +When a request comes in to an async view, Flask will start an event loop +in a thread, run the view function there, then return the result. + +Each request still ties up one worker, even for async views. The upside +is that you can run async code within a view, for example to make +multiple concurrent database queries, HTTP requests to an external API, +etc. However, the number of requests your application can handle at one +time will remain the same. + +**Async is not inherently faster than sync code.** Async is beneficial +when performing concurrent IO-bound tasks, but will probably not improve +CPU-bound tasks. Traditional Flask views will still be appropriate for +most use cases, but Flask's async support enables writing and using +code that wasn't possible natively before. + + +Background tasks +---------------- + +Async functions will run in an event loop until they complete, at +which stage the event loop will stop. This means any additional +spawned tasks that haven't completed when the async function completes +will be cancelled. Therefore you cannot spawn background tasks, for +example via ``asyncio.create_task``. + +If you wish to use background tasks it is best to use a task queue to +trigger background work, rather than spawn tasks in a view +function. With that in mind you can spawn asyncio tasks by serving +Flask with an ASGI server and utilising the asgiref WsgiToAsgi adapter +as described in :doc:`deploying/asgi`. This works as the adapter creates +an event loop that runs continually. + + +When to use Quart instead +------------------------- + +Flask's async support is less performant than async-first frameworks due +to the way it is implemented. If you have a mainly async codebase it +would make sense to consider `Quart`_. Quart is a reimplementation of +Flask based on the `ASGI`_ standard instead of WSGI. This allows it to +handle many concurrent requests, long running requests, and websockets +without requiring multiple worker processes or threads. + +It has also already been possible to run Flask with Gevent or Eventlet +to get many of the benefits of async request handling. These libraries +patch low-level Python functions to accomplish this, whereas ``async``/ +``await`` and ASGI use standard, modern Python capabilities. Deciding +whether you should use Flask, Quart, or something else is ultimately up +to understanding the specific needs of your project. + +.. _Quart: https://github.com/pallets/quart +.. _ASGI: https://asgi.readthedocs.io/en/latest/ + + +Extensions +---------- + +Flask extensions predating Flask's async support do not expect async views. +If they provide decorators to add functionality to views, those will probably +not work with async views because they will not await the function or be +awaitable. Other functions they provide will not be awaitable either and +will probably be blocking if called within an async view. + +Extension authors can support async functions by utilising the +:meth:`flask.Flask.ensure_sync` method. For example, if the extension +provides a view function decorator add ``ensure_sync`` before calling +the decorated function, + +.. code-block:: python + + def extension(func): + @wraps(func) + def wrapper(*args, **kwargs): + ... # Extension logic + return current_app.ensure_sync(func)(*args, **kwargs) + + return wrapper + +Check the changelog of the extension you want to use to see if they've +implemented async support, or make a feature request or PR to them. + + +Other event loops +----------------- + +At the moment Flask only supports :mod:`asyncio`. It's possible to +override :meth:`flask.Flask.ensure_sync` to change how async functions +are wrapped to use a different library. diff --git a/src/flask-main/docs/blueprints.rst b/src/flask-main/docs/blueprints.rst new file mode 100644 index 0000000..d5cf3d8 --- /dev/null +++ b/src/flask-main/docs/blueprints.rst @@ -0,0 +1,315 @@ +Modular Applications with Blueprints +==================================== + +.. currentmodule:: flask + +.. versionadded:: 0.7 + +Flask uses a concept of *blueprints* for making application components and +supporting common patterns within an application or across applications. +Blueprints can greatly simplify how large applications work and provide a +central means for Flask extensions to register operations on applications. +A :class:`Blueprint` object works similarly to a :class:`Flask` +application object, but it is not actually an application. Rather it is a +*blueprint* of how to construct or extend an application. + +Why Blueprints? +--------------- + +Blueprints in Flask are intended for these cases: + +* Factor an application into a set of blueprints. This is ideal for + larger applications; a project could instantiate an application object, + initialize several extensions, and register a collection of blueprints. +* Register a blueprint on an application at a URL prefix and/or subdomain. + Parameters in the URL prefix/subdomain become common view arguments + (with defaults) across all view functions in the blueprint. +* Register a blueprint multiple times on an application with different URL + rules. +* Provide template filters, static files, templates, and other utilities + through blueprints. A blueprint does not have to implement applications + or view functions. +* Register a blueprint on an application for any of these cases when + initializing a Flask extension. + +A blueprint in Flask is not a pluggable app because it is not actually an +application -- it's a set of operations which can be registered on an +application, even multiple times. Why not have multiple application +objects? You can do that (see :doc:`/patterns/appdispatch`), but your +applications will have separate configs and will be managed at the WSGI +layer. + +Blueprints instead provide separation at the Flask level, share +application config, and can change an application object as necessary with +being registered. The downside is that you cannot unregister a blueprint +once an application was created without having to destroy the whole +application object. + +The Concept of Blueprints +------------------------- + +The basic concept of blueprints is that they record operations to execute +when registered on an application. Flask associates view functions with +blueprints when dispatching requests and generating URLs from one endpoint +to another. + +My First Blueprint +------------------ + +This is what a very basic blueprint looks like. In this case we want to +implement a blueprint that does simple rendering of static templates:: + + from flask import Blueprint, render_template, abort + from jinja2 import TemplateNotFound + + simple_page = Blueprint('simple_page', __name__, + template_folder='templates') + + @simple_page.route('/', defaults={'page': 'index'}) + @simple_page.route('/') + def show(page): + try: + return render_template(f'pages/{page}.html') + except TemplateNotFound: + abort(404) + +When you bind a function with the help of the ``@simple_page.route`` +decorator, the blueprint will record the intention of registering the +function ``show`` on the application when it's later registered. +Additionally it will prefix the endpoint of the function with the +name of the blueprint which was given to the :class:`Blueprint` +constructor (in this case also ``simple_page``). The blueprint's name +does not modify the URL, only the endpoint. + +Registering Blueprints +---------------------- + +So how do you register that blueprint? Like this:: + + from flask import Flask + from yourapplication.simple_page import simple_page + + app = Flask(__name__) + app.register_blueprint(simple_page) + +If you check the rules registered on the application, you will find +these:: + + >>> app.url_map + Map([' (HEAD, OPTIONS, GET) -> static>, + ' (HEAD, OPTIONS, GET) -> simple_page.show>, + simple_page.show>]) + +The first one is obviously from the application itself for the static +files. The other two are for the `show` function of the ``simple_page`` +blueprint. As you can see, they are also prefixed with the name of the +blueprint and separated by a dot (``.``). + +Blueprints however can also be mounted at different locations:: + + app.register_blueprint(simple_page, url_prefix='/pages') + +And sure enough, these are the generated rules:: + + >>> app.url_map + Map([' (HEAD, OPTIONS, GET) -> static>, + ' (HEAD, OPTIONS, GET) -> simple_page.show>, + simple_page.show>]) + +On top of that you can register blueprints multiple times though not every +blueprint might respond properly to that. In fact it depends on how the +blueprint is implemented if it can be mounted more than once. + +Nesting Blueprints +------------------ + +It is possible to register a blueprint on another blueprint. + +.. code-block:: python + + parent = Blueprint('parent', __name__, url_prefix='/parent') + child = Blueprint('child', __name__, url_prefix='/child') + parent.register_blueprint(child) + app.register_blueprint(parent) + +The child blueprint will gain the parent's name as a prefix to its +name, and child URLs will be prefixed with the parent's URL prefix. + +.. code-block:: python + + url_for('parent.child.create') + /parent/child/create + +In addition a child blueprint's will gain their parent's subdomain, +with their subdomain as prefix if present i.e. + +.. code-block:: python + + parent = Blueprint('parent', __name__, subdomain='parent') + child = Blueprint('child', __name__, subdomain='child') + parent.register_blueprint(child) + app.register_blueprint(parent) + + url_for('parent.child.create', _external=True) + "child.parent.domain.tld" + +Blueprint-specific before request functions, etc. registered with the +parent will trigger for the child. If a child does not have an error +handler that can handle a given exception, the parent's will be tried. + + +Blueprint Resources +------------------- + +Blueprints can provide resources as well. Sometimes you might want to +introduce a blueprint only for the resources it provides. + +Blueprint Resource Folder +````````````````````````` + +Like for regular applications, blueprints are considered to be contained +in a folder. While multiple blueprints can originate from the same folder, +it does not have to be the case and it's usually not recommended. + +The folder is inferred from the second argument to :class:`Blueprint` which +is usually `__name__`. This argument specifies what logical Python +module or package corresponds to the blueprint. If it points to an actual +Python package that package (which is a folder on the filesystem) is the +resource folder. If it's a module, the package the module is contained in +will be the resource folder. You can access the +:attr:`Blueprint.root_path` property to see what the resource folder is:: + + >>> simple_page.root_path + '/Users/username/TestProject/yourapplication' + +To quickly open sources from this folder you can use the +:meth:`~Blueprint.open_resource` function:: + + with simple_page.open_resource('static/style.css') as f: + code = f.read() + +Static Files +```````````` + +A blueprint can expose a folder with static files by providing the path +to the folder on the filesystem with the ``static_folder`` argument. +It is either an absolute path or relative to the blueprint's location:: + + admin = Blueprint('admin', __name__, static_folder='static') + +By default the rightmost part of the path is where it is exposed on the +web. This can be changed with the ``static_url_path`` argument. Because the +folder is called ``static`` here it will be available at the +``url_prefix`` of the blueprint + ``/static``. If the blueprint +has the prefix ``/admin``, the static URL will be ``/admin/static``. + +The endpoint is named ``blueprint_name.static``. You can generate URLs +to it with :func:`url_for` like you would with the static folder of the +application:: + + url_for('admin.static', filename='style.css') + +However, if the blueprint does not have a ``url_prefix``, it is not +possible to access the blueprint's static folder. This is because the +URL would be ``/static`` in this case, and the application's ``/static`` +route takes precedence. Unlike template folders, blueprint static +folders are not searched if the file does not exist in the application +static folder. + +Templates +````````` + +If you want the blueprint to expose templates you can do that by providing +the `template_folder` parameter to the :class:`Blueprint` constructor:: + + admin = Blueprint('admin', __name__, template_folder='templates') + +For static files, the path can be absolute or relative to the blueprint +resource folder. + +The template folder is added to the search path of templates but with a lower +priority than the actual application's template folder. That way you can +easily override templates that a blueprint provides in the actual application. +This also means that if you don't want a blueprint template to be accidentally +overridden, make sure that no other blueprint or actual application template +has the same relative path. When multiple blueprints provide the same relative +template path the first blueprint registered takes precedence over the others. + + +So if you have a blueprint in the folder ``yourapplication/admin`` and you +want to render the template ``'admin/index.html'`` and you have provided +``templates`` as a `template_folder` you will have to create a file like +this: :file:`yourapplication/admin/templates/admin/index.html`. The reason +for the extra ``admin`` folder is to avoid getting our template overridden +by a template named ``index.html`` in the actual application template +folder. + +To further reiterate this: if you have a blueprint named ``admin`` and you +want to render a template called :file:`index.html` which is specific to this +blueprint, the best idea is to lay out your templates like this:: + + yourpackage/ + blueprints/ + admin/ + templates/ + admin/ + index.html + __init__.py + +And then when you want to render the template, use :file:`admin/index.html` as +the name to look up the template by. If you encounter problems loading +the correct templates enable the ``EXPLAIN_TEMPLATE_LOADING`` config +variable which will instruct Flask to print out the steps it goes through +to locate templates on every ``render_template`` call. + +Building URLs +------------- + +If you want to link from one page to another you can use the +:func:`url_for` function just like you normally would do just that you +prefix the URL endpoint with the name of the blueprint and a dot (``.``):: + + url_for('admin.index') + +Additionally if you are in a view function of a blueprint or a rendered +template and you want to link to another endpoint of the same blueprint, +you can use relative redirects by prefixing the endpoint with a dot only:: + + url_for('.index') + +This will link to ``admin.index`` for instance in case the current request +was dispatched to any other admin blueprint endpoint. + + +Blueprint Error Handlers +------------------------ + +Blueprints support the ``errorhandler`` decorator just like the :class:`Flask` +application object, so it is easy to make Blueprint-specific custom error +pages. + +Here is an example for a "404 Page Not Found" exception:: + + @simple_page.errorhandler(404) + def page_not_found(e): + return render_template('pages/404.html') + +Most errorhandlers will simply work as expected; however, there is a caveat +concerning handlers for 404 and 405 exceptions. These errorhandlers are only +invoked from an appropriate ``raise`` statement or a call to ``abort`` in another +of the blueprint's view functions; they are not invoked by, e.g., an invalid URL +access. This is because the blueprint does not "own" a certain URL space, so +the application instance has no way of knowing which blueprint error handler it +should run if given an invalid URL. If you would like to execute different +handling strategies for these errors based on URL prefixes, they may be defined +at the application level using the ``request`` proxy object:: + + @app.errorhandler(404) + @app.errorhandler(405) + def _handle_api_error(ex): + if request.path.startswith('/api/'): + return jsonify(error=str(ex)), ex.code + else: + return ex + +See :doc:`/errorhandling`. diff --git a/src/flask-main/docs/changes.rst b/src/flask-main/docs/changes.rst new file mode 100644 index 0000000..955deaf --- /dev/null +++ b/src/flask-main/docs/changes.rst @@ -0,0 +1,4 @@ +Changes +======= + +.. include:: ../CHANGES.rst diff --git a/src/flask-main/docs/cli.rst b/src/flask-main/docs/cli.rst new file mode 100644 index 0000000..a72e6d5 --- /dev/null +++ b/src/flask-main/docs/cli.rst @@ -0,0 +1,556 @@ +.. currentmodule:: flask + +Command Line Interface +====================== + +Installing Flask installs the ``flask`` script, a `Click`_ command line +interface, in your virtualenv. Executed from the terminal, this script gives +access to built-in, extension, and application-defined commands. The ``--help`` +option will give more information about any commands and options. + +.. _Click: https://click.palletsprojects.com/ + + +Application Discovery +--------------------- + +The ``flask`` command is installed by Flask, not your application; it must be +told where to find your application in order to use it. The ``--app`` +option is used to specify how to load the application. + +While ``--app`` supports a variety of options for specifying your +application, most use cases should be simple. Here are the typical values: + +(nothing) + The name "app" or "wsgi" is imported (as a ".py" file, or package), + automatically detecting an app (``app`` or ``application``) or + factory (``create_app`` or ``make_app``). + +``--app hello`` + The given name is imported, automatically detecting an app (``app`` + or ``application``) or factory (``create_app`` or ``make_app``). + +---- + +``--app`` has three parts: an optional path that sets the current working +directory, a Python file or dotted import path, and an optional variable +name of the instance or factory. If the name is a factory, it can optionally +be followed by arguments in parentheses. The following values demonstrate these +parts: + +``--app src/hello`` + Sets the current working directory to ``src`` then imports ``hello``. + +``--app hello.web`` + Imports the path ``hello.web``. + +``--app hello:app2`` + Uses the ``app2`` Flask instance in ``hello``. + +``--app 'hello:create_app("dev")'`` + The ``create_app`` factory in ``hello`` is called with the string ``'dev'`` + as the argument. + +If ``--app`` is not set, the command will try to import "app" or +"wsgi" (as a ".py" file, or package) and try to detect an application +instance or factory. + +Within the given import, the command looks for an application instance named +``app`` or ``application``, then any application instance. If no instance is +found, the command looks for a factory function named ``create_app`` or +``make_app`` that returns an instance. + +If parentheses follow the factory name, their contents are parsed as +Python literals and passed as arguments and keyword arguments to the +function. This means that strings must still be in quotes. + + +Run the Development Server +-------------------------- + +The :func:`run ` command will start the development server. It +replaces the :meth:`Flask.run` method in most cases. :: + + $ flask --app hello run + * Serving Flask app "hello" + * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) + +.. warning:: Do not use this command to run your application in production. + Only use the development server during development. The development server + is provided for convenience, but is not designed to be particularly secure, + stable, or efficient. See :doc:`/deploying/index` for how to run in production. + +If another program is already using port 5000, you'll see +``OSError: [Errno 98]`` or ``OSError: [WinError 10013]`` when the +server tries to start. See :ref:`address-already-in-use` for how to +handle that. + + +Debug Mode +~~~~~~~~~~ + +In debug mode, the ``flask run`` command will enable the interactive debugger and the +reloader by default, and make errors easier to see and debug. To enable debug mode, use +the ``--debug`` option. + +.. code-block:: console + + $ flask --app hello run --debug + * Serving Flask app "hello" + * Debug mode: on + * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) + * Restarting with inotify reloader + * Debugger is active! + * Debugger PIN: 223-456-919 + +The ``--debug`` option can also be passed to the top level ``flask`` command to enable +debug mode for any command. The following two ``run`` calls are equivalent. + +.. code-block:: console + + $ flask --app hello --debug run + $ flask --app hello run --debug + + +Watch and Ignore Files with the Reloader +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When using debug mode, the reloader will trigger whenever your Python code or imported +modules change. The reloader can watch additional files with the ``--extra-files`` +option. Multiple paths are separated with ``:``, or ``;`` on Windows. + +.. code-block:: text + + $ flask run --extra-files file1:dirA/file2:dirB/ + * Running on http://127.0.0.1:8000/ + * Detected change in '/path/to/file1', reloading + +The reloader can also ignore files using :mod:`fnmatch` patterns with the +``--exclude-patterns`` option. Multiple patterns are separated with ``:``, or ``;`` on +Windows. + + +Open a Shell +------------ + +To explore the data in your application, you can start an interactive Python +shell with the :func:`shell ` command. An application +context will be active, and the app instance will be imported. :: + + $ flask shell + Python 3.10.0 (default, Oct 27 2021, 06:59:51) [GCC 11.1.0] on linux + App: example [production] + Instance: /home/david/Projects/pallets/flask/instance + >>> + +Use :meth:`~Flask.shell_context_processor` to add other automatic imports. + + +.. _dotenv: + +Environment Variables From dotenv +--------------------------------- + +The ``flask`` command supports setting any option for any command with +environment variables. The variables are named like ``FLASK_OPTION`` or +``FLASK_COMMAND_OPTION``, for example ``FLASK_APP`` or +``FLASK_RUN_PORT``. + +Rather than passing options every time you run a command, or environment +variables every time you open a new terminal, you can use Flask's dotenv +support to set environment variables automatically. + +If `python-dotenv`_ is installed, running the ``flask`` command will set +environment variables defined in the files ``.env`` and ``.flaskenv``. +You can also specify an extra file to load with the ``--env-file`` +option. Dotenv files can be used to avoid having to set ``--app`` or +``FLASK_APP`` manually, and to set configuration using environment +variables similar to how some deployment services work. + +Variables set on the command line are used over those set in :file:`.env`, +which are used over those set in :file:`.flaskenv`. :file:`.flaskenv` should be +used for public variables, such as ``FLASK_APP``, while :file:`.env` should not +be committed to your repository so that it can set private variables. + +Directories are scanned upwards from the directory you call ``flask`` +from to locate the files. + +The files are only loaded by the ``flask`` command or calling +:meth:`~Flask.run`. If you would like to load these files when running in +production, you should call :func:`~cli.load_dotenv` manually. + +.. _python-dotenv: https://github.com/theskumar/python-dotenv#readme + + +Setting Command Options +~~~~~~~~~~~~~~~~~~~~~~~ + +Click is configured to load default values for command options from +environment variables. The variables use the pattern +``FLASK_COMMAND_OPTION``. For example, to set the port for the run +command, instead of ``flask run --port 8000``: + +.. tabs:: + + .. group-tab:: Bash + + .. code-block:: text + + $ export FLASK_RUN_PORT=8000 + $ flask run + * Running on http://127.0.0.1:8000/ + + .. group-tab:: Fish + + .. code-block:: text + + $ set -x FLASK_RUN_PORT 8000 + $ flask run + * Running on http://127.0.0.1:8000/ + + .. group-tab:: CMD + + .. code-block:: text + + > set FLASK_RUN_PORT=8000 + > flask run + * Running on http://127.0.0.1:8000/ + + .. group-tab:: Powershell + + .. code-block:: text + + > $env:FLASK_RUN_PORT = 8000 + > flask run + * Running on http://127.0.0.1:8000/ + +These can be added to the ``.flaskenv`` file just like ``FLASK_APP`` to +control default command options. + + +Disable dotenv +~~~~~~~~~~~~~~ + +The ``flask`` command will show a message if it detects dotenv files but +python-dotenv is not installed. + +.. code-block:: bash + + $ flask run + * Tip: There are .env files present. Do "pip install python-dotenv" to use them. + +You can tell Flask not to load dotenv files even when python-dotenv is +installed by setting the ``FLASK_SKIP_DOTENV`` environment variable. +This can be useful if you want to load them manually, or if you're using +a project runner that loads them already. Keep in mind that the +environment variables must be set before the app loads or it won't +configure as expected. + +.. tabs:: + + .. group-tab:: Bash + + .. code-block:: text + + $ export FLASK_SKIP_DOTENV=1 + $ flask run + + .. group-tab:: Fish + + .. code-block:: text + + $ set -x FLASK_SKIP_DOTENV 1 + $ flask run + + .. group-tab:: CMD + + .. code-block:: text + + > set FLASK_SKIP_DOTENV=1 + > flask run + + .. group-tab:: Powershell + + .. code-block:: text + + > $env:FLASK_SKIP_DOTENV = 1 + > flask run + + +Environment Variables From virtualenv +------------------------------------- + +If you do not want to install dotenv support, you can still set environment +variables by adding them to the end of the virtualenv's :file:`activate` +script. Activating the virtualenv will set the variables. + +.. tabs:: + + .. group-tab:: Bash + + Unix Bash, :file:`.venv/bin/activate`:: + + $ export FLASK_APP=hello + + .. group-tab:: Fish + + Fish, :file:`.venv/bin/activate.fish`:: + + $ set -x FLASK_APP hello + + .. group-tab:: CMD + + Windows CMD, :file:`.venv\\Scripts\\activate.bat`:: + + > set FLASK_APP=hello + + .. group-tab:: Powershell + + Windows Powershell, :file:`.venv\\Scripts\\activate.ps1`:: + + > $env:FLASK_APP = "hello" + +It is preferred to use dotenv support over this, since :file:`.flaskenv` can be +committed to the repository so that it works automatically wherever the project +is checked out. + + +Custom Commands +--------------- + +The ``flask`` command is implemented using `Click`_. See that project's +documentation for full information about writing commands. + +This example adds the command ``create-user`` that takes the argument +``name``. :: + + import click + from flask import Flask + + app = Flask(__name__) + + @app.cli.command("create-user") + @click.argument("name") + def create_user(name): + ... + +:: + + $ flask create-user admin + +This example adds the same command, but as ``user create``, a command in a +group. This is useful if you want to organize multiple related commands. :: + + import click + from flask import Flask + from flask.cli import AppGroup + + app = Flask(__name__) + user_cli = AppGroup('user') + + @user_cli.command('create') + @click.argument('name') + def create_user(name): + ... + + app.cli.add_command(user_cli) + +:: + + $ flask user create demo + +See :ref:`testing-cli` for an overview of how to test your custom +commands. + + +Registering Commands with Blueprints +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If your application uses blueprints, you can optionally register CLI +commands directly onto them. When your blueprint is registered onto your +application, the associated commands will be available to the ``flask`` +command. By default, those commands will be nested in a group matching +the name of the blueprint. + +.. code-block:: python + + from flask import Blueprint + + bp = Blueprint('students', __name__) + + @bp.cli.command('create') + @click.argument('name') + def create(name): + ... + + app.register_blueprint(bp) + +.. code-block:: text + + $ flask students create alice + +You can alter the group name by specifying the ``cli_group`` parameter +when creating the :class:`Blueprint` object, or later with +:meth:`app.register_blueprint(bp, cli_group='...') `. +The following are equivalent: + +.. code-block:: python + + bp = Blueprint('students', __name__, cli_group='other') + # or + app.register_blueprint(bp, cli_group='other') + +.. code-block:: text + + $ flask other create alice + +Specifying ``cli_group=None`` will remove the nesting and merge the +commands directly to the application's level: + +.. code-block:: python + + bp = Blueprint('students', __name__, cli_group=None) + # or + app.register_blueprint(bp, cli_group=None) + +.. code-block:: text + + $ flask create alice + + +Application Context +~~~~~~~~~~~~~~~~~~~ + +Commands added using the Flask app's :attr:`~Flask.cli` or +:class:`~flask.cli.FlaskGroup` :meth:`~cli.AppGroup.command` decorator +will be executed with an application context pushed, so your custom +commands and parameters have access to the app and its configuration. The +:func:`~cli.with_appcontext` decorator can be used to get the same +behavior, but is not needed in most cases. + +.. code-block:: python + + import click + from flask.cli import with_appcontext + + @click.command() + @with_appcontext + def do_work(): + ... + + app.cli.add_command(do_work) + + +Plugins +------- + +Flask will automatically load commands specified in the ``flask.commands`` +`entry point`_. This is useful for extensions that want to add commands when +they are installed. Entry points are specified in :file:`pyproject.toml`: + +.. code-block:: toml + + [project.entry-points."flask.commands"] + my-command = "my_extension.commands:cli" + +.. _entry point: https://packaging.python.org/tutorials/packaging-projects/#entry-points + +Inside :file:`my_extension/commands.py` you can then export a Click +object:: + + import click + + @click.command() + def cli(): + ... + +Once that package is installed in the same virtualenv as your Flask project, +you can run ``flask my-command`` to invoke the command. + + +.. _custom-scripts: + +Custom Scripts +-------------- + +When you are using the app factory pattern, it may be more convenient to define +your own Click script. Instead of using ``--app`` and letting Flask load +your application, you can create your own Click object and export it as a +`console script`_ entry point. + +Create an instance of :class:`~cli.FlaskGroup` and pass it the factory:: + + import click + from flask import Flask + from flask.cli import FlaskGroup + + def create_app(): + app = Flask('wiki') + # other setup + return app + + @click.group(cls=FlaskGroup, create_app=create_app) + def cli(): + """Management script for the Wiki application.""" + +Define the entry point in :file:`pyproject.toml`: + +.. code-block:: toml + + [project.scripts] + wiki = "wiki:cli" + +Install the application in the virtualenv in editable mode and the custom +script is available. Note that you don't need to set ``--app``. :: + + $ pip install -e . + $ wiki run + +.. admonition:: Errors in Custom Scripts + + When using a custom script, if you introduce an error in your + module-level code, the reloader will fail because it can no longer + load the entry point. + + The ``flask`` command, being separate from your code, does not have + this issue and is recommended in most cases. + +.. _console script: https://packaging.python.org/tutorials/packaging-projects/#console-scripts + + +PyCharm Integration +------------------- + +PyCharm Professional provides a special Flask run configuration to run the development +server. For the Community Edition, and for other commands besides ``run``, you need to +create a custom run configuration. These instructions should be similar for any other +IDE you use. + +In PyCharm, with your project open, click on *Run* from the menu bar and go to *Edit +Configurations*. You'll see a screen similar to this: + +.. image:: _static/pycharm-run-config.png + :align: center + :class: screenshot + :alt: Screenshot of PyCharm run configuration. + +Once you create a configuration for the ``flask run``, you can copy and change it to +call any other command. + +Click the *+ (Add New Configuration)* button and select *Python*. Give the configuration +a name such as "flask run". + +Click the *Script path* dropdown and change it to *Module name*, then input ``flask``. + +The *Parameters* field is set to the CLI command to execute along with any arguments. +This example uses ``--app hello run --debug``, which will run the development server in +debug mode. ``--app hello`` should be the import or file with your Flask app. + +If you installed your project as a package in your virtualenv, you may uncheck the +*PYTHONPATH* options. This will more accurately match how you deploy later. + +Click *OK* to save and close the configuration. Select the configuration in the main +PyCharm window and click the play button next to it to run the server. + +Now that you have a configuration for ``flask run``, you can copy that configuration and +change the *Parameters* argument to run a different CLI command. diff --git a/src/flask-main/docs/conf.py b/src/flask-main/docs/conf.py new file mode 100644 index 0000000..af8adf0 --- /dev/null +++ b/src/flask-main/docs/conf.py @@ -0,0 +1,101 @@ +import packaging.version +from pallets_sphinx_themes import get_version +from pallets_sphinx_themes import ProjectLink + +# Project -------------------------------------------------------------- + +project = "Flask" +copyright = "2010 Pallets" +author = "Pallets" +release, version = get_version("Flask") + +# General -------------------------------------------------------------- + +default_role = "code" +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.extlinks", + "sphinx.ext.intersphinx", + "sphinxcontrib.log_cabinet", + "sphinx_tabs.tabs", + "pallets_sphinx_themes", +] +autodoc_member_order = "bysource" +autodoc_typehints = "description" +autodoc_preserve_defaults = True +extlinks = { + "issue": ("https://github.com/pallets/flask/issues/%s", "#%s"), + "pr": ("https://github.com/pallets/flask/pull/%s", "#%s"), + "ghsa": ("https://github.com/pallets/flask/security/advisories/GHSA-%s", "GHSA-%s"), +} +intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), + "werkzeug": ("https://werkzeug.palletsprojects.com/", None), + "click": ("https://click.palletsprojects.com/", None), + "jinja": ("https://jinja.palletsprojects.com/", None), + "itsdangerous": ("https://itsdangerous.palletsprojects.com/", None), + "sqlalchemy": ("https://docs.sqlalchemy.org/", None), + "wtforms": ("https://wtforms.readthedocs.io/", None), + "blinker": ("https://blinker.readthedocs.io/", None), +} + +# HTML ----------------------------------------------------------------- + +html_theme = "flask" +html_theme_options = {"index_sidebar_logo": False} +html_context = { + "project_links": [ + ProjectLink("Donate", "https://palletsprojects.com/donate"), + ProjectLink("PyPI Releases", "https://pypi.org/project/Flask/"), + ProjectLink("Source Code", "https://github.com/pallets/flask/"), + ProjectLink("Issue Tracker", "https://github.com/pallets/flask/issues/"), + ProjectLink("Chat", "https://discord.gg/pallets"), + ] +} +html_sidebars = { + "index": ["project.html", "localtoc.html", "searchbox.html", "ethicalads.html"], + "**": ["localtoc.html", "relations.html", "searchbox.html", "ethicalads.html"], +} +singlehtml_sidebars = {"index": ["project.html", "localtoc.html", "ethicalads.html"]} +html_static_path = ["_static"] +html_favicon = "_static/flask-icon.svg" +html_logo = "_static/flask-logo.svg" +html_title = f"Flask Documentation ({version})" +html_show_sourcelink = False + +gettext_uuid = True +gettext_compact = False + +# Local Extensions ----------------------------------------------------- + + +def github_link(name, rawtext, text, lineno, inliner, options=None, content=None): + app = inliner.document.settings.env.app + release = app.config.release + base_url = "https://github.com/pallets/flask/tree/" + + if text.endswith(">"): + words, text = text[:-1].rsplit("<", 1) + words = words.strip() + else: + words = None + + if packaging.version.parse(release).is_devrelease: + url = f"{base_url}main/{text}" + else: + url = f"{base_url}{release}/{text}" + + if words is None: + words = url + + from docutils.nodes import reference + from docutils.parsers.rst.roles import set_classes + + options = options or {} + set_classes(options) + node = reference(rawtext, words, refuri=url, **options) + return [node], [] + + +def setup(app): + app.add_role("gh", github_link) diff --git a/src/flask-main/docs/config.rst b/src/flask-main/docs/config.rst new file mode 100644 index 0000000..e7d4410 --- /dev/null +++ b/src/flask-main/docs/config.rst @@ -0,0 +1,839 @@ +Configuration Handling +====================== + +Applications need some kind of configuration. There are different settings +you might want to change depending on the application environment like +toggling the debug mode, setting the secret key, and other such +environment-specific things. + +The way Flask is designed usually requires the configuration to be +available when the application starts up. You can hard code the +configuration in the code, which for many small applications is not +actually that bad, but there are better ways. + +Independent of how you load your config, there is a config object +available which holds the loaded configuration values: +The :attr:`~flask.Flask.config` attribute of the :class:`~flask.Flask` +object. This is the place where Flask itself puts certain configuration +values and also where extensions can put their configuration values. But +this is also where you can have your own configuration. + + +Configuration Basics +-------------------- + +The :attr:`~flask.Flask.config` is actually a subclass of a dictionary and +can be modified just like any dictionary:: + + app = Flask(__name__) + app.config['TESTING'] = True + +Certain configuration values are also forwarded to the +:attr:`~flask.Flask` object so you can read and write them from there:: + + app.testing = True + +To update multiple keys at once you can use the :meth:`dict.update` +method:: + + app.config.update( + TESTING=True, + SECRET_KEY='192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf' + ) + + +Debug Mode +---------- + +The :data:`DEBUG` config value is special because it may behave inconsistently if +changed after the app has begun setting up. In order to set debug mode reliably, use the +``--debug`` option on the ``flask`` or ``flask run`` command. ``flask run`` will use the +interactive debugger and reloader by default in debug mode. + +.. code-block:: text + + $ flask --app hello run --debug + +Using the option is recommended. While it is possible to set :data:`DEBUG` in your +config or code, this is strongly discouraged. It can't be read early by the +``flask run`` command, and some systems or extensions may have already configured +themselves based on a previous value. + + +Builtin Configuration Values +---------------------------- + +The following configuration values are used internally by Flask: + +.. py:data:: DEBUG + + Whether debug mode is enabled. When using ``flask run`` to start the development + server, an interactive debugger will be shown for unhandled exceptions, and the + server will be reloaded when code changes. The :attr:`~flask.Flask.debug` attribute + maps to this config key. This is set with the ``FLASK_DEBUG`` environment variable. + It may not behave as expected if set in code. + + **Do not enable debug mode when deploying in production.** + + Default: ``False`` + +.. py:data:: TESTING + + Enable testing mode. Exceptions are propagated rather than handled by the + the app's error handlers. Extensions may also change their behavior to + facilitate easier testing. You should enable this in your own tests. + + Default: ``False`` + +.. py:data:: PROPAGATE_EXCEPTIONS + + Exceptions are re-raised rather than being handled by the app's error + handlers. If not set, this is implicitly true if ``TESTING`` or ``DEBUG`` + is enabled. + + Default: ``None`` + +.. py:data:: TRAP_HTTP_EXCEPTIONS + + If there is no handler for an ``HTTPException``-type exception, re-raise it + to be handled by the interactive debugger instead of returning it as a + simple error response. + + Default: ``False`` + +.. py:data:: TRAP_BAD_REQUEST_ERRORS + + Trying to access a key that doesn't exist from request dicts like ``args`` + and ``form`` will return a 400 Bad Request error page. Enable this to treat + the error as an unhandled exception instead so that you get the interactive + debugger. This is a more specific version of ``TRAP_HTTP_EXCEPTIONS``. If + unset, it is enabled in debug mode. + + Default: ``None`` + +.. py:data:: SECRET_KEY + + A secret key that will be used for securely signing the session cookie + and can be used for any other security related needs by extensions or your + application. It should be a long random ``bytes`` or ``str``. For + example, copy the output of this to your config:: + + $ python -c 'import secrets; print(secrets.token_hex())' + '192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf' + + **Do not reveal the secret key when posting questions or committing code.** + + Default: ``None`` + +.. py:data:: SECRET_KEY_FALLBACKS + + A list of old secret keys that can still be used for unsigning. This allows + a project to implement key rotation without invalidating active sessions or + other recently-signed secrets. + + Keys should be removed after an appropriate period of time, as checking each + additional key adds some overhead. + + Order should not matter, but the default implementation will test the last + key in the list first, so it might make sense to order oldest to newest. + + Flask's built-in secure cookie session supports this. Extensions that use + :data:`SECRET_KEY` may not support this yet. + + Default: ``None`` + + .. versionadded:: 3.1 + +.. py:data:: SESSION_COOKIE_NAME + + The name of the session cookie. Can be changed in case you already have a + cookie with the same name. + + Default: ``'session'`` + +.. py:data:: SESSION_COOKIE_DOMAIN + + The value of the ``Domain`` parameter on the session cookie. If not set, browsers + will only send the cookie to the exact domain it was set from. Otherwise, they + will send it to any subdomain of the given value as well. + + Not setting this value is more restricted and secure than setting it. + + Default: ``None`` + + .. warning:: + If this is changed after the browser created a cookie is created with + one setting, it may result in another being created. Browsers may send + send both in an undefined order. In that case, you may want to change + :data:`SESSION_COOKIE_NAME` as well or otherwise invalidate old sessions. + + .. versionchanged:: 2.3 + Not set by default, does not fall back to ``SERVER_NAME``. + +.. py:data:: SESSION_COOKIE_PATH + + The path that the session cookie will be valid for. If not set, the cookie + will be valid underneath ``APPLICATION_ROOT`` or ``/`` if that is not set. + + Default: ``None`` + +.. py:data:: SESSION_COOKIE_HTTPONLY + + Browsers will not allow JavaScript access to cookies marked as "HTTP only" + for security. + + Default: ``True`` + +.. py:data:: SESSION_COOKIE_SECURE + + Browsers will only send cookies with requests over HTTPS if the cookie is + marked "secure". The application must be served over HTTPS for this to make + sense. + + Default: ``False`` + +.. py:data:: SESSION_COOKIE_PARTITIONED + + Browsers will send cookies based on the top-level document's domain, rather + than only the domain of the document setting the cookie. This prevents third + party cookies set in iframes from "leaking" between separate sites. + + Browsers are beginning to disallow non-partitioned third party cookies, so + you need to mark your cookies partitioned if you expect them to work in such + embedded situations. + + Enabling this implicitly enables :data:`SESSION_COOKIE_SECURE` as well, as + it is only valid when served over HTTPS. + + Default: ``False`` + + .. versionadded:: 3.1 + +.. py:data:: SESSION_COOKIE_SAMESITE + + Restrict how cookies are sent with requests from external sites. Can + be set to ``'Lax'`` (recommended) or ``'Strict'``. + See :ref:`security-cookie`. + + Default: ``None`` + + .. versionadded:: 1.0 + +.. py:data:: PERMANENT_SESSION_LIFETIME + + If ``session.permanent`` is true, the cookie's expiration will be set this + number of seconds in the future. Can either be a + :class:`datetime.timedelta` or an ``int``. + + Flask's default cookie implementation validates that the cryptographic + signature is not older than this value. + + Default: ``timedelta(days=31)`` (``2678400`` seconds) + +.. py:data:: SESSION_REFRESH_EACH_REQUEST + + Control whether the cookie is sent with every response when + ``session.permanent`` is true. Sending the cookie every time (the default) + can more reliably keep the session from expiring, but uses more bandwidth. + Non-permanent sessions are not affected. + + Default: ``True`` + +.. py:data:: USE_X_SENDFILE + + When serving files, set the ``X-Sendfile`` header instead of serving the + data with Flask. Some web servers, such as Apache, recognize this and serve + the data more efficiently. This only makes sense when using such a server. + + Default: ``False`` + +.. py:data:: SEND_FILE_MAX_AGE_DEFAULT + + When serving files, set the cache control max age to this number of + seconds. Can be a :class:`datetime.timedelta` or an ``int``. + Override this value on a per-file basis using + :meth:`~flask.Flask.get_send_file_max_age` on the application or + blueprint. + + If ``None``, ``send_file`` tells the browser to use conditional + requests will be used instead of a timed cache, which is usually + preferable. + + Default: ``None`` + +.. py:data:: TRUSTED_HOSTS + + Validate :attr:`.Request.host` and other attributes that use it against + these trusted values. Raise a :exc:`~werkzeug.exceptions.SecurityError` if + the host is invalid, which results in a 400 error. If it is ``None``, all + hosts are valid. Each value is either an exact match, or can start with + a dot ``.`` to match any subdomain. + + Validation is done during routing against this value. ``before_request`` and + ``after_request`` callbacks will still be called. + + Default: ``None`` + + .. versionadded:: 3.1 + +.. py:data:: SERVER_NAME + + Inform the application what host and port it is bound to. + + Must be set if ``subdomain_matching`` is enabled, to be able to extract the + subdomain from the request. + + Must be set for ``url_for`` to generate external URLs outside of a + request context. + + Default: ``None`` + + .. versionchanged:: 3.1 + Does not restrict requests to only this domain, for both + ``subdomain_matching`` and ``host_matching``. + + .. versionchanged:: 1.0 + Does not implicitly enable ``subdomain_matching``. + + .. versionchanged:: 2.3 + Does not affect ``SESSION_COOKIE_DOMAIN``. + +.. py:data:: APPLICATION_ROOT + + Inform the application what path it is mounted under by the application / + web server. This is used for generating URLs outside the context of a + request (inside a request, the dispatcher is responsible for setting + ``SCRIPT_NAME`` instead; see :doc:`/patterns/appdispatch` + for examples of dispatch configuration). + + Will be used for the session cookie path if ``SESSION_COOKIE_PATH`` is not + set. + + Default: ``'/'`` + +.. py:data:: PREFERRED_URL_SCHEME + + Use this scheme for generating external URLs when not in a request context. + + Default: ``'http'`` + +.. py:data:: MAX_CONTENT_LENGTH + + The maximum number of bytes that will be read during this request. If + this limit is exceeded, a 413 :exc:`~werkzeug.exceptions.RequestEntityTooLarge` + error is raised. If it is set to ``None``, no limit is enforced at the + Flask application level. However, if it is ``None`` and the request has no + ``Content-Length`` header and the WSGI server does not indicate that it + terminates the stream, then no data is read to avoid an infinite stream. + + Each request defaults to this config. It can be set on a specific + :attr:`.Request.max_content_length` to apply the limit to that specific + view. This should be set appropriately based on an application's or view's + specific needs. + + Default: ``None`` + + .. versionadded:: 0.6 + +.. py:data:: MAX_FORM_MEMORY_SIZE + + The maximum size in bytes any non-file form field may be in a + ``multipart/form-data`` body. If this limit is exceeded, a 413 + :exc:`~werkzeug.exceptions.RequestEntityTooLarge` error is raised. If it is + set to ``None``, no limit is enforced at the Flask application level. + + Each request defaults to this config. It can be set on a specific + :attr:`.Request.max_form_memory_parts` to apply the limit to that specific + view. This should be set appropriately based on an application's or view's + specific needs. + + Default: ``500_000`` + + .. versionadded:: 3.1 + +.. py:data:: MAX_FORM_PARTS + + The maximum number of fields that may be present in a + ``multipart/form-data`` body. If this limit is exceeded, a 413 + :exc:`~werkzeug.exceptions.RequestEntityTooLarge` error is raised. If it + is set to ``None``, no limit is enforced at the Flask application level. + + Each request defaults to this config. It can be set on a specific + :attr:`.Request.max_form_parts` to apply the limit to that specific view. + This should be set appropriately based on an application's or view's + specific needs. + + Default: ``1_000`` + + .. versionadded:: 3.1 + +.. py:data:: TEMPLATES_AUTO_RELOAD + + Reload templates when they are changed. If not set, it will be enabled in + debug mode. + + Default: ``None`` + +.. py:data:: EXPLAIN_TEMPLATE_LOADING + + Log debugging information tracing how a template file was loaded. This can + be useful to figure out why a template was not loaded or the wrong file + appears to be loaded. + + Default: ``False`` + +.. py:data:: MAX_COOKIE_SIZE + + Warn if cookie headers are larger than this many bytes. Defaults to + ``4093``. Larger cookies may be silently ignored by browsers. Set to + ``0`` to disable the warning. + +.. py:data:: PROVIDE_AUTOMATIC_OPTIONS + + Set to ``False`` to disable the automatic addition of OPTIONS + responses. This can be overridden per route by altering the + ``provide_automatic_options`` attribute. + +.. versionadded:: 0.4 + ``LOGGER_NAME`` + +.. versionadded:: 0.5 + ``SERVER_NAME`` + +.. versionadded:: 0.6 + ``MAX_CONTENT_LENGTH`` + +.. versionadded:: 0.7 + ``PROPAGATE_EXCEPTIONS``, ``PRESERVE_CONTEXT_ON_EXCEPTION`` + +.. versionadded:: 0.8 + ``TRAP_BAD_REQUEST_ERRORS``, ``TRAP_HTTP_EXCEPTIONS``, + ``APPLICATION_ROOT``, ``SESSION_COOKIE_DOMAIN``, + ``SESSION_COOKIE_PATH``, ``SESSION_COOKIE_HTTPONLY``, + ``SESSION_COOKIE_SECURE`` + +.. versionadded:: 0.9 + ``PREFERRED_URL_SCHEME`` + +.. versionadded:: 0.10 + ``JSON_AS_ASCII``, ``JSON_SORT_KEYS``, ``JSONIFY_PRETTYPRINT_REGULAR`` + +.. versionadded:: 0.11 + ``SESSION_REFRESH_EACH_REQUEST``, ``TEMPLATES_AUTO_RELOAD``, + ``LOGGER_HANDLER_POLICY``, ``EXPLAIN_TEMPLATE_LOADING`` + +.. versionchanged:: 1.0 + ``LOGGER_NAME`` and ``LOGGER_HANDLER_POLICY`` were removed. See + :doc:`/logging` for information about configuration. + + Added :data:`ENV` to reflect the :envvar:`FLASK_ENV` environment + variable. + + Added :data:`SESSION_COOKIE_SAMESITE` to control the session + cookie's ``SameSite`` option. + + Added :data:`MAX_COOKIE_SIZE` to control a warning from Werkzeug. + +.. versionchanged:: 2.2 + Removed ``PRESERVE_CONTEXT_ON_EXCEPTION``. + +.. versionchanged:: 2.3 + ``JSON_AS_ASCII``, ``JSON_SORT_KEYS``, ``JSONIFY_MIMETYPE``, and + ``JSONIFY_PRETTYPRINT_REGULAR`` were removed. The default ``app.json`` provider has + equivalent attributes instead. + +.. versionchanged:: 2.3 + ``ENV`` was removed. + +.. versionadded:: 3.10 + Added :data:`PROVIDE_AUTOMATIC_OPTIONS` to control the default + addition of autogenerated OPTIONS responses. + + +Configuring from Python Files +----------------------------- + +Configuration becomes more useful if you can store it in a separate file, ideally +located outside the actual application package. You can deploy your application, then +separately configure it for the specific deployment. + +A common pattern is this:: + + app = Flask(__name__) + app.config.from_object('yourapplication.default_settings') + app.config.from_envvar('YOURAPPLICATION_SETTINGS') + +This first loads the configuration from the +`yourapplication.default_settings` module and then overrides the values +with the contents of the file the :envvar:`YOURAPPLICATION_SETTINGS` +environment variable points to. This environment variable can be set +in the shell before starting the server: + +.. tabs:: + + .. group-tab:: Bash + + .. code-block:: text + + $ export YOURAPPLICATION_SETTINGS=/path/to/settings.cfg + $ flask run + * Running on http://127.0.0.1:5000/ + + .. group-tab:: Fish + + .. code-block:: text + + $ set -x YOURAPPLICATION_SETTINGS /path/to/settings.cfg + $ flask run + * Running on http://127.0.0.1:5000/ + + .. group-tab:: CMD + + .. code-block:: text + + > set YOURAPPLICATION_SETTINGS=\path\to\settings.cfg + > flask run + * Running on http://127.0.0.1:5000/ + + .. group-tab:: Powershell + + .. code-block:: text + + > $env:YOURAPPLICATION_SETTINGS = "\path\to\settings.cfg" + > flask run + * Running on http://127.0.0.1:5000/ + +The configuration files themselves are actual Python files. Only values +in uppercase are actually stored in the config object later on. So make +sure to use uppercase letters for your config keys. + +Here is an example of a configuration file:: + + # Example configuration + SECRET_KEY = '192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf' + +Make sure to load the configuration very early on, so that extensions have +the ability to access the configuration when starting up. There are other +methods on the config object as well to load from individual files. For a +complete reference, read the :class:`~flask.Config` object's +documentation. + + +Configuring from Data Files +--------------------------- + +It is also possible to load configuration from a file in a format of +your choice using :meth:`~flask.Config.from_file`. For example to load +from a TOML file: + +.. code-block:: python + + import tomllib + app.config.from_file("config.toml", load=tomllib.load, text=False) + +Or from a JSON file: + +.. code-block:: python + + import json + app.config.from_file("config.json", load=json.load) + + +Configuring from Environment Variables +-------------------------------------- + +In addition to pointing to configuration files using environment +variables, you may find it useful (or necessary) to control your +configuration values directly from the environment. Flask can be +instructed to load all environment variables starting with a specific +prefix into the config using :meth:`~flask.Config.from_prefixed_env`. + +Environment variables can be set in the shell before starting the +server: + +.. tabs:: + + .. group-tab:: Bash + + .. code-block:: text + + $ export FLASK_SECRET_KEY="5f352379324c22463451387a0aec5d2f" + $ export FLASK_MAIL_ENABLED=false + $ flask run + * Running on http://127.0.0.1:5000/ + + .. group-tab:: Fish + + .. code-block:: text + + $ set -x FLASK_SECRET_KEY "5f352379324c22463451387a0aec5d2f" + $ set -x FLASK_MAIL_ENABLED false + $ flask run + * Running on http://127.0.0.1:5000/ + + .. group-tab:: CMD + + .. code-block:: text + + > set FLASK_SECRET_KEY="5f352379324c22463451387a0aec5d2f" + > set FLASK_MAIL_ENABLED=false + > flask run + * Running on http://127.0.0.1:5000/ + + .. group-tab:: Powershell + + .. code-block:: text + + > $env:FLASK_SECRET_KEY = "5f352379324c22463451387a0aec5d2f" + > $env:FLASK_MAIL_ENABLED = "false" + > flask run + * Running on http://127.0.0.1:5000/ + +The variables can then be loaded and accessed via the config with a key +equal to the environment variable name without the prefix i.e. + +.. code-block:: python + + app.config.from_prefixed_env() + app.config["SECRET_KEY"] # Is "5f352379324c22463451387a0aec5d2f" + +The prefix is ``FLASK_`` by default. This is configurable via the +``prefix`` argument of :meth:`~flask.Config.from_prefixed_env`. + +Values will be parsed to attempt to convert them to a more specific type +than strings. By default :func:`json.loads` is used, so any valid JSON +value is possible, including lists and dicts. This is configurable via +the ``loads`` argument of :meth:`~flask.Config.from_prefixed_env`. + +When adding a boolean value with the default JSON parsing, only "true" +and "false", lowercase, are valid values. Keep in mind that any +non-empty string is considered ``True`` by Python. + +It is possible to set keys in nested dictionaries by separating the +keys with double underscore (``__``). Any intermediate keys that don't +exist on the parent dict will be initialized to an empty dict. + +.. code-block:: text + + $ export FLASK_MYAPI__credentials__username=user123 + +.. code-block:: python + + app.config["MYAPI"]["credentials"]["username"] # Is "user123" + +On Windows, environment variable keys are always uppercase, therefore +the above example would end up as ``MYAPI__CREDENTIALS__USERNAME``. + +For even more config loading features, including merging and +case-insensitive Windows support, try a dedicated library such as +Dynaconf_, which includes integration with Flask. + +.. _Dynaconf: https://www.dynaconf.com/ + + +Configuration Best Practices +---------------------------- + +The downside with the approach mentioned earlier is that it makes testing +a little harder. There is no single 100% solution for this problem in +general, but there are a couple of things you can keep in mind to improve +that experience: + +1. Create your application in a function and register blueprints on it. + That way you can create multiple instances of your application with + different configurations attached which makes unit testing a lot + easier. You can use this to pass in configuration as needed. + +2. Do not write code that needs the configuration at import time. If you + limit yourself to request-only accesses to the configuration you can + reconfigure the object later on as needed. + +3. Make sure to load the configuration very early on, so that + extensions can access the configuration when calling ``init_app``. + + +.. _config-dev-prod: + +Development / Production +------------------------ + +Most applications need more than one configuration. There should be at +least separate configurations for the production server and the one used +during development. The easiest way to handle this is to use a default +configuration that is always loaded and part of the version control, and a +separate configuration that overrides the values as necessary as mentioned +in the example above:: + + app = Flask(__name__) + app.config.from_object('yourapplication.default_settings') + app.config.from_envvar('YOURAPPLICATION_SETTINGS') + +Then you just have to add a separate :file:`config.py` file and export +``YOURAPPLICATION_SETTINGS=/path/to/config.py`` and you are done. However +there are alternative ways as well. For example you could use imports or +subclassing. + +What is very popular in the Django world is to make the import explicit in +the config file by adding ``from yourapplication.default_settings +import *`` to the top of the file and then overriding the changes by hand. +You could also inspect an environment variable like +``YOURAPPLICATION_MODE`` and set that to `production`, `development` etc +and import different hard-coded files based on that. + +An interesting pattern is also to use classes and inheritance for +configuration:: + + class Config(object): + TESTING = False + + class ProductionConfig(Config): + DATABASE_URI = 'mysql://user@localhost/foo' + + class DevelopmentConfig(Config): + DATABASE_URI = "sqlite:////tmp/foo.db" + + class TestingConfig(Config): + DATABASE_URI = 'sqlite:///:memory:' + TESTING = True + +To enable such a config you just have to call into +:meth:`~flask.Config.from_object`:: + + app.config.from_object('configmodule.ProductionConfig') + +Note that :meth:`~flask.Config.from_object` does not instantiate the class +object. If you need to instantiate the class, such as to access a property, +then you must do so before calling :meth:`~flask.Config.from_object`:: + + from configmodule import ProductionConfig + app.config.from_object(ProductionConfig()) + + # Alternatively, import via string: + from werkzeug.utils import import_string + cfg = import_string('configmodule.ProductionConfig')() + app.config.from_object(cfg) + +Instantiating the configuration object allows you to use ``@property`` in +your configuration classes:: + + class Config(object): + """Base config, uses staging database server.""" + TESTING = False + DB_SERVER = '192.168.1.56' + + @property + def DATABASE_URI(self): # Note: all caps + return f"mysql://user@{self.DB_SERVER}/foo" + + class ProductionConfig(Config): + """Uses production database server.""" + DB_SERVER = '192.168.19.32' + + class DevelopmentConfig(Config): + DB_SERVER = 'localhost' + + class TestingConfig(Config): + DB_SERVER = 'localhost' + DATABASE_URI = 'sqlite:///:memory:' + +There are many different ways and it's up to you how you want to manage +your configuration files. However here a list of good recommendations: + +- Keep a default configuration in version control. Either populate the + config with this default configuration or import it in your own + configuration files before overriding values. +- Use an environment variable to switch between the configurations. + This can be done from outside the Python interpreter and makes + development and deployment much easier because you can quickly and + easily switch between different configs without having to touch the + code at all. If you are working often on different projects you can + even create your own script for sourcing that activates a virtualenv + and exports the development configuration for you. +- Use a tool like `fabric`_ to push code and configuration separately + to the production server(s). + +.. _fabric: https://www.fabfile.org/ + + +.. _instance-folders: + +Instance Folders +---------------- + +.. versionadded:: 0.8 + +Flask 0.8 introduces instance folders. Flask for a long time made it +possible to refer to paths relative to the application's folder directly +(via :attr:`Flask.root_path`). This was also how many developers loaded +configurations stored next to the application. Unfortunately however this +only works well if applications are not packages in which case the root +path refers to the contents of the package. + +With Flask 0.8 a new attribute was introduced: +:attr:`Flask.instance_path`. It refers to a new concept called the +“instance folder”. The instance folder is designed to not be under +version control and be deployment specific. It's the perfect place to +drop things that either change at runtime or configuration files. + +You can either explicitly provide the path of the instance folder when +creating the Flask application or you can let Flask autodetect the +instance folder. For explicit configuration use the `instance_path` +parameter:: + + app = Flask(__name__, instance_path='/path/to/instance/folder') + +Please keep in mind that this path *must* be absolute when provided. + +If the `instance_path` parameter is not provided the following default +locations are used: + +- Uninstalled module:: + + /myapp.py + /instance + +- Uninstalled package:: + + /myapp + /__init__.py + /instance + +- Installed module or package:: + + $PREFIX/lib/pythonX.Y/site-packages/myapp + $PREFIX/var/myapp-instance + + ``$PREFIX`` is the prefix of your Python installation. This can be + ``/usr`` or the path to your virtualenv. You can print the value of + ``sys.prefix`` to see what the prefix is set to. + +Since the config object provided loading of configuration files from +relative filenames we made it possible to change the loading via filenames +to be relative to the instance path if wanted. The behavior of relative +paths in config files can be flipped between “relative to the application +root” (the default) to “relative to instance folder” via the +`instance_relative_config` switch to the application constructor:: + + app = Flask(__name__, instance_relative_config=True) + +Here is a full example of how to configure Flask to preload the config +from a module and then override the config from a file in the instance +folder if it exists:: + + app = Flask(__name__, instance_relative_config=True) + app.config.from_object('yourapplication.default_settings') + app.config.from_pyfile('application.cfg', silent=True) + +The path to the instance folder can be found via the +:attr:`Flask.instance_path`. Flask also provides a shortcut to open a +file from the instance folder with :meth:`Flask.open_instance_resource`. + +Example usage for both:: + + filename = os.path.join(app.instance_path, 'application.cfg') + with open(filename) as f: + config = f.read() + + # or via open_instance_resource: + with app.open_instance_resource('application.cfg') as f: + config = f.read() diff --git a/src/flask-main/docs/contributing.rst b/src/flask-main/docs/contributing.rst new file mode 100644 index 0000000..ca4b3ae --- /dev/null +++ b/src/flask-main/docs/contributing.rst @@ -0,0 +1,8 @@ +Contributing +============ + +See the Pallets `detailed contributing documentation `_ for many ways +to contribute, including reporting issues, requesting features, asking or +answering questions, and making PRs. + +.. _contrib: https://palletsprojects.com/contributing/ diff --git a/src/flask-main/docs/debugging.rst b/src/flask-main/docs/debugging.rst new file mode 100644 index 0000000..f6b56ca --- /dev/null +++ b/src/flask-main/docs/debugging.rst @@ -0,0 +1,99 @@ +Debugging Application Errors +============================ + + +In Production +------------- + +**Do not run the development server, or enable the built-in debugger, in +a production environment.** The debugger allows executing arbitrary +Python code from the browser. It's protected by a pin, but that should +not be relied on for security. + +Use an error logging tool, such as Sentry, as described in +:ref:`error-logging-tools`, or enable logging and notifications as +described in :doc:`/logging`. + +If you have access to the server, you could add some code to start an +external debugger if ``request.remote_addr`` matches your IP. Some IDE +debuggers also have a remote mode so breakpoints on the server can be +interacted with locally. Only enable a debugger temporarily. + + +The Built-In Debugger +--------------------- + +The built-in Werkzeug development server provides a debugger which shows +an interactive traceback in the browser when an unhandled error occurs +during a request. This debugger should only be used during development. + +.. image:: _static/debugger.png + :align: center + :class: screenshot + :alt: screenshot of debugger in action + +.. warning:: + + The debugger allows executing arbitrary Python code from the + browser. It is protected by a pin, but still represents a major + security risk. Do not run the development server or debugger in a + production environment. + +The debugger is enabled by default when the development server is run in debug mode. + +.. code-block:: text + + $ flask --app hello run --debug + +When running from Python code, passing ``debug=True`` enables debug mode, which is +mostly equivalent. + +.. code-block:: python + + app.run(debug=True) + +:doc:`/server` and :doc:`/cli` have more information about running the debugger and +debug mode. More information about the debugger can be found in the `Werkzeug +documentation `__. + + +External Debuggers +------------------ + +External debuggers, such as those provided by IDEs, can offer a more +powerful debugging experience than the built-in debugger. They can also +be used to step through code during a request before an error is raised, +or if no error is raised. Some even have a remote mode so you can debug +code running on another machine. + +When using an external debugger, the app should still be in debug mode, otherwise Flask +turns unhandled errors into generic 500 error pages. However, the built-in debugger and +reloader should be disabled so they don't interfere with the external debugger. + +.. code-block:: text + + $ flask --app hello run --debug --no-debugger --no-reload + +When running from Python: + +.. code-block:: python + + app.run(debug=True, use_debugger=False, use_reloader=False) + +Disabling these isn't required, an external debugger will continue to work with the +following caveats. + +- If the built-in debugger is not disabled, it will catch unhandled exceptions before + the external debugger can. +- If the reloader is not disabled, it could cause an unexpected reload if code changes + during a breakpoint. +- The development server will still catch unhandled exceptions if the built-in + debugger is disabled, otherwise it would crash on any error. If you want that (and + usually you don't) pass ``passthrough_errors=True`` to ``app.run``. + + .. code-block:: python + + app.run( + debug=True, passthrough_errors=True, + use_debugger=False, use_reloader=False + ) diff --git a/src/flask-main/docs/deploying/apache-httpd.rst b/src/flask-main/docs/deploying/apache-httpd.rst new file mode 100644 index 0000000..bdeaf62 --- /dev/null +++ b/src/flask-main/docs/deploying/apache-httpd.rst @@ -0,0 +1,66 @@ +Apache httpd +============ + +`Apache httpd`_ is a fast, production level HTTP server. When serving +your application with one of the WSGI servers listed in :doc:`index`, it +is often good or necessary to put a dedicated HTTP server in front of +it. This "reverse proxy" can handle incoming requests, TLS, and other +security and performance concerns better than the WSGI server. + +httpd can be installed using your system package manager, or a pre-built +executable for Windows. Installing and running httpd itself is outside +the scope of this doc. This page outlines the basics of configuring +httpd to proxy your application. Be sure to read its documentation to +understand what features are available. + +.. _Apache httpd: https://httpd.apache.org/ + + +Domain Name +----------- + +Acquiring and configuring a domain name is outside the scope of this +doc. In general, you will buy a domain name from a registrar, pay for +server space with a hosting provider, and then point your registrar +at the hosting provider's name servers. + +To simulate this, you can also edit your ``hosts`` file, located at +``/etc/hosts`` on Linux. Add a line that associates a name with the +local IP. + +Modern Linux systems may be configured to treat any domain name that +ends with ``.localhost`` like this without adding it to the ``hosts`` +file. + +.. code-block:: python + :caption: ``/etc/hosts`` + + 127.0.0.1 hello.localhost + + +Configuration +------------- + +The httpd configuration is located at ``/etc/httpd/conf/httpd.conf`` on +Linux. It may be different depending on your operating system. Check the +docs and look for ``httpd.conf``. + +Remove or comment out any existing ``DocumentRoot`` directive. Add the +config lines below. We'll assume the WSGI server is listening locally at +``http://127.0.0.1:8000``. + +.. code-block:: apache + :caption: ``/etc/httpd/conf/httpd.conf`` + + LoadModule proxy_module modules/mod_proxy.so + LoadModule proxy_http_module modules/mod_proxy_http.so + ProxyPass / http://127.0.0.1:8000/ + RequestHeader set X-Forwarded-Proto http + RequestHeader set X-Forwarded-Prefix / + +The ``LoadModule`` lines might already exist. If so, make sure they are +uncommented instead of adding them manually. + +Then :doc:`proxy_fix` so that your application uses the ``X-Forwarded`` +headers. ``X-Forwarded-For`` and ``X-Forwarded-Host`` are automatically +set by ``ProxyPass``. diff --git a/src/flask-main/docs/deploying/asgi.rst b/src/flask-main/docs/deploying/asgi.rst new file mode 100644 index 0000000..1dc0aa2 --- /dev/null +++ b/src/flask-main/docs/deploying/asgi.rst @@ -0,0 +1,27 @@ +ASGI +==== + +If you'd like to use an ASGI server you will need to utilise WSGI to +ASGI middleware. The asgiref +`WsgiToAsgi `_ +adapter is recommended as it integrates with the event loop used for +Flask's :ref:`async_await` support. You can use the adapter by +wrapping the Flask app, + +.. code-block:: python + + from asgiref.wsgi import WsgiToAsgi + from flask import Flask + + app = Flask(__name__) + + ... + + asgi_app = WsgiToAsgi(app) + +and then serving the ``asgi_app`` with the ASGI server, e.g. using +`Hypercorn `_, + +.. sourcecode:: text + + $ hypercorn module:asgi_app diff --git a/src/flask-main/docs/deploying/eventlet.rst b/src/flask-main/docs/deploying/eventlet.rst new file mode 100644 index 0000000..8a718b2 --- /dev/null +++ b/src/flask-main/docs/deploying/eventlet.rst @@ -0,0 +1,80 @@ +eventlet +======== + +Prefer using :doc:`gunicorn` with eventlet workers rather than using +`eventlet`_ directly. Gunicorn provides a much more configurable and +production-tested server. + +`eventlet`_ allows writing asynchronous, coroutine-based code that looks +like standard synchronous Python. It uses `greenlet`_ to enable task +switching without writing ``async/await`` or using ``asyncio``. + +:doc:`gevent` is another library that does the same thing. Certain +dependencies you have, or other considerations, may affect which of the +two you choose to use. + +eventlet provides a WSGI server that can handle many connections at once +instead of one per worker process. You must actually use eventlet in +your own code to see any benefit to using the server. + +.. _eventlet: https://eventlet.net/ +.. _greenlet: https://greenlet.readthedocs.io/en/latest/ + + +Installing +---------- + +When using eventlet, greenlet>=1.0 is required, otherwise context locals +such as ``request`` will not work as expected. When using PyPy, +PyPy>=7.3.7 is required. + +Create a virtualenv, install your application, then install +``eventlet``. + +.. code-block:: text + + $ cd hello-app + $ python -m venv .venv + $ . .venv/bin/activate + $ pip install . # install your application + $ pip install eventlet + + +Running +------- + +To use eventlet to serve your application, write a script that imports +its ``wsgi.server``, as well as your app or app factory. + +.. code-block:: python + :caption: ``wsgi.py`` + + import eventlet + from eventlet import wsgi + from hello import create_app + + app = create_app() + wsgi.server(eventlet.listen(("127.0.0.1", 8000)), app) + +.. code-block:: text + + $ python wsgi.py + (x) wsgi starting up on http://127.0.0.1:8000 + + +Binding Externally +------------------ + +eventlet should not be run as root because it would cause your +application code to run as root, which is not secure. However, this +means it will not be possible to bind to port 80 or 443. Instead, a +reverse proxy such as :doc:`nginx` or :doc:`apache-httpd` should be used +in front of eventlet. + +You can bind to all external IPs on a non-privileged port by using +``0.0.0.0`` in the server arguments shown in the previous section. +Don't do this when using a reverse proxy setup, otherwise it will be +possible to bypass the proxy. + +``0.0.0.0`` is not a valid address to navigate to, you'd use a specific +IP address in your browser. diff --git a/src/flask-main/docs/deploying/gevent.rst b/src/flask-main/docs/deploying/gevent.rst new file mode 100644 index 0000000..448b93e --- /dev/null +++ b/src/flask-main/docs/deploying/gevent.rst @@ -0,0 +1,80 @@ +gevent +====== + +Prefer using :doc:`gunicorn` or :doc:`uwsgi` with gevent workers rather +than using `gevent`_ directly. Gunicorn and uWSGI provide much more +configurable and production-tested servers. + +`gevent`_ allows writing asynchronous, coroutine-based code that looks +like standard synchronous Python. It uses `greenlet`_ to enable task +switching without writing ``async/await`` or using ``asyncio``. + +:doc:`eventlet` is another library that does the same thing. Certain +dependencies you have, or other considerations, may affect which of the +two you choose to use. + +gevent provides a WSGI server that can handle many connections at once +instead of one per worker process. You must actually use gevent in your +own code to see any benefit to using the server. + +.. _gevent: https://www.gevent.org/ +.. _greenlet: https://greenlet.readthedocs.io/en/latest/ + + +Installing +---------- + +When using gevent, greenlet>=1.0 is required, otherwise context locals +such as ``request`` will not work as expected. When using PyPy, +PyPy>=7.3.7 is required. + +Create a virtualenv, install your application, then install ``gevent``. + +.. code-block:: text + + $ cd hello-app + $ python -m venv .venv + $ . .venv/bin/activate + $ pip install . # install your application + $ pip install gevent + + +Running +------- + +To use gevent to serve your application, write a script that imports its +``WSGIServer``, as well as your app or app factory. + +.. code-block:: python + :caption: ``wsgi.py`` + + from gevent.pywsgi import WSGIServer + from hello import create_app + + app = create_app() + http_server = WSGIServer(("127.0.0.1", 8000), app) + http_server.serve_forever() + +.. code-block:: text + + $ python wsgi.py + +No output is shown when the server starts. + + +Binding Externally +------------------ + +gevent should not be run as root because it would cause your +application code to run as root, which is not secure. However, this +means it will not be possible to bind to port 80 or 443. Instead, a +reverse proxy such as :doc:`nginx` or :doc:`apache-httpd` should be used +in front of gevent. + +You can bind to all external IPs on a non-privileged port by using +``0.0.0.0`` in the server arguments shown in the previous section. Don't +do this when using a reverse proxy setup, otherwise it will be possible +to bypass the proxy. + +``0.0.0.0`` is not a valid address to navigate to, you'd use a specific +IP address in your browser. diff --git a/src/flask-main/docs/deploying/gunicorn.rst b/src/flask-main/docs/deploying/gunicorn.rst new file mode 100644 index 0000000..c50edc2 --- /dev/null +++ b/src/flask-main/docs/deploying/gunicorn.rst @@ -0,0 +1,130 @@ +Gunicorn +======== + +`Gunicorn`_ is a pure Python WSGI server with simple configuration and +multiple worker implementations for performance tuning. + +* It tends to integrate easily with hosting platforms. +* It does not support Windows (but does run on WSL). +* It is easy to install as it does not require additional dependencies + or compilation. +* It has built-in async worker support using gevent or eventlet. + +This page outlines the basics of running Gunicorn. Be sure to read its +`documentation`_ and use ``gunicorn --help`` to understand what features +are available. + +.. _Gunicorn: https://gunicorn.org/ +.. _documentation: https://docs.gunicorn.org/ + + +Installing +---------- + +Gunicorn is easy to install, as it does not require external +dependencies or compilation. It runs on Windows only under WSL. + +Create a virtualenv, install your application, then install +``gunicorn``. + +.. code-block:: text + + $ cd hello-app + $ python -m venv .venv + $ . .venv/bin/activate + $ pip install . # install your application + $ pip install gunicorn + + +Running +------- + +The only required argument to Gunicorn tells it how to load your Flask +application. The syntax is ``{module_import}:{app_variable}``. +``module_import`` is the dotted import name to the module with your +application. ``app_variable`` is the variable with the application. It +can also be a function call (with any arguments) if you're using the +app factory pattern. + +.. code-block:: text + + # equivalent to 'from hello import app' + $ gunicorn -w 4 'hello:app' + + # equivalent to 'from hello import create_app; create_app()' + $ gunicorn -w 4 'hello:create_app()' + + Starting gunicorn 20.1.0 + Listening at: http://127.0.0.1:8000 (x) + Using worker: sync + Booting worker with pid: x + Booting worker with pid: x + Booting worker with pid: x + Booting worker with pid: x + +The ``-w`` option specifies the number of processes to run; a starting +value could be ``CPU * 2``. The default is only 1 worker, which is +probably not what you want for the default worker type. + +Logs for each request aren't shown by default, only worker info and +errors are shown. To show access logs on stdout, use the +``--access-logfile=-`` option. + + +Binding Externally +------------------ + +Gunicorn should not be run as root because it would cause your +application code to run as root, which is not secure. However, this +means it will not be possible to bind to port 80 or 443. Instead, a +reverse proxy such as :doc:`nginx` or :doc:`apache-httpd` should be used +in front of Gunicorn. + +You can bind to all external IPs on a non-privileged port using the +``-b 0.0.0.0`` option. Don't do this when using a reverse proxy setup, +otherwise it will be possible to bypass the proxy. + +.. code-block:: text + + $ gunicorn -w 4 -b 0.0.0.0 'hello:create_app()' + Listening at: http://0.0.0.0:8000 (x) + +``0.0.0.0`` is not a valid address to navigate to, you'd use a specific +IP address in your browser. + + +Async with gevent or eventlet +----------------------------- + +The default sync worker is appropriate for many use cases. If you need +asynchronous support, Gunicorn provides workers using either `gevent`_ +or `eventlet`_. This is not the same as Python's ``async/await``, or the +ASGI server spec. You must actually use gevent/eventlet in your own code +to see any benefit to using the workers. + +When using either gevent or eventlet, greenlet>=1.0 is required, +otherwise context locals such as ``request`` will not work as expected. +When using PyPy, PyPy>=7.3.7 is required. + +To use gevent: + +.. code-block:: text + + $ gunicorn -k gevent 'hello:create_app()' + Starting gunicorn 20.1.0 + Listening at: http://127.0.0.1:8000 (x) + Using worker: gevent + Booting worker with pid: x + +To use eventlet: + +.. code-block:: text + + $ gunicorn -k eventlet 'hello:create_app()' + Starting gunicorn 20.1.0 + Listening at: http://127.0.0.1:8000 (x) + Using worker: eventlet + Booting worker with pid: x + +.. _gevent: https://www.gevent.org/ +.. _eventlet: https://eventlet.net/ diff --git a/src/flask-main/docs/deploying/index.rst b/src/flask-main/docs/deploying/index.rst new file mode 100644 index 0000000..4135596 --- /dev/null +++ b/src/flask-main/docs/deploying/index.rst @@ -0,0 +1,79 @@ +Deploying to Production +======================= + +After developing your application, you'll want to make it available +publicly to other users. When you're developing locally, you're probably +using the built-in development server, debugger, and reloader. These +should not be used in production. Instead, you should use a dedicated +WSGI server or hosting platform, some of which will be described here. + +"Production" means "not development", which applies whether you're +serving your application publicly to millions of users or privately / +locally to a single user. **Do not use the development server when +deploying to production. It is intended for use only during local +development. It is not designed to be particularly secure, stable, or +efficient.** + +Self-Hosted Options +------------------- + +Flask is a WSGI *application*. A WSGI *server* is used to run the +application, converting incoming HTTP requests to the standard WSGI +environ, and converting outgoing WSGI responses to HTTP responses. + +The primary goal of these docs is to familiarize you with the concepts +involved in running a WSGI application using a production WSGI server +and HTTP server. There are many WSGI servers and HTTP servers, with many +configuration possibilities. The pages below discuss the most common +servers, and show the basics of running each one. The next section +discusses platforms that can manage this for you. + +.. toctree:: + :maxdepth: 1 + + gunicorn + waitress + mod_wsgi + uwsgi + gevent + eventlet + asgi + +WSGI servers have HTTP servers built-in. However, a dedicated HTTP +server may be safer, more efficient, or more capable. Putting an HTTP +server in front of the WSGI server is called a "reverse proxy." + +.. toctree:: + :maxdepth: 1 + + proxy_fix + nginx + apache-httpd + +This list is not exhaustive, and you should evaluate these and other +servers based on your application's needs. Different servers will have +different capabilities, configuration, and support. + + +Hosting Platforms +----------------- + +There are many services available for hosting web applications without +needing to maintain your own server, networking, domain, etc. Some +services may have a free tier up to a certain time or bandwidth. Many of +these services use one of the WSGI servers described above, or a similar +interface. The links below are for some of the most common platforms, +which have instructions for Flask, WSGI, or Python. + +- `PythonAnywhere `_ +- `Google App Engine `_ +- `Google Cloud Run `_ +- `AWS Elastic Beanstalk `_ +- `Microsoft Azure `_ + +This list is not exhaustive, and you should evaluate these and other +services based on your application's needs. Different services will have +different capabilities, configuration, pricing, and support. + +You'll probably need to :doc:`proxy_fix` when using most hosting +platforms. diff --git a/src/flask-main/docs/deploying/mod_wsgi.rst b/src/flask-main/docs/deploying/mod_wsgi.rst new file mode 100644 index 0000000..23e8227 --- /dev/null +++ b/src/flask-main/docs/deploying/mod_wsgi.rst @@ -0,0 +1,94 @@ +mod_wsgi +======== + +`mod_wsgi`_ is a WSGI server integrated with the `Apache httpd`_ server. +The modern `mod_wsgi-express`_ command makes it easy to configure and +start the server without needing to write Apache httpd configuration. + +* Tightly integrated with Apache httpd. +* Supports Windows directly. +* Requires a compiler and the Apache development headers to install. +* Does not require a reverse proxy setup. + +This page outlines the basics of running mod_wsgi-express, not the more +complex installation and configuration with httpd. Be sure to read the +`mod_wsgi-express`_, `mod_wsgi`_, and `Apache httpd`_ documentation to +understand what features are available. + +.. _mod_wsgi-express: https://pypi.org/project/mod-wsgi/ +.. _mod_wsgi: https://modwsgi.readthedocs.io/ +.. _Apache httpd: https://httpd.apache.org/ + + +Installing +---------- + +Installing mod_wsgi requires a compiler and the Apache server and +development headers installed. You will get an error if they are not. +How to install them depends on the OS and package manager that you use. + +Create a virtualenv, install your application, then install +``mod_wsgi``. + +.. code-block:: text + + $ cd hello-app + $ python -m venv .venv + $ . .venv/bin/activate + $ pip install . # install your application + $ pip install mod_wsgi + + +Running +------- + +The only argument to ``mod_wsgi-express`` specifies a script containing +your Flask application, which must be called ``application``. You can +write a small script to import your app with this name, or to create it +if using the app factory pattern. + +.. code-block:: python + :caption: ``wsgi.py`` + + from hello import app + + application = app + +.. code-block:: python + :caption: ``wsgi.py`` + + from hello import create_app + + application = create_app() + +Now run the ``mod_wsgi-express start-server`` command. + +.. code-block:: text + + $ mod_wsgi-express start-server wsgi.py --processes 4 + +The ``--processes`` option specifies the number of worker processes to +run; a starting value could be ``CPU * 2``. + +Logs for each request aren't show in the terminal. If an error occurs, +its information is written to the error log file shown when starting the +server. + + +Binding Externally +------------------ + +Unlike the other WSGI servers in these docs, mod_wsgi can be run as +root to bind to privileged ports like 80 and 443. However, it must be +configured to drop permissions to a different user and group for the +worker processes. + +For example, if you created a ``hello`` user and group, you should +install your virtualenv and application as that user, then tell +mod_wsgi to drop to that user after starting. + +.. code-block:: text + + $ sudo /home/hello/.venv/bin/mod_wsgi-express start-server \ + /home/hello/wsgi.py \ + --user hello --group hello --port 80 --processes 4 diff --git a/src/flask-main/docs/deploying/nginx.rst b/src/flask-main/docs/deploying/nginx.rst new file mode 100644 index 0000000..6b25c07 --- /dev/null +++ b/src/flask-main/docs/deploying/nginx.rst @@ -0,0 +1,69 @@ +nginx +===== + +`nginx`_ is a fast, production level HTTP server. When serving your +application with one of the WSGI servers listed in :doc:`index`, it is +often good or necessary to put a dedicated HTTP server in front of it. +This "reverse proxy" can handle incoming requests, TLS, and other +security and performance concerns better than the WSGI server. + +Nginx can be installed using your system package manager, or a pre-built +executable for Windows. Installing and running Nginx itself is outside +the scope of this doc. This page outlines the basics of configuring +Nginx to proxy your application. Be sure to read its documentation to +understand what features are available. + +.. _nginx: https://nginx.org/ + + +Domain Name +----------- + +Acquiring and configuring a domain name is outside the scope of this +doc. In general, you will buy a domain name from a registrar, pay for +server space with a hosting provider, and then point your registrar +at the hosting provider's name servers. + +To simulate this, you can also edit your ``hosts`` file, located at +``/etc/hosts`` on Linux. Add a line that associates a name with the +local IP. + +Modern Linux systems may be configured to treat any domain name that +ends with ``.localhost`` like this without adding it to the ``hosts`` +file. + +.. code-block:: python + :caption: ``/etc/hosts`` + + 127.0.0.1 hello.localhost + + +Configuration +------------- + +The nginx configuration is located at ``/etc/nginx/nginx.conf`` on +Linux. It may be different depending on your operating system. Check the +docs and look for ``nginx.conf``. + +Remove or comment out any existing ``server`` section. Add a ``server`` +section and use the ``proxy_pass`` directive to point to the address the +WSGI server is listening on. We'll assume the WSGI server is listening +locally at ``http://127.0.0.1:8000``. + +.. code-block:: nginx + :caption: ``/etc/nginx.conf`` + + server { + listen 80; + server_name _; + + location / { + proxy_pass http://127.0.0.1:8000/; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Prefix /; + } + } + +Then :doc:`proxy_fix` so that your application uses these headers. diff --git a/src/flask-main/docs/deploying/proxy_fix.rst b/src/flask-main/docs/deploying/proxy_fix.rst new file mode 100644 index 0000000..e2c42e8 --- /dev/null +++ b/src/flask-main/docs/deploying/proxy_fix.rst @@ -0,0 +1,33 @@ +Tell Flask it is Behind a Proxy +=============================== + +When using a reverse proxy, or many Python hosting platforms, the proxy +will intercept and forward all external requests to the local WSGI +server. + +From the WSGI server and Flask application's perspectives, requests are +now coming from the HTTP server to the local address, rather than from +the remote address to the external server address. + +HTTP servers should set ``X-Forwarded-`` headers to pass on the real +values to the application. The application can then be told to trust and +use those values by wrapping it with the +:doc:`werkzeug:middleware/proxy_fix` middleware provided by Werkzeug. + +This middleware should only be used if the application is actually +behind a proxy, and should be configured with the number of proxies that +are chained in front of it. Not all proxies set all the headers. Since +incoming headers can be faked, you must set how many proxies are setting +each header so the middleware knows what to trust. + +.. code-block:: python + + from werkzeug.middleware.proxy_fix import ProxyFix + + app.wsgi_app = ProxyFix( + app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 + ) + +Remember, only apply this middleware if you are behind a proxy, and set +the correct number of proxies that set each header. It can be a security +issue if you get this configuration wrong. diff --git a/src/flask-main/docs/deploying/uwsgi.rst b/src/flask-main/docs/deploying/uwsgi.rst new file mode 100644 index 0000000..1f9d5ec --- /dev/null +++ b/src/flask-main/docs/deploying/uwsgi.rst @@ -0,0 +1,145 @@ +uWSGI +===== + +`uWSGI`_ is a fast, compiled server suite with extensive configuration +and capabilities beyond a basic server. + +* It can be very performant due to being a compiled program. +* It is complex to configure beyond the basic application, and has so + many options that it can be difficult for beginners to understand. +* It does not support Windows (but does run on WSL). +* It requires a compiler to install in some cases. + +This page outlines the basics of running uWSGI. Be sure to read its +documentation to understand what features are available. + +.. _uWSGI: https://uwsgi-docs.readthedocs.io/en/latest/ + + +Installing +---------- + +uWSGI has multiple ways to install it. The most straightforward is to +install the ``pyuwsgi`` package, which provides precompiled wheels for +common platforms. However, it does not provide SSL support, which can be +provided with a reverse proxy instead. + +Create a virtualenv, install your application, then install ``pyuwsgi``. + +.. code-block:: text + + $ cd hello-app + $ python -m venv .venv + $ . .venv/bin/activate + $ pip install . # install your application + $ pip install pyuwsgi + +If you have a compiler available, you can install the ``uwsgi`` package +instead. Or install the ``pyuwsgi`` package from sdist instead of wheel. +Either method will include SSL support. + +.. code-block:: text + + $ pip install uwsgi + + # or + $ pip install --no-binary pyuwsgi pyuwsgi + + +Running +------- + +The most basic way to run uWSGI is to tell it to start an HTTP server +and import your application. + +.. code-block:: text + + $ uwsgi --http 127.0.0.1:8000 --master -p 4 -w hello:app + + *** Starting uWSGI 2.0.20 (64bit) on [x] *** + *** Operational MODE: preforking *** + mounting hello:app on / + spawned uWSGI master process (pid: x) + spawned uWSGI worker 1 (pid: x, cores: 1) + spawned uWSGI worker 2 (pid: x, cores: 1) + spawned uWSGI worker 3 (pid: x, cores: 1) + spawned uWSGI worker 4 (pid: x, cores: 1) + spawned uWSGI http 1 (pid: x) + +If you're using the app factory pattern, you'll need to create a small +Python file to create the app, then point uWSGI at that. + +.. code-block:: python + :caption: ``wsgi.py`` + + from hello import create_app + + app = create_app() + +.. code-block:: text + + $ uwsgi --http 127.0.0.1:8000 --master -p 4 -w wsgi:app + +The ``--http`` option starts an HTTP server at 127.0.0.1 port 8000. The +``--master`` option specifies the standard worker manager. The ``-p`` +option starts 4 worker processes; a starting value could be ``CPU * 2``. +The ``-w`` option tells uWSGI how to import your application + + +Binding Externally +------------------ + +uWSGI should not be run as root with the configuration shown in this doc +because it would cause your application code to run as root, which is +not secure. However, this means it will not be possible to bind to port +80 or 443. Instead, a reverse proxy such as :doc:`nginx` or +:doc:`apache-httpd` should be used in front of uWSGI. It is possible to +run uWSGI as root securely, but that is beyond the scope of this doc. + +uWSGI has optimized integration with `Nginx uWSGI`_ and +`Apache mod_proxy_uwsgi`_, and possibly other servers, instead of using +a standard HTTP proxy. That configuration is beyond the scope of this +doc, see the links for more information. + +.. _Nginx uWSGI: https://uwsgi-docs.readthedocs.io/en/latest/Nginx.html +.. _Apache mod_proxy_uwsgi: https://uwsgi-docs.readthedocs.io/en/latest/Apache.html#mod-proxy-uwsgi + +You can bind to all external IPs on a non-privileged port using the +``--http 0.0.0.0:8000`` option. Don't do this when using a reverse proxy +setup, otherwise it will be possible to bypass the proxy. + +.. code-block:: text + + $ uwsgi --http 0.0.0.0:8000 --master -p 4 -w wsgi:app + +``0.0.0.0`` is not a valid address to navigate to, you'd use a specific +IP address in your browser. + + +Async with gevent +----------------- + +The default sync worker is appropriate for many use cases. If you need +asynchronous support, uWSGI provides a `gevent`_ worker. This is not the +same as Python's ``async/await``, or the ASGI server spec. You must +actually use gevent in your own code to see any benefit to using the +worker. + +When using gevent, greenlet>=1.0 is required, otherwise context locals +such as ``request`` will not work as expected. When using PyPy, +PyPy>=7.3.7 is required. + +.. code-block:: text + + $ uwsgi --http 127.0.0.1:8000 --master --gevent 100 -w wsgi:app + + *** Starting uWSGI 2.0.20 (64bit) on [x] *** + *** Operational MODE: async *** + mounting hello:app on / + spawned uWSGI master process (pid: x) + spawned uWSGI worker 1 (pid: x, cores: 100) + spawned uWSGI http 1 (pid: x) + *** running gevent loop engine [addr:x] *** + + +.. _gevent: https://www.gevent.org/ diff --git a/src/flask-main/docs/deploying/waitress.rst b/src/flask-main/docs/deploying/waitress.rst new file mode 100644 index 0000000..7bdd695 --- /dev/null +++ b/src/flask-main/docs/deploying/waitress.rst @@ -0,0 +1,75 @@ +Waitress +======== + +`Waitress`_ is a pure Python WSGI server. + +* It is easy to configure. +* It supports Windows directly. +* It is easy to install as it does not require additional dependencies + or compilation. +* It does not support streaming requests, full request data is always + buffered. +* It uses a single process with multiple thread workers. + +This page outlines the basics of running Waitress. Be sure to read its +documentation and ``waitress-serve --help`` to understand what features +are available. + +.. _Waitress: https://docs.pylonsproject.org/projects/waitress/ + + +Installing +---------- + +Create a virtualenv, install your application, then install +``waitress``. + +.. code-block:: text + + $ cd hello-app + $ python -m venv .venv + $ . .venv/bin/activate + $ pip install . # install your application + $ pip install waitress + + +Running +------- + +The only required argument to ``waitress-serve`` tells it how to load +your Flask application. The syntax is ``{module}:{app}``. ``module`` is +the dotted import name to the module with your application. ``app`` is +the variable with the application. If you're using the app factory +pattern, use ``--call {module}:{factory}`` instead. + +.. code-block:: text + + # equivalent to 'from hello import app' + $ waitress-serve --host 127.0.0.1 hello:app + + # equivalent to 'from hello import create_app; create_app()' + $ waitress-serve --host 127.0.0.1 --call hello:create_app + + Serving on http://127.0.0.1:8080 + +The ``--host`` option binds the server to local ``127.0.0.1`` only. + +Logs for each request aren't shown, only errors are shown. Logging can +be configured through the Python interface instead of the command line. + + +Binding Externally +------------------ + +Waitress should not be run as root because it would cause your +application code to run as root, which is not secure. However, this +means it will not be possible to bind to port 80 or 443. Instead, a +reverse proxy such as :doc:`nginx` or :doc:`apache-httpd` should be used +in front of Waitress. + +You can bind to all external IPs on a non-privileged port by not +specifying the ``--host`` option. Don't do this when using a reverse +proxy setup, otherwise it will be possible to bypass the proxy. + +``0.0.0.0`` is not a valid address to navigate to, you'd use a specific +IP address in your browser. diff --git a/src/flask-main/docs/design.rst b/src/flask-main/docs/design.rst new file mode 100644 index 0000000..d8776a9 --- /dev/null +++ b/src/flask-main/docs/design.rst @@ -0,0 +1,229 @@ +Design Decisions in Flask +========================= + +If you are curious why Flask does certain things the way it does and not +differently, this section is for you. This should give you an idea about +some of the design decisions that may appear arbitrary and surprising at +first, especially in direct comparison with other frameworks. + + +The Explicit Application Object +------------------------------- + +A Python web application based on WSGI has to have one central callable +object that implements the actual application. In Flask this is an +instance of the :class:`~flask.Flask` class. Each Flask application has +to create an instance of this class itself and pass it the name of the +module, but why can't Flask do that itself? + +Without such an explicit application object the following code:: + + from flask import Flask + app = Flask(__name__) + + @app.route('/') + def index(): + return 'Hello World!' + +Would look like this instead:: + + from hypothetical_flask import route + + @route('/') + def index(): + return 'Hello World!' + +There are three major reasons for this. The most important one is that +implicit application objects require that there may only be one instance at +the time. There are ways to fake multiple applications with a single +application object, like maintaining a stack of applications, but this +causes some problems I won't outline here in detail. Now the question is: +when does a microframework need more than one application at the same +time? A good example for this is unit testing. When you want to test +something it can be very helpful to create a minimal application to test +specific behavior. When the application object is deleted everything it +allocated will be freed again. + +Another thing that becomes possible when you have an explicit object lying +around in your code is that you can subclass the base class +(:class:`~flask.Flask`) to alter specific behavior. This would not be +possible without hacks if the object were created ahead of time for you +based on a class that is not exposed to you. + +But there is another very important reason why Flask depends on an +explicit instantiation of that class: the package name. Whenever you +create a Flask instance you usually pass it `__name__` as package name. +Flask depends on that information to properly load resources relative +to your module. With Python's outstanding support for reflection it can +then access the package to figure out where the templates and static files +are stored (see :meth:`~flask.Flask.open_resource`). Now obviously there +are frameworks around that do not need any configuration and will still be +able to load templates relative to your application module. But they have +to use the current working directory for that, which is a very unreliable +way to determine where the application is. The current working directory +is process-wide and if you are running multiple applications in one +process (which could happen in a webserver without you knowing) the paths +will be off. Worse: many webservers do not set the working directory to +the directory of your application but to the document root which does not +have to be the same folder. + +The third reason is "explicit is better than implicit". That object is +your WSGI application, you don't have to remember anything else. If you +want to apply a WSGI middleware, just wrap it and you're done (though +there are better ways to do that so that you do not lose the reference +to the application object :meth:`~flask.Flask.wsgi_app`). + +Furthermore this design makes it possible to use a factory function to +create the application which is very helpful for unit testing and similar +things (:doc:`/patterns/appfactories`). + +The Routing System +------------------ + +Flask uses the Werkzeug routing system which was designed to +automatically order routes by complexity. This means that you can declare +routes in arbitrary order and they will still work as expected. This is a +requirement if you want to properly implement decorator based routing +since decorators could be fired in undefined order when the application is +split into multiple modules. + +Another design decision with the Werkzeug routing system is that routes +in Werkzeug try to ensure that URLs are unique. Werkzeug will go quite far +with that in that it will automatically redirect to a canonical URL if a route +is ambiguous. + + +One Template Engine +------------------- + +Flask decides on one template engine: Jinja. Why doesn't Flask have a +pluggable template engine interface? You can obviously use a different +template engine, but Flask will still configure Jinja for you. While +that limitation that Jinja is *always* configured will probably go away, +the decision to bundle one template engine and use that will not. + +Template engines are like programming languages and each of those engines +has a certain understanding about how things work. On the surface they +all work the same: you tell the engine to evaluate a template with a set +of variables and take the return value as string. + +But that's about where similarities end. Jinja for example has an +extensive filter system, a certain way to do template inheritance, +support for reusable blocks (macros) that can be used from inside +templates and also from Python code, supports iterative template +rendering, configurable syntax and more. On the other hand an engine +like Genshi is based on XML stream evaluation, template inheritance by +taking the availability of XPath into account and more. Mako on the +other hand treats templates similar to Python modules. + +When it comes to connecting a template engine with an application or +framework there is more than just rendering templates. For instance, +Flask uses Jinja's extensive autoescaping support. Also it provides +ways to access macros from Jinja templates. + +A template abstraction layer that would not take the unique features of +the template engines away is a science on its own and a too large +undertaking for a microframework like Flask. + +Furthermore extensions can then easily depend on one template language +being present. You can easily use your own templating language, but an +extension could still depend on Jinja itself. + + +What does "micro" mean? +----------------------- + +“Micro” does not mean that your whole web application has to fit into a single +Python file (although it certainly can), nor does it mean that Flask is lacking +in functionality. The "micro" in microframework means Flask aims to keep the +core simple but extensible. Flask won't make many decisions for you, such as +what database to use. Those decisions that it does make, such as what +templating engine to use, are easy to change. Everything else is up to you, so +that Flask can be everything you need and nothing you don't. + +By default, Flask does not include a database abstraction layer, form +validation or anything else where different libraries already exist that can +handle that. Instead, Flask supports extensions to add such functionality to +your application as if it was implemented in Flask itself. Numerous extensions +provide database integration, form validation, upload handling, various open +authentication technologies, and more. Flask may be "micro", but it's ready for +production use on a variety of needs. + +Why does Flask call itself a microframework and yet it depends on two +libraries (namely Werkzeug and Jinja). Why shouldn't it? If we look +over to the Ruby side of web development there we have a protocol very +similar to WSGI. Just that it's called Rack there, but besides that it +looks very much like a WSGI rendition for Ruby. But nearly all +applications in Ruby land do not work with Rack directly, but on top of a +library with the same name. This Rack library has two equivalents in +Python: WebOb (formerly Paste) and Werkzeug. Paste is still around but +from my understanding it's sort of deprecated in favour of WebOb. The +development of WebOb and Werkzeug started side by side with similar ideas +in mind: be a good implementation of WSGI for other applications to take +advantage. + +Flask is a framework that takes advantage of the work already done by +Werkzeug to properly interface WSGI (which can be a complex task at +times). Thanks to recent developments in the Python package +infrastructure, packages with dependencies are no longer an issue and +there are very few reasons against having libraries that depend on others. + + +Context Locals +-------------- + +Flask uses special context locals and proxies to provide access to the +current app and request data to any code running during a request, CLI command, +etc. Context locals are specific to the worker handling the activity, such as a +thread, process, coroutine, or greenlet. + +The context and proxies help solve two development issues: circular imports, and +passing around global data. :data:`.current_app` can be used to access the +application object without needing to import the app object directly, avoiding +circular import issues. :data:`.request`, :data:`.session`, and :data:`.g` can +be imported to access the current data for the request, rather than needing to +pass them as arguments through every single function in your project. + + +Async/await and ASGI support +---------------------------- + +Flask supports ``async`` coroutines for view functions by executing the +coroutine on a separate thread instead of using an event loop on the +main thread as an async-first (ASGI) framework would. This is necessary +for Flask to remain backwards compatible with extensions and code built +before ``async`` was introduced into Python. This compromise introduces +a performance cost compared with the ASGI frameworks, due to the +overhead of the threads. + +Due to how tied to WSGI Flask's code is, it's not clear if it's possible +to make the ``Flask`` class support ASGI and WSGI at the same time. Work +is currently being done in Werkzeug to work with ASGI, which may +eventually enable support in Flask as well. + +See :doc:`/async-await` for more discussion. + + +What Flask is, What Flask is Not +-------------------------------- + +Flask will never have a database layer. It will not have a form library +or anything else in that direction. Flask itself just bridges to Werkzeug +to implement a proper WSGI application and to Jinja to handle templating. +It also binds to a few common standard library packages such as logging. +Everything else is up for extensions. + +Why is this the case? Because people have different preferences and +requirements and Flask could not meet those if it would force any of this +into the core. The majority of web applications will need a template +engine in some sort. However not every application needs a SQL database. + +As your codebase grows, you are free to make the design decisions appropriate +for your project. Flask will continue to provide a very simple glue layer to +the best that Python has to offer. You can implement advanced patterns in +SQLAlchemy or another database tool, introduce non-relational data persistence +as appropriate, and take advantage of framework-agnostic tools built for WSGI, +the Python web interface. + +The idea of Flask is to build a good foundation for all applications. +Everything else is up to you or extensions. diff --git a/src/flask-main/docs/errorhandling.rst b/src/flask-main/docs/errorhandling.rst new file mode 100644 index 0000000..faca58c --- /dev/null +++ b/src/flask-main/docs/errorhandling.rst @@ -0,0 +1,523 @@ +Handling Application Errors +=========================== + +Applications fail, servers fail. Sooner or later you will see an exception +in production. Even if your code is 100% correct, you will still see +exceptions from time to time. Why? Because everything else involved will +fail. Here are some situations where perfectly fine code can lead to server +errors: + +- the client terminated the request early and the application was still + reading from the incoming data +- the database server was overloaded and could not handle the query +- a filesystem is full +- a harddrive crashed +- a backend server overloaded +- a programming error in a library you are using +- network connection of the server to another system failed + +And that's just a small sample of issues you could be facing. So how do we +deal with that sort of problem? By default if your application runs in +production mode, and an exception is raised Flask will display a very simple +page for you and log the exception to the :attr:`~flask.Flask.logger`. + +But there is more you can do, and we will cover some better setups to deal +with errors including custom exceptions and 3rd party tools. + + +.. _error-logging-tools: + +Error Logging Tools +------------------- + +Sending error mails, even if just for critical ones, can become +overwhelming if enough users are hitting the error and log files are +typically never looked at. This is why we recommend using `Sentry +`_ for dealing with application errors. It's +available as a source-available project `on GitHub +`_ and is also available as a `hosted version +`_ which you can try for free. Sentry +aggregates duplicate errors, captures the full stack trace and local +variables for debugging, and sends you mails based on new errors or +frequency thresholds. + +To use Sentry you need to install the ``sentry-sdk`` client with extra +``flask`` dependencies. + +.. code-block:: text + + $ pip install sentry-sdk[flask] + +And then add this to your Flask app: + +.. code-block:: python + + import sentry_sdk + from sentry_sdk.integrations.flask import FlaskIntegration + + sentry_sdk.init('YOUR_DSN_HERE', integrations=[FlaskIntegration()]) + +The ``YOUR_DSN_HERE`` value needs to be replaced with the DSN value you +get from your Sentry installation. + +After installation, failures leading to an Internal Server Error +are automatically reported to Sentry and from there you can +receive error notifications. + +See also: + +- Sentry also supports catching errors from a worker queue + (RQ, Celery, etc.) in a similar fashion. See the `Python SDK docs + `__ for more information. +- `Flask-specific documentation `__ + + +Error Handlers +-------------- + +When an error occurs in Flask, an appropriate `HTTP status code +`__ will be +returned. 400-499 indicate errors with the client's request data, or +about the data requested. 500-599 indicate errors with the server or +application itself. + +You might want to show custom error pages to the user when an error occurs. +This can be done by registering error handlers. + +An error handler is a function that returns a response when a type of error is +raised, similar to how a view is a function that returns a response when a +request URL is matched. It is passed the instance of the error being handled, +which is most likely a :exc:`~werkzeug.exceptions.HTTPException`. + +The status code of the response will not be set to the handler's code. Make +sure to provide the appropriate HTTP status code when returning a response from +a handler. + + +Registering +``````````` + +Register handlers by decorating a function with +:meth:`~flask.Flask.errorhandler`. Or use +:meth:`~flask.Flask.register_error_handler` to register the function later. +Remember to set the error code when returning the response. + +.. code-block:: python + + @app.errorhandler(werkzeug.exceptions.BadRequest) + def handle_bad_request(e): + return 'bad request!', 400 + + # or, without the decorator + app.register_error_handler(400, handle_bad_request) + +:exc:`werkzeug.exceptions.HTTPException` subclasses like +:exc:`~werkzeug.exceptions.BadRequest` and their HTTP codes are interchangeable +when registering handlers. (``BadRequest.code == 400``) + +Non-standard HTTP codes cannot be registered by code because they are not known +by Werkzeug. Instead, define a subclass of +:class:`~werkzeug.exceptions.HTTPException` with the appropriate code and +register and raise that exception class. + +.. code-block:: python + + class InsufficientStorage(werkzeug.exceptions.HTTPException): + code = 507 + description = 'Not enough storage space.' + + app.register_error_handler(InsufficientStorage, handle_507) + + raise InsufficientStorage() + +Handlers can be registered for any exception class, not just +:exc:`~werkzeug.exceptions.HTTPException` subclasses or HTTP status +codes. Handlers can be registered for a specific class, or for all subclasses +of a parent class. + + +Handling +```````` + +When building a Flask application you *will* run into exceptions. If some part +of your code breaks while handling a request (and you have no error handlers +registered), a "500 Internal Server Error" +(:exc:`~werkzeug.exceptions.InternalServerError`) will be returned by default. +Similarly, "404 Not Found" +(:exc:`~werkzeug.exceptions.NotFound`) error will occur if a request is sent to an unregistered route. +If a route receives an unallowed request method, a "405 Method Not Allowed" +(:exc:`~werkzeug.exceptions.MethodNotAllowed`) will be raised. These are all +subclasses of :class:`~werkzeug.exceptions.HTTPException` and are provided by +default in Flask. + +Flask gives you the ability to raise any HTTP exception registered by +Werkzeug. However, the default HTTP exceptions return simple exception +pages. You might want to show custom error pages to the user when an error occurs. +This can be done by registering error handlers. + +When Flask catches an exception while handling a request, it is first looked up by code. +If no handler is registered for the code, Flask looks up the error by its class hierarchy; the most specific handler is chosen. +If no handler is registered, :class:`~werkzeug.exceptions.HTTPException` subclasses show a +generic message about their code, while other exceptions are converted to a +generic "500 Internal Server Error". + +For example, if an instance of :exc:`ConnectionRefusedError` is raised, +and a handler is registered for :exc:`ConnectionError` and +:exc:`ConnectionRefusedError`, the more specific :exc:`ConnectionRefusedError` +handler is called with the exception instance to generate the response. + +Handlers registered on the blueprint take precedence over those registered +globally on the application, assuming a blueprint is handling the request that +raises the exception. However, the blueprint cannot handle 404 routing errors +because the 404 occurs at the routing level before the blueprint can be +determined. + + +Generic Exception Handlers +`````````````````````````` + +It is possible to register error handlers for very generic base classes +such as ``HTTPException`` or even ``Exception``. However, be aware that +these will catch more than you might expect. + +For example, an error handler for ``HTTPException`` might be useful for turning +the default HTML errors pages into JSON. However, this +handler will trigger for things you don't cause directly, such as 404 +and 405 errors during routing. Be sure to craft your handler carefully +so you don't lose information about the HTTP error. + +.. code-block:: python + + from flask import json + from werkzeug.exceptions import HTTPException + + @app.errorhandler(HTTPException) + def handle_exception(e): + """Return JSON instead of HTML for HTTP errors.""" + # start with the correct headers and status code from the error + response = e.get_response() + # replace the body with JSON + response.data = json.dumps({ + "code": e.code, + "name": e.name, + "description": e.description, + }) + response.content_type = "application/json" + return response + +An error handler for ``Exception`` might seem useful for changing how +all errors, even unhandled ones, are presented to the user. However, +this is similar to doing ``except Exception:`` in Python, it will +capture *all* otherwise unhandled errors, including all HTTP status +codes. + +In most cases it will be safer to register handlers for more +specific exceptions. Since ``HTTPException`` instances are valid WSGI +responses, you could also pass them through directly. + +.. code-block:: python + + from werkzeug.exceptions import HTTPException + + @app.errorhandler(Exception) + def handle_exception(e): + # pass through HTTP errors + if isinstance(e, HTTPException): + return e + + # now you're handling non-HTTP exceptions only + return render_template("500_generic.html", e=e), 500 + +Error handlers still respect the exception class hierarchy. If you +register handlers for both ``HTTPException`` and ``Exception``, the +``Exception`` handler will not handle ``HTTPException`` subclasses +because the ``HTTPException`` handler is more specific. + + +Unhandled Exceptions +```````````````````` + +When there is no error handler registered for an exception, a 500 +Internal Server Error will be returned instead. See +:meth:`flask.Flask.handle_exception` for information about this +behavior. + +If there is an error handler registered for ``InternalServerError``, +this will be invoked. As of Flask 1.1.0, this error handler will always +be passed an instance of ``InternalServerError``, not the original +unhandled error. + +The original error is available as ``e.original_exception``. + +An error handler for "500 Internal Server Error" will be passed uncaught +exceptions in addition to explicit 500 errors. In debug mode, a handler +for "500 Internal Server Error" will not be used. Instead, the +interactive debugger will be shown. + + +Custom Error Pages +------------------ + +Sometimes when building a Flask application, you might want to raise a +:exc:`~werkzeug.exceptions.HTTPException` to signal to the user that +something is wrong with the request. Fortunately, Flask comes with a handy +:func:`~flask.abort` function that aborts a request with a HTTP error from +werkzeug as desired. It will also provide a plain black and white error page +for you with a basic description, but nothing fancy. + +Depending on the error code it is less or more likely for the user to +actually see such an error. + +Consider the code below, we might have a user profile route, and if the user +fails to pass a username we can raise a "400 Bad Request". If the user passes a +username and we can't find it, we raise a "404 Not Found". + +.. code-block:: python + + from flask import abort, render_template, request + + # a username needs to be supplied in the query args + # a successful request would be like /profile?username=jack + @app.route("/profile") + def user_profile(): + username = request.arg.get("username") + # if a username isn't supplied in the request, return a 400 bad request + if username is None: + abort(400) + + user = get_user(username=username) + # if a user can't be found by their username, return 404 not found + if user is None: + abort(404) + + return render_template("profile.html", user=user) + +Here is another example implementation for a "404 Page Not Found" exception: + +.. code-block:: python + + from flask import render_template + + @app.errorhandler(404) + def page_not_found(e): + # note that we set the 404 status explicitly + return render_template('404.html'), 404 + +When using :doc:`/patterns/appfactories`: + +.. code-block:: python + + from flask import Flask, render_template + + def page_not_found(e): + return render_template('404.html'), 404 + + def create_app(config_filename): + app = Flask(__name__) + app.register_error_handler(404, page_not_found) + return app + +An example template might be this: + +.. code-block:: html+jinja + + {% extends "layout.html" %} + {% block title %}Page Not Found{% endblock %} + {% block body %} +

Page Not Found

+

What you were looking for is just not there. +

go somewhere nice + {% endblock %} + + +Further Examples +```````````````` + +The above examples wouldn't actually be an improvement on the default +exception pages. We can create a custom 500.html template like this: + +.. code-block:: html+jinja + + {% extends "layout.html" %} + {% block title %}Internal Server Error{% endblock %} + {% block body %} +

Internal Server Error

+

Oops... we seem to have made a mistake, sorry!

+

Go somewhere nice instead + {% endblock %} + +It can be implemented by rendering the template on "500 Internal Server Error": + +.. code-block:: python + + from flask import render_template + + @app.errorhandler(500) + def internal_server_error(e): + # note that we set the 500 status explicitly + return render_template('500.html'), 500 + +When using :doc:`/patterns/appfactories`: + +.. code-block:: python + + from flask import Flask, render_template + + def internal_server_error(e): + return render_template('500.html'), 500 + + def create_app(): + app = Flask(__name__) + app.register_error_handler(500, internal_server_error) + return app + +When using :doc:`/blueprints`: + +.. code-block:: python + + from flask import Blueprint + + blog = Blueprint('blog', __name__) + + # as a decorator + @blog.errorhandler(500) + def internal_server_error(e): + return render_template('500.html'), 500 + + # or with register_error_handler + blog.register_error_handler(500, internal_server_error) + + +Blueprint Error Handlers +------------------------ + +In :doc:`/blueprints`, most error handlers will work as expected. +However, there is a caveat concerning handlers for 404 and 405 +exceptions. These error handlers are only invoked from an appropriate +``raise`` statement or a call to ``abort`` in another of the blueprint's +view functions; they are not invoked by, e.g., an invalid URL access. + +This is because the blueprint does not "own" a certain URL space, so +the application instance has no way of knowing which blueprint error +handler it should run if given an invalid URL. If you would like to +execute different handling strategies for these errors based on URL +prefixes, they may be defined at the application level using the +``request`` proxy object. + +.. code-block:: python + + from flask import jsonify, render_template + + # at the application level + # not the blueprint level + @app.errorhandler(404) + def page_not_found(e): + # if a request is in our blog URL space + if request.path.startswith('/blog/'): + # we return a custom blog 404 page + return render_template("blog/404.html"), 404 + else: + # otherwise we return our generic site-wide 404 page + return render_template("404.html"), 404 + + @app.errorhandler(405) + def method_not_allowed(e): + # if a request has the wrong method to our API + if request.path.startswith('/api/'): + # we return a json saying so + return jsonify(message="Method Not Allowed"), 405 + else: + # otherwise we return a generic site-wide 405 page + return render_template("405.html"), 405 + + +Returning API Errors as JSON +---------------------------- + +When building APIs in Flask, some developers realise that the built-in +exceptions are not expressive enough for APIs and that the content type of +:mimetype:`text/html` they are emitting is not very useful for API consumers. + +Using the same techniques as above and :func:`~flask.json.jsonify` we can return JSON +responses to API errors. :func:`~flask.abort` is called +with a ``description`` parameter. The error handler will +use that as the JSON error message, and set the status code to 404. + +.. code-block:: python + + from flask import abort, jsonify + + @app.errorhandler(404) + def resource_not_found(e): + return jsonify(error=str(e)), 404 + + @app.route("/cheese") + def get_one_cheese(): + resource = get_resource() + + if resource is None: + abort(404, description="Resource not found") + + return jsonify(resource) + +We can also create custom exception classes. For instance, we can +introduce a new custom exception for an API that can take a proper human readable message, +a status code for the error and some optional payload to give more context +for the error. + +This is a simple example: + +.. code-block:: python + + from flask import jsonify, request + + class InvalidAPIUsage(Exception): + status_code = 400 + + def __init__(self, message, status_code=None, payload=None): + super().__init__() + self.message = message + if status_code is not None: + self.status_code = status_code + self.payload = payload + + def to_dict(self): + rv = dict(self.payload or ()) + rv['message'] = self.message + return rv + + @app.errorhandler(InvalidAPIUsage) + def invalid_api_usage(e): + return jsonify(e.to_dict()), e.status_code + + # an API app route for getting user information + # a correct request might be /api/user?user_id=420 + @app.route("/api/user") + def user_api(user_id): + user_id = request.arg.get("user_id") + if not user_id: + raise InvalidAPIUsage("No user id provided!") + + user = get_user(user_id=user_id) + if not user: + raise InvalidAPIUsage("No such user!", status_code=404) + + return jsonify(user.to_dict()) + +A view can now raise that exception with an error message. Additionally +some extra payload can be provided as a dictionary through the `payload` +parameter. + + +Logging +------- + +See :doc:`/logging` for information about how to log exceptions, such as +by emailing them to admins. + + +Debugging +--------- + +See :doc:`/debugging` for information about how to debug errors in +development and production. diff --git a/src/flask-main/docs/extensiondev.rst b/src/flask-main/docs/extensiondev.rst new file mode 100644 index 0000000..ae5ed33 --- /dev/null +++ b/src/flask-main/docs/extensiondev.rst @@ -0,0 +1,305 @@ +Flask Extension Development +=========================== + +.. currentmodule:: flask + +Extensions are extra packages that add functionality to a Flask +application. While `PyPI`_ contains many Flask extensions, you may not +find one that fits your need. If this is the case, you can create your +own, and publish it for others to use as well. + +This guide will show how to create a Flask extension, and some of the +common patterns and requirements involved. Since extensions can do +anything, this guide won't be able to cover every possibility. + +The best ways to learn about extensions are to look at how other +extensions you use are written, and discuss with others. Discuss your +design ideas with others on our `Discord Chat`_ or +`GitHub Discussions`_. + +The best extensions share common patterns, so that anyone familiar with +using one extension won't feel completely lost with another. This can +only work if collaboration happens early. + + +Naming +------ + +A Flask extension typically has ``flask`` in its name as a prefix or +suffix. If it wraps another library, it should include the library name +as well. This makes it easy to search for extensions, and makes their +purpose clearer. + +A general Python packaging recommendation is that the install name from +the package index and the name used in ``import`` statements should be +related. The import name is lowercase, with words separated by +underscores (``_``). The install name is either lower case or title +case, with words separated by dashes (``-``). If it wraps another +library, prefer using the same case as that library's name. + +Here are some example install and import names: + +- ``Flask-Name`` imported as ``flask_name`` +- ``flask-name-lower`` imported as ``flask_name_lower`` +- ``Flask-ComboName`` imported as ``flask_comboname`` +- ``Name-Flask`` imported as ``name_flask`` + + +The Extension Class and Initialization +-------------------------------------- + +All extensions will need some entry point that initializes the +extension with the application. The most common pattern is to create a +class that represents the extension's configuration and behavior, with +an ``init_app`` method to apply the extension instance to the given +application instance. + +.. code-block:: python + + class HelloExtension: + def __init__(self, app=None): + if app is not None: + self.init_app(app) + + def init_app(self, app): + app.before_request(...) + +It is important that the app is not stored on the extension, don't do +``self.app = app``. The only time the extension should have direct +access to an app is during ``init_app``, otherwise it should use +:data:`.current_app`. + +This allows the extension to support the application factory pattern, +avoids circular import issues when importing the extension instance +elsewhere in a user's code, and makes testing with different +configurations easier. + +.. code-block:: python + + hello = HelloExtension() + + def create_app(): + app = Flask(__name__) + hello.init_app(app) + return app + +Above, the ``hello`` extension instance exists independently of the +application. This means that other modules in a user's project can do +``from project import hello`` and use the extension in blueprints before +the app exists. + +The :attr:`Flask.extensions` dict can be used to store a reference to +the extension on the application, or some other state specific to the +application. Be aware that this is a single namespace, so use a name +unique to your extension, such as the extension's name without the +"flask" prefix. + + +Adding Behavior +--------------- + +There are many ways that an extension can add behavior. Any setup +methods that are available on the :class:`Flask` object can be used +during an extension's ``init_app`` method. + +A common pattern is to use :meth:`~Flask.before_request` to initialize +some data or a connection at the beginning of each request, then +:meth:`~Flask.teardown_request` to clean it up at the end. This can be +stored on :data:`.g`, discussed more below. + +A more lazy approach is to provide a method that initializes and caches +the data or connection. For example, a ``ext.get_db`` method could +create a database connection the first time it's called, so that a view +that doesn't use the database doesn't create a connection. + +Besides doing something before and after every view, your extension +might want to add some specific views as well. In this case, you could +define a :class:`Blueprint`, then call :meth:`~Flask.register_blueprint` +during ``init_app`` to add the blueprint to the app. + + +Configuration Techniques +------------------------ + +There can be multiple levels and sources of configuration for an +extension. You should consider what parts of your extension fall into +each one. + +- Configuration per application instance, through ``app.config`` + values. This is configuration that could reasonably change for each + deployment of an application. A common example is a URL to an + external resource, such as a database. Configuration keys should + start with the extension's name so that they don't interfere with + other extensions. +- Configuration per extension instance, through ``__init__`` + arguments. This configuration usually affects how the extension + is used, such that it wouldn't make sense to change it per + deployment. +- Configuration per extension instance, through instance attributes + and decorator methods. It might be more ergonomic to assign to + ``ext.value``, or use a ``@ext.register`` decorator to register a + function, after the extension instance has been created. +- Global configuration through class attributes. Changing a class + attribute like ``Ext.connection_class`` can customize default + behavior without making a subclass. This could be combined + per-extension configuration to override defaults. +- Subclassing and overriding methods and attributes. Making the API of + the extension itself something that can be overridden provides a + very powerful tool for advanced customization. + +The :class:`~flask.Flask` object itself uses all of these techniques. + +It's up to you to decide what configuration is appropriate for your +extension, based on what you need and what you want to support. + +Configuration should not be changed after the application setup phase is +complete and the server begins handling requests. Configuration is +global, any changes to it are not guaranteed to be visible to other +workers. + + +Data During a Request +--------------------- + +When writing a Flask application, the :data:`~flask.g` object is used to +store information during a request. For example the +:doc:`tutorial ` stores a connection to a SQLite +database as ``g.db``. Extensions can also use this, with some care. +Since ``g`` is a single global namespace, extensions must use unique +names that won't collide with user data. For example, use the extension +name as a prefix, or as a namespace. + +.. code-block:: python + + # an internal prefix with the extension name + g._hello_user_id = 2 + + # or an internal prefix as a namespace + from types import SimpleNamespace + g._hello = SimpleNamespace() + g._hello.user_id = 2 + +The data in ``g`` lasts for an application context. An application context is +active during a request, CLI command, or ``with app.app_context()`` block. If +you're storing something that should be closed, use +:meth:`~flask.Flask.teardown_appcontext` to ensure that it gets closed when the +app context ends. If it should only be valid during a request, or would not be +used in the CLI outside a request, use :meth:`~flask.Flask.teardown_request`. + + +Views and Models +---------------- + +Your extension views might want to interact with specific models in your +database, or some other extension or data connected to your application. +For example, let's consider a ``Flask-SimpleBlog`` extension that works +with Flask-SQLAlchemy to provide a ``Post`` model and views to write +and read posts. + +The ``Post`` model needs to subclass the Flask-SQLAlchemy ``db.Model`` +object, but that's only available once you've created an instance of +that extension, not when your extension is defining its views. So how +can the view code, defined before the model exists, access the model? + +One method could be to use :doc:`views`. During ``__init__``, create +the model, then create the views by passing the model to the view +class's :meth:`~views.View.as_view` method. + +.. code-block:: python + + class PostAPI(MethodView): + def __init__(self, model): + self.model = model + + def get(self, id): + post = self.model.query.get(id) + return jsonify(post.to_json()) + + class BlogExtension: + def __init__(self, db): + class Post(db.Model): + id = db.Column(primary_key=True) + title = db.Column(db.String, nullable=False) + + self.post_model = Post + + def init_app(self, app): + api_view = PostAPI.as_view(model=self.post_model) + + db = SQLAlchemy() + blog = BlogExtension(db) + db.init_app(app) + blog.init_app(app) + +Another technique could be to use an attribute on the extension, such as +``self.post_model`` from above. Add the extension to ``app.extensions`` +in ``init_app``, then access +``current_app.extensions["simple_blog"].post_model`` from views. + +You may also want to provide base classes so that users can provide +their own ``Post`` model that conforms to the API your extension +expects. So they could implement ``class Post(blog.BasePost)``, then +set it as ``blog.post_model``. + +As you can see, this can get a bit complex. Unfortunately, there's no +perfect solution here, only different strategies and tradeoffs depending +on your needs and how much customization you want to offer. Luckily, +this sort of resource dependency is not a common need for most +extensions. Remember, if you need help with design, ask on our +`Discord Chat`_ or `GitHub Discussions`_. + + +Recommended Extension Guidelines +-------------------------------- + +Flask previously had the concept of "approved extensions", where the +Flask maintainers evaluated the quality, support, and compatibility of +the extensions before listing them. While the list became too difficult +to maintain over time, the guidelines are still relevant to all +extensions maintained and developed today, as they help the Flask +ecosystem remain consistent and compatible. + +1. An extension requires a maintainer. In the event an extension author + would like to move beyond the project, the project should find a new + maintainer and transfer access to the repository, documentation, + PyPI, and any other services. The `Pallets-Eco`_ organization on + GitHub allows for community maintenance with oversight from the + Pallets maintainers. +2. The naming scheme is *Flask-ExtensionName* or *ExtensionName-Flask*. + It must provide exactly one package or module named + ``flask_extension_name``. +3. The extension must use an open source license. The Python web + ecosystem tends to prefer BSD or MIT. It must be open source and + publicly available. +4. The extension's API must have the following characteristics: + + - It must support multiple applications running in the same Python + process. Use ``current_app`` instead of ``self.app``, store + configuration and state per application instance. + - It must be possible to use the factory pattern for creating + applications. Use the ``ext.init_app()`` pattern. + +5. From a clone of the repository, an extension with its dependencies + must be installable in editable mode with ``pip install -e .``. +6. It must ship tests that can be invoked with a common tool like + ``tox -e py``, ``nox -s test`` or ``pytest``. If not using ``tox``, + the test dependencies should be specified in a requirements file. + The tests must be part of the sdist distribution. +7. A link to the documentation or project website must be in the PyPI + metadata or the readme. The documentation should use the Flask theme + from the `Official Pallets Themes`_. +8. The extension's dependencies should not use upper bounds or assume + any particular version scheme, but should use lower bounds to + indicate minimum compatibility support. For example, + ``sqlalchemy>=1.4``. +9. Indicate the versions of Python supported using ``python_requires=">=version"``. + Flask and Pallets policy is to support all Python versions that are not + within six months of end of life (EOL). See Python's `EOL calendar`_ for + timing. + +.. _PyPI: https://pypi.org/search/?c=Framework+%3A%3A+Flask +.. _Discord Chat: https://discord.gg/pallets +.. _GitHub Discussions: https://github.com/pallets/flask/discussions +.. _Official Pallets Themes: https://pypi.org/project/Pallets-Sphinx-Themes/ +.. _Pallets-Eco: https://github.com/pallets-eco +.. _EOL calendar: https://devguide.python.org/versions/ diff --git a/src/flask-main/docs/extensions.rst b/src/flask-main/docs/extensions.rst new file mode 100644 index 0000000..4713ec8 --- /dev/null +++ b/src/flask-main/docs/extensions.rst @@ -0,0 +1,48 @@ +Extensions +========== + +Extensions are extra packages that add functionality to a Flask +application. For example, an extension might add support for sending +email or connecting to a database. Some extensions add entire new +frameworks to help build certain types of applications, like a REST API. + + +Finding Extensions +------------------ + +Flask extensions are usually named "Flask-Foo" or "Foo-Flask". You can +search PyPI for packages tagged with `Framework :: Flask `_. + + +Using Extensions +---------------- + +Consult each extension's documentation for installation, configuration, +and usage instructions. Generally, extensions pull their own +configuration from :attr:`app.config ` and are +passed an application instance during initialization. For example, +an extension called "Flask-Foo" might be used like this:: + + from flask_foo import Foo + + foo = Foo() + + app = Flask(__name__) + app.config.update( + FOO_BAR='baz', + FOO_SPAM='eggs', + ) + + foo.init_app(app) + + +Building Extensions +------------------- + +While `PyPI `_ contains many Flask extensions, you may not find +an extension that fits your need. If this is the case, you can create +your own, and publish it for others to use as well. Read +:doc:`extensiondev` to develop your own Flask extension. + + +.. _pypi: https://pypi.org/search/?c=Framework+%3A%3A+Flask diff --git a/src/flask-main/docs/index.rst b/src/flask-main/docs/index.rst new file mode 100644 index 0000000..63fdb86 --- /dev/null +++ b/src/flask-main/docs/index.rst @@ -0,0 +1,88 @@ +.. rst-class:: hide-header + +Welcome to Flask +================ + +.. image:: _static/flask-name.svg + :align: center + :height: 200px + +Welcome to Flask's documentation. Flask is a lightweight WSGI web application framework. +It is designed to make getting started quick and easy, with the ability to scale up to +complex applications. + +Get started with :doc:`installation` +and then get an overview with the :doc:`quickstart`. There is also a +more detailed :doc:`tutorial/index` that shows how to create a small but +complete application with Flask. Common patterns are described in the +:doc:`patterns/index` section. The rest of the docs describe each +component of Flask in detail, with a full reference in the :doc:`api` +section. + +Flask depends on the `Werkzeug`_ WSGI toolkit, the `Jinja`_ template engine, and the +`Click`_ CLI toolkit. Be sure to check their documentation as well as Flask's when +looking for information. + +.. _Werkzeug: https://werkzeug.palletsprojects.com +.. _Jinja: https://jinja.palletsprojects.com +.. _Click: https://click.palletsprojects.com + + +User's Guide +------------ + +Flask provides configuration and conventions, with sensible defaults, to get started. +This section of the documentation explains the different parts of the Flask framework +and how they can be used, customized, and extended. Beyond Flask itself, look for +community-maintained extensions to add even more functionality. + +.. toctree:: + :maxdepth: 2 + + installation + quickstart + tutorial/index + templating + testing + errorhandling + debugging + logging + config + signals + views + lifecycle + appcontext + blueprints + extensions + cli + server + shell + patterns/index + web-security + deploying/index + async-await + + +API Reference +------------- + +If you are looking for information on a specific function, class or +method, this part of the documentation is for you. + +.. toctree:: + :maxdepth: 2 + + api + + +Additional Notes +---------------- + +.. toctree:: + :maxdepth: 2 + + design + extensiondev + contributing + license + changes diff --git a/src/flask-main/docs/installation.rst b/src/flask-main/docs/installation.rst new file mode 100644 index 0000000..aed389e --- /dev/null +++ b/src/flask-main/docs/installation.rst @@ -0,0 +1,144 @@ +Installation +============ + + +Python Version +-------------- + +We recommend using the latest version of Python. Flask supports Python 3.10 and newer. + + +Dependencies +------------ + +These distributions will be installed automatically when installing Flask. + +* `Werkzeug`_ implements WSGI, the standard Python interface between + applications and servers. +* `Jinja`_ is a template language that renders the pages your application + serves. +* `MarkupSafe`_ comes with Jinja. It escapes untrusted input when rendering + templates to avoid injection attacks. +* `ItsDangerous`_ securely signs data to ensure its integrity. This is used + to protect Flask's session cookie. +* `Click`_ is a framework for writing command line applications. It provides + the ``flask`` command and allows adding custom management commands. +* `Blinker`_ provides support for :doc:`signals`. + +.. _Werkzeug: https://palletsprojects.com/p/werkzeug/ +.. _Jinja: https://palletsprojects.com/p/jinja/ +.. _MarkupSafe: https://palletsprojects.com/p/markupsafe/ +.. _ItsDangerous: https://palletsprojects.com/p/itsdangerous/ +.. _Click: https://palletsprojects.com/p/click/ +.. _Blinker: https://blinker.readthedocs.io/ + + +Optional dependencies +~~~~~~~~~~~~~~~~~~~~~ + +These distributions will not be installed automatically. Flask will detect and +use them if you install them. + +* `python-dotenv`_ enables support for :ref:`dotenv` when running ``flask`` + commands. +* `Watchdog`_ provides a faster, more efficient reloader for the development + server. + +.. _python-dotenv: https://github.com/theskumar/python-dotenv#readme +.. _watchdog: https://pythonhosted.org/watchdog/ + + +greenlet +~~~~~~~~ + +You may choose to use gevent or eventlet with your application. In this +case, greenlet>=1.0 is required. When using PyPy, PyPy>=7.3.7 is +required. + +These are not minimum supported versions, they only indicate the first +versions that added necessary features. You should use the latest +versions of each. + + +Virtual environments +-------------------- + +Use a virtual environment to manage the dependencies for your project, both in +development and in production. + +What problem does a virtual environment solve? The more Python projects you +have, the more likely it is that you need to work with different versions of +Python libraries, or even Python itself. Newer versions of libraries for one +project can break compatibility in another project. + +Virtual environments are independent groups of Python libraries, one for each +project. Packages installed for one project will not affect other projects or +the operating system's packages. + +Python comes bundled with the :mod:`venv` module to create virtual +environments. + + +.. _install-create-env: + +Create an environment +~~~~~~~~~~~~~~~~~~~~~ + +Create a project folder and a :file:`.venv` folder within: + +.. tabs:: + + .. group-tab:: macOS/Linux + + .. code-block:: text + + $ mkdir myproject + $ cd myproject + $ python3 -m venv .venv + + .. group-tab:: Windows + + .. code-block:: text + + > mkdir myproject + > cd myproject + > py -3 -m venv .venv + + +.. _install-activate-env: + +Activate the environment +~~~~~~~~~~~~~~~~~~~~~~~~ + +Before you work on your project, activate the corresponding environment: + +.. tabs:: + + .. group-tab:: macOS/Linux + + .. code-block:: text + + $ . .venv/bin/activate + + .. group-tab:: Windows + + .. code-block:: text + + > .venv\Scripts\activate + +Your shell prompt will change to show the name of the activated +environment. + + +Install Flask +------------- + +Within the activated environment, use the following command to install +Flask: + +.. code-block:: sh + + $ pip install Flask + +Flask is now installed. Check out the :doc:`/quickstart` or go to the +:doc:`Documentation Overview `. diff --git a/src/flask-main/docs/license.rst b/src/flask-main/docs/license.rst new file mode 100644 index 0000000..2a445f9 --- /dev/null +++ b/src/flask-main/docs/license.rst @@ -0,0 +1,5 @@ +BSD-3-Clause License +==================== + +.. literalinclude:: ../LICENSE.txt + :language: text diff --git a/src/flask-main/docs/lifecycle.rst b/src/flask-main/docs/lifecycle.rst new file mode 100644 index 0000000..37d45cd --- /dev/null +++ b/src/flask-main/docs/lifecycle.rst @@ -0,0 +1,171 @@ +Application Structure and Lifecycle +=================================== + +Flask makes it pretty easy to write a web application. But there are quite a few +different parts to an application and to each request it handles. Knowing what happens +during application setup, serving, and handling requests will help you know what's +possible in Flask and how to structure your application. + + +Application Setup +----------------- + +The first step in creating a Flask application is creating the application object. Each +Flask application is an instance of the :class:`.Flask` class, which collects all +configuration, extensions, and views. + +.. code-block:: python + + from flask import Flask + + app = Flask(__name__) + app.config.from_mapping( + SECRET_KEY="dev", + ) + app.config.from_prefixed_env() + + @app.route("/") + def index(): + return "Hello, World!" + +This is known as the "application setup phase", it's the code you write that's outside +any view functions or other handlers. It can be split up between different modules and +sub-packages, but all code that you want to be part of your application must be imported +in order for it to be registered. + +All application setup must be completed before you start serving your application and +handling requests. This is because WSGI servers divide work between multiple workers, or +can be distributed across multiple machines. If the configuration changed in one worker, +there's no way for Flask to ensure consistency between other workers. + +Flask tries to help developers catch some of these setup ordering issues by showing an +error if setup-related methods are called after requests are handled. In that case +you'll see this error: + + The setup method 'route' can no longer be called on the application. It has already + handled its first request, any changes will not be applied consistently. + Make sure all imports, decorators, functions, etc. needed to set up the application + are done before running it. + +However, it is not possible for Flask to detect all cases of out-of-order setup. In +general, don't do anything to modify the ``Flask`` app object and ``Blueprint`` objects +from within view functions that run during requests. This includes: + +- Adding routes, view functions, and other request handlers with ``@app.route``, + ``@app.errorhandler``, ``@app.before_request``, etc. +- Registering blueprints. +- Loading configuration with ``app.config``. +- Setting up the Jinja template environment with ``app.jinja_env``. +- Setting a session interface, instead of the default itsdangerous cookie. +- Setting a JSON provider with ``app.json``, instead of the default provider. +- Creating and initializing Flask extensions. + + +Serving the Application +----------------------- + +Flask is a WSGI application framework. The other half of WSGI is the WSGI server. During +development, Flask, through Werkzeug, provides a development WSGI server with the +``flask run`` CLI command. When you are done with development, use a production server +to serve your application, see :doc:`deploying/index`. + +Regardless of what server you're using, it will follow the :pep:`3333` WSGI spec. The +WSGI server will be told how to access your Flask application object, which is the WSGI +application. Then it will start listening for HTTP requests, translate the request data +into a WSGI environ, and call the WSGI application with that data. The WSGI application +will return data that is translated into an HTTP response. + +#. Browser or other client makes HTTP request. +#. WSGI server receives request. +#. WSGI server converts HTTP data to WSGI ``environ`` dict. +#. WSGI server calls WSGI application with the ``environ``. +#. Flask, the WSGI application, does all its internal processing to route the request + to a view function, handle errors, etc. +#. Flask translates View function return into WSGI response data, passes it to WSGI + server. +#. WSGI server creates and send an HTTP response. +#. Client receives the HTTP response. + + +Middleware +~~~~~~~~~~ + +The WSGI application above is a callable that behaves in a certain way. Middleware +is a WSGI application that wraps another WSGI application. It's a similar concept to +Python decorators. The outermost middleware will be called by the server. It can modify +the data passed to it, then call the WSGI application (or further middleware) that it +wraps, and so on. And it can take the return value of that call and modify it further. + +From the WSGI server's perspective, there is one WSGI application, the one it calls +directly. Typically, Flask is the "real" application at the end of the chain of +middleware. But even Flask can call further WSGI applications, although that's an +advanced, uncommon use case. + +A common middleware you'll see used with Flask is Werkzeug's +:class:`~werkzeug.middleware.proxy_fix.ProxyFix`, which modifies the request to look +like it came directly from a client even if it passed through HTTP proxies on the way. +There are other middleware that can handle serving static files, authentication, etc. + + +How a Request is Handled +------------------------ + +For us, the interesting part of the steps above is when Flask gets called by the WSGI +server (or middleware). At that point, it will do quite a lot to handle the request and +generate the response. At the most basic, it will match the URL to a view function, call +the view function, and pass the return value back to the server. But there are many more +parts that you can use to customize its behavior. + +#. WSGI server calls the Flask object, which calls :meth:`.Flask.wsgi_app`. +#. An :class:`.AppContext` object is created. This converts the WSGI ``environ`` + dict into a :class:`.Request` object. +#. The :doc:`app context ` is pushed, which makes + :data:`.current_app`, :data:`.g`, :data:`.request`, and :data:`.session` + available. +#. The :data:`.appcontext_pushed` signal is sent. +#. The URL is matched against the URL rules registered with the :meth:`~.Flask.route` + decorator during application setup. If there is no match, the error - usually a 404, + 405, or redirect - is stored to be handled later. +#. The :data:`.request_started` signal is sent. +#. Any :meth:`~.Flask.url_value_preprocessor` decorated functions are called. +#. Any :meth:`~.Flask.before_request` decorated functions are called. If any of + these function returns a value it is treated as the response immediately. +#. If the URL didn't match a route a few steps ago, that error is raised now. +#. The :meth:`~.Flask.route` decorated view function associated with the matched URL + is called and returns a value to be used as the response. +#. If any step so far raised an exception, and there is an :meth:`~.Flask.errorhandler` + decorated function that matches the exception class or HTTP error code, it is + called to handle the error and return a response. +#. Whatever returned a response value - a before request function, the view, or an + error handler, that value is converted to a :class:`.Response` object. +#. Any :func:`~.after_this_request` decorated functions are called, which can modify + the response object. They are then cleared. +#. Any :meth:`~.Flask.after_request` decorated functions are called, which can modify + the response object. +#. The session is saved, persisting any modified session data using the app's + :attr:`~.Flask.session_interface`. +#. The :data:`.request_finished` signal is sent. +#. If any step so far raised an exception, and it was not handled by an error handler + function, it is handled now. HTTP exceptions are treated as responses with their + corresponding status code, other exceptions are converted to a generic 500 response. + The :data:`.got_request_exception` signal is sent. +#. The response object's status, headers, and body are returned to the WSGI server. +#. Any :meth:`~.Flask.teardown_request` decorated functions are called. +#. The :data:`.request_tearing_down` signal is sent. +#. Any :meth:`~.Flask.teardown_appcontext` decorated functions are called. +#. The :data:`.appcontext_tearing_down` signal is sent. +#. The app context is popped, :data:`.current_app`, :data:`.g`, :data:`.request`, + and :data:`.session` are no longer available. +#. The :data:`.appcontext_popped` signal is sent. + +When executing a CLI command or plain app context without request data, the same +order of steps is followed, omitting the steps that refer to the request. + +A :class:`Blueprint` can add handlers for these events that are specific to the +blueprint. The handlers for a blueprint will run if the blueprint +owns the route that matches the request. + +There are even more decorators and customization points than this, but that aren't part +of every request lifecycle. They're more specific to certain things you might use during +a request, such as templates, building URLs, or handling JSON data. See the rest of this +documentation, as well as the :doc:`api` to explore further. diff --git a/src/flask-main/docs/logging.rst b/src/flask-main/docs/logging.rst new file mode 100644 index 0000000..3958824 --- /dev/null +++ b/src/flask-main/docs/logging.rst @@ -0,0 +1,183 @@ +Logging +======= + +Flask uses standard Python :mod:`logging`. Messages about your Flask +application are logged with :meth:`app.logger `, +which takes the same name as :attr:`app.name `. This +logger can also be used to log your own messages. + +.. code-block:: python + + @app.route('/login', methods=['POST']) + def login(): + user = get_user(request.form['username']) + + if user.check_password(request.form['password']): + login_user(user) + app.logger.info('%s logged in successfully', user.username) + return redirect(url_for('index')) + else: + app.logger.info('%s failed to log in', user.username) + abort(401) + +If you don't configure logging, Python's default log level is usually +'warning'. Nothing below the configured level will be visible. + + +Basic Configuration +------------------- + +When you want to configure logging for your project, you should do it as soon +as possible when the program starts. If :meth:`app.logger ` +is accessed before logging is configured, it will add a default handler. If +possible, configure logging before creating the application object. + +This example uses :func:`~logging.config.dictConfig` to create a logging +configuration similar to Flask's default, except for all logs:: + + from logging.config import dictConfig + + dictConfig({ + 'version': 1, + 'formatters': {'default': { + 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', + }}, + 'handlers': {'wsgi': { + 'class': 'logging.StreamHandler', + 'stream': 'ext://flask.logging.wsgi_errors_stream', + 'formatter': 'default' + }}, + 'root': { + 'level': 'INFO', + 'handlers': ['wsgi'] + } + }) + + app = Flask(__name__) + + +Default Configuration +````````````````````` + +If you do not configure logging yourself, Flask will add a +:class:`~logging.StreamHandler` to :meth:`app.logger ` +automatically. During requests, it will write to the stream specified by the +WSGI server in ``environ['wsgi.errors']`` (which is usually +:data:`sys.stderr`). Outside a request, it will log to :data:`sys.stderr`. + + +Removing the Default Handler +```````````````````````````` + +If you configured logging after accessing +:meth:`app.logger `, and need to remove the default +handler, you can import and remove it:: + + from flask.logging import default_handler + + app.logger.removeHandler(default_handler) + + +Email Errors to Admins +---------------------- + +When running the application on a remote server for production, you probably +won't be looking at the log messages very often. The WSGI server will probably +send log messages to a file, and you'll only check that file if a user tells +you something went wrong. + +To be proactive about discovering and fixing bugs, you can configure a +:class:`logging.handlers.SMTPHandler` to send an email when errors and higher +are logged. :: + + import logging + from logging.handlers import SMTPHandler + + mail_handler = SMTPHandler( + mailhost='127.0.0.1', + fromaddr='server-error@example.com', + toaddrs=['admin@example.com'], + subject='Application Error' + ) + mail_handler.setLevel(logging.ERROR) + mail_handler.setFormatter(logging.Formatter( + '[%(asctime)s] %(levelname)s in %(module)s: %(message)s' + )) + + if not app.debug: + app.logger.addHandler(mail_handler) + +This requires that you have an SMTP server set up on the same server. See the +Python docs for more information about configuring the handler. + + +Injecting Request Information +----------------------------- + +Seeing more information about the request, such as the IP address, may help +debugging some errors. You can subclass :class:`logging.Formatter` to inject +your own fields that can be used in messages. You can change the formatter for +Flask's default handler, the mail handler defined above, or any other +handler. :: + + from flask import has_request_context, request + from flask.logging import default_handler + + class RequestFormatter(logging.Formatter): + def format(self, record): + if has_request_context(): + record.url = request.url + record.remote_addr = request.remote_addr + else: + record.url = None + record.remote_addr = None + + return super().format(record) + + formatter = RequestFormatter( + '[%(asctime)s] %(remote_addr)s requested %(url)s\n' + '%(levelname)s in %(module)s: %(message)s' + ) + default_handler.setFormatter(formatter) + mail_handler.setFormatter(formatter) + + +Other Libraries +--------------- + +Other libraries may use logging extensively, and you want to see relevant +messages from those logs too. The simplest way to do this is to add handlers +to the root logger instead of only the app logger. :: + + from flask.logging import default_handler + + root = logging.getLogger() + root.addHandler(default_handler) + root.addHandler(mail_handler) + +Depending on your project, it may be more useful to configure each logger you +care about separately, instead of configuring only the root logger. :: + + for logger in ( + logging.getLogger(app.name), + logging.getLogger('sqlalchemy'), + logging.getLogger('other_package'), + ): + logger.addHandler(default_handler) + logger.addHandler(mail_handler) + + +Werkzeug +```````` + +Werkzeug logs basic request/response information to the ``'werkzeug'`` logger. +If the root logger has no handlers configured, Werkzeug adds a +:class:`~logging.StreamHandler` to its logger. + + +Flask Extensions +```````````````` + +Depending on the situation, an extension may choose to log to +:meth:`app.logger ` or its own named logger. Consult each +extension's documentation for details. diff --git a/src/flask-main/docs/make.bat b/src/flask-main/docs/make.bat new file mode 100644 index 0000000..922152e --- /dev/null +++ b/src/flask-main/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/src/flask-main/docs/patterns/appdispatch.rst b/src/flask-main/docs/patterns/appdispatch.rst new file mode 100644 index 0000000..f22c806 --- /dev/null +++ b/src/flask-main/docs/patterns/appdispatch.rst @@ -0,0 +1,189 @@ +Application Dispatching +======================= + +Application dispatching is the process of combining multiple Flask +applications on the WSGI level. You can combine not only Flask +applications but any WSGI application. This would allow you to run a +Django and a Flask application in the same interpreter side by side if +you want. The usefulness of this depends on how the applications work +internally. + +The fundamental difference from :doc:`packages` is that in this case you +are running the same or different Flask applications that are entirely +isolated from each other. They run different configurations and are +dispatched on the WSGI level. + + +Working with this Document +-------------------------- + +Each of the techniques and examples below results in an ``application`` +object that can be run with any WSGI server. For development, use the +``flask run`` command to start a development server. For production, see +:doc:`/deploying/index`. + +.. code-block:: python + + from flask import Flask + + app = Flask(__name__) + + @app.route('/') + def hello_world(): + return 'Hello World!' + + +Combining Applications +---------------------- + +If you have entirely separated applications and you want them to work next +to each other in the same Python interpreter process you can take +advantage of the :class:`werkzeug.wsgi.DispatcherMiddleware`. The idea +here is that each Flask application is a valid WSGI application and they +are combined by the dispatcher middleware into a larger one that is +dispatched based on prefix. + +For example you could have your main application run on ``/`` and your +backend interface on ``/backend``. + +.. code-block:: python + + from werkzeug.middleware.dispatcher import DispatcherMiddleware + from frontend_app import application as frontend + from backend_app import application as backend + + application = DispatcherMiddleware(frontend, { + '/backend': backend + }) + + +Dispatch by Subdomain +--------------------- + +Sometimes you might want to use multiple instances of the same application +with different configurations. Assuming the application is created inside +a function and you can call that function to instantiate it, that is +really easy to implement. In order to develop your application to support +creating new instances in functions have a look at the +:doc:`appfactories` pattern. + +A very common example would be creating applications per subdomain. For +instance you configure your webserver to dispatch all requests for all +subdomains to your application and you then use the subdomain information +to create user-specific instances. Once you have your server set up to +listen on all subdomains you can use a very simple WSGI application to do +the dynamic application creation. + +The perfect level for abstraction in that regard is the WSGI layer. You +write your own WSGI application that looks at the request that comes and +delegates it to your Flask application. If that application does not +exist yet, it is dynamically created and remembered. + +.. code-block:: python + + from threading import Lock + + class SubdomainDispatcher: + + def __init__(self, domain, create_app): + self.domain = domain + self.create_app = create_app + self.lock = Lock() + self.instances = {} + + def get_application(self, host): + host = host.split(':')[0] + assert host.endswith(self.domain), 'Configuration error' + subdomain = host[:-len(self.domain)].rstrip('.') + with self.lock: + app = self.instances.get(subdomain) + if app is None: + app = self.create_app(subdomain) + self.instances[subdomain] = app + return app + + def __call__(self, environ, start_response): + app = self.get_application(environ['HTTP_HOST']) + return app(environ, start_response) + + +This dispatcher can then be used like this: + +.. code-block:: python + + from myapplication import create_app, get_user_for_subdomain + from werkzeug.exceptions import NotFound + + def make_app(subdomain): + user = get_user_for_subdomain(subdomain) + if user is None: + # if there is no user for that subdomain we still have + # to return a WSGI application that handles that request. + # We can then just return the NotFound() exception as + # application which will render a default 404 page. + # You might also redirect the user to the main page then + return NotFound() + + # otherwise create the application for the specific user + return create_app(user) + + application = SubdomainDispatcher('example.com', make_app) + + +Dispatch by Path +---------------- + +Dispatching by a path on the URL is very similar. Instead of looking at +the ``Host`` header to figure out the subdomain one simply looks at the +request path up to the first slash. + +.. code-block:: python + + from threading import Lock + from wsgiref.util import shift_path_info + + class PathDispatcher: + + def __init__(self, default_app, create_app): + self.default_app = default_app + self.create_app = create_app + self.lock = Lock() + self.instances = {} + + def get_application(self, prefix): + with self.lock: + app = self.instances.get(prefix) + if app is None: + app = self.create_app(prefix) + if app is not None: + self.instances[prefix] = app + return app + + def __call__(self, environ, start_response): + app = self.get_application(_peek_path_info(environ)) + if app is not None: + shift_path_info(environ) + else: + app = self.default_app + return app(environ, start_response) + + def _peek_path_info(environ): + segments = environ.get("PATH_INFO", "").lstrip("/").split("/", 1) + if segments: + return segments[0] + + return None + +The big difference between this and the subdomain one is that this one +falls back to another application if the creator function returns ``None``. + +.. code-block:: python + + from myapplication import create_app, default_app, get_user_for_prefix + + def make_app(prefix): + user = get_user_for_prefix(prefix) + if user is not None: + return create_app(user) + + application = PathDispatcher(default_app, make_app) diff --git a/src/flask-main/docs/patterns/appfactories.rst b/src/flask-main/docs/patterns/appfactories.rst new file mode 100644 index 0000000..0f24878 --- /dev/null +++ b/src/flask-main/docs/patterns/appfactories.rst @@ -0,0 +1,118 @@ +Application Factories +===================== + +If you are already using packages and blueprints for your application +(:doc:`/blueprints`) there are a couple of really nice ways to further improve +the experience. A common pattern is creating the application object when +the blueprint is imported. But if you move the creation of this object +into a function, you can then create multiple instances of this app later. + +So why would you want to do this? + +1. Testing. You can have instances of the application with different + settings to test every case. +2. Multiple instances. Imagine you want to run different versions of the + same application. Of course you could have multiple instances with + different configs set up in your webserver, but if you use factories, + you can have multiple instances of the same application running in the + same application process which can be handy. + +So how would you then actually implement that? + +Basic Factories +--------------- + +The idea is to set up the application in a function. Like this:: + + def create_app(config_filename): + app = Flask(__name__) + app.config.from_pyfile(config_filename) + + from yourapplication.model import db + db.init_app(app) + + from yourapplication.views.admin import admin + from yourapplication.views.frontend import frontend + app.register_blueprint(admin) + app.register_blueprint(frontend) + + return app + +The downside is that you cannot use the application object in the blueprints +at import time. You can however use it from within a request. How do you +get access to the application with the config? Use +:data:`~flask.current_app`:: + + from flask import current_app, Blueprint, render_template + admin = Blueprint('admin', __name__, url_prefix='/admin') + + @admin.route('/') + def index(): + return render_template(current_app.config['INDEX_TEMPLATE']) + +Here we look up the name of a template in the config. + +Factories & Extensions +---------------------- + +It's preferable to create your extensions and app factories so that the +extension object does not initially get bound to the application. + +Using `Flask-SQLAlchemy `_, +as an example, you should not do something along those lines:: + + def create_app(config_filename): + app = Flask(__name__) + app.config.from_pyfile(config_filename) + + db = SQLAlchemy(app) + +But, rather, in model.py (or equivalent):: + + db = SQLAlchemy() + +and in your application.py (or equivalent):: + + def create_app(config_filename): + app = Flask(__name__) + app.config.from_pyfile(config_filename) + + from yourapplication.model import db + db.init_app(app) + +Using this design pattern, no application-specific state is stored on the +extension object, so one extension object can be used for multiple apps. +For more information about the design of extensions refer to :doc:`/extensiondev`. + +Using Applications +------------------ + +To run such an application, you can use the :command:`flask` command: + +.. code-block:: text + + $ flask --app hello run + +Flask will automatically detect the factory if it is named +``create_app`` or ``make_app`` in ``hello``. You can also pass arguments +to the factory like this: + +.. code-block:: text + + $ flask --app 'hello:create_app(local_auth=True)' run + +Then the ``create_app`` factory in ``hello`` is called with the keyword +argument ``local_auth=True``. See :doc:`/cli` for more detail. + +Factory Improvements +-------------------- + +The factory function above is not very clever, but you can improve it. +The following changes are straightforward to implement: + +1. Make it possible to pass in configuration values for unit tests so that + you don't have to create config files on the filesystem. +2. Call a function from a blueprint when the application is setting up so + that you have a place to modify attributes of the application (like + hooking in before/after request handlers etc.) +3. Add in WSGI middlewares when the application is being created if necessary. diff --git a/src/flask-main/docs/patterns/caching.rst b/src/flask-main/docs/patterns/caching.rst new file mode 100644 index 0000000..9bf7b72 --- /dev/null +++ b/src/flask-main/docs/patterns/caching.rst @@ -0,0 +1,16 @@ +Caching +======= + +When your application runs slow, throw some caches in. Well, at least +it's the easiest way to speed up things. What does a cache do? Say you +have a function that takes some time to complete but the results would +still be good enough if they were 5 minutes old. So then the idea is that +you actually put the result of that calculation into a cache for some +time. + +Flask itself does not provide caching for you, but `Flask-Caching`_, an +extension for Flask does. Flask-Caching supports various backends, and it is +even possible to develop your own caching backend. + + +.. _Flask-Caching: https://flask-caching.readthedocs.io/en/latest/ diff --git a/src/flask-main/docs/patterns/celery.rst b/src/flask-main/docs/patterns/celery.rst new file mode 100644 index 0000000..2e9a43a --- /dev/null +++ b/src/flask-main/docs/patterns/celery.rst @@ -0,0 +1,242 @@ +Background Tasks with Celery +============================ + +If your application has a long running task, such as processing some uploaded data or +sending email, you don't want to wait for it to finish during a request. Instead, use a +task queue to send the necessary data to another process that will run the task in the +background while the request returns immediately. + +`Celery`_ is a powerful task queue that can be used for simple background tasks as well +as complex multi-stage programs and schedules. This guide will show you how to configure +Celery using Flask. Read Celery's `First Steps with Celery`_ guide to learn how to use +Celery itself. + +.. _Celery: https://celery.readthedocs.io +.. _First Steps with Celery: https://celery.readthedocs.io/en/latest/getting-started/first-steps-with-celery.html + +The Flask repository contains `an example `_ +based on the information on this page, which also shows how to use JavaScript to submit +tasks and poll for progress and results. + + +Install +------- + +Install Celery from PyPI, for example using pip: + +.. code-block:: text + + $ pip install celery + + +Integrate Celery with Flask +--------------------------- + +You can use Celery without any integration with Flask, but it's convenient to configure +it through Flask's config, and to let tasks access the Flask application. + +Celery uses similar ideas to Flask, with a ``Celery`` app object that has configuration +and registers tasks. While creating a Flask app, use the following code to create and +configure a Celery app as well. + +.. code-block:: python + + from celery import Celery, Task + + def celery_init_app(app: Flask) -> Celery: + class FlaskTask(Task): + def __call__(self, *args: object, **kwargs: object) -> object: + with app.app_context(): + return self.run(*args, **kwargs) + + celery_app = Celery(app.name, task_cls=FlaskTask) + celery_app.config_from_object(app.config["CELERY"]) + celery_app.set_default() + app.extensions["celery"] = celery_app + return celery_app + +This creates and returns a ``Celery`` app object. Celery `configuration`_ is taken from +the ``CELERY`` key in the Flask configuration. The Celery app is set as the default, so +that it is seen during each request. The ``Task`` subclass automatically runs task +functions with a Flask app context active, so that services like your database +connections are available. + +.. _configuration: https://celery.readthedocs.io/en/stable/userguide/configuration.html + +Here's a basic ``example.py`` that configures Celery to use Redis for communication. We +enable a result backend, but ignore results by default. This allows us to store results +only for tasks where we care about the result. + +.. code-block:: python + + from flask import Flask + + app = Flask(__name__) + app.config.from_mapping( + CELERY=dict( + broker_url="redis://localhost", + result_backend="redis://localhost", + task_ignore_result=True, + ), + ) + celery_app = celery_init_app(app) + +Point the ``celery worker`` command at this and it will find the ``celery_app`` object. + +.. code-block:: text + + $ celery -A example worker --loglevel INFO + +You can also run the ``celery beat`` command to run tasks on a schedule. See Celery's +docs for more information about defining schedules. + +.. code-block:: text + + $ celery -A example beat --loglevel INFO + + +Application Factory +------------------- + +When using the Flask application factory pattern, call the ``celery_init_app`` function +inside the factory. It sets ``app.extensions["celery"]`` to the Celery app object, which +can be used to get the Celery app from the Flask app returned by the factory. + +.. code-block:: python + + def create_app() -> Flask: + app = Flask(__name__) + app.config.from_mapping( + CELERY=dict( + broker_url="redis://localhost", + result_backend="redis://localhost", + task_ignore_result=True, + ), + ) + app.config.from_prefixed_env() + celery_init_app(app) + return app + +To use ``celery`` commands, Celery needs an app object, but that's no longer directly +available. Create a ``make_celery.py`` file that calls the Flask app factory and gets +the Celery app from the returned Flask app. + +.. code-block:: python + + from example import create_app + + flask_app = create_app() + celery_app = flask_app.extensions["celery"] + +Point the ``celery`` command to this file. + +.. code-block:: text + + $ celery -A make_celery worker --loglevel INFO + $ celery -A make_celery beat --loglevel INFO + + +Defining Tasks +-------------- + +Using ``@celery_app.task`` to decorate task functions requires access to the +``celery_app`` object, which won't be available when using the factory pattern. It also +means that the decorated tasks are tied to the specific Flask and Celery app instances, +which could be an issue during testing if you change configuration for a test. + +Instead, use Celery's ``@shared_task`` decorator. This creates task objects that will +access whatever the "current app" is, which is a similar concept to Flask's blueprints +and app context. This is why we called ``celery_app.set_default()`` above. + +Here's an example task that adds two numbers together and returns the result. + +.. code-block:: python + + from celery import shared_task + + @shared_task(ignore_result=False) + def add_together(a: int, b: int) -> int: + return a + b + +Earlier, we configured Celery to ignore task results by default. Since we want to know +the return value of this task, we set ``ignore_result=False``. On the other hand, a task +that didn't need a result, such as sending an email, wouldn't set this. + + +Calling Tasks +------------- + +The decorated function becomes a task object with methods to call it in the background. +The simplest way is to use the ``delay(*args, **kwargs)`` method. See Celery's docs for +more methods. + +A Celery worker must be running to run the task. Starting a worker is shown in the +previous sections. + +.. code-block:: python + + from flask import request + + @app.post("/add") + def start_add() -> dict[str, object]: + a = request.form.get("a", type=int) + b = request.form.get("b", type=int) + result = add_together.delay(a, b) + return {"result_id": result.id} + +The route doesn't get the task's result immediately. That would defeat the purpose by +blocking the response. Instead, we return the running task's result id, which we can use +later to get the result. + + +Getting Results +--------------- + +To fetch the result of the task we started above, we'll add another route that takes the +result id we returned before. We return whether the task is finished (ready), whether it +finished successfully, and what the return value (or error) was if it is finished. + +.. code-block:: python + + from celery.result import AsyncResult + + @app.get("/result/") + def task_result(id: str) -> dict[str, object]: + result = AsyncResult(id) + return { + "ready": result.ready(), + "successful": result.successful(), + "value": result.result if result.ready() else None, + } + +Now you can start the task using the first route, then poll for the result using the +second route. This keeps the Flask request workers from being blocked waiting for tasks +to finish. + +The Flask repository contains `an example `_ +using JavaScript to submit tasks and poll for progress and results. + + +Passing Data to Tasks +--------------------- + +The "add" task above took two integers as arguments. To pass arguments to tasks, Celery +has to serialize them to a format that it can pass to other processes. Therefore, +passing complex objects is not recommended. For example, it would be impossible to pass +a SQLAlchemy model object, since that object is probably not serializable and is tied to +the session that queried it. + +Pass the minimal amount of data necessary to fetch or recreate any complex data within +the task. Consider a task that will run when the logged in user asks for an archive of +their data. The Flask request knows the logged in user, and has the user object queried +from the database. It got that by querying the database for a given id, so the task can +do the same thing. Pass the user's id rather than the user object. + +.. code-block:: python + + @shared_task + def generate_user_archive(user_id: str) -> None: + user = db.session.get(User, user_id) + ... + + generate_user_archive.delay(current_user.id) diff --git a/src/flask-main/docs/patterns/deferredcallbacks.rst b/src/flask-main/docs/patterns/deferredcallbacks.rst new file mode 100644 index 0000000..4ff8814 --- /dev/null +++ b/src/flask-main/docs/patterns/deferredcallbacks.rst @@ -0,0 +1,44 @@ +Deferred Request Callbacks +========================== + +One of the design principles of Flask is that response objects are created and +passed down a chain of potential callbacks that can modify them or replace +them. When the request handling starts, there is no response object yet. It is +created as necessary either by a view function or by some other component in +the system. + +What happens if you want to modify the response at a point where the response +does not exist yet? A common example for that would be a +:meth:`~flask.Flask.before_request` callback that wants to set a cookie on the +response object. + +One way is to avoid the situation. Very often that is possible. For instance +you can try to move that logic into a :meth:`~flask.Flask.after_request` +callback instead. However, sometimes moving code there makes it +more complicated or awkward to reason about. + +As an alternative, you can use :func:`~flask.after_this_request` to register +callbacks that will execute after only the current request. This way you can +defer code execution from anywhere in the application, based on the current +request. + +At any time during a request, we can register a function to be called at the +end of the request. For example you can remember the current language of the +user in a cookie in a :meth:`~flask.Flask.before_request` callback:: + + from flask import request, after_this_request + + @app.before_request + def detect_user_language(): + language = request.cookies.get('user_lang') + + if language is None: + language = guess_language_from_request() + + # when the response exists, set a cookie with the language + @after_this_request + def remember_language(response): + response.set_cookie('user_lang', language) + return response + + g.language = language diff --git a/src/flask-main/docs/patterns/favicon.rst b/src/flask-main/docs/patterns/favicon.rst new file mode 100644 index 0000000..b867854 --- /dev/null +++ b/src/flask-main/docs/patterns/favicon.rst @@ -0,0 +1,56 @@ +Adding a favicon +================ + +A "favicon" is an icon used by browsers for tabs and bookmarks. This helps +to distinguish your website and to give it a unique brand. + +A common question is how to add a favicon to a Flask application. First, of +course, you need an icon. It should be 16 × 16 pixels and in the ICO file +format. This is not a requirement but a de-facto standard supported by all +relevant browsers. Put the icon in your static directory as +:file:`favicon.ico`. + +Now, to get browsers to find your icon, the correct way is to add a link +tag in your HTML. So, for example: + +.. sourcecode:: html+jinja + + + +That's all you need for most browsers, however some really old ones do not +support this standard. The old de-facto standard is to serve this file, +with this name, at the website root. If your application is not mounted at +the root path of the domain you either need to configure the web server to +serve the icon at the root or if you can't do that you're out of luck. If +however your application is the root you can simply route a redirect:: + + app.add_url_rule( + "/favicon.ico", + endpoint="favicon", + redirect_to=url_for("static", filename="favicon.ico"), + ) + +If you want to save the extra redirect request you can also write a view +using :func:`~flask.send_from_directory`:: + + import os + from flask import send_from_directory + + @app.route('/favicon.ico') + def favicon(): + return send_from_directory(os.path.join(app.root_path, 'static'), + 'favicon.ico', mimetype='image/vnd.microsoft.icon') + +We can leave out the explicit mimetype and it will be guessed, but we may +as well specify it to avoid the extra guessing, as it will always be the +same. + +The above will serve the icon via your application and if possible it's +better to configure your dedicated web server to serve it; refer to the +web server's documentation. + +See also +-------- + +* The `Favicon `_ article on + Wikipedia diff --git a/src/flask-main/docs/patterns/fileuploads.rst b/src/flask-main/docs/patterns/fileuploads.rst new file mode 100644 index 0000000..304f57d --- /dev/null +++ b/src/flask-main/docs/patterns/fileuploads.rst @@ -0,0 +1,182 @@ +Uploading Files +=============== + +Ah yes, the good old problem of file uploads. The basic idea of file +uploads is actually quite simple. It basically works like this: + +1. A ``

`` tag is marked with ``enctype=multipart/form-data`` + and an ```` is placed in that form. +2. The application accesses the file from the :attr:`~flask.request.files` + dictionary on the request object. +3. use the :meth:`~werkzeug.datastructures.FileStorage.save` method of the file to save + the file permanently somewhere on the filesystem. + +A Gentle Introduction +--------------------- + +Let's start with a very basic application that uploads a file to a +specific upload folder and displays a file to the user. Let's look at the +bootstrapping code for our application:: + + import os + from flask import Flask, flash, request, redirect, url_for + from werkzeug.utils import secure_filename + + UPLOAD_FOLDER = '/path/to/the/uploads' + ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} + + app = Flask(__name__) + app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER + +So first we need a couple of imports. Most should be straightforward, the +:func:`werkzeug.secure_filename` is explained a little bit later. The +``UPLOAD_FOLDER`` is where we will store the uploaded files and the +``ALLOWED_EXTENSIONS`` is the set of allowed file extensions. + +Why do we limit the extensions that are allowed? You probably don't want +your users to be able to upload everything there if the server is directly +sending out the data to the client. That way you can make sure that users +are not able to upload HTML files that would cause XSS problems (see +:ref:`security-xss`). Also make sure to disallow ``.php`` files if the server +executes them, but who has PHP installed on their server, right? :) + +Next the functions that check if an extension is valid and that uploads +the file and redirects the user to the URL for the uploaded file:: + + def allowed_file(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + @app.route('/', methods=['GET', 'POST']) + def upload_file(): + if request.method == 'POST': + # check if the post request has the file part + if 'file' not in request.files: + flash('No file part') + return redirect(request.url) + file = request.files['file'] + # If the user does not select a file, the browser submits an + # empty file without a filename. + if file.filename == '': + flash('No selected file') + return redirect(request.url) + if file and allowed_file(file.filename): + filename = secure_filename(file.filename) + file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename)) + return redirect(url_for('download_file', name=filename)) + return ''' + + Upload new File +

Upload new File

+ + + +
+ ''' + +So what does that :func:`~werkzeug.utils.secure_filename` function actually do? +Now the problem is that there is that principle called "never trust user +input". This is also true for the filename of an uploaded file. All +submitted form data can be forged, and filenames can be dangerous. For +the moment just remember: always use that function to secure a filename +before storing it directly on the filesystem. + +.. admonition:: Information for the Pros + + So you're interested in what that :func:`~werkzeug.utils.secure_filename` + function does and what the problem is if you're not using it? So just + imagine someone would send the following information as `filename` to + your application:: + + filename = "../../../../home/username/.bashrc" + + Assuming the number of ``../`` is correct and you would join this with + the ``UPLOAD_FOLDER`` the user might have the ability to modify a file on + the server's filesystem he or she should not modify. This does require some + knowledge about how the application looks like, but trust me, hackers + are patient :) + + Now let's look how that function works: + + >>> secure_filename('../../../../home/username/.bashrc') + 'home_username_.bashrc' + +We want to be able to serve the uploaded files so they can be downloaded +by users. We'll define a ``download_file`` view to serve files in the +upload folder by name. ``url_for("download_file", name=name)`` generates +download URLs. + +.. code-block:: python + + from flask import send_from_directory + + @app.route('/uploads/') + def download_file(name): + return send_from_directory(app.config["UPLOAD_FOLDER"], name) + +If you're using middleware or the HTTP server to serve files, you can +register the ``download_file`` endpoint as ``build_only`` so ``url_for`` +will work without a view function. + +.. code-block:: python + + app.add_url_rule( + "/uploads/", endpoint="download_file", build_only=True + ) + + +Improving Uploads +----------------- + +.. versionadded:: 0.6 + +So how exactly does Flask handle uploads? Well it will store them in the +webserver's memory if the files are reasonably small, otherwise in a +temporary location (as returned by :func:`tempfile.gettempdir`). But how +do you specify the maximum file size after which an upload is aborted? By +default Flask will happily accept file uploads with an unlimited amount of +memory, but you can limit that by setting the ``MAX_CONTENT_LENGTH`` +config key:: + + from flask import Flask, Request + + app = Flask(__name__) + app.config['MAX_CONTENT_LENGTH'] = 16 * 1000 * 1000 + +The code above will limit the maximum allowed payload to 16 megabytes. +If a larger file is transmitted, Flask will raise a +:exc:`~werkzeug.exceptions.RequestEntityTooLarge` exception. + +.. admonition:: Connection Reset Issue + + When using the local development server, you may get a connection + reset error instead of a 413 response. You will get the correct + status response when running the app with a production WSGI server. + +This feature was added in Flask 0.6 but can be achieved in older versions +as well by subclassing the request object. For more information on that +consult the Werkzeug documentation on file handling. + + +Upload Progress Bars +-------------------- + +A while ago many developers had the idea to read the incoming file in +small chunks and store the upload progress in the database to be able to +poll the progress with JavaScript from the client. The client asks the +server every 5 seconds how much it has transmitted, but this is +something it should already know. + +An Easier Solution +------------------ + +Now there are better solutions that work faster and are more reliable. There +are JavaScript libraries like jQuery_ that have form plugins to ease the +construction of progress bar. + +Because the common pattern for file uploads exists almost unchanged in all +applications dealing with uploads, there are also some Flask extensions that +implement a full fledged upload mechanism that allows controlling which +file extensions are allowed to be uploaded. + +.. _jQuery: https://jquery.com/ diff --git a/src/flask-main/docs/patterns/flashing.rst b/src/flask-main/docs/patterns/flashing.rst new file mode 100644 index 0000000..8eb6b3a --- /dev/null +++ b/src/flask-main/docs/patterns/flashing.rst @@ -0,0 +1,148 @@ +Message Flashing +================ + +Good applications and user interfaces are all about feedback. If the user +does not get enough feedback they will probably end up hating the +application. Flask provides a really simple way to give feedback to a +user with the flashing system. The flashing system basically makes it +possible to record a message at the end of a request and access it next +request and only next request. This is usually combined with a layout +template that does this. Note that browsers and sometimes web servers enforce +a limit on cookie sizes. This means that flashing messages that are too +large for session cookies causes message flashing to fail silently. + +Simple Flashing +--------------- + +So here is a full example:: + + from flask import Flask, flash, redirect, render_template, \ + request, url_for + + app = Flask(__name__) + app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' + + @app.route('/') + def index(): + return render_template('index.html') + + @app.route('/login', methods=['GET', 'POST']) + def login(): + error = None + if request.method == 'POST': + if request.form['username'] != 'admin' or \ + request.form['password'] != 'secret': + error = 'Invalid credentials' + else: + flash('You were successfully logged in') + return redirect(url_for('index')) + return render_template('login.html', error=error) + +And here is the :file:`layout.html` template which does the magic: + +.. sourcecode:: html+jinja + + + My Application + {% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + {% block body %}{% endblock %} + +Here is the :file:`index.html` template which inherits from :file:`layout.html`: + +.. sourcecode:: html+jinja + + {% extends "layout.html" %} + {% block body %} +

Overview

+

Do you want to log in? + {% endblock %} + +And here is the :file:`login.html` template which also inherits from +:file:`layout.html`: + +.. sourcecode:: html+jinja + + {% extends "layout.html" %} + {% block body %} +

Login

+ {% if error %} +

Error: {{ error }} + {% endif %} +

+
+
Username: +
+
Password: +
+
+

+

+ {% endblock %} + +Flashing With Categories +------------------------ + +.. versionadded:: 0.3 + +It is also possible to provide categories when flashing a message. The +default category if nothing is provided is ``'message'``. Alternative +categories can be used to give the user better feedback. For example +error messages could be displayed with a red background. + +To flash a message with a different category, just use the second argument +to the :func:`~flask.flash` function:: + + flash('Invalid password provided', 'error') + +Inside the template you then have to tell the +:func:`~flask.get_flashed_messages` function to also return the +categories. The loop looks slightly different in that situation then: + +.. sourcecode:: html+jinja + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
    + {% for category, message in messages %} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + {% endwith %} + +This is just one example of how to render these flashed messages. One +might also use the category to add a prefix such as +``Error:`` to the message. + +Filtering Flash Messages +------------------------ + +.. versionadded:: 0.9 + +Optionally you can pass a list of categories which filters the results of +:func:`~flask.get_flashed_messages`. This is useful if you wish to +render each category in a separate block. + +.. sourcecode:: html+jinja + + {% with errors = get_flashed_messages(category_filter=["error"]) %} + {% if errors %} +
+ × +
    + {%- for msg in errors %} +
  • {{ msg }}
  • + {% endfor -%} +
+
+ {% endif %} + {% endwith %} diff --git a/src/flask-main/docs/patterns/index.rst b/src/flask-main/docs/patterns/index.rst new file mode 100644 index 0000000..1f2c07d --- /dev/null +++ b/src/flask-main/docs/patterns/index.rst @@ -0,0 +1,40 @@ +Patterns for Flask +================== + +Certain features and interactions are common enough that you will find +them in most web applications. For example, many applications use a +relational database and user authentication. They will open a database +connection at the beginning of the request and get the information for +the logged in user. At the end of the request, the database connection +is closed. + +These types of patterns may be a bit outside the scope of Flask itself, +but Flask makes it easy to implement them. Some common patterns are +collected in the following pages. + +.. toctree:: + :maxdepth: 2 + + packages + appfactories + appdispatch + urlprocessors + sqlite3 + sqlalchemy + fileuploads + caching + viewdecorators + wtforms + templateinheritance + flashing + javascript + lazyloading + mongoengine + favicon + streaming + deferredcallbacks + methodoverrides + requestchecksum + celery + subclassing + singlepageapplications diff --git a/src/flask-main/docs/patterns/javascript.rst b/src/flask-main/docs/patterns/javascript.rst new file mode 100644 index 0000000..d58a3eb --- /dev/null +++ b/src/flask-main/docs/patterns/javascript.rst @@ -0,0 +1,259 @@ +JavaScript, ``fetch``, and JSON +=============================== + +You may want to make your HTML page dynamic, by changing data without +reloading the entire page. Instead of submitting an HTML ``
`` and +performing a redirect to re-render the template, you can add +`JavaScript`_ that calls |fetch|_ and replaces content on the page. + +|fetch|_ is the modern, built-in JavaScript solution to making +requests from a page. You may have heard of other "AJAX" methods and +libraries, such as |XHR|_ or `jQuery`_. These are no longer needed in +modern browsers, although you may choose to use them or another library +depending on your application's requirements. These docs will only focus +on built-in JavaScript features. + +.. _JavaScript: https://developer.mozilla.org/Web/JavaScript +.. |fetch| replace:: ``fetch()`` +.. _fetch: https://developer.mozilla.org/Web/API/Fetch_API +.. |XHR| replace:: ``XMLHttpRequest()`` +.. _XHR: https://developer.mozilla.org/Web/API/XMLHttpRequest +.. _jQuery: https://jquery.com/ + + +Rendering Templates +------------------- + +It is important to understand the difference between templates and +JavaScript. Templates are rendered on the server, before the response is +sent to the user's browser. JavaScript runs in the user's browser, after +the template is rendered and sent. Therefore, it is impossible to use +JavaScript to affect how the Jinja template is rendered, but it is +possible to render data into the JavaScript that will run. + +To provide data to JavaScript when rendering the template, use the +:func:`~jinja-filters.tojson` filter in a `` + +A less common pattern is to add the data to a ``data-`` attribute on an +HTML tag. In this case, you must use single quotes around the value, not +double quotes, otherwise you will produce invalid or unsafe HTML. + +.. code-block:: jinja + +
+ + +Generating URLs +--------------- + +The other way to get data from the server to JavaScript is to make a +request for it. First, you need to know the URL to request. + +The simplest way to generate URLs is to continue to use +:func:`~flask.url_for` when rendering the template. For example: + +.. code-block:: javascript + + const user_url = {{ url_for("user", id=current_user.id)|tojson }} + fetch(user_url).then(...) + +However, you might need to generate a URL based on information you only +know in JavaScript. As discussed above, JavaScript runs in the user's +browser, not as part of the template rendering, so you can't use +``url_for`` at that point. + +In this case, you need to know the "root URL" under which your +application is served. In simple setups, this is ``/``, but it might +also be something else, like ``https://example.com/myapp/``. + +A simple way to tell your JavaScript code about this root is to set it +as a global variable when rendering the template. Then you can use it +when generating URLs from JavaScript. + +.. code-block:: javascript + + const SCRIPT_ROOT = {{ request.script_root|tojson }} + let user_id = ... // do something to get a user id from the page + let user_url = `${SCRIPT_ROOT}/user/${user_id}` + fetch(user_url).then(...) + + +Making a Request with ``fetch`` +------------------------------- + +|fetch|_ takes two arguments, a URL and an object with other options, +and returns a |Promise|_. We won't cover all the available options, and +will only use ``then()`` on the promise, not other callbacks or +``await`` syntax. Read the linked MDN docs for more information about +those features. + +By default, the GET method is used. If the response contains JSON, it +can be used with a ``then()`` callback chain. + +.. code-block:: javascript + + const room_url = {{ url_for("room_detail", id=room.id)|tojson }} + fetch(room_url) + .then(response => response.json()) + .then(data => { + // data is a parsed JSON object + }) + +To send data, use a data method such as POST, and pass the ``body`` +option. The most common types for data are form data or JSON data. + +To send form data, pass a populated |FormData|_ object. This uses the +same format as an HTML form, and would be accessed with ``request.form`` +in a Flask view. + +.. code-block:: javascript + + let data = new FormData() + data.append("name", "Flask Room") + data.append("description", "Talk about Flask here.") + fetch(room_url, { + "method": "POST", + "body": data, + }).then(...) + +In general, prefer sending request data as form data, as would be used +when submitting an HTML form. JSON can represent more complex data, but +unless you need that it's better to stick with the simpler format. When +sending JSON data, the ``Content-Type: application/json`` header must be +sent as well, otherwise Flask will return a 400 error. + +.. code-block:: javascript + + let data = { + "name": "Flask Room", + "description": "Talk about Flask here.", + } + fetch(room_url, { + "method": "POST", + "headers": {"Content-Type": "application/json"}, + "body": JSON.stringify(data), + }).then(...) + +.. |Promise| replace:: ``Promise`` +.. _Promise: https://developer.mozilla.org/Web/JavaScript/Reference/Global_Objects/Promise +.. |FormData| replace:: ``FormData`` +.. _FormData: https://developer.mozilla.org/en-US/docs/Web/API/FormData + + +Following Redirects +------------------- + +A response might be a redirect, for example if you logged in with +JavaScript instead of a traditional HTML form, and your view returned +a redirect instead of JSON. JavaScript requests do follow redirects, but +they don't change the page. If you want to make the page change you can +inspect the response and apply the redirect manually. + +.. code-block:: javascript + + fetch("/login", {"body": ...}).then( + response => { + if (response.redirected) { + window.location = response.url + } else { + showLoginError() + } + } + ) + + +Replacing Content +----------------- + +A response might be new HTML, either a new section of the page to add or +replace, or an entirely new page. In general, if you're returning the +entire page, it would be better to handle that with a redirect as shown +in the previous section. The following example shows how to replace a +``
`` with the HTML returned by a request. + +.. code-block:: html + +
+ {{ include "geology_fact.html" }} +
+ + + +Return JSON from Views +---------------------- + +To return a JSON object from your API view, you can directly return a +dict from the view. It will be serialized to JSON automatically. + +.. code-block:: python + + @app.route("/user/") + def user_detail(id): + user = User.query.get_or_404(id) + return { + "username": User.username, + "email": User.email, + "picture": url_for("static", filename=f"users/{id}/profile.png"), + } + +If you want to return another JSON type, use the +:func:`~flask.json.jsonify` function, which creates a response object +with the given data serialized to JSON. + +.. code-block:: python + + from flask import jsonify + + @app.route("/users") + def user_list(): + users = User.query.order_by(User.name).all() + return jsonify([u.to_json() for u in users]) + +It is usually not a good idea to return file data in a JSON response. +JSON cannot represent binary data directly, so it must be base64 +encoded, which can be slow, takes more bandwidth to send, and is not as +easy to cache. Instead, serve files using one view, and generate a URL +to the desired file to include in the JSON. Then the client can make a +separate request to get the linked resource after getting the JSON. + + +Receiving JSON in Views +----------------------- + +Use the :attr:`~flask.Request.json` property of the +:data:`~flask.request` object to decode the request's body as JSON. If +the body is not valid JSON, or the ``Content-Type`` header is not set to +``application/json``, a 400 Bad Request error will be raised. + +.. code-block:: python + + from flask import request + + @app.post("/user/") + def user_update(id): + user = User.query.get_or_404(id) + user.update_from_json(request.json) + db.session.commit() + return user.to_json() diff --git a/src/flask-main/docs/patterns/jquery.rst b/src/flask-main/docs/patterns/jquery.rst new file mode 100644 index 0000000..7ac6856 --- /dev/null +++ b/src/flask-main/docs/patterns/jquery.rst @@ -0,0 +1,6 @@ +:orphan: + +AJAX with jQuery +================ + +Obsolete, see :doc:`/patterns/javascript` instead. diff --git a/src/flask-main/docs/patterns/lazyloading.rst b/src/flask-main/docs/patterns/lazyloading.rst new file mode 100644 index 0000000..658a1cd --- /dev/null +++ b/src/flask-main/docs/patterns/lazyloading.rst @@ -0,0 +1,109 @@ +Lazily Loading Views +==================== + +Flask is usually used with the decorators. Decorators are simple and you +have the URL right next to the function that is called for that specific +URL. However there is a downside to this approach: it means all your code +that uses decorators has to be imported upfront or Flask will never +actually find your function. + +This can be a problem if your application has to import quick. It might +have to do that on systems like Google's App Engine or other systems. So +if you suddenly notice that your application outgrows this approach you +can fall back to a centralized URL mapping. + +The system that enables having a central URL map is the +:meth:`~flask.Flask.add_url_rule` function. Instead of using decorators, +you have a file that sets up the application with all URLs. + +Converting to Centralized URL Map +--------------------------------- + +Imagine the current application looks somewhat like this:: + + from flask import Flask + app = Flask(__name__) + + @app.route('/') + def index(): + pass + + @app.route('/user/') + def user(username): + pass + +Then, with the centralized approach you would have one file with the views +(:file:`views.py`) but without any decorator:: + + def index(): + pass + + def user(username): + pass + +And then a file that sets up an application which maps the functions to +URLs:: + + from flask import Flask + from yourapplication import views + app = Flask(__name__) + app.add_url_rule('/', view_func=views.index) + app.add_url_rule('/user/', view_func=views.user) + +Loading Late +------------ + +So far we only split up the views and the routing, but the module is still +loaded upfront. The trick is to actually load the view function as needed. +This can be accomplished with a helper class that behaves just like a +function but internally imports the real function on first use:: + + from werkzeug.utils import import_string, cached_property + + class LazyView(object): + + def __init__(self, import_name): + self.__module__, self.__name__ = import_name.rsplit('.', 1) + self.import_name = import_name + + @cached_property + def view(self): + return import_string(self.import_name) + + def __call__(self, *args, **kwargs): + return self.view(*args, **kwargs) + +What's important here is is that `__module__` and `__name__` are properly +set. This is used by Flask internally to figure out how to name the +URL rules in case you don't provide a name for the rule yourself. + +Then you can define your central place to combine the views like this:: + + from flask import Flask + from yourapplication.helpers import LazyView + app = Flask(__name__) + app.add_url_rule('/', + view_func=LazyView('yourapplication.views.index')) + app.add_url_rule('/user/', + view_func=LazyView('yourapplication.views.user')) + +You can further optimize this in terms of amount of keystrokes needed to +write this by having a function that calls into +:meth:`~flask.Flask.add_url_rule` by prefixing a string with the project +name and a dot, and by wrapping `view_func` in a `LazyView` as needed. :: + + def url(import_name, url_rules=[], **options): + view = LazyView(f"yourapplication.{import_name}") + for url_rule in url_rules: + app.add_url_rule(url_rule, view_func=view, **options) + + # add a single route to the index view + url('views.index', ['/']) + + # add two routes to a single function endpoint + url_rules = ['/user/','/user/'] + url('views.user', url_rules) + +One thing to keep in mind is that before and after request handlers have +to be in a file that is imported upfront to work properly on the first +request. The same goes for any kind of remaining decorator. diff --git a/src/flask-main/docs/patterns/methodoverrides.rst b/src/flask-main/docs/patterns/methodoverrides.rst new file mode 100644 index 0000000..45dbb87 --- /dev/null +++ b/src/flask-main/docs/patterns/methodoverrides.rst @@ -0,0 +1,42 @@ +Adding HTTP Method Overrides +============================ + +Some HTTP proxies do not support arbitrary HTTP methods or newer HTTP +methods (such as PATCH). In that case it's possible to "proxy" HTTP +methods through another HTTP method in total violation of the protocol. + +The way this works is by letting the client do an HTTP POST request and +set the ``X-HTTP-Method-Override`` header. Then the method is replaced +with the header value before being passed to Flask. + +This can be accomplished with an HTTP middleware:: + + class HTTPMethodOverrideMiddleware(object): + allowed_methods = frozenset([ + 'GET', + 'HEAD', + 'POST', + 'DELETE', + 'PUT', + 'PATCH', + 'OPTIONS' + ]) + bodyless_methods = frozenset(['GET', 'HEAD', 'OPTIONS', 'DELETE']) + + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + method = environ.get('HTTP_X_HTTP_METHOD_OVERRIDE', '').upper() + if method in self.allowed_methods: + environ['REQUEST_METHOD'] = method + if method in self.bodyless_methods: + environ['CONTENT_LENGTH'] = '0' + return self.app(environ, start_response) + +To use this with Flask, wrap the app object with the middleware:: + + from flask import Flask + + app = Flask(__name__) + app.wsgi_app = HTTPMethodOverrideMiddleware(app.wsgi_app) diff --git a/src/flask-main/docs/patterns/mongoengine.rst b/src/flask-main/docs/patterns/mongoengine.rst new file mode 100644 index 0000000..624988e --- /dev/null +++ b/src/flask-main/docs/patterns/mongoengine.rst @@ -0,0 +1,103 @@ +MongoDB with MongoEngine +======================== + +Using a document database like MongoDB is a common alternative to +relational SQL databases. This pattern shows how to use +`MongoEngine`_, a document mapper library, to integrate with MongoDB. + +A running MongoDB server and `Flask-MongoEngine`_ are required. :: + + pip install flask-mongoengine + +.. _MongoEngine: http://mongoengine.org +.. _Flask-MongoEngine: https://flask-mongoengine.readthedocs.io + + +Configuration +------------- + +Basic setup can be done by defining ``MONGODB_SETTINGS`` on +``app.config`` and creating a ``MongoEngine`` instance. :: + + from flask import Flask + from flask_mongoengine import MongoEngine + + app = Flask(__name__) + app.config['MONGODB_SETTINGS'] = { + "db": "myapp", + } + db = MongoEngine(app) + + +Mapping Documents +----------------- + +To declare a model that represents a Mongo document, create a class that +inherits from ``Document`` and declare each of the fields. :: + + import mongoengine as me + + class Movie(me.Document): + title = me.StringField(required=True) + year = me.IntField() + rated = me.StringField() + director = me.StringField() + actors = me.ListField() + +If the document has nested fields, use ``EmbeddedDocument`` to +defined the fields of the embedded document and +``EmbeddedDocumentField`` to declare it on the parent document. :: + + class Imdb(me.EmbeddedDocument): + imdb_id = me.StringField() + rating = me.DecimalField() + votes = me.IntField() + + class Movie(me.Document): + ... + imdb = me.EmbeddedDocumentField(Imdb) + + +Creating Data +------------- + +Instantiate your document class with keyword arguments for the fields. +You can also assign values to the field attributes after instantiation. +Then call ``doc.save()``. :: + + bttf = Movie(title="Back To The Future", year=1985) + bttf.actors = [ + "Michael J. Fox", + "Christopher Lloyd" + ] + bttf.imdb = Imdb(imdb_id="tt0088763", rating=8.5) + bttf.save() + + +Queries +------- + +Use the class ``objects`` attribute to make queries. A keyword argument +looks for an equal value on the field. :: + + bttf = Movie.objects(title="Back To The Future").get_or_404() + +Query operators may be used by concatenating them with the field name +using a double-underscore. ``objects``, and queries returned by +calling it, are iterable. :: + + some_theron_movie = Movie.objects(actors__in=["Charlize Theron"]).first() + + for recents in Movie.objects(year__gte=2017): + print(recents.title) + + +Documentation +------------- + +There are many more ways to define and query documents with MongoEngine. +For more information, check out the `official documentation +`_. + +Flask-MongoEngine adds helpful utilities on top of MongoEngine. Check +out their `documentation `_ as well. diff --git a/src/flask-main/docs/patterns/packages.rst b/src/flask-main/docs/patterns/packages.rst new file mode 100644 index 0000000..90fa8a8 --- /dev/null +++ b/src/flask-main/docs/patterns/packages.rst @@ -0,0 +1,133 @@ +Large Applications as Packages +============================== + +Imagine a simple flask application structure that looks like this:: + + /yourapplication + yourapplication.py + /static + style.css + /templates + layout.html + index.html + login.html + ... + +While this is fine for small applications, for larger applications +it's a good idea to use a package instead of a module. +The :doc:`/tutorial/index` is structured to use the package pattern, +see the :gh:`example code `. + +Simple Packages +--------------- + +To convert that into a larger one, just create a new folder +:file:`yourapplication` inside the existing one and move everything below it. +Then rename :file:`yourapplication.py` to :file:`__init__.py`. (Make sure to delete +all ``.pyc`` files first, otherwise things would most likely break) + +You should then end up with something like that:: + + /yourapplication + /yourapplication + __init__.py + /static + style.css + /templates + layout.html + index.html + login.html + ... + +But how do you run your application now? The naive ``python +yourapplication/__init__.py`` will not work. Let's just say that Python +does not want modules in packages to be the startup file. But that is not +a big problem, just add a new file called :file:`pyproject.toml` next to the inner +:file:`yourapplication` folder with the following contents: + +.. code-block:: toml + + [project] + name = "yourapplication" + dependencies = [ + "flask", + ] + + [build-system] + requires = ["flit_core<4"] + build-backend = "flit_core.buildapi" + +Install your application so it is importable: + +.. code-block:: text + + $ pip install -e . + +To use the ``flask`` command and run your application you need to set +the ``--app`` option that tells Flask where to find the application +instance: + +.. code-block:: text + + $ flask --app yourapplication run + +What did we gain from this? Now we can restructure the application a bit +into multiple modules. The only thing you have to remember is the +following quick checklist: + +1. the `Flask` application object creation has to be in the + :file:`__init__.py` file. That way each module can import it safely and the + `__name__` variable will resolve to the correct package. +2. all the view functions (the ones with a :meth:`~flask.Flask.route` + decorator on top) have to be imported in the :file:`__init__.py` file. + Not the object itself, but the module it is in. Import the view module + **after the application object is created**. + +Here's an example :file:`__init__.py`:: + + from flask import Flask + app = Flask(__name__) + + import yourapplication.views + +And this is what :file:`views.py` would look like:: + + from yourapplication import app + + @app.route('/') + def index(): + return 'Hello World!' + +You should then end up with something like that:: + + /yourapplication + pyproject.toml + /yourapplication + __init__.py + views.py + /static + style.css + /templates + layout.html + index.html + login.html + ... + +.. admonition:: Circular Imports + + Every Python programmer hates them, and yet we just added some: + circular imports (That's when two modules depend on each other. In this + case :file:`views.py` depends on :file:`__init__.py`). Be advised that this is a + bad idea in general but here it is actually fine. The reason for this is + that we are not actually using the views in :file:`__init__.py` and just + ensuring the module is imported and we are doing that at the bottom of + the file. + + +Working with Blueprints +----------------------- + +If you have larger applications it's recommended to divide them into +smaller groups where each group is implemented with the help of a +blueprint. For a gentle introduction into this topic refer to the +:doc:`/blueprints` chapter of the documentation. diff --git a/src/flask-main/docs/patterns/requestchecksum.rst b/src/flask-main/docs/patterns/requestchecksum.rst new file mode 100644 index 0000000..25bc38b --- /dev/null +++ b/src/flask-main/docs/patterns/requestchecksum.rst @@ -0,0 +1,55 @@ +Request Content Checksums +========================= + +Various pieces of code can consume the request data and preprocess it. +For instance JSON data ends up on the request object already read and +processed, form data ends up there as well but goes through a different +code path. This seems inconvenient when you want to calculate the +checksum of the incoming request data. This is necessary sometimes for +some APIs. + +Fortunately this is however very simple to change by wrapping the input +stream. + +The following example calculates the SHA1 checksum of the incoming data as +it gets read and stores it in the WSGI environment:: + + import hashlib + + class ChecksumCalcStream(object): + + def __init__(self, stream): + self._stream = stream + self._hash = hashlib.sha1() + + def read(self, bytes): + rv = self._stream.read(bytes) + self._hash.update(rv) + return rv + + def readline(self, size_hint): + rv = self._stream.readline(size_hint) + self._hash.update(rv) + return rv + + def generate_checksum(request): + env = request.environ + stream = ChecksumCalcStream(env['wsgi.input']) + env['wsgi.input'] = stream + return stream._hash + +To use this, all you need to do is to hook the calculating stream in +before the request starts consuming data. (Eg: be careful accessing +``request.form`` or anything of that nature. ``before_request_handlers`` +for instance should be careful not to access it). + +Example usage:: + + @app.route('/special-api', methods=['POST']) + def special_api(): + hash = generate_checksum(request) + # Accessing this parses the input stream + files = request.files + # At this point the hash is fully constructed. + checksum = hash.hexdigest() + return f"Hash was: {checksum}" diff --git a/src/flask-main/docs/patterns/singlepageapplications.rst b/src/flask-main/docs/patterns/singlepageapplications.rst new file mode 100644 index 0000000..1cb779b --- /dev/null +++ b/src/flask-main/docs/patterns/singlepageapplications.rst @@ -0,0 +1,24 @@ +Single-Page Applications +======================== + +Flask can be used to serve Single-Page Applications (SPA) by placing static +files produced by your frontend framework in a subfolder inside of your +project. You will also need to create a catch-all endpoint that routes all +requests to your SPA. + +The following example demonstrates how to serve an SPA along with an API:: + + from flask import Flask, jsonify + + app = Flask(__name__, static_folder='app', static_url_path="/app") + + + @app.route("/heartbeat") + def heartbeat(): + return jsonify({"status": "healthy"}) + + + @app.route('/', defaults={'path': ''}) + @app.route('/') + def catch_all(path): + return app.send_static_file("index.html") diff --git a/src/flask-main/docs/patterns/sqlalchemy.rst b/src/flask-main/docs/patterns/sqlalchemy.rst new file mode 100644 index 0000000..9e9afe4 --- /dev/null +++ b/src/flask-main/docs/patterns/sqlalchemy.rst @@ -0,0 +1,213 @@ +SQLAlchemy in Flask +=================== + +Many people prefer `SQLAlchemy`_ for database access. In this case it's +encouraged to use a package instead of a module for your flask application +and drop the models into a separate module (:doc:`packages`). While that +is not necessary, it makes a lot of sense. + +There are four very common ways to use SQLAlchemy. I will outline each +of them here: + +Flask-SQLAlchemy Extension +-------------------------- + +Because SQLAlchemy is a common database abstraction layer and object +relational mapper that requires a little bit of configuration effort, +there is a Flask extension that handles that for you. This is recommended +if you want to get started quickly. + +You can download `Flask-SQLAlchemy`_ from `PyPI +`_. + +.. _Flask-SQLAlchemy: https://flask-sqlalchemy.palletsprojects.com/ + + +Declarative +----------- + +The declarative extension in SQLAlchemy is the most recent method of using +SQLAlchemy. It allows you to define tables and models in one go, similar +to how Django works. In addition to the following text I recommend the +official documentation on the `declarative`_ extension. + +Here's the example :file:`database.py` module for your application:: + + from sqlalchemy import create_engine + from sqlalchemy.orm import scoped_session, sessionmaker, declarative_base + + engine = create_engine('sqlite:////tmp/test.db') + db_session = scoped_session(sessionmaker(autocommit=False, + autoflush=False, + bind=engine)) + Base = declarative_base() + Base.query = db_session.query_property() + + def init_db(): + # import all modules here that might define models so that + # they will be registered properly on the metadata. Otherwise + # you will have to import them first before calling init_db() + import yourapplication.models + Base.metadata.create_all(bind=engine) + +To define your models, just subclass the `Base` class that was created by +the code above. If you are wondering why we don't have to care about +threads here (like we did in the SQLite3 example above with the +:data:`~flask.g` object): that's because SQLAlchemy does that for us +already with the :class:`~sqlalchemy.orm.scoped_session`. + +To use SQLAlchemy in a declarative way with your application, you just +have to put the following code into your application module. Flask will +automatically remove database sessions at the end of the request or +when the application shuts down:: + + from yourapplication.database import db_session + + @app.teardown_appcontext + def shutdown_session(exception=None): + db_session.remove() + +Here is an example model (put this into :file:`models.py`, e.g.):: + + from sqlalchemy import Column, Integer, String + from yourapplication.database import Base + + class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + name = Column(String(50), unique=True) + email = Column(String(120), unique=True) + + def __init__(self, name=None, email=None): + self.name = name + self.email = email + + def __repr__(self): + return f'' + +To create the database you can use the `init_db` function: + +>>> from yourapplication.database import init_db +>>> init_db() + +You can insert entries into the database like this: + +>>> from yourapplication.database import db_session +>>> from yourapplication.models import User +>>> u = User('admin', 'admin@localhost') +>>> db_session.add(u) +>>> db_session.commit() + +Querying is simple as well: + +>>> User.query.all() +[] +>>> User.query.filter(User.name == 'admin').first() + + +.. _SQLAlchemy: https://www.sqlalchemy.org/ +.. _declarative: https://docs.sqlalchemy.org/en/latest/orm/extensions/declarative/ + +Manual Object Relational Mapping +-------------------------------- + +Manual object relational mapping has a few upsides and a few downsides +versus the declarative approach from above. The main difference is that +you define tables and classes separately and map them together. It's more +flexible but a little more to type. In general it works like the +declarative approach, so make sure to also split up your application into +multiple modules in a package. + +Here is an example :file:`database.py` module for your application:: + + from sqlalchemy import create_engine, MetaData + from sqlalchemy.orm import scoped_session, sessionmaker + + engine = create_engine('sqlite:////tmp/test.db') + metadata = MetaData() + db_session = scoped_session(sessionmaker(autocommit=False, + autoflush=False, + bind=engine)) + def init_db(): + metadata.create_all(bind=engine) + +As in the declarative approach, you need to close the session after each app +context. Put this into your application module:: + + from yourapplication.database import db_session + + @app.teardown_appcontext + def shutdown_session(exception=None): + db_session.remove() + +Here is an example table and model (put this into :file:`models.py`):: + + from sqlalchemy import Table, Column, Integer, String + from sqlalchemy.orm import mapper + from yourapplication.database import metadata, db_session + + class User(object): + query = db_session.query_property() + + def __init__(self, name=None, email=None): + self.name = name + self.email = email + + def __repr__(self): + return f'' + + users = Table('users', metadata, + Column('id', Integer, primary_key=True), + Column('name', String(50), unique=True), + Column('email', String(120), unique=True) + ) + mapper(User, users) + +Querying and inserting works exactly the same as in the example above. + + +SQL Abstraction Layer +--------------------- + +If you just want to use the database system (and SQL) abstraction layer +you basically only need the engine:: + + from sqlalchemy import create_engine, MetaData, Table + + engine = create_engine('sqlite:////tmp/test.db') + metadata = MetaData(bind=engine) + +Then you can either declare the tables in your code like in the examples +above, or automatically load them:: + + from sqlalchemy import Table + + users = Table('users', metadata, autoload=True) + +To insert data you can use the `insert` method. We have to get a +connection first so that we can use a transaction: + +>>> con = engine.connect() +>>> con.execute(users.insert(), name='admin', email='admin@localhost') + +SQLAlchemy will automatically commit for us. + +To query your database, you use the engine directly or use a connection: + +>>> users.select(users.c.id == 1).execute().first() +(1, 'admin', 'admin@localhost') + +These results are also dict-like tuples: + +>>> r = users.select(users.c.id == 1).execute().first() +>>> r['name'] +'admin' + +You can also pass strings of SQL statements to the +:meth:`~sqlalchemy.engine.base.Connection.execute` method: + +>>> engine.execute('select * from users where id = :1', [1]).first() +(1, 'admin', 'admin@localhost') + +For more information about SQLAlchemy, head over to the +`website `_. diff --git a/src/flask-main/docs/patterns/sqlite3.rst b/src/flask-main/docs/patterns/sqlite3.rst new file mode 100644 index 0000000..f42e0f8 --- /dev/null +++ b/src/flask-main/docs/patterns/sqlite3.rst @@ -0,0 +1,147 @@ +Using SQLite 3 with Flask +========================= + +You can implement a few functions to work with a SQLite connection during a +request context. The connection is created the first time it's accessed, +reused on subsequent access, until it is closed when the request context ends. + +Here is a simple example of how you can use SQLite 3 with Flask:: + + import sqlite3 + from flask import g + + DATABASE = '/path/to/database.db' + + def get_db(): + db = getattr(g, '_database', None) + if db is None: + db = g._database = sqlite3.connect(DATABASE) + return db + + @app.teardown_appcontext + def close_connection(exception): + db = getattr(g, '_database', None) + if db is not None: + db.close() + +Now, to use the database, the application must either have an active +application context (which is always true if there is a request in flight) +or create an application context itself. At that point the ``get_db`` +function can be used to get the current database connection. Whenever the +context is destroyed the database connection will be terminated. + +Example:: + + @app.route('/') + def index(): + cur = get_db().cursor() + ... + + +.. note:: + + Please keep in mind that the teardown request and appcontext functions + are always executed, even if a before-request handler failed or was + never executed. Because of this we have to make sure here that the + database is there before we close it. + +Connect on Demand +----------------- + +The upside of this approach (connecting on first use) is that this will +only open the connection if truly necessary. If you want to use this +code outside a request context you can use it in a Python shell by opening +the application context by hand:: + + with app.app_context(): + # now you can use get_db() + + +Easy Querying +------------- + +Now in each request handling function you can access `get_db()` to get the +current open database connection. To simplify working with SQLite, a +row factory function is useful. It is executed for every result returned +from the database to convert the result. For instance, in order to get +dictionaries instead of tuples, this could be inserted into the ``get_db`` +function we created above:: + + def make_dicts(cursor, row): + return dict((cursor.description[idx][0], value) + for idx, value in enumerate(row)) + + db.row_factory = make_dicts + +This will make the sqlite3 module return dicts for this database connection, which are much nicer to deal with. Even more simply, we could place this in ``get_db`` instead:: + + db.row_factory = sqlite3.Row + +This would use Row objects rather than dicts to return the results of queries. These are ``namedtuple`` s, so we can access them either by index or by key. For example, assuming we have a ``sqlite3.Row`` called ``r`` for the rows ``id``, ``FirstName``, ``LastName``, and ``MiddleInitial``:: + + >>> # You can get values based on the row's name + >>> r['FirstName'] + John + >>> # Or, you can get them based on index + >>> r[1] + John + # Row objects are also iterable: + >>> for value in r: + ... print(value) + 1 + John + Doe + M + +Additionally, it is a good idea to provide a query function that combines +getting the cursor, executing and fetching the results:: + + def query_db(query, args=(), one=False): + cur = get_db().execute(query, args) + rv = cur.fetchall() + cur.close() + return (rv[0] if rv else None) if one else rv + +This handy little function, in combination with a row factory, makes +working with the database much more pleasant than it is by just using the +raw cursor and connection objects. + +Here is how you can use it:: + + for user in query_db('select * from users'): + print(user['username'], 'has the id', user['user_id']) + +Or if you just want a single result:: + + user = query_db('select * from users where username = ?', + [the_username], one=True) + if user is None: + print('No such user') + else: + print(the_username, 'has the id', user['user_id']) + +To pass variable parts to the SQL statement, use a question mark in the +statement and pass in the arguments as a list. Never directly add them to +the SQL statement with string formatting because this makes it possible +to attack the application using `SQL Injections +`_. + +Initial Schemas +--------------- + +Relational databases need schemas, so applications often ship a +`schema.sql` file that creates the database. It's a good idea to provide +a function that creates the database based on that schema. This function +can do that for you:: + + def init_db(): + with app.app_context(): + db = get_db() + with app.open_resource('schema.sql', mode='r') as f: + db.cursor().executescript(f.read()) + db.commit() + +You can then create such a database from the Python shell: + +>>> from yourapplication import init_db +>>> init_db() diff --git a/src/flask-main/docs/patterns/streaming.rst b/src/flask-main/docs/patterns/streaming.rst new file mode 100644 index 0000000..ff35631 --- /dev/null +++ b/src/flask-main/docs/patterns/streaming.rst @@ -0,0 +1,85 @@ +Streaming Contents +================== + +Sometimes you want to send an enormous amount of data to the client, much +more than you want to keep in memory. When you are generating the data on +the fly though, how do you send that back to the client without the +roundtrip to the filesystem? + +The answer is by using generators and direct responses. + +Basic Usage +----------- + +This is a basic view function that generates a lot of CSV data on the fly. +The trick is to have an inner function that uses a generator to generate +data and to then invoke that function and pass it to a response object:: + + @app.route('/large.csv') + def generate_large_csv(): + def generate(): + for row in iter_all_rows(): + yield f"{','.join(row)}\n" + return generate(), {"Content-Type": "text/csv"} + +Each ``yield`` expression is directly sent to the browser. Note though +that some WSGI middlewares might break streaming, so be careful there in +debug environments with profilers and other things you might have enabled. + +Streaming from Templates +------------------------ + +The Jinja template engine supports rendering a template piece by +piece, returning an iterator of strings. Flask provides the +:func:`~flask.stream_template` and :func:`~flask.stream_template_string` +functions to make this easier to use. + +.. code-block:: python + + from flask import stream_template + + @app.get("/timeline") + def timeline(): + return stream_template("timeline.html") + +The parts yielded by the render stream tend to match statement blocks in +the template. + + +Streaming with Context +---------------------- + +The :data:`.request` proxy will not be active while the generator is +running, because the app has already returned control to the WSGI server at that +point. If you try to access ``request``, you'll get a ``RuntimeError``. + +If your generator function relies on data in ``request``, use the +:func:`.stream_with_context` wrapper. This will keep the request context active +during the generator. + +.. code-block:: python + + from flask import stream_with_context, request + from markupsafe import escape + + @app.route('/stream') + def streamed_response(): + def generate(): + yield '

Hello ' + yield escape(request.args['name']) + yield '!

' + return stream_with_context(generate()) + +It can also be used as a decorator. + +.. code-block:: python + + @stream_with_context + def generate(): + ... + + return generate() + +The :func:`~flask.stream_template` and +:func:`~flask.stream_template_string` functions automatically +use :func:`~flask.stream_with_context` if a request is active. diff --git a/src/flask-main/docs/patterns/subclassing.rst b/src/flask-main/docs/patterns/subclassing.rst new file mode 100644 index 0000000..d8de233 --- /dev/null +++ b/src/flask-main/docs/patterns/subclassing.rst @@ -0,0 +1,17 @@ +Subclassing Flask +================= + +The :class:`~flask.Flask` class is designed for subclassing. + +For example, you may want to override how request parameters are handled to preserve their order:: + + from flask import Flask, Request + from werkzeug.datastructures import ImmutableOrderedMultiDict + class MyRequest(Request): + """Request subclass to override request parameter storage""" + parameter_storage_class = ImmutableOrderedMultiDict + class MyFlask(Flask): + """Flask subclass using the custom request class""" + request_class = MyRequest + +This is the recommended approach for overriding or augmenting Flask's internal functionality. diff --git a/src/flask-main/docs/patterns/templateinheritance.rst b/src/flask-main/docs/patterns/templateinheritance.rst new file mode 100644 index 0000000..bb5cba2 --- /dev/null +++ b/src/flask-main/docs/patterns/templateinheritance.rst @@ -0,0 +1,68 @@ +Template Inheritance +==================== + +The most powerful part of Jinja is template inheritance. Template inheritance +allows you to build a base "skeleton" template that contains all the common +elements of your site and defines **blocks** that child templates can override. + +Sounds complicated but is very basic. It's easiest to understand it by starting +with an example. + + +Base Template +------------- + +This template, which we'll call :file:`layout.html`, defines a simple HTML skeleton +document that you might use for a simple two-column page. It's the job of +"child" templates to fill the empty blocks with content: + +.. sourcecode:: html+jinja + + + + + {% block head %} + + {% block title %}{% endblock %} - My Webpage + {% endblock %} + + +
{% block content %}{% endblock %}
+ + + + +In this example, the ``{% block %}`` tags define four blocks that child templates +can fill in. All the `block` tag does is tell the template engine that a +child template may override those portions of the template. + +Child Template +-------------- + +A child template might look like this: + +.. sourcecode:: html+jinja + + {% extends "layout.html" %} + {% block title %}Index{% endblock %} + {% block head %} + {{ super() }} + + {% endblock %} + {% block content %} +

Index

+

+ Welcome on my awesome homepage. + {% endblock %} + +The ``{% extends %}`` tag is the key here. It tells the template engine that +this template "extends" another template. When the template system evaluates +this template, first it locates the parent. The extends tag must be the +first tag in the template. To render the contents of a block defined in +the parent template, use ``{{ super() }}``. diff --git a/src/flask-main/docs/patterns/urlprocessors.rst b/src/flask-main/docs/patterns/urlprocessors.rst new file mode 100644 index 0000000..0d74320 --- /dev/null +++ b/src/flask-main/docs/patterns/urlprocessors.rst @@ -0,0 +1,126 @@ +Using URL Processors +==================== + +.. versionadded:: 0.7 + +Flask 0.7 introduces the concept of URL processors. The idea is that you +might have a bunch of resources with common parts in the URL that you +don't always explicitly want to provide. For instance you might have a +bunch of URLs that have the language code in it but you don't want to have +to handle it in every single function yourself. + +URL processors are especially helpful when combined with blueprints. We +will handle both application specific URL processors here as well as +blueprint specifics. + +Internationalized Application URLs +---------------------------------- + +Consider an application like this:: + + from flask import Flask, g + + app = Flask(__name__) + + @app.route('//') + def index(lang_code): + g.lang_code = lang_code + ... + + @app.route('//about') + def about(lang_code): + g.lang_code = lang_code + ... + +This is an awful lot of repetition as you have to handle the language code +setting on the :data:`~flask.g` object yourself in every single function. +Sure, a decorator could be used to simplify this, but if you want to +generate URLs from one function to another you would have to still provide +the language code explicitly which can be annoying. + +For the latter, this is where :func:`~flask.Flask.url_defaults` functions +come in. They can automatically inject values into a call to +:func:`~flask.url_for`. The code below checks if the +language code is not yet in the dictionary of URL values and if the +endpoint wants a value named ``'lang_code'``:: + + @app.url_defaults + def add_language_code(endpoint, values): + if 'lang_code' in values or not g.lang_code: + return + if app.url_map.is_endpoint_expecting(endpoint, 'lang_code'): + values['lang_code'] = g.lang_code + +The method :meth:`~werkzeug.routing.Map.is_endpoint_expecting` of the URL +map can be used to figure out if it would make sense to provide a language +code for the given endpoint. + +The reverse of that function are +:meth:`~flask.Flask.url_value_preprocessor`\s. They are executed right +after the request was matched and can execute code based on the URL +values. The idea is that they pull information out of the values +dictionary and put it somewhere else:: + + @app.url_value_preprocessor + def pull_lang_code(endpoint, values): + g.lang_code = values.pop('lang_code', None) + +That way you no longer have to do the `lang_code` assignment to +:data:`~flask.g` in every function. You can further improve that by +writing your own decorator that prefixes URLs with the language code, but +the more beautiful solution is using a blueprint. Once the +``'lang_code'`` is popped from the values dictionary and it will no longer +be forwarded to the view function reducing the code to this:: + + from flask import Flask, g + + app = Flask(__name__) + + @app.url_defaults + def add_language_code(endpoint, values): + if 'lang_code' in values or not g.lang_code: + return + if app.url_map.is_endpoint_expecting(endpoint, 'lang_code'): + values['lang_code'] = g.lang_code + + @app.url_value_preprocessor + def pull_lang_code(endpoint, values): + g.lang_code = values.pop('lang_code', None) + + @app.route('//') + def index(): + ... + + @app.route('//about') + def about(): + ... + +Internationalized Blueprint URLs +-------------------------------- + +Because blueprints can automatically prefix all URLs with a common string +it's easy to automatically do that for every function. Furthermore +blueprints can have per-blueprint URL processors which removes a whole lot +of logic from the :meth:`~flask.Flask.url_defaults` function because it no +longer has to check if the URL is really interested in a ``'lang_code'`` +parameter:: + + from flask import Blueprint, g + + bp = Blueprint('frontend', __name__, url_prefix='/') + + @bp.url_defaults + def add_language_code(endpoint, values): + values.setdefault('lang_code', g.lang_code) + + @bp.url_value_preprocessor + def pull_lang_code(endpoint, values): + g.lang_code = values.pop('lang_code') + + @bp.route('/') + def index(): + ... + + @bp.route('/about') + def about(): + ... diff --git a/src/flask-main/docs/patterns/viewdecorators.rst b/src/flask-main/docs/patterns/viewdecorators.rst new file mode 100644 index 0000000..0b0479e --- /dev/null +++ b/src/flask-main/docs/patterns/viewdecorators.rst @@ -0,0 +1,171 @@ +View Decorators +=============== + +Python has a really interesting feature called function decorators. This +allows some really neat things for web applications. Because each view in +Flask is a function, decorators can be used to inject additional +functionality to one or more functions. The :meth:`~flask.Flask.route` +decorator is the one you probably used already. But there are use cases +for implementing your own decorator. For instance, imagine you have a +view that should only be used by people that are logged in. If a user +goes to the site and is not logged in, they should be redirected to the +login page. This is a good example of a use case where a decorator is an +excellent solution. + +Login Required Decorator +------------------------ + +So let's implement such a decorator. A decorator is a function that +wraps and replaces another function. Since the original function is +replaced, you need to remember to copy the original function's information +to the new function. Use :func:`functools.wraps` to handle this for you. + +This example assumes that the login page is called ``'login'`` and that +the current user is stored in ``g.user`` and is ``None`` if there is no-one +logged in. :: + + from functools import wraps + from flask import g, request, redirect, url_for + + def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if g.user is None: + return redirect(url_for('login', next=request.url)) + return f(*args, **kwargs) + return decorated_function + +To use the decorator, apply it as innermost decorator to a view function. +When applying further decorators, always remember +that the :meth:`~flask.Flask.route` decorator is the outermost. :: + + @app.route('/secret_page') + @login_required + def secret_page(): + pass + +.. note:: + The ``next`` value will exist in ``request.args`` after a ``GET`` request for + the login page. You'll have to pass it along when sending the ``POST`` request + from the login form. You can do this with a hidden input tag, then retrieve it + from ``request.form`` when logging the user in. :: + + + + +Caching Decorator +----------------- + +Imagine you have a view function that does an expensive calculation and +because of that you would like to cache the generated results for a +certain amount of time. A decorator would be nice for that. We're +assuming you have set up a cache like mentioned in :doc:`caching`. + +Here is an example cache function. It generates the cache key from a +specific prefix (actually a format string) and the current path of the +request. Notice that we are using a function that first creates the +decorator that then decorates the function. Sounds awful? Unfortunately +it is a little bit more complex, but the code should still be +straightforward to read. + +The decorated function will then work as follows + +1. get the unique cache key for the current request based on the current + path. +2. get the value for that key from the cache. If the cache returned + something we will return that value. +3. otherwise the original function is called and the return value is + stored in the cache for the timeout provided (by default 5 minutes). + +Here the code:: + + from functools import wraps + from flask import request + + def cached(timeout=5 * 60, key='view/{}'): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + cache_key = key.format(request.path) + rv = cache.get(cache_key) + if rv is not None: + return rv + rv = f(*args, **kwargs) + cache.set(cache_key, rv, timeout=timeout) + return rv + return decorated_function + return decorator + +Notice that this assumes an instantiated ``cache`` object is available, see +:doc:`caching`. + + +Templating Decorator +-------------------- + +A common pattern invented by the TurboGears guys a while back is a +templating decorator. The idea of that decorator is that you return a +dictionary with the values passed to the template from the view function +and the template is automatically rendered. With that, the following +three examples do exactly the same:: + + @app.route('/') + def index(): + return render_template('index.html', value=42) + + @app.route('/') + @templated('index.html') + def index(): + return dict(value=42) + + @app.route('/') + @templated() + def index(): + return dict(value=42) + +As you can see, if no template name is provided it will use the endpoint +of the URL map with dots converted to slashes + ``'.html'``. Otherwise +the provided template name is used. When the decorated function returns, +the dictionary returned is passed to the template rendering function. If +``None`` is returned, an empty dictionary is assumed, if something else than +a dictionary is returned we return it from the function unchanged. That +way you can still use the redirect function or return simple strings. + +Here is the code for that decorator:: + + from functools import wraps + from flask import request, render_template + + def templated(template=None): + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + template_name = template + if template_name is None: + template_name = f"{request.endpoint.replace('.', '/')}.html" + ctx = f(*args, **kwargs) + if ctx is None: + ctx = {} + elif not isinstance(ctx, dict): + return ctx + return render_template(template_name, **ctx) + return decorated_function + return decorator + + +Endpoint Decorator +------------------ + +When you want to use the werkzeug routing system for more flexibility you +need to map the endpoint as defined in the :class:`~werkzeug.routing.Rule` +to a view function. This is possible with this decorator. For example:: + + from flask import Flask + from werkzeug.routing import Rule + + app = Flask(__name__) + app.url_map.add(Rule('/', endpoint='index')) + + @app.endpoint('index') + def my_index(): + return "Hello world" diff --git a/src/flask-main/docs/patterns/wtforms.rst b/src/flask-main/docs/patterns/wtforms.rst new file mode 100644 index 0000000..cb1208f --- /dev/null +++ b/src/flask-main/docs/patterns/wtforms.rst @@ -0,0 +1,126 @@ +Form Validation with WTForms +============================ + +When you have to work with form data submitted by a browser view, code +quickly becomes very hard to read. There are libraries out there designed +to make this process easier to manage. One of them is `WTForms`_ which we +will handle here. If you find yourself in the situation of having many +forms, you might want to give it a try. + +When you are working with WTForms you have to define your forms as classes +first. I recommend breaking up the application into multiple modules +(:doc:`packages`) for that and adding a separate module for the +forms. + +.. admonition:: Getting the most out of WTForms with an Extension + + The `Flask-WTF`_ extension expands on this pattern and adds a + few little helpers that make working with forms and Flask more + fun. You can get it from `PyPI + `_. + +.. _Flask-WTF: https://flask-wtf.readthedocs.io/ + +The Forms +--------- + +This is an example form for a typical registration page:: + + from wtforms import Form, BooleanField, StringField, PasswordField, validators + + class RegistrationForm(Form): + username = StringField('Username', [validators.Length(min=4, max=25)]) + email = StringField('Email Address', [validators.Length(min=6, max=35)]) + password = PasswordField('New Password', [ + validators.DataRequired(), + validators.EqualTo('confirm', message='Passwords must match') + ]) + confirm = PasswordField('Repeat Password') + accept_tos = BooleanField('I accept the TOS', [validators.DataRequired()]) + +In the View +----------- + +In the view function, the usage of this form looks like this:: + + @app.route('/register', methods=['GET', 'POST']) + def register(): + form = RegistrationForm(request.form) + if request.method == 'POST' and form.validate(): + user = User(form.username.data, form.email.data, + form.password.data) + db_session.add(user) + flash('Thanks for registering') + return redirect(url_for('login')) + return render_template('register.html', form=form) + +Notice we're implying that the view is using SQLAlchemy here +(:doc:`sqlalchemy`), but that's not a requirement, of course. Adapt +the code as necessary. + +Things to remember: + +1. create the form from the request :attr:`~flask.request.form` value if + the data is submitted via the HTTP ``POST`` method and + :attr:`~flask.request.args` if the data is submitted as ``GET``. +2. to validate the data, call the :func:`~wtforms.form.Form.validate` + method, which will return ``True`` if the data validates, ``False`` + otherwise. +3. to access individual values from the form, access `form..data`. + +Forms in Templates +------------------ + +Now to the template side. When you pass the form to the templates, you can +easily render them there. Look at the following example template to see +how easy this is. WTForms does half the form generation for us already. +To make it even nicer, we can write a macro that renders a field with +label and a list of errors if there are any. + +Here's an example :file:`_formhelpers.html` template with such a macro: + +.. sourcecode:: html+jinja + + {% macro render_field(field) %} +

{{ field.label }} +
{{ field(**kwargs)|safe }} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endmacro %} + +This macro accepts a couple of keyword arguments that are forwarded to +WTForm's field function, which renders the field for us. The keyword +arguments will be inserted as HTML attributes. So, for example, you can +call ``render_field(form.username, class='username')`` to add a class to +the input element. Note that WTForms returns standard Python strings, +so we have to tell Jinja that this data is already HTML-escaped with +the ``|safe`` filter. + +Here is the :file:`register.html` template for the function we used above, which +takes advantage of the :file:`_formhelpers.html` template: + +.. sourcecode:: html+jinja + + {% from "_formhelpers.html" import render_field %} + +
+ {{ render_field(form.username) }} + {{ render_field(form.email) }} + {{ render_field(form.password) }} + {{ render_field(form.confirm) }} + {{ render_field(form.accept_tos) }} +
+

+

+ +For more information about WTForms, head over to the `WTForms +website`_. + +.. _WTForms: https://wtforms.readthedocs.io/ +.. _WTForms website: https://wtforms.readthedocs.io/ diff --git a/src/flask-main/docs/quickstart.rst b/src/flask-main/docs/quickstart.rst new file mode 100644 index 0000000..6af09eb --- /dev/null +++ b/src/flask-main/docs/quickstart.rst @@ -0,0 +1,858 @@ +Quickstart +========== + +Eager to get started? This page gives a good introduction to Flask. +Follow :doc:`installation` to set up a project and install Flask first. + + +A Minimal Application +--------------------- + +A minimal Flask application looks something like this: + +.. code-block:: python + + from flask import Flask + + app = Flask(__name__) + + @app.route("/") + def hello_world(): + return "

Hello, World!

" + +So what did that code do? + +1. First we imported the :class:`~flask.Flask` class. An instance of + this class will be our WSGI application. +2. Next we create an instance of this class. The first argument is the + name of the application's module or package. ``__name__`` is a + convenient shortcut for this that is appropriate for most cases. + This is needed so that Flask knows where to look for resources such + as templates and static files. +3. We then use the :meth:`~flask.Flask.route` decorator to tell Flask + what URL should trigger our function. +4. The function returns the message we want to display in the user's + browser. The default content type is HTML, so HTML in the string + will be rendered by the browser. + +Save it as :file:`hello.py` or something similar. Make sure to not call +your application :file:`flask.py` because this would conflict with Flask +itself. + +To run the application, use the ``flask`` command or +``python -m flask``. You need to tell the Flask where your application +is with the ``--app`` option. + +.. code-block:: text + + $ flask --app hello run + * Serving Flask app 'hello' + * Running on http://127.0.0.1:5000 (Press CTRL+C to quit) + +.. admonition:: Application Discovery Behavior + + As a shortcut, if the file is named ``app.py`` or ``wsgi.py``, you + don't have to use ``--app``. See :doc:`/cli` for more details. + +This launches a very simple builtin server, which is good enough for +testing but probably not what you want to use in production. For +deployment options see :doc:`deploying/index`. + +Now head over to http://127.0.0.1:5000/, and you should see your hello +world greeting. + +If another program is already using port 5000, you'll see +``OSError: [Errno 98]`` or ``OSError: [WinError 10013]`` when the +server tries to start. See :ref:`address-already-in-use` for how to +handle that. + +.. _public-server: + +.. admonition:: Externally Visible Server + + If you run the server you will notice that the server is only accessible + from your own computer, not from any other in the network. This is the + default because in debugging mode a user of the application can execute + arbitrary Python code on your computer. + + If you have the debugger disabled or trust the users on your network, + you can make the server publicly available simply by adding + ``--host=0.0.0.0`` to the command line:: + + $ flask run --host=0.0.0.0 + + This tells your operating system to listen on all public IPs. + + +Debug Mode +---------- + +The ``flask run`` command can do more than just start the development +server. By enabling debug mode, the server will automatically reload if +code changes, and will show an interactive debugger in the browser if an +error occurs during a request. + +.. image:: _static/debugger.png + :align: center + :class: screenshot + :alt: The interactive debugger in action. + +.. warning:: + + The debugger allows executing arbitrary Python code from the + browser. It is protected by a pin, but still represents a major + security risk. Do not run the development server or debugger in a + production environment. + +To enable debug mode, use the ``--debug`` option. + +.. code-block:: text + + $ flask --app hello run --debug + * Serving Flask app 'hello' + * Debug mode: on + * Running on http://127.0.0.1:5000 (Press CTRL+C to quit) + * Restarting with stat + * Debugger is active! + * Debugger PIN: nnn-nnn-nnn + +See also: + +- :doc:`/server` and :doc:`/cli` for information about running in debug mode. +- :doc:`/debugging` for information about using the built-in debugger + and other debuggers. +- :doc:`/logging` and :doc:`/errorhandling` to log errors and display + nice error pages. + + +HTML Escaping +------------- + +When returning HTML (the default response type in Flask), any +user-provided values rendered in the output must be escaped to protect +from injection attacks. HTML templates rendered with Jinja, introduced +later, will do this automatically. + +:func:`~markupsafe.escape`, shown here, can be used manually. It is +omitted in most examples for brevity, but you should always be aware of +how you're using untrusted data. + +.. code-block:: python + + from flask import request + from markupsafe import escape + + @app.route("/hello") + def hello(): + name = request.args.get("name", "Flask") + return f"Hello, {escape(name)}!" + +If a user submits ``/hello?name=``, escaping causes +it to be rendered as text, rather than running the script in the user's browser. + + +Routing +------- + +Modern web applications use meaningful URLs to help users. Users are more +likely to like a page and come back if the page uses a meaningful URL they can +remember and use to directly visit a page. + +Use the :meth:`~flask.Flask.route` decorator to bind a function to a URL. :: + + @app.route('/') + def index(): + return 'Index Page' + + @app.route('/hello') + def hello(): + return 'Hello, World' + +You can do more! You can make parts of the URL dynamic and attach multiple +rules to a function. + +Variable Rules +`````````````` + +You can add variable sections to a URL by marking sections with +````. Your function then receives the ```` +as a keyword argument. Optionally, you can use a converter to specify the type +of the argument like ````. :: + + from markupsafe import escape + + @app.route('/user/') + def show_user_profile(username): + # show the user profile for that user + return f'User {escape(username)}' + + @app.route('/post/') + def show_post(post_id): + # show the post with the given id, the id is an integer + return f'Post {post_id}' + + @app.route('/path/') + def show_subpath(subpath): + # show the subpath after /path/ + return f'Subpath {escape(subpath)}' + +Converter types: + +========== ========================================== +``string`` (default) accepts any text without a slash +``int`` accepts positive integers +``float`` accepts positive floating point values +``path`` like ``string`` but also accepts slashes +``uuid`` accepts UUID strings +========== ========================================== + + +Unique URLs / Redirection Behavior +`````````````````````````````````` + +The following two rules differ in their use of a trailing slash. :: + + @app.route('/projects/') + def projects(): + return 'The project page' + + @app.route('/about') + def about(): + return 'The about page' + +The canonical URL for the ``projects`` endpoint has a trailing slash. +It's similar to a folder in a file system. If you access the URL without +a trailing slash (``/projects``), Flask redirects you to the canonical URL +with the trailing slash (``/projects/``). + +The canonical URL for the ``about`` endpoint does not have a trailing +slash. It's similar to the pathname of a file. Accessing the URL with a +trailing slash (``/about/``) produces a 404 "Not Found" error. This helps +keep URLs unique for these resources, which helps search engines avoid +indexing the same page twice. + + +.. _url-building: + +URL Building +```````````` + +To build a URL to a specific function, use the :func:`~flask.url_for` function. +It accepts the name of the function as its first argument and any number of +keyword arguments, each corresponding to a variable part of the URL rule. +Unknown variable parts are appended to the URL as query parameters. + +Why would you want to build URLs using the URL reversing function +:func:`~flask.url_for` instead of hard-coding them into your templates? + +1. Reversing is often more descriptive than hard-coding the URLs. +2. You can change your URLs in one go instead of needing to remember to + manually change hard-coded URLs. +3. URL building handles escaping of special characters transparently. +4. The generated paths are always absolute, avoiding unexpected behavior + of relative paths in browsers. +5. If your application is placed outside the URL root, for example, in + ``/myapplication`` instead of ``/``, :func:`~flask.url_for` properly + handles that for you. + +For example, here we use the :meth:`~flask.Flask.test_request_context` method +to try out :func:`~flask.url_for`. :meth:`~flask.Flask.test_request_context` +tells Flask to behave as though it's handling a request even while we use a +Python shell. See :doc:`/appcontext`. + +.. code-block:: python + + from flask import url_for + + @app.route('/') + def index(): + return 'index' + + @app.route('/login') + def login(): + return 'login' + + @app.route('/user/') + def profile(username): + return f'{username}\'s profile' + + with app.test_request_context(): + print(url_for('index')) + print(url_for('login')) + print(url_for('login', next='/')) + print(url_for('profile', username='John Doe')) + +.. code-block:: text + + / + /login + /login?next=/ + /user/John%20Doe + + +HTTP Methods +```````````` + +Web applications use different HTTP methods when accessing URLs. You should +familiarize yourself with the HTTP methods as you work with Flask. By default, +a route only answers to ``GET`` requests. You can use the ``methods`` argument +of the :meth:`~flask.Flask.route` decorator to handle different HTTP methods. +:: + + from flask import request + + @app.route('/login', methods=['GET', 'POST']) + def login(): + if request.method == 'POST': + return do_the_login() + else: + return show_the_login_form() + +The example above keeps all methods for the route within one function, +which can be useful if each part uses some common data. + +You can also separate views for different methods into different +functions. Flask provides a shortcut for decorating such routes with +:meth:`~flask.Flask.get`, :meth:`~flask.Flask.post`, etc. for each +common HTTP method. + +.. code-block:: python + + @app.get('/login') + def login_get(): + return show_the_login_form() + + @app.post('/login') + def login_post(): + return do_the_login() + +If ``GET`` is present, Flask automatically adds support for the ``HEAD`` method +and handles ``HEAD`` requests according to the `HTTP RFC`_. Likewise, +``OPTIONS`` is automatically implemented for you. + +.. _HTTP RFC: https://www.ietf.org/rfc/rfc2068.txt + +Static Files +------------ + +Dynamic web applications also need static files. That's usually where +the CSS and JavaScript files are coming from. Ideally your web server is +configured to serve them for you, but during development Flask can do that +as well. Just create a folder called :file:`static` in your package or next to +your module and it will be available at ``/static`` on the application. + +To generate URLs for static files, use the special ``'static'`` endpoint name:: + + url_for('static', filename='style.css') + +The file has to be stored on the filesystem as :file:`static/style.css`. + +Rendering Templates +------------------- + +Generating HTML from within Python is not fun, and actually pretty +cumbersome because you have to do the HTML escaping on your own to keep +the application secure. Because of that Flask configures the `Jinja +`_ template engine for you automatically. + +Templates can be used to generate any type of text file. For web applications, you'll +primarily be generating HTML pages, but you can also generate markdown, plain text for +emails, and anything else. + +For a reference to HTML, CSS, and other web APIs, use the `MDN Web Docs`_. + +.. _MDN Web Docs: https://developer.mozilla.org/ + +To render a template you can use the :func:`~flask.render_template` +method. All you have to do is provide the name of the template and the +variables you want to pass to the template engine as keyword arguments. +Here's a simple example of how to render a template:: + + from flask import render_template + + @app.route('/hello/') + @app.route('/hello/') + def hello(name=None): + return render_template('hello.html', person=name) + +Flask will look for templates in the :file:`templates` folder. So if your +application is a module, this folder is next to that module, if it's a +package it's actually inside your package: + +**Case 1**: a module:: + + /application.py + /templates + /hello.html + +**Case 2**: a package:: + + /application + /__init__.py + /templates + /hello.html + +For templates you can use the full power of Jinja templates. Head over +to the official `Jinja Template Documentation +`_ for more information. + +Here is an example template: + +.. sourcecode:: html+jinja + + + Hello from Flask + {% if person %} +

Hello {{ person }}!

+ {% else %} +

Hello, World!

+ {% endif %} + +Inside templates you also have access to the :data:`~flask.Flask.config`, +:class:`~flask.request`, :class:`~flask.session` and :class:`~flask.g` [#]_ objects +as well as the :func:`~flask.url_for` and :func:`~flask.get_flashed_messages` functions. + +Templates are especially useful if inheritance is used. If you want to +know how that works, see :doc:`patterns/templateinheritance`. Basically +template inheritance makes it possible to keep certain elements on each +page (like header, navigation and footer). + +Automatic escaping is enabled, so if ``person`` contains HTML it will be escaped +automatically. If you can trust a variable and you know that it will be +safe HTML (for example because it came from a module that converts wiki +markup to HTML) you can mark it as safe by using the +:class:`~markupsafe.Markup` class or by using the ``|safe`` filter in the +template. Head over to the Jinja 2 documentation for more examples. + +Here is a basic introduction to how the :class:`~markupsafe.Markup` class works:: + + >>> from markupsafe import Markup + >>> Markup('Hello %s!') % 'hacker' + Markup('Hello <blink>hacker</blink>!') + >>> Markup.escape('hacker') + Markup('<blink>hacker</blink>') + >>> Markup('Marked up » HTML').striptags() + 'Marked up » HTML' + +.. versionchanged:: 0.5 + + Autoescaping is no longer enabled for all templates. The following + extensions for templates trigger autoescaping: ``.html``, ``.htm``, + ``.xml``, ``.xhtml``. Templates loaded from a string will have + autoescaping disabled. + +.. [#] Unsure what that :class:`~flask.g` object is? It's something in which + you can store information for your own needs. See the documentation + for :class:`flask.g` and :doc:`patterns/sqlite3`. + + +Accessing Request Data +---------------------- + +For web applications it's crucial to react to the data a client sends to the +server. In Flask this information is provided by the global :data:`.request` +object, which is an instance of :class:`.Request`. This object has many +attributes and methods to work with the incoming request data, but here is a +broad overview. First it needs to be imported. + +.. code-block:: python + + from flask import request + +If you have some experience with Python you might be wondering how that object +can be global when Flask handles multiple requests at a time. The answer is +that :data:`.request` is actually a proxy, pointing at whatever request is +currently being handled by a given worker, which is managed interanlly by Flask +and Python. See :doc:`/appcontext` for much more information. + +The current request method is available in the :attr:`~.Request.method` +attribute. To access form data (data transmitted in a ``POST`` or ``PUT`` +request), use the :attr:`~flask.Request.form` attribute, which behaves like a +dict. + +.. code-block:: python + + @app.route("/login", methods=["GET", "POST"]) + def login(): + error = None + + if request.method == "POST": + if valid_login(request.form["username"], request.form["password"]): + return store_login(request.form["username"]) + else: + error = "Invalid username or password" + + # Executed if the request method was GET or the credentials were invalid. + return render_template("login.html", error=error) + +If the key does not exist in ``form``, a special :exc:`KeyError` is raised. You +can catch it like a normal ``KeyError``, otherwise it will return a HTTP 400 +Bad Request error page. You can also use the +:meth:`~werkzeug.datastructures.MultiDict.get` method to get a default +instead of an error. + +To access parameters submitted in the URL (``?key=value``), use the +:attr:`~.Request.args` attribute. Key errors behave the same as ``form``, +returning a 400 response if not caught. + +.. code-block:: python + + searchword = request.args.get('key', '') + +For a full list of methods and attributes of the request object, see the +:class:`~.Request` documentation. + + +File Uploads +```````````` + +You can handle uploaded files with Flask easily. Just make sure not to +forget to set the ``enctype="multipart/form-data"`` attribute on your HTML +form, otherwise the browser will not transmit your files at all. + +Uploaded files are stored in memory or at a temporary location on the +filesystem. You can access those files by looking at the +:attr:`~flask.request.files` attribute on the request object. Each +uploaded file is stored in that dictionary. It behaves just like a +standard Python :class:`file` object, but it also has a +:meth:`~werkzeug.datastructures.FileStorage.save` method that +allows you to store that file on the filesystem of the server. +Here is a simple example showing how that works:: + + from flask import request + + @app.route('/upload', methods=['GET', 'POST']) + def upload_file(): + if request.method == 'POST': + f = request.files['the_file'] + f.save('/var/www/uploads/uploaded_file.txt') + ... + +If you want to know how the file was named on the client before it was +uploaded to your application, you can access the +:attr:`~werkzeug.datastructures.FileStorage.filename` attribute. +However please keep in mind that this value can be forged +so never ever trust that value. If you want to use the filename +of the client to store the file on the server, pass it through the +:func:`~werkzeug.utils.secure_filename` function that +Werkzeug provides for you:: + + from werkzeug.utils import secure_filename + + @app.route('/upload', methods=['GET', 'POST']) + def upload_file(): + if request.method == 'POST': + file = request.files['the_file'] + file.save(f"/var/www/uploads/{secure_filename(file.filename)}") + ... + +For some better examples, see :doc:`patterns/fileuploads`. + +Cookies +``````` + +To access cookies you can use the :attr:`~flask.Request.cookies` +attribute. To set cookies you can use the +:attr:`~flask.Response.set_cookie` method of response objects. The +:attr:`~flask.Request.cookies` attribute of request objects is a +dictionary with all the cookies the client transmits. If you want to use +sessions, do not use the cookies directly but instead use the +:ref:`sessions` in Flask that add some security on top of cookies for you. + +Reading cookies:: + + from flask import request + + @app.route('/') + def index(): + username = request.cookies.get('username') + # use cookies.get(key) instead of cookies[key] to not get a + # KeyError if the cookie is missing. + +Storing cookies:: + + from flask import make_response + + @app.route('/') + def index(): + resp = make_response(render_template(...)) + resp.set_cookie('username', 'the username') + return resp + +Note that cookies are set on response objects. Since you normally +just return strings from the view functions Flask will convert them into +response objects for you. If you explicitly want to do that you can use +the :meth:`~flask.make_response` function and then modify it. + +Sometimes you might want to set a cookie at a point where the response +object does not exist yet. This is possible by utilizing the +:doc:`patterns/deferredcallbacks` pattern. + +For this also see :ref:`about-responses`. + +Redirects and Errors +-------------------- + +To redirect a user to another endpoint, use the :func:`~flask.redirect` +function; to abort a request early with an error code, use the +:func:`~flask.abort` function:: + + from flask import abort, redirect, url_for + + @app.route('/') + def index(): + return redirect(url_for('login')) + + @app.route('/login') + def login(): + abort(401) + this_is_never_executed() + +This is a rather pointless example because a user will be redirected from +the index to a page they cannot access (401 means access denied) but it +shows how that works. + +By default a black and white error page is shown for each error code. If +you want to customize the error page, you can use the +:meth:`~flask.Flask.errorhandler` decorator:: + + from flask import render_template + + @app.errorhandler(404) + def page_not_found(error): + return render_template('page_not_found.html'), 404 + +Note the ``404`` after the :func:`~flask.render_template` call. This +tells Flask that the status code of that page should be 404 which means +not found. By default 200 is assumed which translates to: all went well. + +See :doc:`errorhandling` for more details. + +.. _about-responses: + +About Responses +--------------- + +The return value from a view function is automatically converted into +a response object for you. If the return value is a string it's +converted into a response object with the string as response body, a +``200 OK`` status code and a :mimetype:`text/html` mimetype. If the +return value is a dict or list, :func:`jsonify` is called to produce a +response. The logic that Flask applies to converting return values into +response objects is as follows: + +1. If a response object of the correct type is returned it's directly + returned from the view. +2. If it's a string, a response object is created with that data and + the default parameters. +3. If it's an iterator or generator returning strings or bytes, it is + treated as a streaming response. +4. If it's a dict or list, a response object is created using + :func:`~flask.json.jsonify`. +5. If a tuple is returned the items in the tuple can provide extra + information. Such tuples have to be in the form + ``(response, status)``, ``(response, headers)``, or + ``(response, status, headers)``. The ``status`` value will override + the status code and ``headers`` can be a list or dictionary of + additional header values. +6. If none of that works, Flask will assume the return value is a + valid WSGI application and convert that into a response object. + +If you want to get hold of the resulting response object inside the view +you can use the :func:`~flask.make_response` function. + +Imagine you have a view like this:: + + from flask import render_template + + @app.errorhandler(404) + def not_found(error): + return render_template('error.html'), 404 + +You just need to wrap the return expression with +:func:`~flask.make_response` and get the response object to modify it, then +return it:: + + from flask import make_response + + @app.errorhandler(404) + def not_found(error): + resp = make_response(render_template('error.html'), 404) + resp.headers['X-Something'] = 'A value' + return resp + + +APIs with JSON +`````````````` + +A common response format when writing an API is JSON. It's easy to get +started writing such an API with Flask. If you return a ``dict`` or +``list`` from a view, it will be converted to a JSON response. + +.. code-block:: python + + @app.route("/me") + def me_api(): + user = get_current_user() + return { + "username": user.username, + "theme": user.theme, + "image": url_for("user_image", filename=user.image), + } + + @app.route("/users") + def users_api(): + users = get_all_users() + return [user.to_json() for user in users] + +This is a shortcut to passing the data to the +:func:`~flask.json.jsonify` function, which will serialize any supported +JSON data type. That means that all the data in the dict or list must be +JSON serializable. + +For complex types such as database models, you'll want to use a +serialization library to convert the data to valid JSON types first. +There are many serialization libraries and Flask API extensions +maintained by the community that support more complex applications. + + +.. _sessions: + +Sessions +-------- + +In addition to the request object there is also a second object called +:class:`~flask.session` which allows you to store information specific to a +user from one request to the next. This is implemented on top of cookies +for you and signs the cookies cryptographically. What this means is that +the user could look at the contents of your cookie but not modify it, +unless they know the secret key used for signing. + +In order to use sessions you have to set a secret key. Here is how +sessions work:: + + from flask import session + + # Set the secret key to some random bytes. Keep this really secret! + app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' + + @app.route('/') + def index(): + if 'username' in session: + return f'Logged in as {session["username"]}' + return 'You are not logged in' + + @app.route('/login', methods=['GET', 'POST']) + def login(): + if request.method == 'POST': + session['username'] = request.form['username'] + return redirect(url_for('index')) + return ''' +
+

+

+

+ ''' + + @app.route('/logout') + def logout(): + # remove the username from the session if it's there + session.pop('username', None) + return redirect(url_for('index')) + +.. admonition:: How to generate good secret keys + + A secret key should be as random as possible. Your operating system has + ways to generate pretty random data based on a cryptographic random + generator. Use the following command to quickly generate a value for + :attr:`Flask.secret_key` (or :data:`SECRET_KEY`):: + + $ python -c 'import secrets; print(secrets.token_hex())' + '192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf' + +A note on cookie-based sessions: Flask will take the values you put into the +session object and serialize them into a cookie. If you are finding some +values do not persist across requests, cookies are indeed enabled, and you are +not getting a clear error message, check the size of the cookie in your page +responses compared to the size supported by web browsers. + +Besides the default client-side based sessions, if you want to handle +sessions on the server-side instead, there are several +Flask extensions that support this. + +Message Flashing +---------------- + +Good applications and user interfaces are all about feedback. If the user +does not get enough feedback they will probably end up hating the +application. Flask provides a really simple way to give feedback to a +user with the flashing system. The flashing system basically makes it +possible to record a message at the end of a request and access it on the next +(and only the next) request. This is usually combined with a layout +template to expose the message. + +To flash a message use the :func:`~flask.flash` method, to get hold of the +messages you can use :func:`~flask.get_flashed_messages` which is also +available in the templates. See :doc:`patterns/flashing` for a full +example. + +Logging +------- + +.. versionadded:: 0.3 + +Sometimes you might be in a situation where you deal with data that +should be correct, but actually is not. For example you may have +some client-side code that sends an HTTP request to the server +but it's obviously malformed. This might be caused by a user tampering +with the data, or the client code failing. Most of the time it's okay +to reply with ``400 Bad Request`` in that situation, but sometimes +that won't do and the code has to continue working. + +You may still want to log that something fishy happened. This is where +loggers come in handy. As of Flask 0.3 a logger is preconfigured for you +to use. + +Here are some example log calls:: + + app.logger.debug('A value for debugging') + app.logger.warning('A warning occurred (%d apples)', 42) + app.logger.error('An error occurred') + +The attached :attr:`~flask.Flask.logger` is a standard logging +:class:`~logging.Logger`, so head over to the official :mod:`logging` +docs for more information. + +See :doc:`errorhandling`. + + +Hooking in WSGI Middleware +-------------------------- + +To add WSGI middleware to your Flask application, wrap the application's +``wsgi_app`` attribute. For example, to apply Werkzeug's +:class:`~werkzeug.middleware.proxy_fix.ProxyFix` middleware for running +behind Nginx: + +.. code-block:: python + + from werkzeug.middleware.proxy_fix import ProxyFix + app.wsgi_app = ProxyFix(app.wsgi_app) + +Wrapping ``app.wsgi_app`` instead of ``app`` means that ``app`` still +points at your Flask application, not at the middleware, so you can +continue to use and configure ``app`` directly. + +Using Flask Extensions +---------------------- + +Extensions are packages that help you accomplish common tasks. For +example, Flask-SQLAlchemy provides SQLAlchemy support that makes it simple +and easy to use with Flask. + +For more on Flask extensions, see :doc:`extensions`. + +Deploying to a Web Server +------------------------- + +Ready to deploy your new Flask app? See :doc:`deploying/index`. diff --git a/src/flask-main/docs/reqcontext.rst b/src/flask-main/docs/reqcontext.rst new file mode 100644 index 0000000..6660671 --- /dev/null +++ b/src/flask-main/docs/reqcontext.rst @@ -0,0 +1,6 @@ +:orphan: + +The Request Context +=================== + +Obsolete, see :doc:`/appcontext` instead. diff --git a/src/flask-main/docs/server.rst b/src/flask-main/docs/server.rst new file mode 100644 index 0000000..d6beb1d --- /dev/null +++ b/src/flask-main/docs/server.rst @@ -0,0 +1,115 @@ +.. currentmodule:: flask + +Development Server +================== + +Flask provides a ``run`` command to run the application with a development server. In +debug mode, this server provides an interactive debugger and will reload when code is +changed. + +.. warning:: + + Do not use the development server when deploying to production. It + is intended for use only during local development. It is not + designed to be particularly efficient, stable, or secure. + + See :doc:`/deploying/index` for deployment options. + +Command Line +------------ + +The ``flask run`` CLI command is the recommended way to run the development server. Use +the ``--app`` option to point to your application, and the ``--debug`` option to enable +debug mode. + +.. code-block:: text + + $ flask --app hello run --debug + +This enables debug mode, including the interactive debugger and reloader, and then +starts the server on http://localhost:5000/. Use ``flask run --help`` to see the +available options, and :doc:`/cli` for detailed instructions about configuring and using +the CLI. + + +.. _address-already-in-use: + +Address already in use +~~~~~~~~~~~~~~~~~~~~~~ + +If another program is already using port 5000, you'll see an ``OSError`` +when the server tries to start. It may have one of the following +messages: + +- ``OSError: [Errno 98] Address already in use`` +- ``OSError: [WinError 10013] An attempt was made to access a socket + in a way forbidden by its access permissions`` + +Either identify and stop the other program, or use +``flask run --port 5001`` to pick a different port. + +You can use ``netstat`` or ``lsof`` to identify what process id is using +a port, then use other operating system tools stop that process. The +following example shows that process id 6847 is using port 5000. + +.. tabs:: + + .. tab:: ``netstat`` (Linux) + + .. code-block:: text + + $ netstat -nlp | grep 5000 + tcp 0 0 127.0.0.1:5000 0.0.0.0:* LISTEN 6847/python + + .. tab:: ``lsof`` (macOS / Linux) + + .. code-block:: text + + $ lsof -P -i :5000 + Python 6847 IPv4 TCP localhost:5000 (LISTEN) + + .. tab:: ``netstat`` (Windows) + + .. code-block:: text + + > netstat -ano | findstr 5000 + TCP 127.0.0.1:5000 0.0.0.0:0 LISTENING 6847 + +macOS Monterey and later automatically starts a service that uses port +5000. You can choose to disable this service instead of using a different port by +searching for "AirPlay Receiver" in System Settings and toggling it off. + + +Deferred Errors on Reload +~~~~~~~~~~~~~~~~~~~~~~~~~ + +When using the ``flask run`` command with the reloader, the server will +continue to run even if you introduce syntax errors or other +initialization errors into the code. Accessing the site will show the +interactive debugger for the error, rather than crashing the server. + +If a syntax error is already present when calling ``flask run``, it will +fail immediately and show the traceback rather than waiting until the +site is accessed. This is intended to make errors more visible initially +while still allowing the server to handle errors on reload. + + +In Code +------- + +The development server can also be started from Python with the :meth:`Flask.run` +method. This method takes arguments similar to the CLI options to control the server. +The main difference from the CLI command is that the server will crash if there are +errors when reloading. ``debug=True`` can be passed to enable debug mode. + +Place the call in a main block, otherwise it will interfere when trying to import and +run the application with a production server later. + +.. code-block:: python + + if __name__ == "__main__": + app.run(debug=True) + +.. code-block:: text + + $ python hello.py diff --git a/src/flask-main/docs/shell.rst b/src/flask-main/docs/shell.rst new file mode 100644 index 0000000..d8821e2 --- /dev/null +++ b/src/flask-main/docs/shell.rst @@ -0,0 +1,81 @@ +Working with the Shell +====================== + +One of the reasons everybody loves Python is the interactive shell. It allows +you to play around with code in real time and immediately get results back. +Flask provides the ``flask shell`` CLI command to start an interactive Python +shell with some setup done to make working with the Flask app easier. + +.. code-block:: text + + $ flask shell + +Creating a Request Context +-------------------------- + +``flask shell`` pushes an app context automatically, so :data:`.current_app` and +:data:`.g` are already available. However, there is no HTTP request being +handled in the shell, so :data:`.request` and :data:`.session` are not yet +available. + +The easiest way to create a proper request context from the shell is by +using the :attr:`~flask.Flask.test_request_context` method which creates +us a :class:`~flask.ctx.RequestContext`: + +>>> ctx = app.test_request_context() + +Normally you would use the ``with`` statement to make this context active, but +in the shell it's easier to call :meth:`~.RequestContext.push` and +:meth:`~.RequestContext.pop` manually: + +>>> ctx.push() + +From that point onwards you can work with the request object until you call +``pop``: + +>>> ctx.pop() + +Firing Before/After Request +--------------------------- + +By just creating a request context, you still don't have run the code that +is normally run before a request. This might result in your database +being unavailable if you are connecting to the database in a +before-request callback or the current user not being stored on the +:data:`~flask.g` object etc. + +This however can easily be done yourself. Just call +:meth:`~flask.Flask.preprocess_request`: + +>>> ctx = app.test_request_context() +>>> ctx.push() +>>> app.preprocess_request() + +Keep in mind that the :meth:`~flask.Flask.preprocess_request` function +might return a response object, in that case just ignore it. + +To shutdown a request, you need to trick a bit before the after request +functions (triggered by :meth:`~flask.Flask.process_response`) operate on +a response object: + +>>> app.process_response(app.response_class()) + +>>> ctx.pop() + +The functions registered as :meth:`~flask.Flask.teardown_request` are +automatically called when the context is popped. So this is the perfect +place to automatically tear down resources that were needed by the request +context (such as database connections). + + +Further Improving the Shell Experience +-------------------------------------- + +If you like the idea of experimenting in a shell, create yourself a module +with stuff you want to star import into your interactive session. There +you could also define some more helper methods for common things such as +initializing the database, dropping tables etc. + +Just put them into a module (like `shelltools`) and import from there: + +>>> from shelltools import * diff --git a/src/flask-main/docs/signals.rst b/src/flask-main/docs/signals.rst new file mode 100644 index 0000000..7ca81a9 --- /dev/null +++ b/src/flask-main/docs/signals.rst @@ -0,0 +1,166 @@ +Signals +======= + +Signals are a lightweight way to notify subscribers of certain events during the +lifecycle of the application and each request. When an event occurs, it emits the +signal, which calls each subscriber. + +Signals are implemented by the `Blinker`_ library. See its documentation for detailed +information. Flask provides some built-in signals. Extensions may provide their own. + +Many signals mirror Flask's decorator-based callbacks with similar names. For example, +the :data:`.request_started` signal is similar to the :meth:`~.Flask.before_request` +decorator. The advantage of signals over handlers is that they can be subscribed to +temporarily, and can't directly affect the application. This is useful for testing, +metrics, auditing, and more. For example, if you want to know what templates were +rendered at what parts of what requests, there is a signal that will notify you of that +information. + + +Core Signals +------------ + +See :ref:`core-signals-list` for a list of all built-in signals. The :doc:`lifecycle` +page also describes the order that signals and decorators execute. + + +Subscribing to Signals +---------------------- + +To subscribe to a signal, you can use the +:meth:`~blinker.base.Signal.connect` method of a signal. The first +argument is the function that should be called when the signal is emitted, +the optional second argument specifies a sender. To unsubscribe from a +signal, you can use the :meth:`~blinker.base.Signal.disconnect` method. + +For all core Flask signals, the sender is the application that issued the +signal. When you subscribe to a signal, be sure to also provide a sender +unless you really want to listen for signals from all applications. This is +especially true if you are developing an extension. + +For example, here is a helper context manager that can be used in a unit test +to determine which templates were rendered and what variables were passed +to the template:: + + from flask import template_rendered + from contextlib import contextmanager + + @contextmanager + def captured_templates(app): + recorded = [] + def record(sender, template, context, **extra): + recorded.append((template, context)) + template_rendered.connect(record, app) + try: + yield recorded + finally: + template_rendered.disconnect(record, app) + +This can now easily be paired with a test client:: + + with captured_templates(app) as templates: + rv = app.test_client().get('/') + assert rv.status_code == 200 + assert len(templates) == 1 + template, context = templates[0] + assert template.name == 'index.html' + assert len(context['items']) == 10 + +Make sure to subscribe with an extra ``**extra`` argument so that your +calls don't fail if Flask introduces new arguments to the signals. + +All the template rendering in the code issued by the application `app` +in the body of the ``with`` block will now be recorded in the `templates` +variable. Whenever a template is rendered, the template object as well as +context are appended to it. + +Additionally there is a convenient helper method +(:meth:`~blinker.base.Signal.connected_to`) that allows you to +temporarily subscribe a function to a signal with a context manager on +its own. Because the return value of the context manager cannot be +specified that way, you have to pass the list in as an argument:: + + from flask import template_rendered + + def captured_templates(app, recorded, **extra): + def record(sender, template, context): + recorded.append((template, context)) + return template_rendered.connected_to(record, app) + +The example above would then look like this:: + + templates = [] + with captured_templates(app, templates, **extra): + ... + template, context = templates[0] + +Creating Signals +---------------- + +If you want to use signals in your own application, you can use the +blinker library directly. The most common use case are named signals in a +custom :class:`~blinker.base.Namespace`. This is what is recommended +most of the time:: + + from blinker import Namespace + my_signals = Namespace() + +Now you can create new signals like this:: + + model_saved = my_signals.signal('model-saved') + +The name for the signal here makes it unique and also simplifies +debugging. You can access the name of the signal with the +:attr:`~blinker.base.NamedSignal.name` attribute. + +.. _signals-sending: + +Sending Signals +--------------- + +If you want to emit a signal, you can do so by calling the +:meth:`~blinker.base.Signal.send` method. It accepts a sender as first +argument and optionally some keyword arguments that are forwarded to the +signal subscribers:: + + class Model(object): + ... + + def save(self): + model_saved.send(self) + +Try to always pick a good sender. If you have a class that is emitting a +signal, pass ``self`` as sender. If you are emitting a signal from a random +function, you can pass ``current_app._get_current_object()`` as sender. + +.. admonition:: Passing Proxies as Senders + + Never pass :data:`~flask.current_app` as sender to a signal. Use + ``current_app._get_current_object()`` instead. The reason for this is + that :data:`~flask.current_app` is a proxy and not the real application + object. + + +Signals and Flask's Request Context +----------------------------------- + +Context-local proxies are available between :data:`~flask.request_started` and +:data:`~flask.request_finished`, so you can rely on :class:`flask.g` and others +as needed. Note the limitations described in :ref:`signals-sending` and the +:data:`~flask.request_tearing_down` signal. + + +Decorator Based Signal Subscriptions +------------------------------------ + +You can also easily subscribe to signals by using the +:meth:`~blinker.base.NamedSignal.connect_via` decorator:: + + from flask import template_rendered + + @template_rendered.connect_via(app) + def when_template_rendered(sender, template, context, **extra): + print(f'Template {template.name} is rendered with {context}') + + +.. _blinker: https://pypi.org/project/blinker/ diff --git a/src/flask-main/docs/templating.rst b/src/flask-main/docs/templating.rst new file mode 100644 index 0000000..1bde59d --- /dev/null +++ b/src/flask-main/docs/templating.rst @@ -0,0 +1,255 @@ +Templates +========= + +Flask leverages Jinja as its template engine. You are obviously free to use +a different template engine, but you still have to install Jinja to run +Flask itself. This requirement is necessary to enable rich extensions. +An extension can depend on Jinja being present. + +This section only gives a very quick introduction into how Jinja +is integrated into Flask. If you want information on the template +engine's syntax itself, head over to the official `Jinja Template +Documentation `_ for +more information. + +Jinja Setup +----------- + +Unless customized, Jinja is configured by Flask as follows: + +- autoescaping is enabled for all templates ending in ``.html``, + ``.htm``, ``.xml``, ``.xhtml``, as well as ``.svg`` when using + :func:`~flask.templating.render_template`. +- autoescaping is enabled for all strings when using + :func:`~flask.templating.render_template_string`. +- a template has the ability to opt in/out autoescaping with the + ``{% autoescape %}`` tag. +- Flask inserts a couple of global functions and helpers into the + Jinja context, additionally to the values that are present by + default. + +Standard Context +---------------- + +The following global variables are available within Jinja templates +by default: + +.. data:: config + :noindex: + + The current configuration object (:data:`flask.Flask.config`) + + .. versionadded:: 0.6 + + .. versionchanged:: 0.10 + This is now always available, even in imported templates. + +.. data:: request + :noindex: + + The current request object (:class:`flask.request`). This variable is + unavailable if the template was rendered without an active request + context. + +.. data:: session + :noindex: + + The current session object (:class:`flask.session`). This variable + is unavailable if the template was rendered without an active request + context. + +.. data:: g + :noindex: + + The request-bound object for global variables (:data:`flask.g`). This + variable is unavailable if the template was rendered without an active + request context. + +.. function:: url_for + :noindex: + + The :func:`flask.url_for` function. + +.. function:: get_flashed_messages + :noindex: + + The :func:`flask.get_flashed_messages` function. + +.. admonition:: The Jinja Context Behavior + + These variables are added to the context of variables, they are not + global variables. The difference is that by default these will not + show up in the context of imported templates. This is partially caused + by performance considerations, partially to keep things explicit. + + What does this mean for you? If you have a macro you want to import, + that needs to access the request object you have two possibilities: + + 1. you explicitly pass the request to the macro as parameter, or + the attribute of the request object you are interested in. + 2. you import the macro "with context". + + Importing with context looks like this: + + .. sourcecode:: jinja + + {% from '_helpers.html' import my_macro with context %} + + +Controlling Autoescaping +------------------------ + +Autoescaping is the concept of automatically escaping special characters +for you. Special characters in the sense of HTML (or XML, and thus XHTML) +are ``&``, ``>``, ``<``, ``"`` as well as ``'``. Because these characters +carry specific meanings in documents on their own you have to replace them +by so called "entities" if you want to use them for text. Not doing so +would not only cause user frustration by the inability to use these +characters in text, but can also lead to security problems. (see +:ref:`security-xss`) + +Sometimes however you will need to disable autoescaping in templates. +This can be the case if you want to explicitly inject HTML into pages, for +example if they come from a system that generates secure HTML like a +markdown to HTML converter. + +There are three ways to accomplish that: + +- In the Python code, wrap the HTML string in a :class:`~markupsafe.Markup` + object before passing it to the template. This is in general the + recommended way. +- Inside the template, use the ``|safe`` filter to explicitly mark a + string as safe HTML (``{{ myvariable|safe }}``) +- Temporarily disable the autoescape system altogether. + +To disable the autoescape system in templates, you can use the ``{% +autoescape %}`` block: + +.. sourcecode:: html+jinja + + {% autoescape false %} +

autoescaping is disabled here +

{{ will_not_be_escaped }} + {% endautoescape %} + +Whenever you do this, please be very cautious about the variables you are +using in this block. + +.. _registering-filters: + +Registering Filters, Tests, and Globals +--------------------------------------- + +The Flask app and blueprints provide decorators and methods to register your own +filters, tests, and global functions for use in Jinja templates. They all follow +the same pattern, so the following examples only discuss filters. + +Decorate a function with :meth:`~.Flask.template_filter` to register it as a +template filter. + +.. code-block:: python + + @app.template_filter + def reverse(s): + return reversed(s) + +.. code-block:: jinja + + {% for item in data | reverse %} + {% endfor %} + +By default it will use the name of the function as the name of the filter, but +that can be changed by passing a name to the decorator. + +.. code-block:: python + + @app.template_filter("reverse") + def reverse_filter(s): + return reversed(s) + +A filter can be registered separately using :meth:`~.Flask.add_template_filter`. +The name is optional and will use the function name if not given. + +.. code-block:: python + + def reverse_filter(s): + return reversed(s) + + app.add_template_filter(reverse_filter, "reverse") + +For template tests, use the :meth:`~.Flask.template_test` decorator or +:meth:`~.Flask.add_template_test` method. For template global functions, use the +:meth:`~.Flask.template_global` decorator or :meth:`~.Flask.add_template_global` +method. + +The same methods also exist on :class:`.Blueprint`, prefixed with ``app_`` to +indicate that the registered functions will be avaialble to all templates, not +only when rendering from within the blueprint. + +The Jinja environment is also available as :attr:`~.Flask.jinja_env`. It may be +modified directly, as you would when using Jinja outside Flask. + + +Context Processors +------------------ + +To inject new variables automatically into the context of a template, +context processors exist in Flask. Context processors run before the +template is rendered and have the ability to inject new values into the +template context. A context processor is a function that returns a +dictionary. The keys and values of this dictionary are then merged with +the template context, for all templates in the app:: + + @app.context_processor + def inject_user(): + return dict(user=g.user) + +The context processor above makes a variable called `user` available in +the template with the value of `g.user`. This example is not very +interesting because `g` is available in templates anyways, but it gives an +idea how this works. + +Variables are not limited to values; a context processor can also make +functions available to templates (since Python allows passing around +functions):: + + @app.context_processor + def utility_processor(): + def format_price(amount, currency="€"): + return f"{amount:.2f}{currency}" + return dict(format_price=format_price) + +The context processor above makes the `format_price` function available to all +templates:: + + {{ format_price(0.33) }} + +You could also build `format_price` as a template filter (see +:ref:`registering-filters`), but this demonstrates how to pass functions in a +context processor. + +Streaming +--------- + +It can be useful to not render the whole template as one complete +string, instead render it as a stream, yielding smaller incremental +strings. This can be used for streaming HTML in chunks to speed up +initial page load, or to save memory when rendering a very large +template. + +The Jinja template engine supports rendering a template piece +by piece, returning an iterator of strings. Flask provides the +:func:`~flask.stream_template` and :func:`~flask.stream_template_string` +functions to make this easier to use. + +.. code-block:: python + + from flask import stream_template + + @app.get("/timeline") + def timeline(): + return stream_template("timeline.html") + +These functions automatically apply the +:func:`~flask.stream_with_context` wrapper if a request is active, so +that it remains available in the template. diff --git a/src/flask-main/docs/testing.rst b/src/flask-main/docs/testing.rst new file mode 100644 index 0000000..c171abd --- /dev/null +++ b/src/flask-main/docs/testing.rst @@ -0,0 +1,318 @@ +Testing Flask Applications +========================== + +Flask provides utilities for testing an application. This documentation +goes over techniques for working with different parts of the application +in tests. + +We will use the `pytest`_ framework to set up and run our tests. + +.. code-block:: text + + $ pip install pytest + +.. _pytest: https://docs.pytest.org/ + +The :doc:`tutorial ` goes over how to write tests for +100% coverage of the sample Flaskr blog application. See +:doc:`the tutorial on tests ` for a detailed +explanation of specific tests for an application. + + +Identifying Tests +----------------- + +Tests are typically located in the ``tests`` folder. Tests are functions +that start with ``test_``, in Python modules that start with ``test_``. +Tests can also be further grouped in classes that start with ``Test``. + +It can be difficult to know what to test. Generally, try to test the +code that you write, not the code of libraries that you use, since they +are already tested. Try to extract complex behaviors as separate +functions to test individually. + + +Fixtures +-------- + +Pytest *fixtures* allow writing pieces of code that are reusable across +tests. A simple fixture returns a value, but a fixture can also do +setup, yield a value, then do teardown. Fixtures for the application, +test client, and CLI runner are shown below, they can be placed in +``tests/conftest.py``. + +If you're using an +:doc:`application factory `, define an ``app`` +fixture to create and configure an app instance. You can add code before +and after the ``yield`` to set up and tear down other resources, such as +creating and clearing a database. + +If you're not using a factory, you already have an app object you can +import and configure directly. You can still use an ``app`` fixture to +set up and tear down resources. + +.. code-block:: python + + import pytest + from my_project import create_app + + @pytest.fixture() + def app(): + app = create_app() + app.config.update({ + "TESTING": True, + }) + + # other setup can go here + + yield app + + # clean up / reset resources here + + + @pytest.fixture() + def client(app): + return app.test_client() + + + @pytest.fixture() + def runner(app): + return app.test_cli_runner() + + +Sending Requests with the Test Client +------------------------------------- + +The test client makes requests to the application without running a live +server. Flask's client extends +:doc:`Werkzeug's client `, see those docs for additional +information. + +The ``client`` has methods that match the common HTTP request methods, +such as ``client.get()`` and ``client.post()``. They take many arguments +for building the request; you can find the full documentation in +:class:`~werkzeug.test.EnvironBuilder`. Typically you'll use ``path``, +``query_string``, ``headers``, and ``data`` or ``json``. + +To make a request, call the method the request should use with the path +to the route to test. A :class:`~werkzeug.test.TestResponse` is returned +to examine the response data. It has all the usual properties of a +response object. You'll usually look at ``response.data``, which is the +bytes returned by the view. If you want to use text, Werkzeug 2.1 +provides ``response.text``, or use ``response.get_data(as_text=True)``. + +.. code-block:: python + + def test_request_example(client): + response = client.get("/posts") + assert b"

Hello, World!

" in response.data + + +Pass a dict ``query_string={"key": "value", ...}`` to set arguments in +the query string (after the ``?`` in the URL). Pass a dict +``headers={}`` to set request headers. + +To send a request body in a POST or PUT request, pass a value to +``data``. If raw bytes are passed, that exact body is used. Usually, +you'll pass a dict to set form data. + + +Form Data +~~~~~~~~~ + +To send form data, pass a dict to ``data``. The ``Content-Type`` header +will be set to ``multipart/form-data`` or +``application/x-www-form-urlencoded`` automatically. + +If a value is a file object opened for reading bytes (``"rb"`` mode), it +will be treated as an uploaded file. To change the detected filename and +content type, pass a ``(file, filename, content_type)`` tuple. File +objects will be closed after making the request, so they do not need to +use the usual ``with open() as f:`` pattern. + +It can be useful to store files in a ``tests/resources`` folder, then +use ``pathlib.Path`` to get files relative to the current test file. + +.. code-block:: python + + from pathlib import Path + + # get the resources folder in the tests folder + resources = Path(__file__).parent / "resources" + + def test_edit_user(client): + response = client.post("/user/2/edit", data={ + "name": "Flask", + "theme": "dark", + "picture": (resources / "picture.png").open("rb"), + }) + assert response.status_code == 200 + + +JSON Data +~~~~~~~~~ + +To send JSON data, pass an object to ``json``. The ``Content-Type`` +header will be set to ``application/json`` automatically. + +Similarly, if the response contains JSON data, the ``response.json`` +attribute will contain the deserialized object. + +.. code-block:: python + + def test_json_data(client): + response = client.post("/graphql", json={ + "query": """ + query User($id: String!) { + user(id: $id) { + name + theme + picture_url + } + } + """, + variables={"id": 2}, + }) + assert response.json["data"]["user"]["name"] == "Flask" + + +Following Redirects +------------------- + +By default, the client does not make additional requests if the response +is a redirect. By passing ``follow_redirects=True`` to a request method, +the client will continue to make requests until a non-redirect response +is returned. + +:attr:`TestResponse.history ` is +a tuple of the responses that led up to the final response. Each +response has a :attr:`~werkzeug.test.TestResponse.request` attribute +which records the request that produced that response. + +.. code-block:: python + + def test_logout_redirect(client): + response = client.get("/logout", follow_redirects=True) + # Check that there was one redirect response. + assert len(response.history) == 1 + # Check that the second request was to the index page. + assert response.request.path == "/index" + + +Accessing and Modifying the Session +----------------------------------- + +To access Flask's context variables, mainly +:data:`~flask.session`, use the client in a ``with`` statement. +The app and request context will remain active *after* making a request, +until the ``with`` block ends. + +.. code-block:: python + + from flask import session + + def test_access_session(client): + with client: + client.post("/auth/login", data={"username": "flask"}) + # session is still accessible + assert session["user_id"] == 1 + + # session is no longer accessible + +If you want to access or set a value in the session *before* making a +request, use the client's +:meth:`~flask.testing.FlaskClient.session_transaction` method in a +``with`` statement. It returns a session object, and will save the +session once the block ends. + +.. code-block:: python + + from flask import session + + def test_modify_session(client): + with client.session_transaction() as session: + # set a user id without going through the login route + session["user_id"] = 1 + + # session is saved now + + response = client.get("/users/me") + assert response.json["username"] == "flask" + + +.. _testing-cli: + +Running Commands with the CLI Runner +------------------------------------ + +Flask provides :meth:`~flask.Flask.test_cli_runner` to create a +:class:`~flask.testing.FlaskCliRunner`, which runs CLI commands in +isolation and captures the output in a :class:`~click.testing.Result` +object. Flask's runner extends :doc:`Click's runner `, +see those docs for additional information. + +Use the runner's :meth:`~flask.testing.FlaskCliRunner.invoke` method to +call commands in the same way they would be called with the ``flask`` +command from the command line. + +.. code-block:: python + + import click + + @app.cli.command("hello") + @click.option("--name", default="World") + def hello_command(name): + click.echo(f"Hello, {name}!") + + def test_hello_command(runner): + result = runner.invoke(args="hello") + assert "World" in result.output + + result = runner.invoke(args=["hello", "--name", "Flask"]) + assert "Flask" in result.output + + +Tests that depend on an Active Context +-------------------------------------- + +You may have functions that are called from views or commands, that expect an +active :doc:`app context ` because they access :data:`.request`, +:data:`.session`, :data:`.g`, or :data:`.current_app`. Rather than testing them by +making a request or invoking the command, you can create and activate a context +directly. + +Use ``with app.app_context()`` to push an application context. For +example, database extensions usually require an active app context to +make queries. + +.. code-block:: python + + def test_db_post_model(app): + with app.app_context(): + post = db.session.query(Post).get(1) + +Use ``with app.test_request_context()`` to push a request context. It +takes the same arguments as the test client's request methods. + +.. code-block:: python + + def test_validate_user_edit(app): + with app.test_request_context( + "/user/2/edit", method="POST", data={"name": ""} + ): + # call a function that accesses `request` + messages = validate_edit_user() + + assert messages["name"][0] == "Name cannot be empty." + +Creating a test request context doesn't run any of the Flask dispatching +code, so ``before_request`` functions are not called. If you need to +call these, usually it's better to make a full request instead. However, +it's possible to call them manually. + +.. code-block:: python + + def test_auth_token(app): + with app.test_request_context("/user/2/edit", headers={"X-Auth-Token": "1"}): + app.preprocess_request() + assert g.user.name == "Flask" diff --git a/src/flask-main/docs/tutorial/blog.rst b/src/flask-main/docs/tutorial/blog.rst new file mode 100644 index 0000000..6418f5f --- /dev/null +++ b/src/flask-main/docs/tutorial/blog.rst @@ -0,0 +1,336 @@ +.. currentmodule:: flask + +Blog Blueprint +============== + +You'll use the same techniques you learned about when writing the +authentication blueprint to write the blog blueprint. The blog should +list all posts, allow logged in users to create posts, and allow the +author of a post to edit or delete it. + +As you implement each view, keep the development server running. As you +save your changes, try going to the URL in your browser and testing them +out. + +The Blueprint +------------- + +Define the blueprint and register it in the application factory. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + from flask import ( + Blueprint, flash, g, redirect, render_template, request, url_for + ) + from werkzeug.exceptions import abort + + from flaskr.auth import login_required + from flaskr.db import get_db + + bp = Blueprint('blog', __name__) + +Import and register the blueprint from the factory using +:meth:`app.register_blueprint() `. Place the +new code at the end of the factory function before returning the app. + +.. code-block:: python + :caption: ``flaskr/__init__.py`` + + def create_app(): + app = ... + # existing code omitted + + from . import blog + app.register_blueprint(blog.bp) + app.add_url_rule('/', endpoint='index') + + return app + + +Unlike the auth blueprint, the blog blueprint does not have a +``url_prefix``. So the ``index`` view will be at ``/``, the ``create`` +view at ``/create``, and so on. The blog is the main feature of Flaskr, +so it makes sense that the blog index will be the main index. + +However, the endpoint for the ``index`` view defined below will be +``blog.index``. Some of the authentication views referred to a plain +``index`` endpoint. :meth:`app.add_url_rule() ` +associates the endpoint name ``'index'`` with the ``/`` url so that +``url_for('index')`` or ``url_for('blog.index')`` will both work, +generating the same ``/`` URL either way. + +In another application you might give the blog blueprint a +``url_prefix`` and define a separate ``index`` view in the application +factory, similar to the ``hello`` view. Then the ``index`` and +``blog.index`` endpoints and URLs would be different. + + +Index +----- + +The index will show all of the posts, most recent first. A ``JOIN`` is +used so that the author information from the ``user`` table is +available in the result. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + @bp.route('/') + def index(): + db = get_db() + posts = db.execute( + 'SELECT p.id, title, body, created, author_id, username' + ' FROM post p JOIN user u ON p.author_id = u.id' + ' ORDER BY created DESC' + ).fetchall() + return render_template('blog/index.html', posts=posts) + +.. code-block:: html+jinja + :caption: ``flaskr/templates/blog/index.html`` + + {% extends 'base.html' %} + + {% block header %} +

{% block title %}Posts{% endblock %}

+ {% if g.user %} + New + {% endif %} + {% endblock %} + + {% block content %} + {% for post in posts %} +
+
+
+

{{ post['title'] }}

+
by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}
+
+ {% if g.user['id'] == post['author_id'] %} + Edit + {% endif %} +
+

{{ post['body'] }}

+
+ {% if not loop.last %} +
+ {% endif %} + {% endfor %} + {% endblock %} + +When a user is logged in, the ``header`` block adds a link to the +``create`` view. When the user is the author of a post, they'll see an +"Edit" link to the ``update`` view for that post. ``loop.last`` is a +special variable available inside `Jinja for loops`_. It's used to +display a line after each post except the last one, to visually separate +them. + +.. _Jinja for loops: https://jinja.palletsprojects.com/templates/#for + + +Create +------ + +The ``create`` view works the same as the auth ``register`` view. Either +the form is displayed, or the posted data is validated and the post is +added to the database or an error is shown. + +The ``login_required`` decorator you wrote earlier is used on the blog +views. A user must be logged in to visit these views, otherwise they +will be redirected to the login page. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + @bp.route('/create', methods=('GET', 'POST')) + @login_required + def create(): + if request.method == 'POST': + title = request.form['title'] + body = request.form['body'] + error = None + + if not title: + error = 'Title is required.' + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + 'INSERT INTO post (title, body, author_id)' + ' VALUES (?, ?, ?)', + (title, body, g.user['id']) + ) + db.commit() + return redirect(url_for('blog.index')) + + return render_template('blog/create.html') + +.. code-block:: html+jinja + :caption: ``flaskr/templates/blog/create.html`` + + {% extends 'base.html' %} + + {% block header %} +

{% block title %}New Post{% endblock %}

+ {% endblock %} + + {% block content %} +
+ + + + + +
+ {% endblock %} + + +Update +------ + +Both the ``update`` and ``delete`` views will need to fetch a ``post`` +by ``id`` and check if the author matches the logged in user. To avoid +duplicating code, you can write a function to get the ``post`` and call +it from each view. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + def get_post(id, check_author=True): + post = get_db().execute( + 'SELECT p.id, title, body, created, author_id, username' + ' FROM post p JOIN user u ON p.author_id = u.id' + ' WHERE p.id = ?', + (id,) + ).fetchone() + + if post is None: + abort(404, f"Post id {id} doesn't exist.") + + if check_author and post['author_id'] != g.user['id']: + abort(403) + + return post + +:func:`abort` will raise a special exception that returns an HTTP status +code. It takes an optional message to show with the error, otherwise a +default message is used. ``404`` means "Not Found", and ``403`` means +"Forbidden". (``401`` means "Unauthorized", but you redirect to the +login page instead of returning that status.) + +The ``check_author`` argument is defined so that the function can be +used to get a ``post`` without checking the author. This would be useful +if you wrote a view to show an individual post on a page, where the user +doesn't matter because they're not modifying the post. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + @bp.route('//update', methods=('GET', 'POST')) + @login_required + def update(id): + post = get_post(id) + + if request.method == 'POST': + title = request.form['title'] + body = request.form['body'] + error = None + + if not title: + error = 'Title is required.' + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + 'UPDATE post SET title = ?, body = ?' + ' WHERE id = ?', + (title, body, id) + ) + db.commit() + return redirect(url_for('blog.index')) + + return render_template('blog/update.html', post=post) + +Unlike the views you've written so far, the ``update`` function takes +an argument, ``id``. That corresponds to the ```` in the route. +A real URL will look like ``/1/update``. Flask will capture the ``1``, +ensure it's an :class:`int`, and pass it as the ``id`` argument. If you +don't specify ``int:`` and instead do ````, it will be a string. +To generate a URL to the update page, :func:`url_for` needs to be passed +the ``id`` so it knows what to fill in: +``url_for('blog.update', id=post['id'])``. This is also in the +``index.html`` file above. + +The ``create`` and ``update`` views look very similar. The main +difference is that the ``update`` view uses a ``post`` object and an +``UPDATE`` query instead of an ``INSERT``. With some clever refactoring, +you could use one view and template for both actions, but for the +tutorial it's clearer to keep them separate. + +.. code-block:: html+jinja + :caption: ``flaskr/templates/blog/update.html`` + + {% extends 'base.html' %} + + {% block header %} +

{% block title %}Edit "{{ post['title'] }}"{% endblock %}

+ {% endblock %} + + {% block content %} +
+ + + + + +
+
+
+ +
+ {% endblock %} + +This template has two forms. The first posts the edited data to the +current page (``//update``). The other form contains only a button +and specifies an ``action`` attribute that posts to the delete view +instead. The button uses some JavaScript to show a confirmation dialog +before submitting. + +The pattern ``{{ request.form['title'] or post['title'] }}`` is used to +choose what data appears in the form. When the form hasn't been +submitted, the original ``post`` data appears, but if invalid form data +was posted you want to display that so the user can fix the error, so +``request.form`` is used instead. :data:`.request` is another variable +that's automatically available in templates. + + +Delete +------ + +The delete view doesn't have its own template, the delete button is part +of ``update.html`` and posts to the ``//delete`` URL. Since there +is no template, it will only handle the ``POST`` method and then redirect +to the ``index`` view. + +.. code-block:: python + :caption: ``flaskr/blog.py`` + + @bp.route('//delete', methods=('POST',)) + @login_required + def delete(id): + get_post(id) + db = get_db() + db.execute('DELETE FROM post WHERE id = ?', (id,)) + db.commit() + return redirect(url_for('blog.index')) + +Congratulations, you've now finished writing your application! Take some +time to try out everything in the browser. However, there's still more +to do before the project is complete. + +Continue to :doc:`install`. diff --git a/src/flask-main/docs/tutorial/database.rst b/src/flask-main/docs/tutorial/database.rst new file mode 100644 index 0000000..cf13260 --- /dev/null +++ b/src/flask-main/docs/tutorial/database.rst @@ -0,0 +1,219 @@ +.. currentmodule:: flask + +Define and Access the Database +============================== + +The application will use a `SQLite`_ database to store users and posts. +Python comes with built-in support for SQLite in the :mod:`sqlite3` +module. + +SQLite is convenient because it doesn't require setting up a separate +database server and is built-in to Python. However, if concurrent +requests try to write to the database at the same time, they will slow +down as each write happens sequentially. Small applications won't notice +this. Once you become big, you may want to switch to a different +database. + +The tutorial doesn't go into detail about SQL. If you are not familiar +with it, the SQLite docs describe the `language`_. + +.. _SQLite: https://sqlite.org/about.html +.. _language: https://sqlite.org/lang.html + + +Connect to the Database +----------------------- + +The first thing to do when working with a SQLite database (and most +other Python database libraries) is to create a connection to it. Any +queries and operations are performed using the connection, which is +closed after the work is finished. + +In web applications this connection is typically tied to the request. It +is created at some point when handling a request, and closed before the +response is sent. + +.. code-block:: python + :caption: ``flaskr/db.py`` + + import sqlite3 + from datetime import datetime + + import click + from flask import current_app, g + + + def get_db(): + if 'db' not in g: + g.db = sqlite3.connect( + current_app.config['DATABASE'], + detect_types=sqlite3.PARSE_DECLTYPES + ) + g.db.row_factory = sqlite3.Row + + return g.db + + + def close_db(e=None): + db = g.pop('db', None) + + if db is not None: + db.close() + +:data:`.g` is a special object that is unique for each request. It is +used to store data that might be accessed by multiple functions during +the request. The connection is stored and reused instead of creating a +new connection if ``get_db`` is called a second time in the same +request. + +:data:`.current_app` is another special object that points to the Flask +application handling the request. Since you used an application factory, +there is no application object when writing the rest of your code. +``get_db`` will be called when the application has been created and is +handling a request, so :data:`.current_app` can be used. + +:func:`sqlite3.connect` establishes a connection to the file pointed at +by the ``DATABASE`` configuration key. This file doesn't have to exist +yet, and won't until you initialize the database later. + +:class:`sqlite3.Row` tells the connection to return rows that behave +like dicts. This allows accessing the columns by name. + +``close_db`` checks if a connection was created by checking if ``g.db`` +was set. If the connection exists, it is closed. Further down you will +tell your application about the ``close_db`` function in the application +factory so that it is called after each request. + + +Create the Tables +----------------- + +In SQLite, data is stored in *tables* and *columns*. These need to be +created before you can store and retrieve data. Flaskr will store users +in the ``user`` table, and posts in the ``post`` table. Create a file +with the SQL commands needed to create empty tables: + +.. code-block:: sql + :caption: ``flaskr/schema.sql`` + + DROP TABLE IF EXISTS user; + DROP TABLE IF EXISTS post; + + CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL + ); + + CREATE TABLE post ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL, + body TEXT NOT NULL, + FOREIGN KEY (author_id) REFERENCES user (id) + ); + +Add the Python functions that will run these SQL commands to the +``db.py`` file: + +.. code-block:: python + :caption: ``flaskr/db.py`` + + def init_db(): + db = get_db() + + with current_app.open_resource('schema.sql') as f: + db.executescript(f.read().decode('utf8')) + + + @click.command('init-db') + def init_db_command(): + """Clear the existing data and create new tables.""" + init_db() + click.echo('Initialized the database.') + + + sqlite3.register_converter( + "timestamp", lambda v: datetime.fromisoformat(v.decode()) + ) + +:meth:`open_resource() ` opens a file relative to +the ``flaskr`` package, which is useful since you won't necessarily know +where that location is when deploying the application later. ``get_db`` +returns a database connection, which is used to execute the commands +read from the file. + +:func:`click.command` defines a command line command called ``init-db`` +that calls the ``init_db`` function and shows a success message to the +user. You can read :doc:`/cli` to learn more about writing commands. + +The call to :func:`sqlite3.register_converter` tells Python how to +interpret timestamp values in the database. We convert the value to a +:class:`datetime.datetime`. + + +Register with the Application +----------------------------- + +The ``close_db`` and ``init_db_command`` functions need to be registered +with the application instance; otherwise, they won't be used by the +application. However, since you're using a factory function, that +instance isn't available when writing the functions. Instead, write a +function that takes an application and does the registration. + +.. code-block:: python + :caption: ``flaskr/db.py`` + + def init_app(app): + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) + +:meth:`app.teardown_appcontext() ` tells +Flask to call that function when cleaning up after returning the +response. + +:meth:`app.cli.add_command() ` adds a new +command that can be called with the ``flask`` command. + +Import and call this function from the factory. Place the new code at +the end of the factory function before returning the app. + +.. code-block:: python + :caption: ``flaskr/__init__.py`` + + def create_app(): + app = ... + # existing code omitted + + from . import db + db.init_app(app) + + return app + + +Initialize the Database File +---------------------------- + +Now that ``init-db`` has been registered with the app, it can be called +using the ``flask`` command, similar to the ``run`` command from the +previous page. + +.. note:: + + If you're still running the server from the previous page, you can + either stop the server, or run this command in a new terminal. If + you use a new terminal, remember to change to your project directory + and activate the env as described in :doc:`/installation`. + +Run the ``init-db`` command: + +.. code-block:: none + + $ flask --app flaskr init-db + Initialized the database. + +There will now be a ``flaskr.sqlite`` file in the ``instance`` folder in +your project. + +Continue to :doc:`views`. diff --git a/src/flask-main/docs/tutorial/deploy.rst b/src/flask-main/docs/tutorial/deploy.rst new file mode 100644 index 0000000..eb3a53a --- /dev/null +++ b/src/flask-main/docs/tutorial/deploy.rst @@ -0,0 +1,111 @@ +Deploy to Production +==================== + +This part of the tutorial assumes you have a server that you want to +deploy your application to. It gives an overview of how to create the +distribution file and install it, but won't go into specifics about +what server or software to use. You can set up a new environment on your +development computer to try out the instructions below, but probably +shouldn't use it for hosting a real public application. See +:doc:`/deploying/index` for a list of many different ways to host your +application. + + +Build and Install +----------------- + +When you want to deploy your application elsewhere, you build a *wheel* +(``.whl``) file. Install and use the ``build`` tool to do this. + +.. code-block:: none + + $ pip install build + $ python -m build --wheel + +You can find the file in ``dist/flaskr-1.0.0-py3-none-any.whl``. The +file name is in the format of {project name}-{version}-{python tag} +-{abi tag}-{platform tag}. + +Copy this file to another machine, +:ref:`set up a new virtualenv `, then install the +file with ``pip``. + +.. code-block:: none + + $ pip install flaskr-1.0.0-py3-none-any.whl + +Pip will install your project along with its dependencies. + +Since this is a different machine, you need to run ``init-db`` again to +create the database in the instance folder. + + .. code-block:: text + + $ flask --app flaskr init-db + +When Flask detects that it's installed (not in editable mode), it uses +a different directory for the instance folder. You can find it at +``.venv/var/flaskr-instance`` instead. + + +Configure the Secret Key +------------------------ + +In the beginning of the tutorial that you gave a default value for +:data:`SECRET_KEY`. This should be changed to some random bytes in +production. Otherwise, attackers could use the public ``'dev'`` key to +modify the session cookie, or anything else that uses the secret key. + +You can use the following command to output a random secret key: + +.. code-block:: none + + $ python -c 'import secrets; print(secrets.token_hex())' + + '192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf' + +Create the ``config.py`` file in the instance folder, which the factory +will read from if it exists. Copy the generated value into it. + +.. code-block:: python + :caption: ``.venv/var/flaskr-instance/config.py`` + + SECRET_KEY = '192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf' + +You can also set any other necessary configuration here, although +``SECRET_KEY`` is the only one needed for Flaskr. + + +Run with a Production Server +---------------------------- + +When running publicly rather than in development, you should not use the +built-in development server (``flask run``). The development server is +provided by Werkzeug for convenience, but is not designed to be +particularly efficient, stable, or secure. + +Instead, use a production WSGI server. For example, to use `Waitress`_, +first install it in the virtual environment: + +.. code-block:: none + + $ pip install waitress + +You need to tell Waitress about your application, but it doesn't use +``--app`` like ``flask run`` does. You need to tell it to import and +call the application factory to get an application object. + +.. code-block:: none + + $ waitress-serve --call 'flaskr:create_app' + + Serving on http://0.0.0.0:8080 + +See :doc:`/deploying/index` for a list of many different ways to host +your application. Waitress is just an example, chosen for the tutorial +because it supports both Windows and Linux. There are many more WSGI +servers and deployment options that you may choose for your project. + +.. _Waitress: https://docs.pylonsproject.org/projects/waitress/en/stable/ + +Continue to :doc:`next`. diff --git a/src/flask-main/docs/tutorial/factory.rst b/src/flask-main/docs/tutorial/factory.rst new file mode 100644 index 0000000..39febd1 --- /dev/null +++ b/src/flask-main/docs/tutorial/factory.rst @@ -0,0 +1,162 @@ +.. currentmodule:: flask + +Application Setup +================= + +A Flask application is an instance of the :class:`Flask` class. +Everything about the application, such as configuration and URLs, will +be registered with this class. + +The most straightforward way to create a Flask application is to create +a global :class:`Flask` instance directly at the top of your code, like +how the "Hello, World!" example did on the previous page. While this is +simple and useful in some cases, it can cause some tricky issues as the +project grows. + +Instead of creating a :class:`Flask` instance globally, you will create +it inside a function. This function is known as the *application +factory*. Any configuration, registration, and other setup the +application needs will happen inside the function, then the application +will be returned. + + +The Application Factory +----------------------- + +It's time to start coding! Create the ``flaskr`` directory and add the +``__init__.py`` file. The ``__init__.py`` serves double duty: it will +contain the application factory, and it tells Python that the ``flaskr`` +directory should be treated as a package. + +.. code-block:: none + + $ mkdir flaskr + +.. code-block:: python + :caption: ``flaskr/__init__.py`` + + import os + + from flask import Flask + + + def create_app(test_config=None): + # create and configure the app + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + SECRET_KEY='dev', + DATABASE=os.path.join(app.instance_path, 'flaskr.sqlite'), + ) + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile('config.py', silent=True) + else: + # load the test config if passed in + app.config.from_mapping(test_config) + + # ensure the instance folder exists + try: + os.makedirs(app.instance_path) + except OSError: + pass + + # a simple page that says hello + @app.route('/hello') + def hello(): + return 'Hello, World!' + + return app + +``create_app`` is the application factory function. You'll add to it +later in the tutorial, but it already does a lot. + +#. ``app = Flask(__name__, instance_relative_config=True)`` creates the + :class:`Flask` instance. + + * ``__name__`` is the name of the current Python module. The app + needs to know where it's located to set up some paths, and + ``__name__`` is a convenient way to tell it that. + + * ``instance_relative_config=True`` tells the app that + configuration files are relative to the + :ref:`instance folder `. The instance folder + is located outside the ``flaskr`` package and can hold local + data that shouldn't be committed to version control, such as + configuration secrets and the database file. + +#. :meth:`app.config.from_mapping() ` sets + some default configuration that the app will use: + + * :data:`SECRET_KEY` is used by Flask and extensions to keep data + safe. It's set to ``'dev'`` to provide a convenient value + during development, but it should be overridden with a random + value when deploying. + + * ``DATABASE`` is the path where the SQLite database file will be + saved. It's under + :attr:`app.instance_path `, which is the + path that Flask has chosen for the instance folder. You'll learn + more about the database in the next section. + +#. :meth:`app.config.from_pyfile() ` overrides + the default configuration with values taken from the ``config.py`` + file in the instance folder if it exists. For example, when + deploying, this can be used to set a real ``SECRET_KEY``. + + * ``test_config`` can also be passed to the factory, and will be + used instead of the instance configuration. This is so the tests + you'll write later in the tutorial can be configured + independently of any development values you have configured. + +#. :func:`os.makedirs` ensures that + :attr:`app.instance_path ` exists. Flask + doesn't create the instance folder automatically, but it needs to be + created because your project will create the SQLite database file + there. + +#. :meth:`@app.route() ` creates a simple route so you can + see the application working before getting into the rest of the + tutorial. It creates a connection between the URL ``/hello`` and a + function that returns a response, the string ``'Hello, World!'`` in + this case. + + +Run The Application +------------------- + +Now you can run your application using the ``flask`` command. From the +terminal, tell Flask where to find your application, then run it in +debug mode. Remember, you should still be in the top-level +``flask-tutorial`` directory, not the ``flaskr`` package. + +Debug mode shows an interactive debugger whenever a page raises an +exception, and restarts the server whenever you make changes to the +code. You can leave it running and just reload the browser page as you +follow the tutorial. + +.. code-block:: text + + $ flask --app flaskr run --debug + +You'll see output similar to this: + +.. code-block:: text + + * Serving Flask app "flaskr" + * Debug mode: on + * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) + * Restarting with stat + * Debugger is active! + * Debugger PIN: nnn-nnn-nnn + +Visit http://127.0.0.1:5000/hello in a browser and you should see the +"Hello, World!" message. Congratulations, you're now running your Flask +web application! + +If another program is already using port 5000, you'll see +``OSError: [Errno 98]`` or ``OSError: [WinError 10013]`` when the +server tries to start. See :ref:`address-already-in-use` for how to +handle that. + +Continue to :doc:`database`. diff --git a/src/flask-main/docs/tutorial/flaskr_edit.png b/src/flask-main/docs/tutorial/flaskr_edit.png new file mode 100644 index 0000000..6cd6e39 Binary files /dev/null and b/src/flask-main/docs/tutorial/flaskr_edit.png differ diff --git a/src/flask-main/docs/tutorial/flaskr_index.png b/src/flask-main/docs/tutorial/flaskr_index.png new file mode 100644 index 0000000..aa2b50f Binary files /dev/null and b/src/flask-main/docs/tutorial/flaskr_index.png differ diff --git a/src/flask-main/docs/tutorial/flaskr_login.png b/src/flask-main/docs/tutorial/flaskr_login.png new file mode 100644 index 0000000..d482c64 Binary files /dev/null and b/src/flask-main/docs/tutorial/flaskr_login.png differ diff --git a/src/flask-main/docs/tutorial/index.rst b/src/flask-main/docs/tutorial/index.rst new file mode 100644 index 0000000..d5dc5b3 --- /dev/null +++ b/src/flask-main/docs/tutorial/index.rst @@ -0,0 +1,64 @@ +Tutorial +======== + +.. toctree:: + :caption: Contents: + :maxdepth: 1 + + layout + factory + database + views + templates + static + blog + install + tests + deploy + next + +This tutorial will walk you through creating a basic blog application +called Flaskr. Users will be able to register, log in, create posts, +and edit or delete their own posts. You will be able to package and +install the application on other computers. + +.. image:: flaskr_index.png + :align: center + :class: screenshot + :alt: screenshot of index page + +It's assumed that you're already familiar with Python. The `official +tutorial`_ in the Python docs is a great way to learn or review first. + +.. _official tutorial: https://docs.python.org/3/tutorial/ + +While it's designed to give a good starting point, the tutorial doesn't +cover all of Flask's features. Check out the :doc:`/quickstart` for an +overview of what Flask can do, then dive into the docs to find out more. +The tutorial only uses what's provided by Flask and Python. In another +project, you might decide to use :doc:`/extensions` or other libraries +to make some tasks simpler. + +.. image:: flaskr_login.png + :align: center + :class: screenshot + :alt: screenshot of login page + +Flask is flexible. It doesn't require you to use any particular project +or code layout. However, when first starting, it's helpful to use a more +structured approach. This means that the tutorial will require a bit of +boilerplate up front, but it's done to avoid many common pitfalls that +new developers encounter, and it creates a project that's easy to expand +on. Once you become more comfortable with Flask, you can step out of +this structure and take full advantage of Flask's flexibility. + +.. image:: flaskr_edit.png + :align: center + :class: screenshot + :alt: screenshot of edit page + +:gh:`The tutorial project is available as an example in the Flask +repository `, if you want to compare your project +with the final product as you follow the tutorial. + +Continue to :doc:`layout`. diff --git a/src/flask-main/docs/tutorial/install.rst b/src/flask-main/docs/tutorial/install.rst new file mode 100644 index 0000000..db83e10 --- /dev/null +++ b/src/flask-main/docs/tutorial/install.rst @@ -0,0 +1,89 @@ +Make the Project Installable +============================ + +Making your project installable means that you can build a *wheel* file and install that +in another environment, just like you installed Flask in your project's environment. +This makes deploying your project the same as installing any other library, so you're +using all the standard Python tools to manage everything. + +Installing also comes with other benefits that might not be obvious from +the tutorial or as a new Python user, including: + +* Currently, Python and Flask understand how to use the ``flaskr`` + package only because you're running from your project's directory. + Installing means you can import it no matter where you run from. + +* You can manage your project's dependencies just like other packages + do, so ``pip install yourproject.whl`` installs them. + +* Test tools can isolate your test environment from your development + environment. + +.. note:: + This is being introduced late in the tutorial, but in your future + projects you should always start with this. + + +Describe the Project +-------------------- + +The ``pyproject.toml`` file describes your project and how to build it. + +.. code-block:: toml + :caption: ``pyproject.toml`` + + [project] + name = "flaskr" + version = "1.0.0" + description = "The basic blog app built in the Flask tutorial." + dependencies = [ + "flask", + ] + + [build-system] + requires = ["flit_core<4"] + build-backend = "flit_core.buildapi" + +See the official `Packaging tutorial `_ for more +explanation of the files and options used. + +.. _packaging tutorial: https://packaging.python.org/tutorials/packaging-projects/ + + +Install the Project +------------------- + +Use ``pip`` to install your project in the virtual environment. + +.. code-block:: none + + $ pip install -e . + +This tells pip to find ``pyproject.toml`` in the current directory and install the +project in *editable* or *development* mode. Editable mode means that as you make +changes to your local code, you'll only need to re-install if you change the metadata +about the project, such as its dependencies. + +You can observe that the project is now installed with ``pip list``. + +.. code-block:: none + + $ pip list + + Package Version Location + -------------- --------- ---------------------------------- + click 6.7 + Flask 1.0 + flaskr 1.0.0 /home/user/Projects/flask-tutorial + itsdangerous 0.24 + Jinja2 2.10 + MarkupSafe 1.0 + pip 9.0.3 + Werkzeug 0.14.1 + +Nothing changes from how you've been running your project so far. +``--app`` is still set to ``flaskr`` and ``flask run`` still runs +the application, but you can call it from anywhere, not just the +``flask-tutorial`` directory. + +Continue to :doc:`tests`. diff --git a/src/flask-main/docs/tutorial/layout.rst b/src/flask-main/docs/tutorial/layout.rst new file mode 100644 index 0000000..6f8e59f --- /dev/null +++ b/src/flask-main/docs/tutorial/layout.rst @@ -0,0 +1,110 @@ +Project Layout +============== + +Create a project directory and enter it: + +.. code-block:: none + + $ mkdir flask-tutorial + $ cd flask-tutorial + +Then follow the :doc:`installation instructions ` to set +up a Python virtual environment and install Flask for your project. + +The tutorial will assume you're working from the ``flask-tutorial`` +directory from now on. The file names at the top of each code block are +relative to this directory. + +---- + +A Flask application can be as simple as a single file. + +.. code-block:: python + :caption: ``hello.py`` + + from flask import Flask + + app = Flask(__name__) + + + @app.route('/') + def hello(): + return 'Hello, World!' + +However, as a project gets bigger, it becomes overwhelming to keep all +the code in one file. Python projects use *packages* to organize code +into multiple modules that can be imported where needed, and the +tutorial will do this as well. + +The project directory will contain: + +* ``flaskr/``, a Python package containing your application code and + files. +* ``tests/``, a directory containing test modules. +* ``.venv/``, a Python virtual environment where Flask and other + dependencies are installed. +* Installation files telling Python how to install your project. +* Version control config, such as `git`_. You should make a habit of + using some type of version control for all your projects, no matter + the size. +* Any other project files you might add in the future. + +.. _git: https://git-scm.com/ + +By the end, your project layout will look like this: + +.. code-block:: none + + /home/user/Projects/flask-tutorial + ├── flaskr/ + │ ├── __init__.py + │ ├── db.py + │ ├── schema.sql + │ ├── auth.py + │ ├── blog.py + │ ├── templates/ + │ │ ├── base.html + │ │ ├── auth/ + │ │ │ ├── login.html + │ │ │ └── register.html + │ │ └── blog/ + │ │ ├── create.html + │ │ ├── index.html + │ │ └── update.html + │ └── static/ + │ └── style.css + ├── tests/ + │ ├── conftest.py + │ ├── data.sql + │ ├── test_factory.py + │ ├── test_db.py + │ ├── test_auth.py + │ └── test_blog.py + ├── .venv/ + ├── pyproject.toml + └── MANIFEST.in + +If you're using version control, the following files that are generated +while running your project should be ignored. There may be other files +based on the editor you use. In general, ignore files that you didn't +write. For example, with git: + +.. code-block:: none + :caption: ``.gitignore`` + + .venv/ + + *.pyc + __pycache__/ + + instance/ + + .pytest_cache/ + .coverage + htmlcov/ + + dist/ + build/ + *.egg-info/ + +Continue to :doc:`factory`. diff --git a/src/flask-main/docs/tutorial/next.rst b/src/flask-main/docs/tutorial/next.rst new file mode 100644 index 0000000..d41e8ef --- /dev/null +++ b/src/flask-main/docs/tutorial/next.rst @@ -0,0 +1,38 @@ +Keep Developing! +================ + +You've learned about quite a few Flask and Python concepts throughout +the tutorial. Go back and review the tutorial and compare your code with +the steps you took to get there. Compare your project to the +:gh:`example project `, which might look a bit +different due to the step-by-step nature of the tutorial. + +There's a lot more to Flask than what you've seen so far. Even so, +you're now equipped to start developing your own web applications. Check +out the :doc:`/quickstart` for an overview of what Flask can do, then +dive into the docs to keep learning. Flask uses `Jinja`_, `Click`_, +`Werkzeug`_, and `ItsDangerous`_ behind the scenes, and they all have +their own documentation too. You'll also be interested in +:doc:`/extensions` which make tasks like working with the database or +validating form data easier and more powerful. + +If you want to keep developing your Flaskr project, here are some ideas +for what to try next: + +* A detail view to show a single post. Click a post's title to go to + its page. +* Like / unlike a post. +* Comments. +* Tags. Clicking a tag shows all the posts with that tag. +* A search box that filters the index page by name. +* Paged display. Only show 5 posts per page. +* Upload an image to go along with a post. +* Format posts using Markdown. +* An RSS feed of new posts. + +Have fun and make awesome applications! + +.. _Jinja: https://palletsprojects.com/p/jinja/ +.. _Click: https://palletsprojects.com/p/click/ +.. _Werkzeug: https://palletsprojects.com/p/werkzeug/ +.. _ItsDangerous: https://palletsprojects.com/p/itsdangerous/ diff --git a/src/flask-main/docs/tutorial/static.rst b/src/flask-main/docs/tutorial/static.rst new file mode 100644 index 0000000..8e76c40 --- /dev/null +++ b/src/flask-main/docs/tutorial/static.rst @@ -0,0 +1,72 @@ +Static Files +============ + +The authentication views and templates work, but they look very plain +right now. Some `CSS`_ can be added to add style to the HTML layout you +constructed. The style won't change, so it's a *static* file rather than +a template. + +Flask automatically adds a ``static`` view that takes a path relative +to the ``flaskr/static`` directory and serves it. The ``base.html`` +template already has a link to the ``style.css`` file: + +.. code-block:: html+jinja + + {{ url_for('static', filename='style.css') }} + +Besides CSS, other types of static files might be files with JavaScript +functions, or a logo image. They are all placed under the +``flaskr/static`` directory and referenced with +``url_for('static', filename='...')``. + +This tutorial isn't focused on how to write CSS, so you can just copy +the following into the ``flaskr/static/style.css`` file: + +.. code-block:: css + :caption: ``flaskr/static/style.css`` + + html { font-family: sans-serif; background: #eee; padding: 1rem; } + body { max-width: 960px; margin: 0 auto; background: white; } + h1 { font-family: serif; color: #377ba8; margin: 1rem 0; } + a { color: #377ba8; } + hr { border: none; border-top: 1px solid lightgray; } + nav { background: lightgray; display: flex; align-items: center; padding: 0 0.5rem; } + nav h1 { flex: auto; margin: 0; } + nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; } + nav ul { display: flex; list-style: none; margin: 0; padding: 0; } + nav ul li a, nav ul li span, header .action { display: block; padding: 0.5rem; } + .content { padding: 0 1rem 1rem; } + .content > header { border-bottom: 1px solid lightgray; display: flex; align-items: flex-end; } + .content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; } + .flash { margin: 1em 0; padding: 1em; background: #cae6f6; border: 1px solid #377ba8; } + .post > header { display: flex; align-items: flex-end; font-size: 0.85em; } + .post > header > div:first-of-type { flex: auto; } + .post > header h1 { font-size: 1.5em; margin-bottom: 0; } + .post .about { color: slategray; font-style: italic; } + .post .body { white-space: pre-line; } + .content:last-child { margin-bottom: 0; } + .content form { margin: 1em 0; display: flex; flex-direction: column; } + .content label { font-weight: bold; margin-bottom: 0.5em; } + .content input, .content textarea { margin-bottom: 1em; } + .content textarea { min-height: 12em; resize: vertical; } + input.danger { color: #cc2f2e; } + input[type=submit] { align-self: start; min-width: 10em; } + +You can find a less compact version of ``style.css`` in the +:gh:`example code `. + +Go to http://127.0.0.1:5000/auth/login and the page should look like the +screenshot below. + +.. image:: flaskr_login.png + :align: center + :class: screenshot + :alt: screenshot of login page + +You can read more about CSS from `Mozilla's documentation `_. If +you change a static file, refresh the browser page. If the change +doesn't show up, try clearing your browser's cache. + +.. _CSS: https://developer.mozilla.org/docs/Web/CSS + +Continue to :doc:`blog`. diff --git a/src/flask-main/docs/tutorial/templates.rst b/src/flask-main/docs/tutorial/templates.rst new file mode 100644 index 0000000..ca9d4b3 --- /dev/null +++ b/src/flask-main/docs/tutorial/templates.rst @@ -0,0 +1,187 @@ +.. currentmodule:: flask + +Templates +========= + +You've written the authentication views for your application, but if +you're running the server and try to go to any of the URLs, you'll see a +``TemplateNotFound`` error. That's because the views are calling +:func:`render_template`, but you haven't written the templates yet. +The template files will be stored in the ``templates`` directory inside +the ``flaskr`` package. + +Templates are files that contain static data as well as placeholders +for dynamic data. A template is rendered with specific data to produce a +final document. Flask uses the `Jinja`_ template library to render +templates. + +In your application, you will use templates to render `HTML`_ which +will display in the user's browser. In Flask, Jinja is configured to +*autoescape* any data that is rendered in HTML templates. This means +that it's safe to render user input; any characters they've entered that +could mess with the HTML, such as ``<`` and ``>`` will be *escaped* with +*safe* values that look the same in the browser but don't cause unwanted +effects. + +Jinja looks and behaves mostly like Python. Special delimiters are used +to distinguish Jinja syntax from the static data in the template. +Anything between ``{{`` and ``}}`` is an expression that will be output +to the final document. ``{%`` and ``%}`` denotes a control flow +statement like ``if`` and ``for``. Unlike Python, blocks are denoted +by start and end tags rather than indentation since static text within +a block could change indentation. + +.. _Jinja: https://jinja.palletsprojects.com/templates/ +.. _HTML: https://developer.mozilla.org/docs/Web/HTML + + +The Base Layout +--------------- + +Each page in the application will have the same basic layout around a +different body. Instead of writing the entire HTML structure in each +template, each template will *extend* a base template and override +specific sections. + +.. code-block:: html+jinja + :caption: ``flaskr/templates/base.html`` + + + {% block title %}{% endblock %} - Flaskr + + +
+
+ {% block header %}{% endblock %} +
+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + {% block content %}{% endblock %} +
+ +:data:`.g` is automatically available in templates. Based on if +``g.user`` is set (from ``load_logged_in_user``), either the username +and a log out link are displayed, or links to register and log in +are displayed. :func:`url_for` is also automatically available, and is +used to generate URLs to views instead of writing them out manually. + +After the page title, and before the content, the template loops over +each message returned by :func:`get_flashed_messages`. You used +:func:`flash` in the views to show error messages, and this is the code +that will display them. + +There are three blocks defined here that will be overridden in the other +templates: + +#. ``{% block title %}`` will change the title displayed in the + browser's tab and window title. + +#. ``{% block header %}`` is similar to ``title`` but will change the + title displayed on the page. + +#. ``{% block content %}`` is where the content of each page goes, such + as the login form or a blog post. + +The base template is directly in the ``templates`` directory. To keep +the others organized, the templates for a blueprint will be placed in a +directory with the same name as the blueprint. + + +Register +-------- + +.. code-block:: html+jinja + :caption: ``flaskr/templates/auth/register.html`` + + {% extends 'base.html' %} + + {% block header %} +

{% block title %}Register{% endblock %}

+ {% endblock %} + + {% block content %} +
+ + + + + +
+ {% endblock %} + +``{% extends 'base.html' %}`` tells Jinja that this template should +replace the blocks from the base template. All the rendered content must +appear inside ``{% block %}`` tags that override blocks from the base +template. + +A useful pattern used here is to place ``{% block title %}`` inside +``{% block header %}``. This will set the title block and then output +the value of it into the header block, so that both the window and page +share the same title without writing it twice. + +The ``input`` tags are using the ``required`` attribute here. This tells +the browser not to submit the form until those fields are filled in. If +the user is using an older browser that doesn't support that attribute, +or if they are using something besides a browser to make requests, you +still want to validate the data in the Flask view. It's important to +always fully validate the data on the server, even if the client does +some validation as well. + + +Log In +------ + +This is identical to the register template except for the title and +submit button. + +.. code-block:: html+jinja + :caption: ``flaskr/templates/auth/login.html`` + + {% extends 'base.html' %} + + {% block header %} +

{% block title %}Log In{% endblock %}

+ {% endblock %} + + {% block content %} +
+ + + + + +
+ {% endblock %} + + +Register A User +--------------- + +Now that the authentication templates are written, you can register a +user. Make sure the server is still running (``flask run`` if it's not), +then go to http://127.0.0.1:5000/auth/register. + +Try clicking the "Register" button without filling out the form and see +that the browser shows an error message. Try removing the ``required`` +attributes from the ``register.html`` template and click "Register" +again. Instead of the browser showing an error, the page will reload and +the error from :func:`flash` in the view will be shown. + +Fill out a username and password and you'll be redirected to the login +page. Try entering an incorrect username, or the correct username and +incorrect password. If you log in you'll get an error because there's +no ``index`` view to redirect to yet. + +Continue to :doc:`static`. diff --git a/src/flask-main/docs/tutorial/tests.rst b/src/flask-main/docs/tutorial/tests.rst new file mode 100644 index 0000000..8958e77 --- /dev/null +++ b/src/flask-main/docs/tutorial/tests.rst @@ -0,0 +1,559 @@ +.. currentmodule:: flask + +Test Coverage +============= + +Writing unit tests for your application lets you check that the code +you wrote works the way you expect. Flask provides a test client that +simulates requests to the application and returns the response data. + +You should test as much of your code as possible. Code in functions only +runs when the function is called, and code in branches, such as ``if`` +blocks, only runs when the condition is met. You want to make sure that +each function is tested with data that covers each branch. + +The closer you get to 100% coverage, the more comfortable you can be +that making a change won't unexpectedly change other behavior. However, +100% coverage doesn't guarantee that your application doesn't have bugs. +In particular, it doesn't test how the user interacts with the +application in the browser. Despite this, test coverage is an important +tool to use during development. + +.. note:: + This is being introduced late in the tutorial, but in your future + projects you should test as you develop. + +You'll use `pytest`_ and `coverage`_ to test and measure your code. +Install them both: + +.. code-block:: none + + $ pip install pytest coverage + +.. _pytest: https://pytest.readthedocs.io/ +.. _coverage: https://coverage.readthedocs.io/ + + +Setup and Fixtures +------------------ + +The test code is located in the ``tests`` directory. This directory is +*next to* the ``flaskr`` package, not inside it. The +``tests/conftest.py`` file contains setup functions called *fixtures* +that each test will use. Tests are in Python modules that start with +``test_``, and each test function in those modules also starts with +``test_``. + +Each test will create a new temporary database file and populate some +data that will be used in the tests. Write a SQL file to insert that +data. + +.. code-block:: sql + :caption: ``tests/data.sql`` + + INSERT INTO user (username, password) + VALUES + ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'), + ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79'); + + INSERT INTO post (title, body, author_id, created) + VALUES + ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00'); + +The ``app`` fixture will call the factory and pass ``test_config`` to +configure the application and database for testing instead of using your +local development configuration. + +.. code-block:: python + :caption: ``tests/conftest.py`` + + import os + import tempfile + + import pytest + from flaskr import create_app + from flaskr.db import get_db, init_db + + with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f: + _data_sql = f.read().decode('utf8') + + + @pytest.fixture + def app(): + db_fd, db_path = tempfile.mkstemp() + + app = create_app({ + 'TESTING': True, + 'DATABASE': db_path, + }) + + with app.app_context(): + init_db() + get_db().executescript(_data_sql) + + yield app + + os.close(db_fd) + os.unlink(db_path) + + + @pytest.fixture + def client(app): + return app.test_client() + + + @pytest.fixture + def runner(app): + return app.test_cli_runner() + +:func:`tempfile.mkstemp` creates and opens a temporary file, returning +the file descriptor and the path to it. The ``DATABASE`` path is +overridden so it points to this temporary path instead of the instance +folder. After setting the path, the database tables are created and the +test data is inserted. After the test is over, the temporary file is +closed and removed. + +:data:`TESTING` tells Flask that the app is in test mode. Flask changes +some internal behavior so it's easier to test, and other extensions can +also use the flag to make testing them easier. + +The ``client`` fixture calls +:meth:`app.test_client() ` with the application +object created by the ``app`` fixture. Tests will use the client to make +requests to the application without running the server. + +The ``runner`` fixture is similar to ``client``. +:meth:`app.test_cli_runner() ` creates a runner +that can call the Click commands registered with the application. + +Pytest uses fixtures by matching their function names with the names +of arguments in the test functions. For example, the ``test_hello`` +function you'll write next takes a ``client`` argument. Pytest matches +that with the ``client`` fixture function, calls it, and passes the +returned value to the test function. + + +Factory +------- + +There's not much to test about the factory itself. Most of the code will +be executed for each test already, so if something fails the other tests +will notice. + +The only behavior that can change is passing test config. If config is +not passed, there should be some default configuration, otherwise the +configuration should be overridden. + +.. code-block:: python + :caption: ``tests/test_factory.py`` + + from flaskr import create_app + + + def test_config(): + assert not create_app().testing + assert create_app({'TESTING': True}).testing + + + def test_hello(client): + response = client.get('/hello') + assert response.data == b'Hello, World!' + +You added the ``hello`` route as an example when writing the factory at +the beginning of the tutorial. It returns "Hello, World!", so the test +checks that the response data matches. + + +Database +-------- + +Within an application context, ``get_db`` should return the same +connection each time it's called. After the context, the connection +should be closed. + +.. code-block:: python + :caption: ``tests/test_db.py`` + + import sqlite3 + + import pytest + from flaskr.db import get_db + + + def test_get_close_db(app): + with app.app_context(): + db = get_db() + assert db is get_db() + + with pytest.raises(sqlite3.ProgrammingError) as e: + db.execute('SELECT 1') + + assert 'closed' in str(e.value) + +The ``init-db`` command should call the ``init_db`` function and output +a message. + +.. code-block:: python + :caption: ``tests/test_db.py`` + + def test_init_db_command(runner, monkeypatch): + class Recorder(object): + called = False + + def fake_init_db(): + Recorder.called = True + + monkeypatch.setattr('flaskr.db.init_db', fake_init_db) + result = runner.invoke(args=['init-db']) + assert 'Initialized' in result.output + assert Recorder.called + +This test uses Pytest's ``monkeypatch`` fixture to replace the +``init_db`` function with one that records that it's been called. The +``runner`` fixture you wrote above is used to call the ``init-db`` +command by name. + + +Authentication +-------------- + +For most of the views, a user needs to be logged in. The easiest way to +do this in tests is to make a ``POST`` request to the ``login`` view +with the client. Rather than writing that out every time, you can write +a class with methods to do that, and use a fixture to pass it the client +for each test. + +.. code-block:: python + :caption: ``tests/conftest.py`` + + class AuthActions(object): + def __init__(self, client): + self._client = client + + def login(self, username='test', password='test'): + return self._client.post( + '/auth/login', + data={'username': username, 'password': password} + ) + + def logout(self): + return self._client.get('/auth/logout') + + + @pytest.fixture + def auth(client): + return AuthActions(client) + +With the ``auth`` fixture, you can call ``auth.login()`` in a test to +log in as the ``test`` user, which was inserted as part of the test +data in the ``app`` fixture. + +The ``register`` view should render successfully on ``GET``. On ``POST`` +with valid form data, it should redirect to the login URL and the user's +data should be in the database. Invalid data should display error +messages. + +.. code-block:: python + :caption: ``tests/test_auth.py`` + + import pytest + from flask import g, session + from flaskr.db import get_db + + + def test_register(client, app): + assert client.get('/auth/register').status_code == 200 + response = client.post( + '/auth/register', data={'username': 'a', 'password': 'a'} + ) + assert response.headers["Location"] == "/auth/login" + + with app.app_context(): + assert get_db().execute( + "SELECT * FROM user WHERE username = 'a'", + ).fetchone() is not None + + + @pytest.mark.parametrize(('username', 'password', 'message'), ( + ('', '', b'Username is required.'), + ('a', '', b'Password is required.'), + ('test', 'test', b'already registered'), + )) + def test_register_validate_input(client, username, password, message): + response = client.post( + '/auth/register', + data={'username': username, 'password': password} + ) + assert message in response.data + +:meth:`client.get() ` makes a ``GET`` request +and returns the :class:`Response` object returned by Flask. Similarly, +:meth:`client.post() ` makes a ``POST`` +request, converting the ``data`` dict into form data. + +To test that the page renders successfully, a simple request is made and +checked for a ``200 OK`` :attr:`~Response.status_code`. If +rendering failed, Flask would return a ``500 Internal Server Error`` +code. + +:attr:`~Response.headers` will have a ``Location`` header with the login +URL when the register view redirects to the login view. + +:attr:`~Response.data` contains the body of the response as bytes. If +you expect a certain value to render on the page, check that it's in +``data``. Bytes must be compared to bytes. If you want to compare text, +use :meth:`get_data(as_text=True) ` +instead. + +``pytest.mark.parametrize`` tells Pytest to run the same test function +with different arguments. You use it here to test different invalid +input and error messages without writing the same code three times. + +The tests for the ``login`` view are very similar to those for +``register``. Rather than testing the data in the database, +:data:`.session` should have ``user_id`` set after logging in. + +.. code-block:: python + :caption: ``tests/test_auth.py`` + + def test_login(client, auth): + assert client.get('/auth/login').status_code == 200 + response = auth.login() + assert response.headers["Location"] == "/" + + with client: + client.get('/') + assert session['user_id'] == 1 + assert g.user['username'] == 'test' + + + @pytest.mark.parametrize(('username', 'password', 'message'), ( + ('a', 'test', b'Incorrect username.'), + ('test', 'a', b'Incorrect password.'), + )) + def test_login_validate_input(auth, username, password, message): + response = auth.login(username, password) + assert message in response.data + +Using ``client`` in a ``with`` block allows accessing context variables +such as :data:`.session` after the response is returned. Normally, +accessing ``session`` outside of a request would raise an error. + +Testing ``logout`` is the opposite of ``login``. :data:`.session` should +not contain ``user_id`` after logging out. + +.. code-block:: python + :caption: ``tests/test_auth.py`` + + def test_logout(client, auth): + auth.login() + + with client: + auth.logout() + assert 'user_id' not in session + + +Blog +---- + +All the blog views use the ``auth`` fixture you wrote earlier. Call +``auth.login()`` and subsequent requests from the client will be logged +in as the ``test`` user. + +The ``index`` view should display information about the post that was +added with the test data. When logged in as the author, there should be +a link to edit the post. + +You can also test some more authentication behavior while testing the +``index`` view. When not logged in, each page shows links to log in or +register. When logged in, there's a link to log out. + +.. code-block:: python + :caption: ``tests/test_blog.py`` + + import pytest + from flaskr.db import get_db + + + def test_index(client, auth): + response = client.get('/') + assert b"Log In" in response.data + assert b"Register" in response.data + + auth.login() + response = client.get('/') + assert b'Log Out' in response.data + assert b'test title' in response.data + assert b'by test on 2018-01-01' in response.data + assert b'test\nbody' in response.data + assert b'href="/1/update"' in response.data + +A user must be logged in to access the ``create``, ``update``, and +``delete`` views. The logged in user must be the author of the post to +access ``update`` and ``delete``, otherwise a ``403 Forbidden`` status +is returned. If a ``post`` with the given ``id`` doesn't exist, +``update`` and ``delete`` should return ``404 Not Found``. + +.. code-block:: python + :caption: ``tests/test_blog.py`` + + @pytest.mark.parametrize('path', ( + '/create', + '/1/update', + '/1/delete', + )) + def test_login_required(client, path): + response = client.post(path) + assert response.headers["Location"] == "/auth/login" + + + def test_author_required(app, client, auth): + # change the post author to another user + with app.app_context(): + db = get_db() + db.execute('UPDATE post SET author_id = 2 WHERE id = 1') + db.commit() + + auth.login() + # current user can't modify other user's post + assert client.post('/1/update').status_code == 403 + assert client.post('/1/delete').status_code == 403 + # current user doesn't see edit link + assert b'href="/1/update"' not in client.get('/').data + + + @pytest.mark.parametrize('path', ( + '/2/update', + '/2/delete', + )) + def test_exists_required(client, auth, path): + auth.login() + assert client.post(path).status_code == 404 + +The ``create`` and ``update`` views should render and return a +``200 OK`` status for a ``GET`` request. When valid data is sent in a +``POST`` request, ``create`` should insert the new post data into the +database, and ``update`` should modify the existing data. Both pages +should show an error message on invalid data. + +.. code-block:: python + :caption: ``tests/test_blog.py`` + + def test_create(client, auth, app): + auth.login() + assert client.get('/create').status_code == 200 + client.post('/create', data={'title': 'created', 'body': ''}) + + with app.app_context(): + db = get_db() + count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0] + assert count == 2 + + + def test_update(client, auth, app): + auth.login() + assert client.get('/1/update').status_code == 200 + client.post('/1/update', data={'title': 'updated', 'body': ''}) + + with app.app_context(): + db = get_db() + post = db.execute('SELECT * FROM post WHERE id = 1').fetchone() + assert post['title'] == 'updated' + + + @pytest.mark.parametrize('path', ( + '/create', + '/1/update', + )) + def test_create_update_validate(client, auth, path): + auth.login() + response = client.post(path, data={'title': '', 'body': ''}) + assert b'Title is required.' in response.data + +The ``delete`` view should redirect to the index URL and the post should +no longer exist in the database. + +.. code-block:: python + :caption: ``tests/test_blog.py`` + + def test_delete(client, auth, app): + auth.login() + response = client.post('/1/delete') + assert response.headers["Location"] == "/" + + with app.app_context(): + db = get_db() + post = db.execute('SELECT * FROM post WHERE id = 1').fetchone() + assert post is None + + +Running the Tests +----------------- + +Some extra configuration, which is not required but makes running tests with coverage +less verbose, can be added to the project's ``pyproject.toml`` file. + +.. code-block:: toml + :caption: ``pyproject.toml`` + + [tool.pytest.ini_options] + testpaths = ["tests"] + + [tool.coverage.run] + branch = true + source = ["flaskr"] + +To run the tests, use the ``pytest`` command. It will find and run all +the test functions you've written. + +.. code-block:: none + + $ pytest + + ========================= test session starts ========================== + platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0 + rootdir: /home/user/Projects/flask-tutorial + collected 23 items + + tests/test_auth.py ........ [ 34%] + tests/test_blog.py ............ [ 86%] + tests/test_db.py .. [ 95%] + tests/test_factory.py .. [100%] + + ====================== 24 passed in 0.64 seconds ======================= + +If any tests fail, pytest will show the error that was raised. You can +run ``pytest -v`` to get a list of each test function rather than dots. + +To measure the code coverage of your tests, use the ``coverage`` command +to run pytest instead of running it directly. + +.. code-block:: none + + $ coverage run -m pytest + +You can either view a simple coverage report in the terminal: + +.. code-block:: none + + $ coverage report + + Name Stmts Miss Branch BrPart Cover + ------------------------------------------------------ + flaskr/__init__.py 21 0 2 0 100% + flaskr/auth.py 54 0 22 0 100% + flaskr/blog.py 54 0 16 0 100% + flaskr/db.py 24 0 4 0 100% + ------------------------------------------------------ + TOTAL 153 0 44 0 100% + +An HTML report allows you to see which lines were covered in each file: + +.. code-block:: none + + $ coverage html + +This generates files in the ``htmlcov`` directory. Open +``htmlcov/index.html`` in your browser to see the report. + +Continue to :doc:`deploy`. diff --git a/src/flask-main/docs/tutorial/views.rst b/src/flask-main/docs/tutorial/views.rst new file mode 100644 index 0000000..6626628 --- /dev/null +++ b/src/flask-main/docs/tutorial/views.rst @@ -0,0 +1,305 @@ +.. currentmodule:: flask + +Blueprints and Views +==================== + +A view function is the code you write to respond to requests to your +application. Flask uses patterns to match the incoming request URL to +the view that should handle it. The view returns data that Flask turns +into an outgoing response. Flask can also go the other direction and +generate a URL to a view based on its name and arguments. + + +Create a Blueprint +------------------ + +A :class:`Blueprint` is a way to organize a group of related views and +other code. Rather than registering views and other code directly with +an application, they are registered with a blueprint. Then the blueprint +is registered with the application when it is available in the factory +function. + +Flaskr will have two blueprints, one for authentication functions and +one for the blog posts functions. The code for each blueprint will go +in a separate module. Since the blog needs to know about authentication, +you'll write the authentication one first. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + import functools + + from flask import ( + Blueprint, flash, g, redirect, render_template, request, session, url_for + ) + from werkzeug.security import check_password_hash, generate_password_hash + + from flaskr.db import get_db + + bp = Blueprint('auth', __name__, url_prefix='/auth') + +This creates a :class:`Blueprint` named ``'auth'``. Like the application +object, the blueprint needs to know where it's defined, so ``__name__`` +is passed as the second argument. The ``url_prefix`` will be prepended +to all the URLs associated with the blueprint. + +Import and register the blueprint from the factory using +:meth:`app.register_blueprint() `. Place the +new code at the end of the factory function before returning the app. + +.. code-block:: python + :caption: ``flaskr/__init__.py`` + + def create_app(): + app = ... + # existing code omitted + + from . import auth + app.register_blueprint(auth.bp) + + return app + +The authentication blueprint will have views to register new users and +to log in and log out. + + +The First View: Register +------------------------ + +When the user visits the ``/auth/register`` URL, the ``register`` view +will return `HTML`_ with a form for them to fill out. When they submit +the form, it will validate their input and either show the form again +with an error message or create the new user and go to the login page. + +.. _HTML: https://developer.mozilla.org/docs/Web/HTML + +For now you will just write the view code. On the next page, you'll +write templates to generate the HTML form. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + @bp.route('/register', methods=('GET', 'POST')) + def register(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + db = get_db() + error = None + + if not username: + error = 'Username is required.' + elif not password: + error = 'Password is required.' + + if error is None: + try: + db.execute( + "INSERT INTO user (username, password) VALUES (?, ?)", + (username, generate_password_hash(password)), + ) + db.commit() + except db.IntegrityError: + error = f"User {username} is already registered." + else: + return redirect(url_for("auth.login")) + + flash(error) + + return render_template('auth/register.html') + +Here's what the ``register`` view function is doing: + +#. :meth:`@bp.route ` associates the URL ``/register`` + with the ``register`` view function. When Flask receives a request + to ``/auth/register``, it will call the ``register`` view and use + the return value as the response. + +#. If the user submitted the form, + :attr:`request.method ` will be ``'POST'``. In this + case, start validating the input. + +#. :attr:`request.form ` is a special type of + :class:`dict` mapping submitted form keys and values. The user will + input their ``username`` and ``password``. + +#. Validate that ``username`` and ``password`` are not empty. + +#. If validation succeeds, insert the new user data into the database. + + - :meth:`db.execute ` takes a SQL + query with ``?`` placeholders for any user input, and a tuple of + values to replace the placeholders with. The database library + will take care of escaping the values so you are not vulnerable + to a *SQL injection attack*. + + - For security, passwords should never be stored in the database + directly. Instead, + :func:`~werkzeug.security.generate_password_hash` is used to + securely hash the password, and that hash is stored. Since this + query modifies data, + :meth:`db.commit() ` needs to be + called afterwards to save the changes. + + - An :exc:`sqlite3.IntegrityError` will occur if the username + already exists, which should be shown to the user as another + validation error. + +#. After storing the user, they are redirected to the login page. + :func:`url_for` generates the URL for the login view based on its + name. This is preferable to writing the URL directly as it allows + you to change the URL later without changing all code that links to + it. :func:`redirect` generates a redirect response to the generated + URL. + +#. If validation fails, the error is shown to the user. :func:`flash` + stores messages that can be retrieved when rendering the template. + +#. When the user initially navigates to ``auth/register``, or + there was a validation error, an HTML page with the registration + form should be shown. :func:`render_template` will render a template + containing the HTML, which you'll write in the next step of the + tutorial. + + +Login +----- + +This view follows the same pattern as the ``register`` view above. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + @bp.route('/login', methods=('GET', 'POST')) + def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + db = get_db() + error = None + user = db.execute( + 'SELECT * FROM user WHERE username = ?', (username,) + ).fetchone() + + if user is None: + error = 'Incorrect username.' + elif not check_password_hash(user['password'], password): + error = 'Incorrect password.' + + if error is None: + session.clear() + session['user_id'] = user['id'] + return redirect(url_for('index')) + + flash(error) + + return render_template('auth/login.html') + +There are a few differences from the ``register`` view: + +#. The user is queried first and stored in a variable for later use. + + :meth:`~sqlite3.Cursor.fetchone` returns one row from the query. + If the query returned no results, it returns ``None``. Later, + :meth:`~sqlite3.Cursor.fetchall` will be used, which returns a list + of all results. + +#. :func:`~werkzeug.security.check_password_hash` hashes the submitted + password in the same way as the stored hash and securely compares + them. If they match, the password is valid. + +#. :data:`.session` is a :class:`dict` that stores data across requests. + When validation succeeds, the user's ``id`` is stored in a new + session. The data is stored in a *cookie* that is sent to the + browser, and the browser then sends it back with subsequent requests. + Flask securely *signs* the data so that it can't be tampered with. + +Now that the user's ``id`` is stored in the :data:`.session`, it will be +available on subsequent requests. At the beginning of each request, if +a user is logged in their information should be loaded and made +available to other views. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + @bp.before_app_request + def load_logged_in_user(): + user_id = session.get('user_id') + + if user_id is None: + g.user = None + else: + g.user = get_db().execute( + 'SELECT * FROM user WHERE id = ?', (user_id,) + ).fetchone() + +:meth:`bp.before_app_request() ` registers +a function that runs before the view function, no matter what URL is +requested. ``load_logged_in_user`` checks if a user id is stored in the +:data:`.session` and gets that user's data from the database, storing it +on :data:`g.user `, which lasts for the length of the request. If +there is no user id, or if the id doesn't exist, ``g.user`` will be +``None``. + + +Logout +------ + +To log out, you need to remove the user id from the :data:`.session`. +Then ``load_logged_in_user`` won't load a user on subsequent requests. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + @bp.route('/logout') + def logout(): + session.clear() + return redirect(url_for('index')) + + +Require Authentication in Other Views +------------------------------------- + +Creating, editing, and deleting blog posts will require a user to be +logged in. A *decorator* can be used to check this for each view it's +applied to. + +.. code-block:: python + :caption: ``flaskr/auth.py`` + + def login_required(view): + @functools.wraps(view) + def wrapped_view(**kwargs): + if g.user is None: + return redirect(url_for('auth.login')) + + return view(**kwargs) + + return wrapped_view + +This decorator returns a new view function that wraps the original view +it's applied to. The new function checks if a user is loaded and +redirects to the login page otherwise. If a user is loaded the original +view is called and continues normally. You'll use this decorator when +writing the blog views. + +Endpoints and URLs +------------------ + +The :func:`url_for` function generates the URL to a view based on a name +and arguments. The name associated with a view is also called the +*endpoint*, and by default it's the same as the name of the view +function. + +For example, the ``hello()`` view that was added to the app +factory earlier in the tutorial has the name ``'hello'`` and can be +linked to with ``url_for('hello')``. If it took an argument, which +you'll see later, it would be linked to using +``url_for('hello', who='World')``. + +When using a blueprint, the name of the blueprint is prepended to the +name of the function, so the endpoint for the ``login`` function you +wrote above is ``'auth.login'`` because you added it to the ``'auth'`` +blueprint. + +Continue to :doc:`templates`. diff --git a/src/flask-main/docs/views.rst b/src/flask-main/docs/views.rst new file mode 100644 index 0000000..f221027 --- /dev/null +++ b/src/flask-main/docs/views.rst @@ -0,0 +1,324 @@ +Class-based Views +================= + +.. currentmodule:: flask.views + +This page introduces using the :class:`View` and :class:`MethodView` +classes to write class-based views. + +A class-based view is a class that acts as a view function. Because it +is a class, different instances of the class can be created with +different arguments, to change the behavior of the view. This is also +known as generic, reusable, or pluggable views. + +An example of where this is useful is defining a class that creates an +API based on the database model it is initialized with. + +For more complex API behavior and customization, look into the various +API extensions for Flask. + + +Basic Reusable View +------------------- + +Let's walk through an example converting a view function to a view +class. We start with a view function that queries a list of users then +renders a template to show the list. + +.. code-block:: python + + @app.route("/users/") + def user_list(): + users = User.query.all() + return render_template("users.html", users=users) + +This works for the user model, but let's say you also had more models +that needed list pages. You'd need to write another view function for +each model, even though the only thing that would change is the model +and template name. + +Instead, you can write a :class:`View` subclass that will query a model +and render a template. As the first step, we'll convert the view to a +class without any customization. + +.. code-block:: python + + from flask.views import View + + class UserList(View): + def dispatch_request(self): + users = User.query.all() + return render_template("users.html", objects=users) + + app.add_url_rule("/users/", view_func=UserList.as_view("user_list")) + +The :meth:`View.dispatch_request` method is the equivalent of the view +function. Calling :meth:`View.as_view` method will create a view +function that can be registered on the app with its +:meth:`~flask.Flask.add_url_rule` method. The first argument to +``as_view`` is the name to use to refer to the view with +:func:`~flask.url_for`. + +.. note:: + + You can't decorate the class with ``@app.route()`` the way you'd + do with a basic view function. + +Next, we need to be able to register the same view class for different +models and templates, to make it more useful than the original function. +The class will take two arguments, the model and template, and store +them on ``self``. Then ``dispatch_request`` can reference these instead +of hard-coded values. + +.. code-block:: python + + class ListView(View): + def __init__(self, model, template): + self.model = model + self.template = template + + def dispatch_request(self): + items = self.model.query.all() + return render_template(self.template, items=items) + +Remember, we create the view function with ``View.as_view()`` instead of +creating the class directly. Any extra arguments passed to ``as_view`` +are then passed when creating the class. Now we can register the same +view to handle multiple models. + +.. code-block:: python + + app.add_url_rule( + "/users/", + view_func=ListView.as_view("user_list", User, "users.html"), + ) + app.add_url_rule( + "/stories/", + view_func=ListView.as_view("story_list", Story, "stories.html"), + ) + + +URL Variables +------------- + +Any variables captured by the URL are passed as keyword arguments to the +``dispatch_request`` method, as they would be for a regular view +function. + +.. code-block:: python + + class DetailView(View): + def __init__(self, model): + self.model = model + self.template = f"{model.__name__.lower()}/detail.html" + + def dispatch_request(self, id) + item = self.model.query.get_or_404(id) + return render_template(self.template, item=item) + + app.add_url_rule( + "/users/", + view_func=DetailView.as_view("user_detail", User) + ) + + +View Lifetime and ``self`` +-------------------------- + +By default, a new instance of the view class is created every time a +request is handled. This means that it is safe to write other data to +``self`` during the request, since the next request will not see it, +unlike other forms of global state. + +However, if your view class needs to do a lot of complex initialization, +doing it for every request is unnecessary and can be inefficient. To +avoid this, set :attr:`View.init_every_request` to ``False``, which will +only create one instance of the class and use it for every request. In +this case, writing to ``self`` is not safe. If you need to store data +during the request, use :data:`~flask.g` instead. + +In the ``ListView`` example, nothing writes to ``self`` during the +request, so it is more efficient to create a single instance. + +.. code-block:: python + + class ListView(View): + init_every_request = False + + def __init__(self, model, template): + self.model = model + self.template = template + + def dispatch_request(self): + items = self.model.query.all() + return render_template(self.template, items=items) + +Different instances will still be created each for each ``as_view`` +call, but not for each request to those views. + + +View Decorators +--------------- + +The view class itself is not the view function. View decorators need to +be applied to the view function returned by ``as_view``, not the class +itself. Set :attr:`View.decorators` to a list of decorators to apply. + +.. code-block:: python + + class UserList(View): + decorators = [cache(minutes=2), login_required] + + app.add_url_rule('/users/', view_func=UserList.as_view()) + +If you didn't set ``decorators``, you could apply them manually instead. +This is equivalent to: + +.. code-block:: python + + view = UserList.as_view("users_list") + view = cache(minutes=2)(view) + view = login_required(view) + app.add_url_rule('/users/', view_func=view) + +Keep in mind that order matters. If you're used to ``@decorator`` style, +this is equivalent to: + +.. code-block:: python + + @app.route("/users/") + @login_required + @cache(minutes=2) + def user_list(): + ... + + +Method Hints +------------ + +A common pattern is to register a view with ``methods=["GET", "POST"]``, +then check ``request.method == "POST"`` to decide what to do. Setting +:attr:`View.methods` is equivalent to passing the list of methods to +``add_url_rule`` or ``route``. + +.. code-block:: python + + class MyView(View): + methods = ["GET", "POST"] + + def dispatch_request(self): + if request.method == "POST": + ... + ... + + app.add_url_rule('/my-view', view_func=MyView.as_view('my-view')) + +This is equivalent to the following, except further subclasses can +inherit or change the methods. + +.. code-block:: python + + app.add_url_rule( + "/my-view", + view_func=MyView.as_view("my-view"), + methods=["GET", "POST"], + ) + + +Method Dispatching and APIs +--------------------------- + +For APIs it can be helpful to use a different function for each HTTP +method. :class:`MethodView` extends the basic :class:`View` to dispatch +to different methods of the class based on the request method. Each HTTP +method maps to a method of the class with the same (lowercase) name. + +:class:`MethodView` automatically sets :attr:`View.methods` based on the +methods defined by the class. It even knows how to handle subclasses +that override or define other methods. + +We can make a generic ``ItemAPI`` class that provides get (detail), +patch (edit), and delete methods for a given model. A ``GroupAPI`` can +provide get (list) and post (create) methods. + +.. code-block:: python + + from flask.views import MethodView + + class ItemAPI(MethodView): + init_every_request = False + + def __init__(self, model): + self.model = model + self.validator = generate_validator(model) + + def _get_item(self, id): + return self.model.query.get_or_404(id) + + def get(self, id): + item = self._get_item(id) + return jsonify(item.to_json()) + + def patch(self, id): + item = self._get_item(id) + errors = self.validator.validate(item, request.json) + + if errors: + return jsonify(errors), 400 + + item.update_from_json(request.json) + db.session.commit() + return jsonify(item.to_json()) + + def delete(self, id): + item = self._get_item(id) + db.session.delete(item) + db.session.commit() + return "", 204 + + class GroupAPI(MethodView): + init_every_request = False + + def __init__(self, model): + self.model = model + self.validator = generate_validator(model, create=True) + + def get(self): + items = self.model.query.all() + return jsonify([item.to_json() for item in items]) + + def post(self): + errors = self.validator.validate(request.json) + + if errors: + return jsonify(errors), 400 + + db.session.add(self.model.from_json(request.json)) + db.session.commit() + return jsonify(item.to_json()) + + def register_api(app, model, name): + item = ItemAPI.as_view(f"{name}-item", model) + group = GroupAPI.as_view(f"{name}-group", model) + app.add_url_rule(f"/{name}/", view_func=item) + app.add_url_rule(f"/{name}/", view_func=group) + + register_api(app, User, "users") + register_api(app, Story, "stories") + +This produces the following views, a standard REST API! + +================= ========== =================== +URL Method Description +----------------- ---------- ------------------- +``/users/`` ``GET`` List all users +``/users/`` ``POST`` Create a new user +``/users/`` ``GET`` Show a single user +``/users/`` ``PATCH`` Update a user +``/users/`` ``DELETE`` Delete a user +``/stories/`` ``GET`` List all stories +``/stories/`` ``POST`` Create a new story +``/stories/`` ``GET`` Show a single story +``/stories/`` ``PATCH`` Update a story +``/stories/`` ``DELETE`` Delete a story +================= ========== =================== diff --git a/src/flask-main/docs/web-security.rst b/src/flask-main/docs/web-security.rst new file mode 100644 index 0000000..4118b5e --- /dev/null +++ b/src/flask-main/docs/web-security.rst @@ -0,0 +1,316 @@ +Security Considerations +======================= + +Web applications face many types of potential security problems, and it can be +hard to get everything right, or even to know what "right" is in general. Flask +tries to solve a few of these things by default, but there are other parts you +may have to take care of yourself. Many of these solutions are tradeoffs, and +will depend on each application's specific needs and threat model. Many hosting +platforms may take care of certain types of problems without the need for the +Flask application to handle them. + +Resource Use +------------ + +A common category of attacks is "Denial of Service" (DoS or DDoS). This is a +very broad category, and different variants target different layers in a +deployed application. In general, something is done to increase how much +processing time or memory is used to handle each request, to the point where +there are not enough resources to handle legitimate requests. + +Flask provides a few configuration options to handle resource use. They can +also be set on individual requests to customize only that request. The +documentation for each goes into more detail. + +- :data:`MAX_CONTENT_LENGTH` or :attr:`.Request.max_content_length` controls + how much data will be read from a request. It is not set by default, + although it will still block truly unlimited streams unless the WSGI server + indicates support. +- :data:`MAX_FORM_MEMORY_SIZE` or :attr:`.Request.max_form_memory_size` + controls how large any non-file ``multipart/form-data`` field can be. It is + set to 500kB by default. +- :data:`MAX_FORM_PARTS` or :attr:`.Request.max_form_parts` controls how many + ``multipart/form-data`` fields can be parsed. It is set to 1000 by default. + Combined with the default `max_form_memory_size`, this means that a form + will occupy at most 500MB of memory. + +Regardless of these settings, you should also review what settings are available +from your operating system, container deployment (Docker etc), WSGI server, HTTP +server, and hosting platform. They typically have ways to set process resource +limits, timeouts, and other checks regardless of how Flask is configured. + +.. _security-xss: + +Cross-Site Scripting (XSS) +-------------------------- + +Cross site scripting is the concept of injecting arbitrary HTML (and with +it JavaScript) into the context of a website. To remedy this, developers +have to properly escape text so that it cannot include arbitrary HTML +tags. For more information on that have a look at the Wikipedia article +on `Cross-Site Scripting +`_. + +Flask configures Jinja to automatically escape all values unless +explicitly told otherwise. This should rule out all XSS problems caused +in templates, but there are still other places where you have to be +careful: + +- generating HTML without the help of Jinja +- calling :class:`~markupsafe.Markup` on data submitted by users +- sending out HTML from uploaded files, never do that, use the + ``Content-Disposition: attachment`` header to prevent that problem. +- sending out textfiles from uploaded files. Some browsers are using + content-type guessing based on the first few bytes so users could + trick a browser to execute HTML. + +Another thing that is very important are unquoted attributes. While +Jinja can protect you from XSS issues by escaping HTML, there is one +thing it cannot protect you from: XSS by attribute injection. To counter +this possible attack vector, be sure to always quote your attributes with +either double or single quotes when using Jinja expressions in them: + +.. sourcecode:: html+jinja + + + +Why is this necessary? Because if you would not be doing that, an +attacker could easily inject custom JavaScript handlers. For example an +attacker could inject this piece of HTML+JavaScript: + +.. sourcecode:: html + + onmouseover=alert(document.cookie) + +When the user would then move with the mouse over the input, the cookie +would be presented to the user in an alert window. But instead of showing +the cookie to the user, a good attacker might also execute any other +JavaScript code. In combination with CSS injections the attacker might +even make the element fill out the entire page so that the user would +just have to have the mouse anywhere on the page to trigger the attack. + +There is one class of XSS issues that Jinja's escaping does not protect +against. The ``a`` tag's ``href`` attribute can contain a `javascript:` URI, +which the browser will execute when clicked if not secured properly. + +.. sourcecode:: html + + click here + click here + +To prevent this, you'll need to set the :ref:`security-csp` response header. + +Cross-Site Request Forgery (CSRF) +--------------------------------- + +Another big problem is CSRF. This is a very complex topic and I won't +outline it here in detail just mention what it is and how to theoretically +prevent it. + +If your authentication information is stored in cookies, you have implicit +state management. The state of "being logged in" is controlled by a +cookie, and that cookie is sent with each request to a page. +Unfortunately that includes requests triggered by 3rd party sites. If you +don't keep that in mind, some people might be able to trick your +application's users with social engineering to do stupid things without +them knowing. + +Say you have a specific URL that, when you sent ``POST`` requests to will +delete a user's profile (say ``http://example.com/user/delete``). If an +attacker now creates a page that sends a post request to that page with +some JavaScript they just have to trick some users to load that page and +their profiles will end up being deleted. + +Imagine you were to run Facebook with millions of concurrent users and +someone would send out links to images of little kittens. When users +would go to that page, their profiles would get deleted while they are +looking at images of fluffy cats. + +How can you prevent that? Basically for each request that modifies +content on the server you would have to either use a one-time token and +store that in the cookie **and** also transmit it with the form data. +After receiving the data on the server again, you would then have to +compare the two tokens and ensure they are equal. + +Why does Flask not do that for you? The ideal place for this to happen is +the form validation framework, which does not exist in Flask. + +.. _security-json: + +JSON Security +------------- + +In Flask 0.10 and lower, :func:`~flask.jsonify` did not serialize top-level +arrays to JSON. This was because of a security vulnerability in ECMAScript 4. + +ECMAScript 5 closed this vulnerability, so only extremely old browsers are +still vulnerable. All of these browsers have `other more serious +vulnerabilities +`_, so +this behavior was changed and :func:`~flask.jsonify` now supports serializing +arrays. + +Security Headers +---------------- + +Browsers recognize various response headers in order to control security. We +recommend reviewing each of the headers below for use in your application. +The `Flask-Talisman`_ extension can be used to manage HTTPS and the security +headers for you. + +.. _Flask-Talisman: https://github.com/wntrblm/flask-talisman + +HTTP Strict Transport Security (HSTS) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tells the browser to convert all HTTP requests to HTTPS, preventing +man-in-the-middle (MITM) attacks. :: + + response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' + +- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security + +.. _security-csp: + +Content Security Policy (CSP) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tell the browser where it can load various types of resource from. This header +should be used whenever possible, but requires some work to define the correct +policy for your site. A very strict policy would be:: + + response.headers['Content-Security-Policy'] = "default-src 'self'" + +- https://csp.withgoogle.com/docs/index.html +- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + +X-Content-Type-Options +~~~~~~~~~~~~~~~~~~~~~~ + +Forces the browser to honor the response content type instead of trying to +detect it, which can be abused to generate a cross-site scripting (XSS) +attack. :: + + response.headers['X-Content-Type-Options'] = 'nosniff' + +- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options + +X-Frame-Options +~~~~~~~~~~~~~~~ + +Prevents external sites from embedding your site in an ``iframe``. This +prevents a class of attacks where clicks in the outer frame can be translated +invisibly to clicks on your page's elements. This is also known as +"clickjacking". :: + + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + +- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options + +.. _security-cookie: + +Set-Cookie options +~~~~~~~~~~~~~~~~~~ + +These options can be added to a ``Set-Cookie`` header to improve their +security. Flask has configuration options to set these on the session cookie. +They can be set on other cookies too. + +- ``Secure`` limits cookies to HTTPS traffic only. +- ``HttpOnly`` protects the contents of cookies from being read with + JavaScript. +- ``SameSite`` restricts how cookies are sent with requests from + external sites. Can be set to ``'Lax'`` (recommended) or ``'Strict'``. + ``Lax`` prevents sending cookies with CSRF-prone requests from + external sites, such as submitting a form. ``Strict`` prevents sending + cookies with all external requests, including following regular links. + +:: + + app.config.update( + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE='Lax', + ) + + response.set_cookie('username', 'flask', secure=True, httponly=True, samesite='Lax') + +Specifying ``Expires`` or ``Max-Age`` options, will remove the cookie after +the given time, or the current time plus the age, respectively. If neither +option is set, the cookie will be removed when the browser is closed. :: + + # cookie expires after 10 minutes + response.set_cookie('snakes', '3', max_age=600) + +For the session cookie, if :attr:`session.permanent ` +is set, then :data:`PERMANENT_SESSION_LIFETIME` is used to set the expiration. +Flask's default cookie implementation validates that the cryptographic +signature is not older than this value. Lowering this value may help mitigate +replay attacks, where intercepted cookies can be sent at a later time. :: + + app.config.update( + PERMANENT_SESSION_LIFETIME=600 + ) + + @app.route('/login', methods=['POST']) + def login(): + ... + session.clear() + session['user_id'] = user.id + session.permanent = True + ... + +Use :class:`itsdangerous.TimedSerializer` to sign and validate other cookie +values (or any values that need secure signatures). + +- https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies +- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie + +.. _samesite_support: https://caniuse.com/#feat=same-site-cookie-attribute + + +Host Header Validation +---------------------- + +The ``Host`` header is used by the client to indicate what host name the request +was made to. This is used, for example, by ``url_for(..., _external=True)`` to +generate full URLs, for use in email or other messages outside the browser +window. + +By default the app doesn't know what host(s) it is allowed to be accessed +through, and assumes any host is valid. Although browsers do not allow setting +the ``Host`` header, requests made by attackers in other scenarios could set +the ``Host`` header to a value they want. + +When deploying your application, set :data:`TRUSTED_HOSTS` to restrict what +values the ``Host`` header may be. + +The ``Host`` header may be modified by proxies in between the client and your +application. See :doc:`deploying/proxy_fix` to tell your app which proxy values +to trust. + + +Copy/Paste to Terminal +---------------------- + +Hidden characters such as the backspace character (``\b``, ``^H``) can +cause text to render differently in HTML than how it is interpreted if +`pasted into a terminal `__. + +For example, ``import y\bose\bm\bi\bt\be\b`` renders as +``import yosemite`` in HTML, but the backspaces are applied when pasted +into a terminal, and it becomes ``import os``. + +If you expect users to copy and paste untrusted code from your site, +such as from comments posted by users on a technical blog, consider +applying extra filtering, such as replacing all ``\b`` characters. + +.. code-block:: python + + body = body.replace("\b", "") + +Most modern terminals will warn about and remove hidden characters when +pasting, so this isn't strictly necessary. It's also possible to craft +dangerous commands in other ways that aren't possible to filter. +Depending on your site's use case, it may be good to show a warning +about copying code in general. diff --git a/src/flask-main/examples/celery/README.md b/src/flask-main/examples/celery/README.md new file mode 100644 index 0000000..038eb51 --- /dev/null +++ b/src/flask-main/examples/celery/README.md @@ -0,0 +1,27 @@ +Background Tasks with Celery +============================ + +This example shows how to configure Celery with Flask, how to set up an API for +submitting tasks and polling results, and how to use that API with JavaScript. See +[Flask's documentation about Celery](https://flask.palletsprojects.com/patterns/celery/). + +From this directory, create a virtualenv and install the application into it. Then run a +Celery worker. + +```shell +$ python3 -m venv .venv +$ . ./.venv/bin/activate +$ pip install -r requirements.txt && pip install -e . +$ celery -A make_celery worker --loglevel INFO +``` + +In a separate terminal, activate the virtualenv and run the Flask development server. + +```shell +$ . ./.venv/bin/activate +$ flask -A task_app run --debug +``` + +Go to http://localhost:5000/ and use the forms to submit tasks. You can see the polling +requests in the browser dev tools and the Flask logs. You can see the tasks submitting +and completing in the Celery logs. diff --git a/src/flask-main/examples/celery/make_celery.py b/src/flask-main/examples/celery/make_celery.py new file mode 100644 index 0000000..f7d138e --- /dev/null +++ b/src/flask-main/examples/celery/make_celery.py @@ -0,0 +1,4 @@ +from task_app import create_app + +flask_app = create_app() +celery_app = flask_app.extensions["celery"] diff --git a/src/flask-main/examples/celery/pyproject.toml b/src/flask-main/examples/celery/pyproject.toml new file mode 100644 index 0000000..cca36d8 --- /dev/null +++ b/src/flask-main/examples/celery/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "flask-example-celery" +version = "1.0.0" +description = "Example Flask application with Celery background tasks." +readme = "README.md" +classifiers = ["Private :: Do Not Upload"] +dependencies = ["flask", "celery[redis]"] + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "task_app" + +[tool.ruff] +src = ["src"] diff --git a/src/flask-main/examples/celery/requirements.txt b/src/flask-main/examples/celery/requirements.txt new file mode 100644 index 0000000..29075ab --- /dev/null +++ b/src/flask-main/examples/celery/requirements.txt @@ -0,0 +1,58 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile --resolver=backtracking pyproject.toml +# +amqp==5.1.1 + # via kombu +async-timeout==4.0.2 + # via redis +billiard==3.6.4.0 + # via celery +blinker==1.6.2 + # via flask +celery[redis]==5.2.7 + # via flask-example-celery (pyproject.toml) +click==8.1.3 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl + # flask +click-didyoumean==0.3.0 + # via celery +click-plugins==1.1.1 + # via celery +click-repl==0.2.0 + # via celery +flask==2.3.2 + # via flask-example-celery (pyproject.toml) +itsdangerous==2.1.2 + # via flask +jinja2==3.1.2 + # via flask +kombu==5.2.4 + # via celery +markupsafe==2.1.2 + # via + # jinja2 + # werkzeug +prompt-toolkit==3.0.38 + # via click-repl +pytz==2023.3 + # via celery +redis==4.5.4 + # via celery +six==1.16.0 + # via click-repl +vine==5.0.0 + # via + # amqp + # celery + # kombu +wcwidth==0.2.6 + # via prompt-toolkit +werkzeug==2.3.3 + # via flask diff --git a/src/flask-main/examples/celery/src/task_app/__init__.py b/src/flask-main/examples/celery/src/task_app/__init__.py new file mode 100644 index 0000000..dafff8a --- /dev/null +++ b/src/flask-main/examples/celery/src/task_app/__init__.py @@ -0,0 +1,39 @@ +from celery import Celery +from celery import Task +from flask import Flask +from flask import render_template + + +def create_app() -> Flask: + app = Flask(__name__) + app.config.from_mapping( + CELERY=dict( + broker_url="redis://localhost", + result_backend="redis://localhost", + task_ignore_result=True, + ), + ) + app.config.from_prefixed_env() + celery_init_app(app) + + @app.route("/") + def index() -> str: + return render_template("index.html") + + from . import views + + app.register_blueprint(views.bp) + return app + + +def celery_init_app(app: Flask) -> Celery: + class FlaskTask(Task): + def __call__(self, *args: object, **kwargs: object) -> object: + with app.app_context(): + return self.run(*args, **kwargs) + + celery_app = Celery(app.name, task_cls=FlaskTask) + celery_app.config_from_object(app.config["CELERY"]) + celery_app.set_default() + app.extensions["celery"] = celery_app + return celery_app diff --git a/src/flask-main/examples/celery/src/task_app/tasks.py b/src/flask-main/examples/celery/src/task_app/tasks.py new file mode 100644 index 0000000..b6b3595 --- /dev/null +++ b/src/flask-main/examples/celery/src/task_app/tasks.py @@ -0,0 +1,23 @@ +import time + +from celery import shared_task +from celery import Task + + +@shared_task(ignore_result=False) +def add(a: int, b: int) -> int: + return a + b + + +@shared_task() +def block() -> None: + time.sleep(5) + + +@shared_task(bind=True, ignore_result=False) +def process(self: Task, total: int) -> object: + for i in range(total): + self.update_state(state="PROGRESS", meta={"current": i + 1, "total": total}) + time.sleep(1) + + return {"current": total, "total": total} diff --git a/src/flask-main/examples/celery/src/task_app/templates/index.html b/src/flask-main/examples/celery/src/task_app/templates/index.html new file mode 100644 index 0000000..4e1145c --- /dev/null +++ b/src/flask-main/examples/celery/src/task_app/templates/index.html @@ -0,0 +1,108 @@ + + + + + Celery Example + + +

Celery Example

+Execute background tasks with Celery. Submits tasks and shows results using JavaScript. + +
+

Add

+

Start a task to add two numbers, then poll for the result. +

+
+
+ +
+

Result:

+ +
+

Block

+

Start a task that takes 5 seconds. However, the response will return immediately. +

+ +
+

+ +
+

Process

+

Start a task that counts, waiting one second each time, showing progress. +

+
+ +
+

+ + + + diff --git a/src/flask-main/examples/celery/src/task_app/views.py b/src/flask-main/examples/celery/src/task_app/views.py new file mode 100644 index 0000000..99cf92d --- /dev/null +++ b/src/flask-main/examples/celery/src/task_app/views.py @@ -0,0 +1,38 @@ +from celery.result import AsyncResult +from flask import Blueprint +from flask import request + +from . import tasks + +bp = Blueprint("tasks", __name__, url_prefix="/tasks") + + +@bp.get("/result/") +def result(id: str) -> dict[str, object]: + result = AsyncResult(id) + ready = result.ready() + return { + "ready": ready, + "successful": result.successful() if ready else None, + "value": result.get() if ready else result.result, + } + + +@bp.post("/add") +def add() -> dict[str, object]: + a = request.form.get("a", type=int) + b = request.form.get("b", type=int) + result = tasks.add.delay(a, b) + return {"result_id": result.id} + + +@bp.post("/block") +def block() -> dict[str, object]: + result = tasks.block.delay() + return {"result_id": result.id} + + +@bp.post("/process") +def process() -> dict[str, object]: + result = tasks.process.delay(total=request.form.get("total", type=int)) + return {"result_id": result.id} diff --git a/src/flask-main/examples/javascript/.gitignore b/src/flask-main/examples/javascript/.gitignore new file mode 100644 index 0000000..a306afb --- /dev/null +++ b/src/flask-main/examples/javascript/.gitignore @@ -0,0 +1,14 @@ +.venv/ +*.pyc +__pycache__/ +instance/ +.cache/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ +.idea/ +*.swp +*~ diff --git a/src/flask-main/examples/javascript/LICENSE.txt b/src/flask-main/examples/javascript/LICENSE.txt new file mode 100644 index 0000000..9d227a0 --- /dev/null +++ b/src/flask-main/examples/javascript/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 2010 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/flask-main/examples/javascript/README.rst b/src/flask-main/examples/javascript/README.rst new file mode 100644 index 0000000..f5f6691 --- /dev/null +++ b/src/flask-main/examples/javascript/README.rst @@ -0,0 +1,48 @@ +JavaScript Ajax Example +======================= + +Demonstrates how to post form data and process a JSON response using +JavaScript. This allows making requests without navigating away from the +page. Demonstrates using |fetch|_, |XMLHttpRequest|_, and +|jQuery.ajax|_. See the `Flask docs`_ about JavaScript and Ajax. + +.. |fetch| replace:: ``fetch`` +.. _fetch: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch + +.. |XMLHttpRequest| replace:: ``XMLHttpRequest`` +.. _XMLHttpRequest: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest + +.. |jQuery.ajax| replace:: ``jQuery.ajax`` +.. _jQuery.ajax: https://api.jquery.com/jQuery.ajax/ + +.. _Flask docs: https://flask.palletsprojects.com/patterns/javascript/ + + +Install +------- + +.. code-block:: text + + $ python3 -m venv .venv + $ . .venv/bin/activate + $ pip install -e . + + +Run +--- + +.. code-block:: text + + $ flask --app js_example run + +Open http://127.0.0.1:5000 in a browser. + + +Test +---- + +.. code-block:: text + + $ pip install -e '.[test]' + $ coverage run -m pytest + $ coverage report diff --git a/src/flask-main/examples/javascript/js_example/__init__.py b/src/flask-main/examples/javascript/js_example/__init__.py new file mode 100644 index 0000000..0ec3ca2 --- /dev/null +++ b/src/flask-main/examples/javascript/js_example/__init__.py @@ -0,0 +1,5 @@ +from flask import Flask + +app = Flask(__name__) + +from js_example import views # noqa: E402, F401 diff --git a/src/flask-main/examples/javascript/js_example/templates/base.html b/src/flask-main/examples/javascript/js_example/templates/base.html new file mode 100644 index 0000000..a4d35bd --- /dev/null +++ b/src/flask-main/examples/javascript/js_example/templates/base.html @@ -0,0 +1,33 @@ + +JavaScript Example + + + + +
+

{% block intro %}{% endblock %}

+
+
+ + + + + +
+= +{% block script %}{% endblock %} diff --git a/src/flask-main/examples/javascript/js_example/templates/fetch.html b/src/flask-main/examples/javascript/js_example/templates/fetch.html new file mode 100644 index 0000000..e2944b8 --- /dev/null +++ b/src/flask-main/examples/javascript/js_example/templates/fetch.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block intro %} + fetch + is the modern plain JavaScript way to make requests. It's + supported in all modern browsers. +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/src/flask-main/examples/javascript/js_example/templates/jquery.html b/src/flask-main/examples/javascript/js_example/templates/jquery.html new file mode 100644 index 0000000..48f0c11 --- /dev/null +++ b/src/flask-main/examples/javascript/js_example/templates/jquery.html @@ -0,0 +1,27 @@ +{% extends 'base.html' %} + +{% block intro %} + jQuery is a popular library that + adds cross browser APIs for common tasks. However, it requires loading + an extra library. +{% endblock %} + +{% block script %} + + +{% endblock %} diff --git a/src/flask-main/examples/javascript/js_example/templates/xhr.html b/src/flask-main/examples/javascript/js_example/templates/xhr.html new file mode 100644 index 0000000..1672d4d --- /dev/null +++ b/src/flask-main/examples/javascript/js_example/templates/xhr.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} + +{% block intro %} + XMLHttpRequest + is the original JavaScript way to make requests. It's natively supported + by all browsers, but has been superseded by + fetch. +{% endblock %} + +{% block script %} + +{% endblock %} diff --git a/src/flask-main/examples/javascript/js_example/views.py b/src/flask-main/examples/javascript/js_example/views.py new file mode 100644 index 0000000..9f0d26c --- /dev/null +++ b/src/flask-main/examples/javascript/js_example/views.py @@ -0,0 +1,18 @@ +from flask import jsonify +from flask import render_template +from flask import request + +from . import app + + +@app.route("/", defaults={"js": "fetch"}) +@app.route("/") +def index(js): + return render_template(f"{js}.html", js=js) + + +@app.route("/add", methods=["POST"]) +def add(): + a = request.form.get("a", 0, type=float) + b = request.form.get("b", 0, type=float) + return jsonify(result=a + b) diff --git a/src/flask-main/examples/javascript/pyproject.toml b/src/flask-main/examples/javascript/pyproject.toml new file mode 100644 index 0000000..ea0efab --- /dev/null +++ b/src/flask-main/examples/javascript/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "js_example" +version = "1.1.0" +description = "Demonstrates making AJAX requests to Flask." +readme = "README.rst" +license = {file = "LICENSE.txt"} +maintainers = [{name = "Pallets", email = "contact@palletsprojects.com"}] +classifiers = ["Private :: Do Not Upload"] +dependencies = ["flask"] + +[project.urls] +Documentation = "https://flask.palletsprojects.com/patterns/javascript/" + +[project.optional-dependencies] +test = ["pytest"] + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "js_example" + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = ["error"] + +[tool.coverage.run] +branch = true +source = ["js_example", "tests"] + +[tool.ruff] +src = ["src"] diff --git a/src/flask-main/examples/javascript/tests/conftest.py b/src/flask-main/examples/javascript/tests/conftest.py new file mode 100644 index 0000000..e0cabbf --- /dev/null +++ b/src/flask-main/examples/javascript/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from js_example import app + + +@pytest.fixture(name="app") +def fixture_app(): + app.testing = True + yield app + app.testing = False + + +@pytest.fixture +def client(app): + return app.test_client() diff --git a/src/flask-main/examples/javascript/tests/test_js_example.py b/src/flask-main/examples/javascript/tests/test_js_example.py new file mode 100644 index 0000000..856f5f7 --- /dev/null +++ b/src/flask-main/examples/javascript/tests/test_js_example.py @@ -0,0 +1,27 @@ +import pytest +from flask import template_rendered + + +@pytest.mark.parametrize( + ("path", "template_name"), + ( + ("/", "fetch.html"), + ("/plain", "xhr.html"), + ("/fetch", "fetch.html"), + ("/jquery", "jquery.html"), + ), +) +def test_index(app, client, path, template_name): + def check(sender, template, context): + assert template.name == template_name + + with template_rendered.connected_to(check, app): + client.get(path) + + +@pytest.mark.parametrize( + ("a", "b", "result"), ((2, 3, 5), (2.5, 3, 5.5), (2, None, 2), (2, "b", 2)) +) +def test_add(client, a, b, result): + response = client.post("/add", data={"a": a, "b": b}) + assert response.get_json()["result"] == result diff --git a/src/flask-main/examples/tutorial/.gitignore b/src/flask-main/examples/tutorial/.gitignore new file mode 100644 index 0000000..a306afb --- /dev/null +++ b/src/flask-main/examples/tutorial/.gitignore @@ -0,0 +1,14 @@ +.venv/ +*.pyc +__pycache__/ +instance/ +.cache/ +.pytest_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.egg-info/ +.idea/ +*.swp +*~ diff --git a/src/flask-main/examples/tutorial/LICENSE.txt b/src/flask-main/examples/tutorial/LICENSE.txt new file mode 100644 index 0000000..9d227a0 --- /dev/null +++ b/src/flask-main/examples/tutorial/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 2010 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/flask-main/examples/tutorial/README.rst b/src/flask-main/examples/tutorial/README.rst new file mode 100644 index 0000000..653c216 --- /dev/null +++ b/src/flask-main/examples/tutorial/README.rst @@ -0,0 +1,68 @@ +Flaskr +====== + +The basic blog app built in the Flask `tutorial`_. + +.. _tutorial: https://flask.palletsprojects.com/tutorial/ + + +Install +------- + +**Be sure to use the same version of the code as the version of the docs +you're reading.** You probably want the latest tagged version, but the +default Git version is the main branch. :: + + # clone the repository + $ git clone https://github.com/pallets/flask + $ cd flask + # checkout the correct version + $ git tag # shows the tagged versions + $ git checkout latest-tag-found-above + $ cd examples/tutorial + +Create a virtualenv and activate it:: + + $ python3 -m venv .venv + $ . .venv/bin/activate + +Or on Windows cmd:: + + $ py -3 -m venv .venv + $ .venv\Scripts\activate.bat + +Install Flaskr:: + + $ pip install -e . + +Or if you are using the main branch, install Flask from source before +installing Flaskr:: + + $ pip install -e ../.. + $ pip install -e . + + +Run +--- + +.. code-block:: text + + $ flask --app flaskr init-db + $ flask --app flaskr run --debug + +Open http://127.0.0.1:5000 in a browser. + + +Test +---- + +:: + + $ pip install '.[test]' + $ pytest + +Run with coverage report:: + + $ coverage run -m pytest + $ coverage report + $ coverage html # open htmlcov/index.html in a browser diff --git a/src/flask-main/examples/tutorial/flaskr/__init__.py b/src/flask-main/examples/tutorial/flaskr/__init__.py new file mode 100644 index 0000000..e35934d --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/__init__.py @@ -0,0 +1,51 @@ +import os + +from flask import Flask + + +def create_app(test_config=None): + """Create and configure an instance of the Flask application.""" + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + # a default secret that should be overridden by instance config + SECRET_KEY="dev", + # store the database in the instance folder + DATABASE=os.path.join(app.instance_path, "flaskr.sqlite"), + ) + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile("config.py", silent=True) + else: + # load the test config if passed in + app.config.update(test_config) + + # ensure the instance folder exists + try: + os.makedirs(app.instance_path) + except OSError: + pass + + @app.route("/hello") + def hello(): + return "Hello, World!" + + # register the database commands + from . import db + + db.init_app(app) + + # apply the blueprints to the app + from . import auth + from . import blog + + app.register_blueprint(auth.bp) + app.register_blueprint(blog.bp) + + # make url_for('index') == url_for('blog.index') + # in another app, you might define a separate main index here with + # app.route, while giving the blog blueprint a url_prefix, but for + # the tutorial the blog will be the main index + app.add_url_rule("/", endpoint="index") + + return app diff --git a/src/flask-main/examples/tutorial/flaskr/auth.py b/src/flask-main/examples/tutorial/flaskr/auth.py new file mode 100644 index 0000000..34c03a2 --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/auth.py @@ -0,0 +1,116 @@ +import functools + +from flask import Blueprint +from flask import flash +from flask import g +from flask import redirect +from flask import render_template +from flask import request +from flask import session +from flask import url_for +from werkzeug.security import check_password_hash +from werkzeug.security import generate_password_hash + +from .db import get_db + +bp = Blueprint("auth", __name__, url_prefix="/auth") + + +def login_required(view): + """View decorator that redirects anonymous users to the login page.""" + + @functools.wraps(view) + def wrapped_view(**kwargs): + if g.user is None: + return redirect(url_for("auth.login")) + + return view(**kwargs) + + return wrapped_view + + +@bp.before_app_request +def load_logged_in_user(): + """If a user id is stored in the session, load the user object from + the database into ``g.user``.""" + user_id = session.get("user_id") + + if user_id is None: + g.user = None + else: + g.user = ( + get_db().execute("SELECT * FROM user WHERE id = ?", (user_id,)).fetchone() + ) + + +@bp.route("/register", methods=("GET", "POST")) +def register(): + """Register a new user. + + Validates that the username is not already taken. Hashes the + password for security. + """ + if request.method == "POST": + username = request.form["username"] + password = request.form["password"] + db = get_db() + error = None + + if not username: + error = "Username is required." + elif not password: + error = "Password is required." + + if error is None: + try: + db.execute( + "INSERT INTO user (username, password) VALUES (?, ?)", + (username, generate_password_hash(password)), + ) + db.commit() + except db.IntegrityError: + # The username was already taken, which caused the + # commit to fail. Show a validation error. + error = f"User {username} is already registered." + else: + # Success, go to the login page. + return redirect(url_for("auth.login")) + + flash(error) + + return render_template("auth/register.html") + + +@bp.route("/login", methods=("GET", "POST")) +def login(): + """Log in a registered user by adding the user id to the session.""" + if request.method == "POST": + username = request.form["username"] + password = request.form["password"] + db = get_db() + error = None + user = db.execute( + "SELECT * FROM user WHERE username = ?", (username,) + ).fetchone() + + if user is None: + error = "Incorrect username." + elif not check_password_hash(user["password"], password): + error = "Incorrect password." + + if error is None: + # store the user id in a new session and return to the index + session.clear() + session["user_id"] = user["id"] + return redirect(url_for("index")) + + flash(error) + + return render_template("auth/login.html") + + +@bp.route("/logout") +def logout(): + """Clear the current session, including the stored user id.""" + session.clear() + return redirect(url_for("index")) diff --git a/src/flask-main/examples/tutorial/flaskr/blog.py b/src/flask-main/examples/tutorial/flaskr/blog.py new file mode 100644 index 0000000..be0d92c --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/blog.py @@ -0,0 +1,125 @@ +from flask import Blueprint +from flask import flash +from flask import g +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for +from werkzeug.exceptions import abort + +from .auth import login_required +from .db import get_db + +bp = Blueprint("blog", __name__) + + +@bp.route("/") +def index(): + """Show all the posts, most recent first.""" + db = get_db() + posts = db.execute( + "SELECT p.id, title, body, created, author_id, username" + " FROM post p JOIN user u ON p.author_id = u.id" + " ORDER BY created DESC" + ).fetchall() + return render_template("blog/index.html", posts=posts) + + +def get_post(id, check_author=True): + """Get a post and its author by id. + + Checks that the id exists and optionally that the current user is + the author. + + :param id: id of post to get + :param check_author: require the current user to be the author + :return: the post with author information + :raise 404: if a post with the given id doesn't exist + :raise 403: if the current user isn't the author + """ + post = ( + get_db() + .execute( + "SELECT p.id, title, body, created, author_id, username" + " FROM post p JOIN user u ON p.author_id = u.id" + " WHERE p.id = ?", + (id,), + ) + .fetchone() + ) + + if post is None: + abort(404, f"Post id {id} doesn't exist.") + + if check_author and post["author_id"] != g.user["id"]: + abort(403) + + return post + + +@bp.route("/create", methods=("GET", "POST")) +@login_required +def create(): + """Create a new post for the current user.""" + if request.method == "POST": + title = request.form["title"] + body = request.form["body"] + error = None + + if not title: + error = "Title is required." + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + "INSERT INTO post (title, body, author_id) VALUES (?, ?, ?)", + (title, body, g.user["id"]), + ) + db.commit() + return redirect(url_for("blog.index")) + + return render_template("blog/create.html") + + +@bp.route("//update", methods=("GET", "POST")) +@login_required +def update(id): + """Update a post if the current user is the author.""" + post = get_post(id) + + if request.method == "POST": + title = request.form["title"] + body = request.form["body"] + error = None + + if not title: + error = "Title is required." + + if error is not None: + flash(error) + else: + db = get_db() + db.execute( + "UPDATE post SET title = ?, body = ? WHERE id = ?", (title, body, id) + ) + db.commit() + return redirect(url_for("blog.index")) + + return render_template("blog/update.html", post=post) + + +@bp.route("//delete", methods=("POST",)) +@login_required +def delete(id): + """Delete a post. + + Ensures that the post exists and that the logged in user is the + author of the post. + """ + get_post(id) + db = get_db() + db.execute("DELETE FROM post WHERE id = ?", (id,)) + db.commit() + return redirect(url_for("blog.index")) diff --git a/src/flask-main/examples/tutorial/flaskr/db.py b/src/flask-main/examples/tutorial/flaskr/db.py new file mode 100644 index 0000000..dec22fd --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/db.py @@ -0,0 +1,56 @@ +import sqlite3 +from datetime import datetime + +import click +from flask import current_app +from flask import g + + +def get_db(): + """Connect to the application's configured database. The connection + is unique for each request and will be reused if this is called + again. + """ + if "db" not in g: + g.db = sqlite3.connect( + current_app.config["DATABASE"], detect_types=sqlite3.PARSE_DECLTYPES + ) + g.db.row_factory = sqlite3.Row + + return g.db + + +def close_db(e=None): + """If this request connected to the database, close the + connection. + """ + db = g.pop("db", None) + + if db is not None: + db.close() + + +def init_db(): + """Clear existing data and create new tables.""" + db = get_db() + + with current_app.open_resource("schema.sql") as f: + db.executescript(f.read().decode("utf8")) + + +@click.command("init-db") +def init_db_command(): + """Clear existing data and create new tables.""" + init_db() + click.echo("Initialized the database.") + + +sqlite3.register_converter("timestamp", lambda v: datetime.fromisoformat(v.decode())) + + +def init_app(app): + """Register database functions with the Flask app. This is called by + the application factory. + """ + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) diff --git a/src/flask-main/examples/tutorial/flaskr/schema.sql b/src/flask-main/examples/tutorial/flaskr/schema.sql new file mode 100644 index 0000000..dd4c866 --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/schema.sql @@ -0,0 +1,20 @@ +-- Initialize the database. +-- Drop any existing data and create empty tables. + +DROP TABLE IF EXISTS user; +DROP TABLE IF EXISTS post; + +CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL +); + +CREATE TABLE post ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + author_id INTEGER NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + title TEXT NOT NULL, + body TEXT NOT NULL, + FOREIGN KEY (author_id) REFERENCES user (id) +); diff --git a/src/flask-main/examples/tutorial/flaskr/static/style.css b/src/flask-main/examples/tutorial/flaskr/static/style.css new file mode 100644 index 0000000..2f1f4d0 --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/static/style.css @@ -0,0 +1,134 @@ +html { + font-family: sans-serif; + background: #eee; + padding: 1rem; +} + +body { + max-width: 960px; + margin: 0 auto; + background: white; +} + +h1, h2, h3, h4, h5, h6 { + font-family: serif; + color: #377ba8; + margin: 1rem 0; +} + +a { + color: #377ba8; +} + +hr { + border: none; + border-top: 1px solid lightgray; +} + +nav { + background: lightgray; + display: flex; + align-items: center; + padding: 0 0.5rem; +} + +nav h1 { + flex: auto; + margin: 0; +} + +nav h1 a { + text-decoration: none; + padding: 0.25rem 0.5rem; +} + +nav ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; +} + +nav ul li a, nav ul li span, header .action { + display: block; + padding: 0.5rem; +} + +.content { + padding: 0 1rem 1rem; +} + +.content > header { + border-bottom: 1px solid lightgray; + display: flex; + align-items: flex-end; +} + +.content > header h1 { + flex: auto; + margin: 1rem 0 0.25rem 0; +} + +.flash { + margin: 1em 0; + padding: 1em; + background: #cae6f6; + border: 1px solid #377ba8; +} + +.post > header { + display: flex; + align-items: flex-end; + font-size: 0.85em; +} + +.post > header > div:first-of-type { + flex: auto; +} + +.post > header h1 { + font-size: 1.5em; + margin-bottom: 0; +} + +.post .about { + color: slategray; + font-style: italic; +} + +.post .body { + white-space: pre-line; +} + +.content:last-child { + margin-bottom: 0; +} + +.content form { + margin: 1em 0; + display: flex; + flex-direction: column; +} + +.content label { + font-weight: bold; + margin-bottom: 0.5em; +} + +.content input, .content textarea { + margin-bottom: 1em; +} + +.content textarea { + min-height: 12em; + resize: vertical; +} + +input.danger { + color: #cc2f2e; +} + +input[type=submit] { + align-self: start; + min-width: 10em; +} diff --git a/src/flask-main/examples/tutorial/flaskr/templates/auth/login.html b/src/flask-main/examples/tutorial/flaskr/templates/auth/login.html new file mode 100644 index 0000000..b326b5a --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/templates/auth/login.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Log In{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/src/flask-main/examples/tutorial/flaskr/templates/auth/register.html b/src/flask-main/examples/tutorial/flaskr/templates/auth/register.html new file mode 100644 index 0000000..4320e17 --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/templates/auth/register.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Register{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/src/flask-main/examples/tutorial/flaskr/templates/base.html b/src/flask-main/examples/tutorial/flaskr/templates/base.html new file mode 100644 index 0000000..f09e926 --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/templates/base.html @@ -0,0 +1,24 @@ + +{% block title %}{% endblock %} - Flaskr + + +
+
+ {% block header %}{% endblock %} +
+ {% for message in get_flashed_messages() %} +
{{ message }}
+ {% endfor %} + {% block content %}{% endblock %} +
diff --git a/src/flask-main/examples/tutorial/flaskr/templates/blog/create.html b/src/flask-main/examples/tutorial/flaskr/templates/blog/create.html new file mode 100644 index 0000000..88e31e4 --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/templates/blog/create.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}New Post{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+{% endblock %} diff --git a/src/flask-main/examples/tutorial/flaskr/templates/blog/index.html b/src/flask-main/examples/tutorial/flaskr/templates/blog/index.html new file mode 100644 index 0000000..3481b8e --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/templates/blog/index.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Posts{% endblock %}

+ {% if g.user %} + New + {% endif %} +{% endblock %} + +{% block content %} + {% for post in posts %} +
+
+
+

{{ post['title'] }}

+
by {{ post['username'] }} on {{ post['created'].strftime('%Y-%m-%d') }}
+
+ {% if g.user['id'] == post['author_id'] %} + Edit + {% endif %} +
+

{{ post['body'] }}

+
+ {% if not loop.last %} +
+ {% endif %} + {% endfor %} +{% endblock %} diff --git a/src/flask-main/examples/tutorial/flaskr/templates/blog/update.html b/src/flask-main/examples/tutorial/flaskr/templates/blog/update.html new file mode 100644 index 0000000..2c405e6 --- /dev/null +++ b/src/flask-main/examples/tutorial/flaskr/templates/blog/update.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block header %} +

{% block title %}Edit "{{ post['title'] }}"{% endblock %}

+{% endblock %} + +{% block content %} +
+ + + + + +
+
+
+ +
+{% endblock %} diff --git a/src/flask-main/examples/tutorial/pyproject.toml b/src/flask-main/examples/tutorial/pyproject.toml new file mode 100644 index 0000000..ca31e53 --- /dev/null +++ b/src/flask-main/examples/tutorial/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "flaskr" +version = "1.0.0" +description = "The basic blog app built in the Flask tutorial." +readme = "README.rst" +license = {file = "LICENSE.txt"} +maintainers = [{name = "Pallets", email = "contact@palletsprojects.com"}] +classifiers = ["Private :: Do Not Upload"] +dependencies = [ + "flask", +] + +[project.urls] +Documentation = "https://flask.palletsprojects.com/tutorial/" + +[project.optional-dependencies] +test = ["pytest"] + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "flaskr" + +[tool.flit.sdist] +include = [ + "tests/", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = ["error"] + +[tool.coverage.run] +branch = true +source = ["flaskr", "tests"] + +[tool.ruff] +src = ["src"] diff --git a/src/flask-main/examples/tutorial/tests/conftest.py b/src/flask-main/examples/tutorial/tests/conftest.py new file mode 100644 index 0000000..6bf62f0 --- /dev/null +++ b/src/flask-main/examples/tutorial/tests/conftest.py @@ -0,0 +1,62 @@ +import os +import tempfile + +import pytest + +from flaskr import create_app +from flaskr.db import get_db +from flaskr.db import init_db + +# read in SQL for populating test data +with open(os.path.join(os.path.dirname(__file__), "data.sql"), "rb") as f: + _data_sql = f.read().decode("utf8") + + +@pytest.fixture +def app(): + """Create and configure a new app instance for each test.""" + # create a temporary file to isolate the database for each test + db_fd, db_path = tempfile.mkstemp() + # create the app with common test config + app = create_app({"TESTING": True, "DATABASE": db_path}) + + # create the database and load test data + with app.app_context(): + init_db() + get_db().executescript(_data_sql) + + yield app + + # close and remove the temporary database + os.close(db_fd) + os.unlink(db_path) + + +@pytest.fixture +def client(app): + """A test client for the app.""" + return app.test_client() + + +@pytest.fixture +def runner(app): + """A test runner for the app's Click commands.""" + return app.test_cli_runner() + + +class AuthActions: + def __init__(self, client): + self._client = client + + def login(self, username="test", password="test"): + return self._client.post( + "/auth/login", data={"username": username, "password": password} + ) + + def logout(self): + return self._client.get("/auth/logout") + + +@pytest.fixture +def auth(client): + return AuthActions(client) diff --git a/src/flask-main/examples/tutorial/tests/data.sql b/src/flask-main/examples/tutorial/tests/data.sql new file mode 100644 index 0000000..9b68006 --- /dev/null +++ b/src/flask-main/examples/tutorial/tests/data.sql @@ -0,0 +1,8 @@ +INSERT INTO user (username, password) +VALUES + ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'), + ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79'); + +INSERT INTO post (title, body, author_id, created) +VALUES + ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00'); diff --git a/src/flask-main/examples/tutorial/tests/test_auth.py b/src/flask-main/examples/tutorial/tests/test_auth.py new file mode 100644 index 0000000..76db62f --- /dev/null +++ b/src/flask-main/examples/tutorial/tests/test_auth.py @@ -0,0 +1,69 @@ +import pytest +from flask import g +from flask import session + +from flaskr.db import get_db + + +def test_register(client, app): + # test that viewing the page renders without template errors + assert client.get("/auth/register").status_code == 200 + + # test that successful registration redirects to the login page + response = client.post("/auth/register", data={"username": "a", "password": "a"}) + assert response.headers["Location"] == "/auth/login" + + # test that the user was inserted into the database + with app.app_context(): + assert ( + get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone() + is not None + ) + + +@pytest.mark.parametrize( + ("username", "password", "message"), + ( + ("", "", b"Username is required."), + ("a", "", b"Password is required."), + ("test", "test", b"already registered"), + ), +) +def test_register_validate_input(client, username, password, message): + response = client.post( + "/auth/register", data={"username": username, "password": password} + ) + assert message in response.data + + +def test_login(client, auth): + # test that viewing the page renders without template errors + assert client.get("/auth/login").status_code == 200 + + # test that successful login redirects to the index page + response = auth.login() + assert response.headers["Location"] == "/" + + # login request set the user_id in the session + # check that the user is loaded from the session + with client: + client.get("/") + assert session["user_id"] == 1 + assert g.user["username"] == "test" + + +@pytest.mark.parametrize( + ("username", "password", "message"), + (("a", "test", b"Incorrect username."), ("test", "a", b"Incorrect password.")), +) +def test_login_validate_input(auth, username, password, message): + response = auth.login(username, password) + assert message in response.data + + +def test_logout(client, auth): + auth.login() + + with client: + auth.logout() + assert "user_id" not in session diff --git a/src/flask-main/examples/tutorial/tests/test_blog.py b/src/flask-main/examples/tutorial/tests/test_blog.py new file mode 100644 index 0000000..55c769d --- /dev/null +++ b/src/flask-main/examples/tutorial/tests/test_blog.py @@ -0,0 +1,83 @@ +import pytest + +from flaskr.db import get_db + + +def test_index(client, auth): + response = client.get("/") + assert b"Log In" in response.data + assert b"Register" in response.data + + auth.login() + response = client.get("/") + assert b"test title" in response.data + assert b"by test on 2018-01-01" in response.data + assert b"test\nbody" in response.data + assert b'href="/1/update"' in response.data + + +@pytest.mark.parametrize("path", ("/create", "/1/update", "/1/delete")) +def test_login_required(client, path): + response = client.post(path) + assert response.headers["Location"] == "/auth/login" + + +def test_author_required(app, client, auth): + # change the post author to another user + with app.app_context(): + db = get_db() + db.execute("UPDATE post SET author_id = 2 WHERE id = 1") + db.commit() + + auth.login() + # current user can't modify other user's post + assert client.post("/1/update").status_code == 403 + assert client.post("/1/delete").status_code == 403 + # current user doesn't see edit link + assert b'href="/1/update"' not in client.get("/").data + + +@pytest.mark.parametrize("path", ("/2/update", "/2/delete")) +def test_exists_required(client, auth, path): + auth.login() + assert client.post(path).status_code == 404 + + +def test_create(client, auth, app): + auth.login() + assert client.get("/create").status_code == 200 + client.post("/create", data={"title": "created", "body": ""}) + + with app.app_context(): + db = get_db() + count = db.execute("SELECT COUNT(id) FROM post").fetchone()[0] + assert count == 2 + + +def test_update(client, auth, app): + auth.login() + assert client.get("/1/update").status_code == 200 + client.post("/1/update", data={"title": "updated", "body": ""}) + + with app.app_context(): + db = get_db() + post = db.execute("SELECT * FROM post WHERE id = 1").fetchone() + assert post["title"] == "updated" + + +@pytest.mark.parametrize("path", ("/create", "/1/update")) +def test_create_update_validate(client, auth, path): + auth.login() + response = client.post(path, data={"title": "", "body": ""}) + assert b"Title is required." in response.data + + +def test_delete(client, auth, app): + auth.login() + response = client.post("/1/delete") + assert response.headers["Location"] == "/" + + with app.app_context(): + db = get_db() + post = db.execute("SELECT * FROM post WHERE id = 1").fetchone() + assert post is None diff --git a/src/flask-main/examples/tutorial/tests/test_db.py b/src/flask-main/examples/tutorial/tests/test_db.py new file mode 100644 index 0000000..2363bf8 --- /dev/null +++ b/src/flask-main/examples/tutorial/tests/test_db.py @@ -0,0 +1,29 @@ +import sqlite3 + +import pytest + +from flaskr.db import get_db + + +def test_get_close_db(app): + with app.app_context(): + db = get_db() + assert db is get_db() + + with pytest.raises(sqlite3.ProgrammingError) as e: + db.execute("SELECT 1") + + assert "closed" in str(e.value) + + +def test_init_db_command(runner, monkeypatch): + class Recorder: + called = False + + def fake_init_db(): + Recorder.called = True + + monkeypatch.setattr("flaskr.db.init_db", fake_init_db) + result = runner.invoke(args=["init-db"]) + assert "Initialized" in result.output + assert Recorder.called diff --git a/src/flask-main/examples/tutorial/tests/test_factory.py b/src/flask-main/examples/tutorial/tests/test_factory.py new file mode 100644 index 0000000..9b7ca57 --- /dev/null +++ b/src/flask-main/examples/tutorial/tests/test_factory.py @@ -0,0 +1,12 @@ +from flaskr import create_app + + +def test_config(): + """Test create_app without passing test config.""" + assert not create_app().testing + assert create_app({"TESTING": True}).testing + + +def test_hello(client): + response = client.get("/hello") + assert response.data == b"Hello, World!" diff --git a/src/flask-main/pyproject.toml b/src/flask-main/pyproject.toml new file mode 100644 index 0000000..1a4e6be --- /dev/null +++ b/src/flask-main/pyproject.toml @@ -0,0 +1,278 @@ +[project] +name = "Flask" +version = "3.2.0.dev" +description = "A simple framework for building complex web applications." +readme = "README.md" +license = "BSD-3-Clause" +license-files = ["LICENSE.txt"] +maintainers = [{name = "Pallets", email = "contact@palletsprojects.com"}] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Flask", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Internet :: WWW/HTTP :: WSGI", + "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Typing :: Typed", +] +requires-python = ">=3.10" +dependencies = [ + "blinker>=1.9.0", + "click>=8.1.3", + "itsdangerous>=2.2.0", + "jinja2>=3.1.2", + "markupsafe>=2.1.1", + "werkzeug>=3.1.0", +] + +[project.optional-dependencies] +async = ["asgiref>=3.2"] +dotenv = ["python-dotenv"] + +[dependency-groups] +dev = [ + "ruff", + "tox", + "tox-uv", +] +docs = [ + "pallets-sphinx-themes", + "sphinx", + "sphinx-tabs", + "sphinxcontrib-log-cabinet", +] +docs-auto = [ + "sphinx-autobuild", +] +gha-update = [ + "gha-update ; python_full_version >= '3.12'", +] +pre-commit = [ + "pre-commit", + "pre-commit-uv", +] +tests = [ + "asgiref", + "greenlet", + "pytest", + "python-dotenv", +] +typing = [ + "asgiref", + "cryptography", + "mypy", + "pyright", + "pytest", + "python-dotenv", + "types-contextvars", + "types-dataclasses", +] + +[project.urls] +Donate = "https://palletsprojects.com/donate" +Documentation = "https://flask.palletsprojects.com/" +Changes = "https://flask.palletsprojects.com/page/changes/" +Source = "https://github.com/pallets/flask/" +Chat = "https://discord.gg/pallets" + +[project.scripts] +flask = "flask.cli:main" + +[build-system] +requires = ["flit_core<4"] +build-backend = "flit_core.buildapi" + +[tool.flit.module] +name = "flask" + +[tool.flit.sdist] +include = [ + "docs/", + "examples/", + "tests/", + "CHANGES.rst", + "uv.lock" +] +exclude = [ + "docs/_build/", +] + +[tool.uv] +default-groups = ["dev", "pre-commit", "tests", "typing"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = [ + "error", +] + +[tool.coverage.run] +branch = true +source = ["flask", "tests"] + +[tool.coverage.paths] +source = ["src", "*/site-packages"] + +[tool.coverage.report] +exclude_also = [ + "if t.TYPE_CHECKING", + "raise NotImplementedError", + ": \\.{3}", +] + +[tool.mypy] +python_version = "3.10" +files = ["src", "tests/type_check"] +show_error_codes = true +pretty = true +strict = true + +[[tool.mypy.overrides]] +module = [ + "asgiref.*", + "dotenv.*", + "cryptography.*", + "importlib_metadata", +] +ignore_missing_imports = true + +[tool.pyright] +pythonVersion = "3.10" +include = ["src", "tests/type_check"] +typeCheckingMode = "basic" + +[tool.ruff] +src = ["src"] +fix = true +show-fixes = true +output-format = "full" + +[tool.ruff.lint] +select = [ + "B", # flake8-bugbear + "E", # pycodestyle error + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "W", # pycodestyle warning +] +ignore = [ + "UP038", # keep isinstance tuple +] + +[tool.ruff.lint.isort] +force-single-line = true +order-by-type = false + +[tool.tox] +env_list = [ + "py3.14", "py3.14t", "py3.13", "py3.13t", + "py3.12", "py3.11", "py3.10", + "pypy3.11", + "tests-min", "tests-dev", + "style", + "typing", + "docs", +] + +[tool.tox.env_run_base] +description = "pytest on latest dependency versions" +runner = "uv-venv-lock-runner" +package = "wheel" +wheel_build_env = ".pkg" +constrain_package_deps = true +use_frozen_constraints = true +dependency_groups = ["tests"] +env_tmp_dir = "{toxworkdir}/tmp/{envname}" +commands = [[ + "pytest", "-v", "--tb=short", "--basetemp={env_tmp_dir}", + {replace = "posargs", default = [], extend = true}, +]] + +[tool.tox.env.tests-min] +description = "pytest on minimum dependency versions" +base_python = ["3.14"] +commands = [ + [ + "uv", "pip", "install", + "blinker==1.9.0", + "click==8.1.3", + "itsdangerous==2.2.0", + "jinja2==3.1.2", + "markupsafe==2.1.1", + "werkzeug==3.1.0", + ], + [ + "pytest", "-v", "--tb=short", "--basetemp={env_tmp_dir}", + {replace = "posargs", default = [], extend = true}, + ], +] + +[tool.tox.env.tests-dev] +description = "pytest on development dependency versions (git main branch)" +base_python = ["3.10"] +commands = [ + [ + "uv", "pip", "install", + "https://github.com/pallets-eco/blinker/archive/refs/heads/main.tar.gz", + "https://github.com/pallets/click/archive/refs/heads/main.tar.gz", + "https://github.com/pallets/itsdangerous/archive/refs/heads/main.tar.gz", + "https://github.com/pallets/jinja/archive/refs/heads/main.tar.gz", + "https://github.com/pallets/markupsafe/archive/refs/heads/main.tar.gz", + "https://github.com/pallets/werkzeug/archive/refs/heads/main.tar.gz", + ], + [ + "pytest", "-v", "--tb=short", "--basetemp={env_tmp_dir}", + {replace = "posargs", default = [], extend = true}, + ], +] + +[tool.tox.env.style] +description = "run all pre-commit hooks on all files" +dependency_groups = ["pre-commit"] +skip_install = true +commands = [["pre-commit", "run", "--all-files"]] + +[tool.tox.env.typing] +description = "run static type checkers" +dependency_groups = ["typing"] +commands = [ + ["mypy"], + ["pyright"], +] + +[tool.tox.env.docs] +description = "build docs" +dependency_groups = ["docs"] +commands = [["sphinx-build", "-E", "-W", "-b", "dirhtml", "docs", "docs/_build/dirhtml"]] + +[tool.tox.env.docs-auto] +description = "continuously rebuild docs and start a local server" +dependency_groups = ["docs", "docs-auto"] +commands = [["sphinx-autobuild", "-W", "-b", "dirhtml", "--watch", "src", "docs", "docs/_build/dirhtml"]] + +[tool.tox.env.update-actions] +description = "update GitHub Actions pins" +labels = ["update"] +dependency_groups = ["gha-update"] +skip_install = true +commands = [["gha-update"]] + +[tool.tox.env.update-pre_commit] +description = "update pre-commit pins" +labels = ["update"] +dependency_groups = ["pre-commit"] +skip_install = true +commands = [["pre-commit", "autoupdate", "--freeze", "-j4"]] + +[tool.tox.env.update-requirements] +description = "update uv lock" +labels = ["update"] +dependency_groups = [] +no_default_groups = true +skip_install = true +commands = [["uv", "lock", {replace = "posargs", default = ["-U"], extend = true}]] diff --git a/src/flask-main/src/flask/__init__.py b/src/flask-main/src/flask/__init__.py new file mode 100644 index 0000000..30dce6f --- /dev/null +++ b/src/flask-main/src/flask/__init__.py @@ -0,0 +1,39 @@ +from . import json as json +from .app import Flask as Flask +from .blueprints import Blueprint as Blueprint +from .config import Config as Config +from .ctx import after_this_request as after_this_request +from .ctx import copy_current_request_context as copy_current_request_context +from .ctx import has_app_context as has_app_context +from .ctx import has_request_context as has_request_context +from .globals import current_app as current_app +from .globals import g as g +from .globals import request as request +from .globals import session as session +from .helpers import abort as abort +from .helpers import flash as flash +from .helpers import get_flashed_messages as get_flashed_messages +from .helpers import get_template_attribute as get_template_attribute +from .helpers import make_response as make_response +from .helpers import redirect as redirect +from .helpers import send_file as send_file +from .helpers import send_from_directory as send_from_directory +from .helpers import stream_with_context as stream_with_context +from .helpers import url_for as url_for +from .json import jsonify as jsonify +from .signals import appcontext_popped as appcontext_popped +from .signals import appcontext_pushed as appcontext_pushed +from .signals import appcontext_tearing_down as appcontext_tearing_down +from .signals import before_render_template as before_render_template +from .signals import got_request_exception as got_request_exception +from .signals import message_flashed as message_flashed +from .signals import request_finished as request_finished +from .signals import request_started as request_started +from .signals import request_tearing_down as request_tearing_down +from .signals import template_rendered as template_rendered +from .templating import render_template as render_template +from .templating import render_template_string as render_template_string +from .templating import stream_template as stream_template +from .templating import stream_template_string as stream_template_string +from .wrappers import Request as Request +from .wrappers import Response as Response diff --git a/src/flask-main/src/flask/__main__.py b/src/flask-main/src/flask/__main__.py new file mode 100644 index 0000000..4e28416 --- /dev/null +++ b/src/flask-main/src/flask/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +main() diff --git a/src/flask-main/src/flask/app.py b/src/flask-main/src/flask/app.py new file mode 100644 index 0000000..e0c193d --- /dev/null +++ b/src/flask-main/src/flask/app.py @@ -0,0 +1,1591 @@ +from __future__ import annotations + +import collections.abc as cabc +import inspect +import os +import sys +import typing as t +import weakref +from datetime import timedelta +from functools import update_wrapper +from inspect import iscoroutinefunction +from itertools import chain +from types import TracebackType +from urllib.parse import quote as _url_quote + +import click +from werkzeug.datastructures import Headers +from werkzeug.datastructures import ImmutableDict +from werkzeug.exceptions import BadRequestKeyError +from werkzeug.exceptions import HTTPException +from werkzeug.exceptions import InternalServerError +from werkzeug.routing import BuildError +from werkzeug.routing import MapAdapter +from werkzeug.routing import RequestRedirect +from werkzeug.routing import RoutingException +from werkzeug.routing import Rule +from werkzeug.serving import is_running_from_reloader +from werkzeug.wrappers import Response as BaseResponse +from werkzeug.wsgi import get_host + +from . import cli +from . import typing as ft +from .ctx import AppContext +from .globals import _cv_app +from .globals import app_ctx +from .globals import g +from .globals import request +from .globals import session +from .helpers import get_debug_flag +from .helpers import get_flashed_messages +from .helpers import get_load_dotenv +from .helpers import send_from_directory +from .sansio.app import App +from .sessions import SecureCookieSessionInterface +from .sessions import SessionInterface +from .signals import appcontext_tearing_down +from .signals import got_request_exception +from .signals import request_finished +from .signals import request_started +from .signals import request_tearing_down +from .templating import Environment +from .wrappers import Request +from .wrappers import Response + +if t.TYPE_CHECKING: # pragma: no cover + from _typeshed.wsgi import StartResponse + from _typeshed.wsgi import WSGIEnvironment + + from .testing import FlaskClient + from .testing import FlaskCliRunner + from .typing import HeadersValue + +T_shell_context_processor = t.TypeVar( + "T_shell_context_processor", bound=ft.ShellContextProcessorCallable +) +T_teardown = t.TypeVar("T_teardown", bound=ft.TeardownCallable) +T_template_filter = t.TypeVar("T_template_filter", bound=ft.TemplateFilterCallable) +T_template_global = t.TypeVar("T_template_global", bound=ft.TemplateGlobalCallable) +T_template_test = t.TypeVar("T_template_test", bound=ft.TemplateTestCallable) + + +def _make_timedelta(value: timedelta | int | None) -> timedelta | None: + if value is None or isinstance(value, timedelta): + return value + + return timedelta(seconds=value) + + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + + +# Other methods may call the overridden method with the new ctx arg. Remove it +# and call the method with the remaining args. +def remove_ctx(f: F) -> F: + def wrapper(self: Flask, *args: t.Any, **kwargs: t.Any) -> t.Any: + if args and isinstance(args[0], AppContext): + args = args[1:] + + return f(self, *args, **kwargs) + + return update_wrapper(wrapper, f) # type: ignore[return-value] + + +# The overridden method may call super().base_method without the new ctx arg. +# Add it to the args for the call. +def add_ctx(f: F) -> F: + def wrapper(self: Flask, *args: t.Any, **kwargs: t.Any) -> t.Any: + if not args: + args = (app_ctx._get_current_object(),) + elif not isinstance(args[0], AppContext): + args = (app_ctx._get_current_object(), *args) + + return f(self, *args, **kwargs) + + return update_wrapper(wrapper, f) # type: ignore[return-value] + + +class Flask(App): + """The flask object implements a WSGI application and acts as the central + object. It is passed the name of the module or package of the + application. Once it is created it will act as a central registry for + the view functions, the URL rules, template configuration and much more. + + The name of the package is used to resolve resources from inside the + package or the folder the module is contained in depending on if the + package parameter resolves to an actual python package (a folder with + an :file:`__init__.py` file inside) or a standard module (just a ``.py`` file). + + For more information about resource loading, see :func:`open_resource`. + + Usually you create a :class:`Flask` instance in your main module or + in the :file:`__init__.py` file of your package like this:: + + from flask import Flask + app = Flask(__name__) + + .. admonition:: About the First Parameter + + The idea of the first parameter is to give Flask an idea of what + belongs to your application. This name is used to find resources + on the filesystem, can be used by extensions to improve debugging + information and a lot more. + + So it's important what you provide there. If you are using a single + module, `__name__` is always the correct value. If you however are + using a package, it's usually recommended to hardcode the name of + your package there. + + For example if your application is defined in :file:`yourapplication/app.py` + you should create it with one of the two versions below:: + + app = Flask('yourapplication') + app = Flask(__name__.split('.')[0]) + + Why is that? The application will work even with `__name__`, thanks + to how resources are looked up. However it will make debugging more + painful. Certain extensions can make assumptions based on the + import name of your application. For example the Flask-SQLAlchemy + extension will look for the code in your application that triggered + an SQL query in debug mode. If the import name is not properly set + up, that debugging information is lost. (For example it would only + pick up SQL queries in `yourapplication.app` and not + `yourapplication.views.frontend`) + + .. versionadded:: 0.7 + The `static_url_path`, `static_folder`, and `template_folder` + parameters were added. + + .. versionadded:: 0.8 + The `instance_path` and `instance_relative_config` parameters were + added. + + .. versionadded:: 0.11 + The `root_path` parameter was added. + + .. versionadded:: 1.0 + The ``host_matching`` and ``static_host`` parameters were added. + + .. versionadded:: 1.0 + The ``subdomain_matching`` parameter was added. Subdomain + matching needs to be enabled manually now. Setting + :data:`SERVER_NAME` does not implicitly enable it. + + :param import_name: the name of the application package + :param static_url_path: can be used to specify a different path for the + static files on the web. Defaults to the name + of the `static_folder` folder. + :param static_folder: The folder with static files that is served at + ``static_url_path``. Relative to the application ``root_path`` + or an absolute path. Defaults to ``'static'``. + :param static_host: the host to use when adding the static route. + Defaults to None. Required when using ``host_matching=True`` + with a ``static_folder`` configured. + :param host_matching: set ``url_map.host_matching`` attribute. + Defaults to False. + :param subdomain_matching: consider the subdomain relative to + :data:`SERVER_NAME` when matching routes. Defaults to False. + :param template_folder: the folder that contains the templates that should + be used by the application. Defaults to + ``'templates'`` folder in the root path of the + application. + :param instance_path: An alternative instance path for the application. + By default the folder ``'instance'`` next to the + package or module is assumed to be the instance + path. + :param instance_relative_config: if set to ``True`` relative filenames + for loading the config are assumed to + be relative to the instance path instead + of the application root. + :param root_path: The path to the root of the application files. + This should only be set manually when it can't be detected + automatically, such as for namespace packages. + """ + + default_config = ImmutableDict( + { + "DEBUG": None, + "TESTING": False, + "PROPAGATE_EXCEPTIONS": None, + "SECRET_KEY": None, + "SECRET_KEY_FALLBACKS": None, + "PERMANENT_SESSION_LIFETIME": timedelta(days=31), + "USE_X_SENDFILE": False, + "TRUSTED_HOSTS": None, + "SERVER_NAME": None, + "APPLICATION_ROOT": "/", + "SESSION_COOKIE_NAME": "session", + "SESSION_COOKIE_DOMAIN": None, + "SESSION_COOKIE_PATH": None, + "SESSION_COOKIE_HTTPONLY": True, + "SESSION_COOKIE_SECURE": False, + "SESSION_COOKIE_PARTITIONED": False, + "SESSION_COOKIE_SAMESITE": None, + "SESSION_REFRESH_EACH_REQUEST": True, + "MAX_CONTENT_LENGTH": None, + "MAX_FORM_MEMORY_SIZE": 500_000, + "MAX_FORM_PARTS": 1_000, + "SEND_FILE_MAX_AGE_DEFAULT": None, + "TRAP_BAD_REQUEST_ERRORS": None, + "TRAP_HTTP_EXCEPTIONS": False, + "EXPLAIN_TEMPLATE_LOADING": False, + "PREFERRED_URL_SCHEME": "http", + "TEMPLATES_AUTO_RELOAD": None, + "MAX_COOKIE_SIZE": 4093, + "PROVIDE_AUTOMATIC_OPTIONS": True, + } + ) + + #: The class that is used for request objects. See :class:`~flask.Request` + #: for more information. + request_class: type[Request] = Request + + #: The class that is used for response objects. See + #: :class:`~flask.Response` for more information. + response_class: type[Response] = Response + + #: the session interface to use. By default an instance of + #: :class:`~flask.sessions.SecureCookieSessionInterface` is used here. + #: + #: .. versionadded:: 0.8 + session_interface: SessionInterface = SecureCookieSessionInterface() + + def __init_subclass__(cls, **kwargs: t.Any) -> None: + import warnings + + # These method signatures were updated to take a ctx param. Detect + # overridden methods in subclasses that still have the old signature. + # Show a deprecation warning and wrap to call with correct args. + for method in ( + cls.handle_http_exception, + cls.handle_user_exception, + cls.handle_exception, + cls.log_exception, + cls.dispatch_request, + cls.full_dispatch_request, + cls.finalize_request, + cls.make_default_options_response, + cls.preprocess_request, + cls.process_response, + cls.do_teardown_request, + cls.do_teardown_appcontext, + ): + base_method = getattr(Flask, method.__name__) + + if method is base_method: + # not overridden + continue + + # get the second parameter (first is self) + iter_params = iter(inspect.signature(method).parameters.values()) + next(iter_params) + param = next(iter_params, None) + + # must have second parameter named ctx or annotated AppContext + if param is None or not ( + # no annotation, match name + (param.annotation is inspect.Parameter.empty and param.name == "ctx") + or ( + # string annotation, access path ends with AppContext + isinstance(param.annotation, str) + and param.annotation.rpartition(".")[2] == "AppContext" + ) + or ( + # class annotation + inspect.isclass(param.annotation) + and issubclass(param.annotation, AppContext) + ) + ): + warnings.warn( + f"The '{method.__name__}' method now takes 'ctx: AppContext'" + " as the first parameter. The old signature is deprecated" + " and will not be supported in Flask 4.0.", + DeprecationWarning, + stacklevel=2, + ) + setattr(cls, method.__name__, remove_ctx(method)) + setattr(Flask, method.__name__, add_ctx(base_method)) + + def __init__( + self, + import_name: str, + static_url_path: str | None = None, + static_folder: str | os.PathLike[str] | None = "static", + static_host: str | None = None, + host_matching: bool = False, + subdomain_matching: bool = False, + template_folder: str | os.PathLike[str] | None = "templates", + instance_path: str | None = None, + instance_relative_config: bool = False, + root_path: str | None = None, + ): + super().__init__( + import_name=import_name, + static_url_path=static_url_path, + static_folder=static_folder, + static_host=static_host, + host_matching=host_matching, + subdomain_matching=subdomain_matching, + template_folder=template_folder, + instance_path=instance_path, + instance_relative_config=instance_relative_config, + root_path=root_path, + ) + + #: The Click command group for registering CLI commands for this + #: object. The commands are available from the ``flask`` command + #: once the application has been discovered and blueprints have + #: been registered. + self.cli = cli.AppGroup() + + # Set the name of the Click group in case someone wants to add + # the app's commands to another CLI tool. + self.cli.name = self.name + + # Add a static route using the provided static_url_path, static_host, + # and static_folder if there is a configured static_folder. + # Note we do this without checking if static_folder exists. + # For one, it might be created while the server is running (e.g. during + # development). Also, Google App Engine stores static files somewhere + if self.has_static_folder: + assert bool(static_host) == host_matching, ( + "Invalid static_host/host_matching combination" + ) + # Use a weakref to avoid creating a reference cycle between the app + # and the view function (see #3761). + self_ref = weakref.ref(self) + self.add_url_rule( + f"{self.static_url_path}/", + endpoint="static", + host=static_host, + view_func=lambda **kw: self_ref().send_static_file(**kw), # type: ignore # noqa: B950 + ) + + def get_send_file_max_age(self, filename: str | None) -> int | None: + """Used by :func:`send_file` to determine the ``max_age`` cache + value for a given file path if it wasn't passed. + + By default, this returns :data:`SEND_FILE_MAX_AGE_DEFAULT` from + the configuration of :data:`~flask.current_app`. This defaults + to ``None``, which tells the browser to use conditional requests + instead of a timed cache, which is usually preferable. + + Note this is a duplicate of the same method in the Flask + class. + + .. versionchanged:: 2.0 + The default configuration is ``None`` instead of 12 hours. + + .. versionadded:: 0.9 + """ + value = self.config["SEND_FILE_MAX_AGE_DEFAULT"] + + if value is None: + return None + + if isinstance(value, timedelta): + return int(value.total_seconds()) + + return value # type: ignore[no-any-return] + + def send_static_file(self, filename: str) -> Response: + """The view function used to serve files from + :attr:`static_folder`. A route is automatically registered for + this view at :attr:`static_url_path` if :attr:`static_folder` is + set. + + Note this is a duplicate of the same method in the Flask + class. + + .. versionadded:: 0.5 + + """ + if not self.has_static_folder: + raise RuntimeError("'static_folder' must be set to serve static_files.") + + # send_file only knows to call get_send_file_max_age on the app, + # call it here so it works for blueprints too. + max_age = self.get_send_file_max_age(filename) + return send_from_directory( + t.cast(str, self.static_folder), filename, max_age=max_age + ) + + def open_resource( + self, resource: str, mode: str = "rb", encoding: str | None = None + ) -> t.IO[t.AnyStr]: + """Open a resource file relative to :attr:`root_path` for reading. + + For example, if the file ``schema.sql`` is next to the file + ``app.py`` where the ``Flask`` app is defined, it can be opened + with: + + .. code-block:: python + + with app.open_resource("schema.sql") as f: + conn.executescript(f.read()) + + :param resource: Path to the resource relative to :attr:`root_path`. + :param mode: Open the file in this mode. Only reading is supported, + valid values are ``"r"`` (or ``"rt"``) and ``"rb"``. + :param encoding: Open the file with this encoding when opening in text + mode. This is ignored when opening in binary mode. + + .. versionchanged:: 3.1 + Added the ``encoding`` parameter. + """ + if mode not in {"r", "rt", "rb"}: + raise ValueError("Resources can only be opened for reading.") + + path = os.path.join(self.root_path, resource) + + if mode == "rb": + return open(path, mode) # pyright: ignore + + return open(path, mode, encoding=encoding) + + def open_instance_resource( + self, resource: str, mode: str = "rb", encoding: str | None = "utf-8" + ) -> t.IO[t.AnyStr]: + """Open a resource file relative to the application's instance folder + :attr:`instance_path`. Unlike :meth:`open_resource`, files in the + instance folder can be opened for writing. + + :param resource: Path to the resource relative to :attr:`instance_path`. + :param mode: Open the file in this mode. + :param encoding: Open the file with this encoding when opening in text + mode. This is ignored when opening in binary mode. + + .. versionchanged:: 3.1 + Added the ``encoding`` parameter. + """ + path = os.path.join(self.instance_path, resource) + + if "b" in mode: + return open(path, mode) + + return open(path, mode, encoding=encoding) + + def create_jinja_environment(self) -> Environment: + """Create the Jinja environment based on :attr:`jinja_options` + and the various Jinja-related methods of the app. Changing + :attr:`jinja_options` after this will have no effect. Also adds + Flask-related globals and filters to the environment. + + .. versionchanged:: 0.11 + ``Environment.auto_reload`` set in accordance with + ``TEMPLATES_AUTO_RELOAD`` configuration option. + + .. versionadded:: 0.5 + """ + options = dict(self.jinja_options) + + if "autoescape" not in options: + options["autoescape"] = self.select_jinja_autoescape + + if "auto_reload" not in options: + auto_reload = self.config["TEMPLATES_AUTO_RELOAD"] + + if auto_reload is None: + auto_reload = self.debug + + options["auto_reload"] = auto_reload + + rv = self.jinja_environment(self, **options) + rv.globals.update( + url_for=self.url_for, + get_flashed_messages=get_flashed_messages, + config=self.config, + # request, session and g are normally added with the + # context processor for efficiency reasons but for imported + # templates we also want the proxies in there. + request=request, + session=session, + g=g, + ) + rv.policies["json.dumps_function"] = self.json.dumps + return rv + + def create_url_adapter(self, request: Request | None) -> MapAdapter | None: + """Creates a URL adapter for the given request. The URL adapter + is created at a point where the request context is not yet set + up so the request is passed explicitly. + + .. versionchanged:: 3.1 + If :data:`SERVER_NAME` is set, it does not restrict requests to + only that domain, for both ``subdomain_matching`` and + ``host_matching``. + + .. versionchanged:: 1.0 + :data:`SERVER_NAME` no longer implicitly enables subdomain + matching. Use :attr:`subdomain_matching` instead. + + .. versionchanged:: 0.9 + This can be called outside a request when the URL adapter is created + for an application context. + + .. versionadded:: 0.6 + """ + if request is not None: + if (trusted_hosts := self.config["TRUSTED_HOSTS"]) is not None: + request.trusted_hosts = trusted_hosts + + # Check trusted_hosts here until bind_to_environ does. + request.host = get_host(request.environ, request.trusted_hosts) # pyright: ignore + subdomain = None + server_name = self.config["SERVER_NAME"] + + if self.url_map.host_matching: + # Don't pass SERVER_NAME, otherwise it's used and the actual + # host is ignored, which breaks host matching. + server_name = None + elif not self.subdomain_matching: + # Werkzeug doesn't implement subdomain matching yet. Until then, + # disable it by forcing the current subdomain to the default, or + # the empty string. + subdomain = self.url_map.default_subdomain or "" + + return self.url_map.bind_to_environ( + request.environ, server_name=server_name, subdomain=subdomain + ) + + # Need at least SERVER_NAME to match/build outside a request. + if self.config["SERVER_NAME"] is not None: + return self.url_map.bind( + self.config["SERVER_NAME"], + script_name=self.config["APPLICATION_ROOT"], + url_scheme=self.config["PREFERRED_URL_SCHEME"], + ) + + return None + + def raise_routing_exception(self, request: Request) -> t.NoReturn: + """Intercept routing exceptions and possibly do something else. + + In debug mode, intercept a routing redirect and replace it with + an error if the body will be discarded. + + With modern Werkzeug this shouldn't occur, since it now uses a + 308 status which tells the browser to resend the method and + body. + + .. versionchanged:: 2.1 + Don't intercept 307 and 308 redirects. + + :meta private: + :internal: + """ + if ( + not self.debug + or not isinstance(request.routing_exception, RequestRedirect) + or request.routing_exception.code in {307, 308} + or request.method in {"GET", "HEAD", "OPTIONS"} + ): + raise request.routing_exception # type: ignore[misc] + + from .debughelpers import FormDataRoutingRedirect + + raise FormDataRoutingRedirect(request) + + def update_template_context( + self, ctx: AppContext, context: dict[str, t.Any] + ) -> None: + """Update the template context with some commonly used variables. + This injects request, session, config and g into the template + context as well as everything template context processors want + to inject. Note that the as of Flask 0.6, the original values + in the context will not be overridden if a context processor + decides to return a value with the same key. + + :param context: the context as a dictionary that is updated in place + to add extra variables. + """ + names: t.Iterable[str | None] = (None,) + + # A template may be rendered outside a request context. + if ctx.has_request: + names = chain(names, reversed(ctx.request.blueprints)) + + # The values passed to render_template take precedence. Keep a + # copy to re-apply after all context functions. + orig_ctx = context.copy() + + for name in names: + if name in self.template_context_processors: + for func in self.template_context_processors[name]: + context.update(self.ensure_sync(func)()) + + context.update(orig_ctx) + + def make_shell_context(self) -> dict[str, t.Any]: + """Returns the shell context for an interactive shell for this + application. This runs all the registered shell context + processors. + + .. versionadded:: 0.11 + """ + rv = {"app": self, "g": g} + for processor in self.shell_context_processors: + rv.update(processor()) + return rv + + def run( + self, + host: str | None = None, + port: int | None = None, + debug: bool | None = None, + load_dotenv: bool = True, + **options: t.Any, + ) -> None: + """Runs the application on a local development server. + + Do not use ``run()`` in a production setting. It is not intended to + meet security and performance requirements for a production server. + Instead, see :doc:`/deploying/index` for WSGI server recommendations. + + If the :attr:`debug` flag is set the server will automatically reload + for code changes and show a debugger in case an exception happened. + + If you want to run the application in debug mode, but disable the + code execution on the interactive debugger, you can pass + ``use_evalex=False`` as parameter. This will keep the debugger's + traceback screen active, but disable code execution. + + It is not recommended to use this function for development with + automatic reloading as this is badly supported. Instead you should + be using the :command:`flask` command line script's ``run`` support. + + .. admonition:: Keep in Mind + + Flask will suppress any server error with a generic error page + unless it is in debug mode. As such to enable just the + interactive debugger without the code reloading, you have to + invoke :meth:`run` with ``debug=True`` and ``use_reloader=False``. + Setting ``use_debugger`` to ``True`` without being in debug mode + won't catch any exceptions because there won't be any to + catch. + + :param host: the hostname to listen on. Set this to ``'0.0.0.0'`` to + have the server available externally as well. Defaults to + ``'127.0.0.1'`` or the host in the ``SERVER_NAME`` config variable + if present. + :param port: the port of the webserver. Defaults to ``5000`` or the + port defined in the ``SERVER_NAME`` config variable if present. + :param debug: if given, enable or disable debug mode. See + :attr:`debug`. + :param load_dotenv: Load the nearest :file:`.env` and :file:`.flaskenv` + files to set environment variables. Will also change the working + directory to the directory containing the first file found. + :param options: the options to be forwarded to the underlying Werkzeug + server. See :func:`werkzeug.serving.run_simple` for more + information. + + .. versionchanged:: 1.0 + If installed, python-dotenv will be used to load environment + variables from :file:`.env` and :file:`.flaskenv` files. + + The :envvar:`FLASK_DEBUG` environment variable will override :attr:`debug`. + + Threaded mode is enabled by default. + + .. versionchanged:: 0.10 + The default port is now picked from the ``SERVER_NAME`` + variable. + """ + # Ignore this call so that it doesn't start another server if + # the 'flask run' command is used. + if os.environ.get("FLASK_RUN_FROM_CLI") == "true": + if not is_running_from_reloader(): + click.secho( + " * Ignoring a call to 'app.run()' that would block" + " the current 'flask' CLI command.\n" + " Only call 'app.run()' in an 'if __name__ ==" + ' "__main__"\' guard.', + fg="red", + ) + + return + + if get_load_dotenv(load_dotenv): + cli.load_dotenv() + + # if set, env var overrides existing value + if "FLASK_DEBUG" in os.environ: + self.debug = get_debug_flag() + + # debug passed to method overrides all other sources + if debug is not None: + self.debug = bool(debug) + + server_name = self.config.get("SERVER_NAME") + sn_host = sn_port = None + + if server_name: + sn_host, _, sn_port = server_name.partition(":") + + if not host: + if sn_host: + host = sn_host + else: + host = "127.0.0.1" + + if port or port == 0: + port = int(port) + elif sn_port: + port = int(sn_port) + else: + port = 5000 + + options.setdefault("use_reloader", self.debug) + options.setdefault("use_debugger", self.debug) + options.setdefault("threaded", True) + + cli.show_server_banner(self.debug, self.name) + + from werkzeug.serving import run_simple + + try: + run_simple(t.cast(str, host), port, self, **options) + finally: + # reset the first request information if the development server + # reset normally. This makes it possible to restart the server + # without reloader and that stuff from an interactive shell. + self._got_first_request = False + + def test_client(self, use_cookies: bool = True, **kwargs: t.Any) -> FlaskClient: + """Creates a test client for this application. For information + about unit testing head over to :doc:`/testing`. + + Note that if you are testing for assertions or exceptions in your + application code, you must set ``app.testing = True`` in order for the + exceptions to propagate to the test client. Otherwise, the exception + will be handled by the application (not visible to the test client) and + the only indication of an AssertionError or other exception will be a + 500 status code response to the test client. See the :attr:`testing` + attribute. For example:: + + app.testing = True + client = app.test_client() + + The test client can be used in a ``with`` block to defer the closing down + of the context until the end of the ``with`` block. This is useful if + you want to access the context locals for testing:: + + with app.test_client() as c: + rv = c.get('/?vodka=42') + assert request.args['vodka'] == '42' + + Additionally, you may pass optional keyword arguments that will then + be passed to the application's :attr:`test_client_class` constructor. + For example:: + + from flask.testing import FlaskClient + + class CustomClient(FlaskClient): + def __init__(self, *args, **kwargs): + self._authentication = kwargs.pop("authentication") + super(CustomClient,self).__init__( *args, **kwargs) + + app.test_client_class = CustomClient + client = app.test_client(authentication='Basic ....') + + See :class:`~flask.testing.FlaskClient` for more information. + + .. versionchanged:: 0.4 + added support for ``with`` block usage for the client. + + .. versionadded:: 0.7 + The `use_cookies` parameter was added as well as the ability + to override the client to be used by setting the + :attr:`test_client_class` attribute. + + .. versionchanged:: 0.11 + Added `**kwargs` to support passing additional keyword arguments to + the constructor of :attr:`test_client_class`. + """ + cls = self.test_client_class + if cls is None: + from .testing import FlaskClient as cls + return cls( # type: ignore + self, self.response_class, use_cookies=use_cookies, **kwargs + ) + + def test_cli_runner(self, **kwargs: t.Any) -> FlaskCliRunner: + """Create a CLI runner for testing CLI commands. + See :ref:`testing-cli`. + + Returns an instance of :attr:`test_cli_runner_class`, by default + :class:`~flask.testing.FlaskCliRunner`. The Flask app object is + passed as the first argument. + + .. versionadded:: 1.0 + """ + cls = self.test_cli_runner_class + + if cls is None: + from .testing import FlaskCliRunner as cls + + return cls(self, **kwargs) # type: ignore + + def handle_http_exception( + self, ctx: AppContext, e: HTTPException + ) -> HTTPException | ft.ResponseReturnValue: + """Handles an HTTP exception. By default this will invoke the + registered error handlers and fall back to returning the + exception as response. + + .. versionchanged:: 1.0.3 + ``RoutingException``, used internally for actions such as + slash redirects during routing, is not passed to error + handlers. + + .. versionchanged:: 1.0 + Exceptions are looked up by code *and* by MRO, so + ``HTTPException`` subclasses can be handled with a catch-all + handler for the base ``HTTPException``. + + .. versionadded:: 0.3 + """ + # Proxy exceptions don't have error codes. We want to always return + # those unchanged as errors + if e.code is None: + return e + + # RoutingExceptions are used internally to trigger routing + # actions, such as slash redirects raising RequestRedirect. They + # are not raised or handled in user code. + if isinstance(e, RoutingException): + return e + + handler = self._find_error_handler(e, ctx.request.blueprints) + if handler is None: + return e + return self.ensure_sync(handler)(e) # type: ignore[no-any-return] + + def handle_user_exception( + self, ctx: AppContext, e: Exception + ) -> HTTPException | ft.ResponseReturnValue: + """This method is called whenever an exception occurs that + should be handled. A special case is :class:`~werkzeug + .exceptions.HTTPException` which is forwarded to the + :meth:`handle_http_exception` method. This function will either + return a response value or reraise the exception with the same + traceback. + + .. versionchanged:: 1.0 + Key errors raised from request data like ``form`` show the + bad key in debug mode rather than a generic bad request + message. + + .. versionadded:: 0.7 + """ + if isinstance(e, BadRequestKeyError) and ( + self.debug or self.config["TRAP_BAD_REQUEST_ERRORS"] + ): + e.show_exception = True + + if isinstance(e, HTTPException) and not self.trap_http_exception(e): + return self.handle_http_exception(ctx, e) + + handler = self._find_error_handler(e, ctx.request.blueprints) + + if handler is None: + raise + + return self.ensure_sync(handler)(e) # type: ignore[no-any-return] + + def handle_exception(self, ctx: AppContext, e: Exception) -> Response: + """Handle an exception that did not have an error handler + associated with it, or that was raised from an error handler. + This always causes a 500 ``InternalServerError``. + + Always sends the :data:`got_request_exception` signal. + + If :data:`PROPAGATE_EXCEPTIONS` is ``True``, such as in debug + mode, the error will be re-raised so that the debugger can + display it. Otherwise, the original exception is logged, and + an :exc:`~werkzeug.exceptions.InternalServerError` is returned. + + If an error handler is registered for ``InternalServerError`` or + ``500``, it will be used. For consistency, the handler will + always receive the ``InternalServerError``. The original + unhandled exception is available as ``e.original_exception``. + + .. versionchanged:: 1.1.0 + Always passes the ``InternalServerError`` instance to the + handler, setting ``original_exception`` to the unhandled + error. + + .. versionchanged:: 1.1.0 + ``after_request`` functions and other finalization is done + even for the default 500 response when there is no handler. + + .. versionadded:: 0.3 + """ + exc_info = sys.exc_info() + got_request_exception.send(self, _async_wrapper=self.ensure_sync, exception=e) + propagate = self.config["PROPAGATE_EXCEPTIONS"] + + if propagate is None: + propagate = self.testing or self.debug + + if propagate: + # Re-raise if called with an active exception, otherwise + # raise the passed in exception. + if exc_info[1] is e: + raise + + raise e + + self.log_exception(ctx, exc_info) + server_error: InternalServerError | ft.ResponseReturnValue + server_error = InternalServerError(original_exception=e) + handler = self._find_error_handler(server_error, ctx.request.blueprints) + + if handler is not None: + server_error = self.ensure_sync(handler)(server_error) + + return self.finalize_request(ctx, server_error, from_error_handler=True) + + def log_exception( + self, + ctx: AppContext, + exc_info: tuple[type, BaseException, TracebackType] | tuple[None, None, None], + ) -> None: + """Logs an exception. This is called by :meth:`handle_exception` + if debugging is disabled and right before the handler is called. + The default implementation logs the exception as error on the + :attr:`logger`. + + .. versionadded:: 0.8 + """ + self.logger.error( + f"Exception on {ctx.request.path} [{ctx.request.method}]", exc_info=exc_info + ) + + def dispatch_request(self, ctx: AppContext) -> ft.ResponseReturnValue: + """Does the request dispatching. Matches the URL and returns the + return value of the view or error handler. This does not have to + be a response object. In order to convert the return value to a + proper response object, call :func:`make_response`. + + .. versionchanged:: 0.7 + This no longer does the exception handling, this code was + moved to the new :meth:`full_dispatch_request`. + """ + req = ctx.request + + if req.routing_exception is not None: + self.raise_routing_exception(req) + rule: Rule = req.url_rule # type: ignore[assignment] + # if we provide automatic options for this URL and the + # request came with the OPTIONS method, reply automatically + if ( + getattr(rule, "provide_automatic_options", False) + and req.method == "OPTIONS" + ): + return self.make_default_options_response(ctx) + # otherwise dispatch to the handler for that endpoint + view_args: dict[str, t.Any] = req.view_args # type: ignore[assignment] + return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return] + + def full_dispatch_request(self, ctx: AppContext) -> Response: + """Dispatches the request and on top of that performs request + pre and postprocessing as well as HTTP exception catching and + error handling. + + .. versionadded:: 0.7 + """ + self._got_first_request = True + + try: + request_started.send(self, _async_wrapper=self.ensure_sync) + rv = self.preprocess_request(ctx) + if rv is None: + rv = self.dispatch_request(ctx) + except Exception as e: + rv = self.handle_user_exception(ctx, e) + return self.finalize_request(ctx, rv) + + def finalize_request( + self, + ctx: AppContext, + rv: ft.ResponseReturnValue | HTTPException, + from_error_handler: bool = False, + ) -> Response: + """Given the return value from a view function this finalizes + the request by converting it into a response and invoking the + postprocessing functions. This is invoked for both normal + request dispatching as well as error handlers. + + Because this means that it might be called as a result of a + failure a special safe mode is available which can be enabled + with the `from_error_handler` flag. If enabled, failures in + response processing will be logged and otherwise ignored. + + :internal: + """ + response = self.make_response(rv) + try: + response = self.process_response(ctx, response) + request_finished.send( + self, _async_wrapper=self.ensure_sync, response=response + ) + except Exception: + if not from_error_handler: + raise + self.logger.exception( + "Request finalizing failed with an error while handling an error" + ) + return response + + def make_default_options_response(self, ctx: AppContext) -> Response: + """This method is called to create the default ``OPTIONS`` response. + This can be changed through subclassing to change the default + behavior of ``OPTIONS`` responses. + + .. versionadded:: 0.7 + """ + methods = ctx.url_adapter.allowed_methods() # type: ignore[union-attr] + rv = self.response_class() + rv.allow.update(methods) + return rv + + def ensure_sync(self, func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + """Ensure that the function is synchronous for WSGI workers. + Plain ``def`` functions are returned as-is. ``async def`` + functions are wrapped to run and wait for the response. + + Override this method to change how the app runs async views. + + .. versionadded:: 2.0 + """ + if iscoroutinefunction(func): + return self.async_to_sync(func) + + return func + + def async_to_sync( + self, func: t.Callable[..., t.Coroutine[t.Any, t.Any, t.Any]] + ) -> t.Callable[..., t.Any]: + """Return a sync function that will run the coroutine function. + + .. code-block:: python + + result = app.async_to_sync(func)(*args, **kwargs) + + Override this method to change how the app converts async code + to be synchronously callable. + + .. versionadded:: 2.0 + """ + try: + from asgiref.sync import async_to_sync as asgiref_async_to_sync + except ImportError: + raise RuntimeError( + "Install Flask with the 'async' extra in order to use async views." + ) from None + + return asgiref_async_to_sync(func) + + def url_for( + self, + /, + endpoint: str, + *, + _anchor: str | None = None, + _method: str | None = None, + _scheme: str | None = None, + _external: bool | None = None, + **values: t.Any, + ) -> str: + """Generate a URL to the given endpoint with the given values. + + This is called by :func:`flask.url_for`, and can be called + directly as well. + + An *endpoint* is the name of a URL rule, usually added with + :meth:`@app.route() `, and usually the same name as the + view function. A route defined in a :class:`~flask.Blueprint` + will prepend the blueprint's name separated by a ``.`` to the + endpoint. + + In some cases, such as email messages, you want URLs to include + the scheme and domain, like ``https://example.com/hello``. When + not in an active request, URLs will be external by default, but + this requires setting :data:`SERVER_NAME` so Flask knows what + domain to use. :data:`APPLICATION_ROOT` and + :data:`PREFERRED_URL_SCHEME` should also be configured as + needed. This config is only used when not in an active request. + + Functions can be decorated with :meth:`url_defaults` to modify + keyword arguments before the URL is built. + + If building fails for some reason, such as an unknown endpoint + or incorrect values, the app's :meth:`handle_url_build_error` + method is called. If that returns a string, that is returned, + otherwise a :exc:`~werkzeug.routing.BuildError` is raised. + + :param endpoint: The endpoint name associated with the URL to + generate. If this starts with a ``.``, the current blueprint + name (if any) will be used. + :param _anchor: If given, append this as ``#anchor`` to the URL. + :param _method: If given, generate the URL associated with this + method for the endpoint. + :param _scheme: If given, the URL will have this scheme if it + is external. + :param _external: If given, prefer the URL to be internal + (False) or require it to be external (True). External URLs + include the scheme and domain. When not in an active + request, URLs are external by default. + :param values: Values to use for the variable parts of the URL + rule. Unknown keys are appended as query string arguments, + like ``?a=b&c=d``. + + .. versionadded:: 2.2 + Moved from ``flask.url_for``, which calls this method. + """ + if (ctx := _cv_app.get(None)) is not None and ctx.has_request: + url_adapter = ctx.url_adapter + blueprint_name = ctx.request.blueprint + + # If the endpoint starts with "." and the request matches a + # blueprint, the endpoint is relative to the blueprint. + if endpoint[:1] == ".": + if blueprint_name is not None: + endpoint = f"{blueprint_name}{endpoint}" + else: + endpoint = endpoint[1:] + + # When in a request, generate a URL without scheme and + # domain by default, unless a scheme is given. + if _external is None: + _external = _scheme is not None + else: + # If called by helpers.url_for, an app context is active, + # use its url_adapter. Otherwise, app.url_for was called + # directly, build an adapter. + if ctx is not None: + url_adapter = ctx.url_adapter + else: + url_adapter = self.create_url_adapter(None) + + if url_adapter is None: + raise RuntimeError( + "Unable to build URLs outside an active request" + " without 'SERVER_NAME' configured. Also configure" + " 'APPLICATION_ROOT' and 'PREFERRED_URL_SCHEME' as" + " needed." + ) + + # When outside a request, generate a URL with scheme and + # domain by default. + if _external is None: + _external = True + + # It is an error to set _scheme when _external=False, in order + # to avoid accidental insecure URLs. + if _scheme is not None and not _external: + raise ValueError("When specifying '_scheme', '_external' must be True.") + + self.inject_url_defaults(endpoint, values) + + try: + rv = url_adapter.build( # type: ignore[union-attr] + endpoint, + values, + method=_method, + url_scheme=_scheme, + force_external=_external, + ) + except BuildError as error: + values.update( + _anchor=_anchor, _method=_method, _scheme=_scheme, _external=_external + ) + return self.handle_url_build_error(error, endpoint, values) + + if _anchor is not None: + _anchor = _url_quote(_anchor, safe="%!#$&'()*+,/:;=?@") + rv = f"{rv}#{_anchor}" + + return rv + + def make_response(self, rv: ft.ResponseReturnValue) -> Response: + """Convert the return value from a view function to an instance of + :attr:`response_class`. + + :param rv: the return value from the view function. The view function + must return a response. Returning ``None``, or the view ending + without returning, is not allowed. The following types are allowed + for ``view_rv``: + + ``str`` + A response object is created with the string encoded to UTF-8 + as the body. + + ``bytes`` + A response object is created with the bytes as the body. + + ``dict`` + A dictionary that will be jsonify'd before being returned. + + ``list`` + A list that will be jsonify'd before being returned. + + ``generator`` or ``iterator`` + A generator that returns ``str`` or ``bytes`` to be + streamed as the response. + + ``tuple`` + Either ``(body, status, headers)``, ``(body, status)``, or + ``(body, headers)``, where ``body`` is any of the other types + allowed here, ``status`` is a string or an integer, and + ``headers`` is a dictionary or a list of ``(key, value)`` + tuples. If ``body`` is a :attr:`response_class` instance, + ``status`` overwrites the exiting value and ``headers`` are + extended. + + :attr:`response_class` + The object is returned unchanged. + + other :class:`~werkzeug.wrappers.Response` class + The object is coerced to :attr:`response_class`. + + :func:`callable` + The function is called as a WSGI application. The result is + used to create a response object. + + .. versionchanged:: 2.2 + A generator will be converted to a streaming response. + A list will be converted to a JSON response. + + .. versionchanged:: 1.1 + A dict will be converted to a JSON response. + + .. versionchanged:: 0.9 + Previously a tuple was interpreted as the arguments for the + response object. + """ + + status: int | None = None + headers: HeadersValue | None = None + + # unpack tuple returns + if isinstance(rv, tuple): + len_rv = len(rv) + + # a 3-tuple is unpacked directly + if len_rv == 3: + rv, status, headers = rv # type: ignore[misc] + # decide if a 2-tuple has status or headers + elif len_rv == 2: + if isinstance(rv[1], (Headers, dict, tuple, list)): + rv, headers = rv # pyright: ignore + else: + rv, status = rv # type: ignore[assignment,misc] + # other sized tuples are not allowed + else: + raise TypeError( + "The view function did not return a valid response tuple." + " The tuple must have the form (body, status, headers)," + " (body, status), or (body, headers)." + ) + + # the body must not be None + if rv is None: + raise TypeError( + f"The view function for {request.endpoint!r} did not" + " return a valid response. The function either returned" + " None or ended without a return statement." + ) + + # make sure the body is an instance of the response class + if not isinstance(rv, self.response_class): + if isinstance(rv, (str, bytes, bytearray)) or isinstance(rv, cabc.Iterator): + # let the response class set the status and headers instead of + # waiting to do it manually, so that the class can handle any + # special logic + rv = self.response_class( + rv, # pyright: ignore + status=status, + headers=headers, # type: ignore[arg-type] + ) + status = headers = None + elif isinstance(rv, (dict, list)): + rv = self.json.response(rv) + elif isinstance(rv, BaseResponse) or callable(rv): + # evaluate a WSGI callable, or coerce a different response + # class to the correct type + try: + rv = self.response_class.force_type( + rv, # type: ignore[arg-type] + request.environ, + ) + except TypeError as e: + raise TypeError( + f"{e}\nThe view function did not return a valid" + " response. The return type must be a string," + " dict, list, tuple with headers or status," + " Response instance, or WSGI callable, but it" + f" was a {type(rv).__name__}." + ).with_traceback(sys.exc_info()[2]) from None + else: + raise TypeError( + "The view function did not return a valid" + " response. The return type must be a string," + " dict, list, tuple with headers or status," + " Response instance, or WSGI callable, but it was a" + f" {type(rv).__name__}." + ) + + rv = t.cast(Response, rv) + # prefer the status if it was provided + if status is not None: + if isinstance(status, (str, bytes, bytearray)): + rv.status = status + else: + rv.status_code = status + + # extend existing headers with provided headers + if headers: + rv.headers.update(headers) + + return rv + + def preprocess_request(self, ctx: AppContext) -> ft.ResponseReturnValue | None: + """Called before the request is dispatched. Calls + :attr:`url_value_preprocessors` registered with the app and the + current blueprint (if any). Then calls :attr:`before_request_funcs` + registered with the app and the blueprint. + + If any :meth:`before_request` handler returns a non-None value, the + value is handled as if it was the return value from the view, and + further request handling is stopped. + """ + req = ctx.request + names = (None, *reversed(req.blueprints)) + + for name in names: + if name in self.url_value_preprocessors: + for url_func in self.url_value_preprocessors[name]: + url_func(req.endpoint, req.view_args) + + for name in names: + if name in self.before_request_funcs: + for before_func in self.before_request_funcs[name]: + rv = self.ensure_sync(before_func)() + + if rv is not None: + return rv # type: ignore[no-any-return] + + return None + + def process_response(self, ctx: AppContext, response: Response) -> Response: + """Can be overridden in order to modify the response object + before it's sent to the WSGI server. By default this will + call all the :meth:`after_request` decorated functions. + + .. versionchanged:: 0.5 + As of Flask 0.5 the functions registered for after request + execution are called in reverse order of registration. + + :param response: a :attr:`response_class` object. + :return: a new response object or the same, has to be an + instance of :attr:`response_class`. + """ + for func in ctx._after_request_functions: + response = self.ensure_sync(func)(response) + + for name in chain(ctx.request.blueprints, (None,)): + if name in self.after_request_funcs: + for func in reversed(self.after_request_funcs[name]): + response = self.ensure_sync(func)(response) + + if not self.session_interface.is_null_session(ctx.session): + self.session_interface.save_session(self, ctx.session, response) + + return response + + def do_teardown_request( + self, ctx: AppContext, exc: BaseException | None = None + ) -> None: + """Called after the request is dispatched and the response is finalized, + right before the request context is popped. Called by + :meth:`.AppContext.pop`. + + This calls all functions decorated with :meth:`teardown_request`, and + :meth:`Blueprint.teardown_request` if a blueprint handled the request. + Finally, the :data:`request_tearing_down` signal is sent. + + :param exc: An unhandled exception raised while dispatching the request. + Passed to each teardown function. + + .. versionchanged:: 0.9 + Added the ``exc`` argument. + """ + for name in chain(ctx.request.blueprints, (None,)): + if name in self.teardown_request_funcs: + for func in reversed(self.teardown_request_funcs[name]): + self.ensure_sync(func)(exc) + + request_tearing_down.send(self, _async_wrapper=self.ensure_sync, exc=exc) + + def do_teardown_appcontext( + self, ctx: AppContext, exc: BaseException | None = None + ) -> None: + """Called right before the application context is popped. Called by + :meth:`.AppContext.pop`. + + This calls all functions decorated with :meth:`teardown_appcontext`. + Then the :data:`appcontext_tearing_down` signal is sent. + + :param exc: An unhandled exception raised while the context was active. + Passed to each teardown function. + + .. versionadded:: 0.9 + """ + for func in reversed(self.teardown_appcontext_funcs): + self.ensure_sync(func)(exc) + + appcontext_tearing_down.send(self, _async_wrapper=self.ensure_sync, exc=exc) + + def app_context(self) -> AppContext: + """Create an :class:`.AppContext`. When the context is pushed, + :data:`.current_app` and :data:`.g` become available. + + A context is automatically pushed when handling each request, and when + running any ``flask`` CLI command. Use this as a ``with`` block to + manually push a context outside of those situations, such as during + setup or testing. + + .. code-block:: python + + with app.app_context(): + init_db() + + See :doc:`/appcontext`. + + .. versionadded:: 0.9 + """ + return AppContext(self) + + def request_context(self, environ: WSGIEnvironment) -> AppContext: + """Create an :class:`.AppContext` with request information representing + the given WSGI environment. A context is automatically pushed when + handling each request. When the context is pushed, :data:`.request`, + :data:`.session`, :data:`g:, and :data:`.current_app` become available. + + This method should not be used in your own code. Creating a valid WSGI + environ is not trivial. Use :meth:`test_request_context` to correctly + create a WSGI environ and request context instead. + + See :doc:`/appcontext`. + + :param environ: A WSGI environment. + """ + return AppContext.from_environ(self, environ) + + def test_request_context(self, *args: t.Any, **kwargs: t.Any) -> AppContext: + """Create an :class:`.AppContext` with request information created from + the given arguments. When the context is pushed, :data:`.request`, + :data:`.session`, :data:`g:, and :data:`.current_app` become available. + + This is useful during testing to run a function that uses request data + without dispatching a full request. Use this as a ``with`` block to push + a context. + + .. code-block:: python + + with app.test_request_context(...): + generate_report() + + See :doc:`/appcontext`. + + Takes the same arguments as Werkzeug's + :class:`~werkzeug.test.EnvironBuilder`, with some defaults from + the application. See the linked Werkzeug docs for most of the + available arguments. Flask-specific behavior is listed here. + + :param path: URL path being requested. + :param base_url: Base URL where the app is being served, which + ``path`` is relative to. If not given, built from + :data:`PREFERRED_URL_SCHEME`, ``subdomain``, :data:`SERVER_NAME`, + and :data:`APPLICATION_ROOT`. + :param subdomain: Subdomain name to prepend to :data:`SERVER_NAME`. + :param url_scheme: Scheme to use instead of + :data:`PREFERRED_URL_SCHEME`. + :param data: The request body text or bytes,or a dict of form data. + :param json: If given, this is serialized as JSON and passed as + ``data``. Also defaults ``content_type`` to + ``application/json``. + :param args: Other positional arguments passed to + :class:`~werkzeug.test.EnvironBuilder`. + :param kwargs: Other keyword arguments passed to + :class:`~werkzeug.test.EnvironBuilder`. + """ + from .testing import EnvironBuilder + + builder = EnvironBuilder(self, *args, **kwargs) + + try: + environ = builder.get_environ() + finally: + builder.close() + + return self.request_context(environ) + + def wsgi_app( + self, environ: WSGIEnvironment, start_response: StartResponse + ) -> cabc.Iterable[bytes]: + """The actual WSGI application. This is not implemented in + :meth:`__call__` so that middlewares can be applied without + losing a reference to the app object. Instead of doing this:: + + app = MyMiddleware(app) + + It's a better idea to do this instead:: + + app.wsgi_app = MyMiddleware(app.wsgi_app) + + Then you still have the original application object around and + can continue to call methods on it. + + .. versionchanged:: 0.7 + Teardown events for the request and app contexts are called + even if an unhandled error occurs. Other events may not be + called depending on when an error occurs during dispatch. + + :param environ: A WSGI environment. + :param start_response: A callable accepting a status code, + a list of headers, and an optional exception context to + start the response. + """ + ctx = self.request_context(environ) + error: BaseException | None = None + try: + try: + ctx.push() + response = self.full_dispatch_request(ctx) + except Exception as e: + error = e + response = self.handle_exception(ctx, e) + except: # noqa: B001 + error = sys.exc_info()[1] + raise + return response(environ, start_response) + finally: + if "werkzeug.debug.preserve_context" in environ: + environ["werkzeug.debug.preserve_context"](ctx) + + if error is not None and self.should_ignore_error(error): + error = None + + ctx.pop(error) + + def __call__( + self, environ: WSGIEnvironment, start_response: StartResponse + ) -> cabc.Iterable[bytes]: + """The WSGI server calls the Flask application object as the + WSGI application. This calls :meth:`wsgi_app`, which can be + wrapped to apply middleware. + """ + return self.wsgi_app(environ, start_response) diff --git a/src/flask-main/src/flask/blueprints.py b/src/flask-main/src/flask/blueprints.py new file mode 100644 index 0000000..b6d4e43 --- /dev/null +++ b/src/flask-main/src/flask/blueprints.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import os +import typing as t +from datetime import timedelta + +from .cli import AppGroup +from .globals import current_app +from .helpers import send_from_directory +from .sansio.blueprints import Blueprint as SansioBlueprint +from .sansio.blueprints import BlueprintSetupState as BlueprintSetupState # noqa +from .sansio.scaffold import _sentinel + +if t.TYPE_CHECKING: # pragma: no cover + from .wrappers import Response + + +class Blueprint(SansioBlueprint): + def __init__( + self, + name: str, + import_name: str, + static_folder: str | os.PathLike[str] | None = None, + static_url_path: str | None = None, + template_folder: str | os.PathLike[str] | None = None, + url_prefix: str | None = None, + subdomain: str | None = None, + url_defaults: dict[str, t.Any] | None = None, + root_path: str | None = None, + cli_group: str | None = _sentinel, # type: ignore + ) -> None: + super().__init__( + name, + import_name, + static_folder, + static_url_path, + template_folder, + url_prefix, + subdomain, + url_defaults, + root_path, + cli_group, + ) + + #: The Click command group for registering CLI commands for this + #: object. The commands are available from the ``flask`` command + #: once the application has been discovered and blueprints have + #: been registered. + self.cli = AppGroup() + + # Set the name of the Click group in case someone wants to add + # the app's commands to another CLI tool. + self.cli.name = self.name + + def get_send_file_max_age(self, filename: str | None) -> int | None: + """Used by :func:`send_file` to determine the ``max_age`` cache + value for a given file path if it wasn't passed. + + By default, this returns :data:`SEND_FILE_MAX_AGE_DEFAULT` from + the configuration of :data:`~flask.current_app`. This defaults + to ``None``, which tells the browser to use conditional requests + instead of a timed cache, which is usually preferable. + + Note this is a duplicate of the same method in the Flask + class. + + .. versionchanged:: 2.0 + The default configuration is ``None`` instead of 12 hours. + + .. versionadded:: 0.9 + """ + value = current_app.config["SEND_FILE_MAX_AGE_DEFAULT"] + + if value is None: + return None + + if isinstance(value, timedelta): + return int(value.total_seconds()) + + return value # type: ignore[no-any-return] + + def send_static_file(self, filename: str) -> Response: + """The view function used to serve files from + :attr:`static_folder`. A route is automatically registered for + this view at :attr:`static_url_path` if :attr:`static_folder` is + set. + + Note this is a duplicate of the same method in the Flask + class. + + .. versionadded:: 0.5 + + """ + if not self.has_static_folder: + raise RuntimeError("'static_folder' must be set to serve static_files.") + + # send_file only knows to call get_send_file_max_age on the app, + # call it here so it works for blueprints too. + max_age = self.get_send_file_max_age(filename) + return send_from_directory( + t.cast(str, self.static_folder), filename, max_age=max_age + ) + + def open_resource( + self, resource: str, mode: str = "rb", encoding: str | None = "utf-8" + ) -> t.IO[t.AnyStr]: + """Open a resource file relative to :attr:`root_path` for reading. The + blueprint-relative equivalent of the app's :meth:`~.Flask.open_resource` + method. + + :param resource: Path to the resource relative to :attr:`root_path`. + :param mode: Open the file in this mode. Only reading is supported, + valid values are ``"r"`` (or ``"rt"``) and ``"rb"``. + :param encoding: Open the file with this encoding when opening in text + mode. This is ignored when opening in binary mode. + + .. versionchanged:: 3.1 + Added the ``encoding`` parameter. + """ + if mode not in {"r", "rt", "rb"}: + raise ValueError("Resources can only be opened for reading.") + + path = os.path.join(self.root_path, resource) + + if mode == "rb": + return open(path, mode) # pyright: ignore + + return open(path, mode, encoding=encoding) diff --git a/src/flask-main/src/flask/cli.py b/src/flask-main/src/flask/cli.py new file mode 100644 index 0000000..3fa65cf --- /dev/null +++ b/src/flask-main/src/flask/cli.py @@ -0,0 +1,1127 @@ +from __future__ import annotations + +import ast +import collections.abc as cabc +import importlib.metadata +import inspect +import os +import platform +import re +import sys +import traceback +import typing as t +from functools import update_wrapper +from operator import itemgetter +from types import ModuleType + +import click +from click.core import ParameterSource +from werkzeug import run_simple +from werkzeug.serving import is_running_from_reloader +from werkzeug.utils import import_string + +from .globals import current_app +from .helpers import get_debug_flag +from .helpers import get_load_dotenv + +if t.TYPE_CHECKING: + import ssl + + from _typeshed.wsgi import StartResponse + from _typeshed.wsgi import WSGIApplication + from _typeshed.wsgi import WSGIEnvironment + + from .app import Flask + + +class NoAppException(click.UsageError): + """Raised if an application cannot be found or loaded.""" + + +def find_best_app(module: ModuleType) -> Flask: + """Given a module instance this tries to find the best possible + application in the module or raises an exception. + """ + from . import Flask + + # Search for the most common names first. + for attr_name in ("app", "application"): + app = getattr(module, attr_name, None) + + if isinstance(app, Flask): + return app + + # Otherwise find the only object that is a Flask instance. + matches = [v for v in module.__dict__.values() if isinstance(v, Flask)] + + if len(matches) == 1: + return matches[0] + elif len(matches) > 1: + raise NoAppException( + "Detected multiple Flask applications in module" + f" '{module.__name__}'. Use '{module.__name__}:name'" + " to specify the correct one." + ) + + # Search for app factory functions. + for attr_name in ("create_app", "make_app"): + app_factory = getattr(module, attr_name, None) + + if inspect.isfunction(app_factory): + try: + app = app_factory() + + if isinstance(app, Flask): + return app + except TypeError as e: + if not _called_with_wrong_args(app_factory): + raise + + raise NoAppException( + f"Detected factory '{attr_name}' in module '{module.__name__}'," + " but could not call it without arguments. Use" + f" '{module.__name__}:{attr_name}(args)'" + " to specify arguments." + ) from e + + raise NoAppException( + "Failed to find Flask application or factory in module" + f" '{module.__name__}'. Use '{module.__name__}:name'" + " to specify one." + ) + + +def _called_with_wrong_args(f: t.Callable[..., Flask]) -> bool: + """Check whether calling a function raised a ``TypeError`` because + the call failed or because something in the factory raised the + error. + + :param f: The function that was called. + :return: ``True`` if the call failed. + """ + tb = sys.exc_info()[2] + + try: + while tb is not None: + if tb.tb_frame.f_code is f.__code__: + # In the function, it was called successfully. + return False + + tb = tb.tb_next + + # Didn't reach the function. + return True + finally: + # Delete tb to break a circular reference. + # https://docs.python.org/2/library/sys.html#sys.exc_info + del tb + + +def find_app_by_string(module: ModuleType, app_name: str) -> Flask: + """Check if the given string is a variable name or a function. Call + a function to get the app instance, or return the variable directly. + """ + from . import Flask + + # Parse app_name as a single expression to determine if it's a valid + # attribute name or function call. + try: + expr = ast.parse(app_name.strip(), mode="eval").body + except SyntaxError: + raise NoAppException( + f"Failed to parse {app_name!r} as an attribute name or function call." + ) from None + + if isinstance(expr, ast.Name): + name = expr.id + args = [] + kwargs = {} + elif isinstance(expr, ast.Call): + # Ensure the function name is an attribute name only. + if not isinstance(expr.func, ast.Name): + raise NoAppException( + f"Function reference must be a simple name: {app_name!r}." + ) + + name = expr.func.id + + # Parse the positional and keyword arguments as literals. + try: + args = [ast.literal_eval(arg) for arg in expr.args] + kwargs = { + kw.arg: ast.literal_eval(kw.value) + for kw in expr.keywords + if kw.arg is not None + } + except ValueError: + # literal_eval gives cryptic error messages, show a generic + # message with the full expression instead. + raise NoAppException( + f"Failed to parse arguments as literal values: {app_name!r}." + ) from None + else: + raise NoAppException( + f"Failed to parse {app_name!r} as an attribute name or function call." + ) + + try: + attr = getattr(module, name) + except AttributeError as e: + raise NoAppException( + f"Failed to find attribute {name!r} in {module.__name__!r}." + ) from e + + # If the attribute is a function, call it with any args and kwargs + # to get the real application. + if inspect.isfunction(attr): + try: + app = attr(*args, **kwargs) + except TypeError as e: + if not _called_with_wrong_args(attr): + raise + + raise NoAppException( + f"The factory {app_name!r} in module" + f" {module.__name__!r} could not be called with the" + " specified arguments." + ) from e + else: + app = attr + + if isinstance(app, Flask): + return app + + raise NoAppException( + "A valid Flask application was not obtained from" + f" '{module.__name__}:{app_name}'." + ) + + +def prepare_import(path: str) -> str: + """Given a filename this will try to calculate the python path, add it + to the search path and return the actual module name that is expected. + """ + path = os.path.realpath(path) + + fname, ext = os.path.splitext(path) + if ext == ".py": + path = fname + + if os.path.basename(path) == "__init__": + path = os.path.dirname(path) + + module_name = [] + + # move up until outside package structure (no __init__.py) + while True: + path, name = os.path.split(path) + module_name.append(name) + + if not os.path.exists(os.path.join(path, "__init__.py")): + break + + if sys.path[0] != path: + sys.path.insert(0, path) + + return ".".join(module_name[::-1]) + + +@t.overload +def locate_app( + module_name: str, app_name: str | None, raise_if_not_found: t.Literal[True] = True +) -> Flask: ... + + +@t.overload +def locate_app( + module_name: str, app_name: str | None, raise_if_not_found: t.Literal[False] = ... +) -> Flask | None: ... + + +def locate_app( + module_name: str, app_name: str | None, raise_if_not_found: bool = True +) -> Flask | None: + try: + __import__(module_name) + except ImportError: + # Reraise the ImportError if it occurred within the imported module. + # Determine this by checking whether the trace has a depth > 1. + if sys.exc_info()[2].tb_next: # type: ignore[union-attr] + raise NoAppException( + f"While importing {module_name!r}, an ImportError was" + f" raised:\n\n{traceback.format_exc()}" + ) from None + elif raise_if_not_found: + raise NoAppException(f"Could not import {module_name!r}.") from None + else: + return None + + module = sys.modules[module_name] + + if app_name is None: + return find_best_app(module) + else: + return find_app_by_string(module, app_name) + + +def get_version(ctx: click.Context, param: click.Parameter, value: t.Any) -> None: + if not value or ctx.resilient_parsing: + return + + flask_version = importlib.metadata.version("flask") + werkzeug_version = importlib.metadata.version("werkzeug") + + click.echo( + f"Python {platform.python_version()}\n" + f"Flask {flask_version}\n" + f"Werkzeug {werkzeug_version}", + color=ctx.color, + ) + ctx.exit() + + +version_option = click.Option( + ["--version"], + help="Show the Flask version.", + expose_value=False, + callback=get_version, + is_flag=True, + is_eager=True, +) + + +class ScriptInfo: + """Helper object to deal with Flask applications. This is usually not + necessary to interface with as it's used internally in the dispatching + to click. In future versions of Flask this object will most likely play + a bigger role. Typically it's created automatically by the + :class:`FlaskGroup` but you can also manually create it and pass it + onwards as click object. + + .. versionchanged:: 3.1 + Added the ``load_dotenv_defaults`` parameter and attribute. + """ + + def __init__( + self, + app_import_path: str | None = None, + create_app: t.Callable[..., Flask] | None = None, + set_debug_flag: bool = True, + load_dotenv_defaults: bool = True, + ) -> None: + #: Optionally the import path for the Flask application. + self.app_import_path = app_import_path + #: Optionally a function that is passed the script info to create + #: the instance of the application. + self.create_app = create_app + #: A dictionary with arbitrary data that can be associated with + #: this script info. + self.data: dict[t.Any, t.Any] = {} + self.set_debug_flag = set_debug_flag + + self.load_dotenv_defaults = get_load_dotenv(load_dotenv_defaults) + """Whether default ``.flaskenv`` and ``.env`` files should be loaded. + + ``ScriptInfo`` doesn't load anything, this is for reference when doing + the load elsewhere during processing. + + .. versionadded:: 3.1 + """ + + self._loaded_app: Flask | None = None + + def load_app(self) -> Flask: + """Loads the Flask app (if not yet loaded) and returns it. Calling + this multiple times will just result in the already loaded app to + be returned. + """ + if self._loaded_app is not None: + return self._loaded_app + app: Flask | None = None + if self.create_app is not None: + app = self.create_app() + else: + if self.app_import_path: + path, name = ( + re.split(r":(?![\\/])", self.app_import_path, maxsplit=1) + [None] + )[:2] + import_name = prepare_import(path) + app = locate_app(import_name, name) + else: + for path in ("wsgi.py", "app.py"): + import_name = prepare_import(path) + app = locate_app(import_name, None, raise_if_not_found=False) + + if app is not None: + break + + if app is None: + raise NoAppException( + "Could not locate a Flask application. Use the" + " 'flask --app' option, 'FLASK_APP' environment" + " variable, or a 'wsgi.py' or 'app.py' file in the" + " current directory." + ) + + if self.set_debug_flag: + # Update the app's debug flag through the descriptor so that + # other values repopulate as well. + app.debug = get_debug_flag() + + self._loaded_app = app + return app + + +pass_script_info = click.make_pass_decorator(ScriptInfo, ensure=True) + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + + +def with_appcontext(f: F) -> F: + """Wraps a callback so that it's guaranteed to be executed with the + script's application context. + + Custom commands (and their options) registered under ``app.cli`` or + ``blueprint.cli`` will always have an app context available, this + decorator is not required in that case. + + .. versionchanged:: 2.2 + The app context is active for subcommands as well as the + decorated callback. The app context is always available to + ``app.cli`` command and parameter callbacks. + """ + + @click.pass_context + def decorator(ctx: click.Context, /, *args: t.Any, **kwargs: t.Any) -> t.Any: + if not current_app: + app = ctx.ensure_object(ScriptInfo).load_app() + ctx.with_resource(app.app_context()) + + return ctx.invoke(f, *args, **kwargs) + + return update_wrapper(decorator, f) # type: ignore[return-value] + + +class AppGroup(click.Group): + """This works similar to a regular click :class:`~click.Group` but it + changes the behavior of the :meth:`command` decorator so that it + automatically wraps the functions in :func:`with_appcontext`. + + Not to be confused with :class:`FlaskGroup`. + """ + + def command( # type: ignore[override] + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], click.Command]: + """This works exactly like the method of the same name on a regular + :class:`click.Group` but it wraps callbacks in :func:`with_appcontext` + unless it's disabled by passing ``with_appcontext=False``. + """ + wrap_for_ctx = kwargs.pop("with_appcontext", True) + + def decorator(f: t.Callable[..., t.Any]) -> click.Command: + if wrap_for_ctx: + f = with_appcontext(f) + return super(AppGroup, self).command(*args, **kwargs)(f) # type: ignore[no-any-return] + + return decorator + + def group( # type: ignore[override] + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], click.Group]: + """This works exactly like the method of the same name on a regular + :class:`click.Group` but it defaults the group class to + :class:`AppGroup`. + """ + kwargs.setdefault("cls", AppGroup) + return super().group(*args, **kwargs) # type: ignore[no-any-return] + + +def _set_app(ctx: click.Context, param: click.Option, value: str | None) -> str | None: + if value is None: + return None + + info = ctx.ensure_object(ScriptInfo) + info.app_import_path = value + return value + + +# This option is eager so the app will be available if --help is given. +# --help is also eager, so --app must be before it in the param list. +# no_args_is_help bypasses eager processing, so this option must be +# processed manually in that case to ensure FLASK_APP gets picked up. +_app_option = click.Option( + ["-A", "--app"], + metavar="IMPORT", + help=( + "The Flask application or factory function to load, in the form 'module:name'." + " Module can be a dotted import or file path. Name is not required if it is" + " 'app', 'application', 'create_app', or 'make_app', and can be 'name(args)' to" + " pass arguments." + ), + is_eager=True, + expose_value=False, + callback=_set_app, +) + + +def _set_debug(ctx: click.Context, param: click.Option, value: bool) -> bool | None: + # If the flag isn't provided, it will default to False. Don't use + # that, let debug be set by env in that case. + source = ctx.get_parameter_source(param.name) # type: ignore[arg-type] + + if source is not None and source in ( + ParameterSource.DEFAULT, + ParameterSource.DEFAULT_MAP, + ): + return None + + # Set with env var instead of ScriptInfo.load so that it can be + # accessed early during a factory function. + os.environ["FLASK_DEBUG"] = "1" if value else "0" + return value + + +_debug_option = click.Option( + ["--debug/--no-debug"], + help="Set debug mode.", + expose_value=False, + callback=_set_debug, +) + + +def _env_file_callback( + ctx: click.Context, param: click.Option, value: str | None +) -> str | None: + try: + import dotenv # noqa: F401 + except ImportError: + # Only show an error if a value was passed, otherwise we still want to + # call load_dotenv and show a message without exiting. + if value is not None: + raise click.BadParameter( + "python-dotenv must be installed to load an env file.", + ctx=ctx, + param=param, + ) from None + + # Load if a value was passed, or we want to load default files, or both. + if value is not None or ctx.obj.load_dotenv_defaults: + load_dotenv(value, load_defaults=ctx.obj.load_dotenv_defaults) + + return value + + +# This option is eager so env vars are loaded as early as possible to be +# used by other options. +_env_file_option = click.Option( + ["-e", "--env-file"], + type=click.Path(exists=True, dir_okay=False), + help=( + "Load environment variables from this file, taking precedence over" + " those set by '.env' and '.flaskenv'. Variables set directly in the" + " environment take highest precedence. python-dotenv must be installed." + ), + is_eager=True, + expose_value=False, + callback=_env_file_callback, +) + + +class FlaskGroup(AppGroup): + """Special subclass of the :class:`AppGroup` group that supports + loading more commands from the configured Flask app. Normally a + developer does not have to interface with this class but there are + some very advanced use cases for which it makes sense to create an + instance of this. see :ref:`custom-scripts`. + + :param add_default_commands: if this is True then the default run and + shell commands will be added. + :param add_version_option: adds the ``--version`` option. + :param create_app: an optional callback that is passed the script info and + returns the loaded app. + :param load_dotenv: Load the nearest :file:`.env` and :file:`.flaskenv` + files to set environment variables. Will also change the working + directory to the directory containing the first file found. + :param set_debug_flag: Set the app's debug flag. + + .. versionchanged:: 3.1 + ``-e path`` takes precedence over default ``.env`` and ``.flaskenv`` files. + + .. versionchanged:: 2.2 + Added the ``-A/--app``, ``--debug/--no-debug``, ``-e/--env-file`` options. + + .. versionchanged:: 2.2 + An app context is pushed when running ``app.cli`` commands, so + ``@with_appcontext`` is no longer required for those commands. + + .. versionchanged:: 1.0 + If installed, python-dotenv will be used to load environment variables + from :file:`.env` and :file:`.flaskenv` files. + """ + + def __init__( + self, + add_default_commands: bool = True, + create_app: t.Callable[..., Flask] | None = None, + add_version_option: bool = True, + load_dotenv: bool = True, + set_debug_flag: bool = True, + **extra: t.Any, + ) -> None: + params: list[click.Parameter] = list(extra.pop("params", None) or ()) + # Processing is done with option callbacks instead of a group + # callback. This allows users to make a custom group callback + # without losing the behavior. --env-file must come first so + # that it is eagerly evaluated before --app. + params.extend((_env_file_option, _app_option, _debug_option)) + + if add_version_option: + params.append(version_option) + + if "context_settings" not in extra: + extra["context_settings"] = {} + + extra["context_settings"].setdefault("auto_envvar_prefix", "FLASK") + + super().__init__(params=params, **extra) + + self.create_app = create_app + self.load_dotenv = load_dotenv + self.set_debug_flag = set_debug_flag + + if add_default_commands: + self.add_command(run_command) + self.add_command(shell_command) + self.add_command(routes_command) + + self._loaded_plugin_commands = False + + def _load_plugin_commands(self) -> None: + if self._loaded_plugin_commands: + return + + for ep in importlib.metadata.entry_points(group="flask.commands"): + self.add_command(ep.load(), ep.name) + + self._loaded_plugin_commands = True + + def get_command(self, ctx: click.Context, name: str) -> click.Command | None: + self._load_plugin_commands() + # Look up built-in and plugin commands, which should be + # available even if the app fails to load. + rv = super().get_command(ctx, name) + + if rv is not None: + return rv + + info = ctx.ensure_object(ScriptInfo) + + # Look up commands provided by the app, showing an error and + # continuing if the app couldn't be loaded. + try: + app = info.load_app() + except NoAppException as e: + click.secho(f"Error: {e.format_message()}\n", err=True, fg="red") + return None + + # Push an app context for the loaded app unless it is already + # active somehow. This makes the context available to parameter + # and command callbacks without needing @with_appcontext. + if not current_app or current_app._get_current_object() is not app: + ctx.with_resource(app.app_context()) + + return app.cli.get_command(ctx, name) + + def list_commands(self, ctx: click.Context) -> list[str]: + self._load_plugin_commands() + # Start with the built-in and plugin commands. + rv = set(super().list_commands(ctx)) + info = ctx.ensure_object(ScriptInfo) + + # Add commands provided by the app, showing an error and + # continuing if the app couldn't be loaded. + try: + rv.update(info.load_app().cli.list_commands(ctx)) + except NoAppException as e: + # When an app couldn't be loaded, show the error message + # without the traceback. + click.secho(f"Error: {e.format_message()}\n", err=True, fg="red") + except Exception: + # When any other errors occurred during loading, show the + # full traceback. + click.secho(f"{traceback.format_exc()}\n", err=True, fg="red") + + return sorted(rv) + + def make_context( + self, + info_name: str | None, + args: list[str], + parent: click.Context | None = None, + **extra: t.Any, + ) -> click.Context: + # Set a flag to tell app.run to become a no-op. If app.run was + # not in a __name__ == __main__ guard, it would start the server + # when importing, blocking whatever command is being called. + os.environ["FLASK_RUN_FROM_CLI"] = "true" + + if "obj" not in extra and "obj" not in self.context_settings: + extra["obj"] = ScriptInfo( + create_app=self.create_app, + set_debug_flag=self.set_debug_flag, + load_dotenv_defaults=self.load_dotenv, + ) + + return super().make_context(info_name, args, parent=parent, **extra) + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + if (not args and self.no_args_is_help) or ( + len(args) == 1 and args[0] in self.get_help_option_names(ctx) + ): + # Attempt to load --env-file and --app early in case they + # were given as env vars. Otherwise no_args_is_help will not + # see commands from app.cli. + _env_file_option.handle_parse_result(ctx, {}, []) + _app_option.handle_parse_result(ctx, {}, []) + + return super().parse_args(ctx, args) + + +def _path_is_ancestor(path: str, other: str) -> bool: + """Take ``other`` and remove the length of ``path`` from it. Then join it + to ``path``. If it is the original value, ``path`` is an ancestor of + ``other``.""" + return os.path.join(path, other[len(path) :].lstrip(os.sep)) == other + + +def load_dotenv( + path: str | os.PathLike[str] | None = None, load_defaults: bool = True +) -> bool: + """Load "dotenv" files to set environment variables. A given path takes + precedence over ``.env``, which takes precedence over ``.flaskenv``. After + loading and combining these files, values are only set if the key is not + already set in ``os.environ``. + + This is a no-op if `python-dotenv`_ is not installed. + + .. _python-dotenv: https://github.com/theskumar/python-dotenv#readme + + :param path: Load the file at this location. + :param load_defaults: Search for and load the default ``.flaskenv`` and + ``.env`` files. + :return: ``True`` if at least one env var was loaded. + + .. versionchanged:: 3.1 + Added the ``load_defaults`` parameter. A given path takes precedence + over default files. + + .. versionchanged:: 2.0 + The current directory is not changed to the location of the + loaded file. + + .. versionchanged:: 2.0 + When loading the env files, set the default encoding to UTF-8. + + .. versionchanged:: 1.1.0 + Returns ``False`` when python-dotenv is not installed, or when + the given path isn't a file. + + .. versionadded:: 1.0 + """ + try: + import dotenv + except ImportError: + if path or os.path.isfile(".env") or os.path.isfile(".flaskenv"): + click.secho( + " * Tip: There are .env files present. Install python-dotenv" + " to use them.", + fg="yellow", + err=True, + ) + + return False + + data: dict[str, str | None] = {} + + if load_defaults: + for default_name in (".flaskenv", ".env"): + if not (default_path := dotenv.find_dotenv(default_name, usecwd=True)): + continue + + data |= dotenv.dotenv_values(default_path, encoding="utf-8") + + if path is not None and os.path.isfile(path): + data |= dotenv.dotenv_values(path, encoding="utf-8") + + for key, value in data.items(): + if key in os.environ or value is None: + continue + + os.environ[key] = value + + return bool(data) # True if at least one env var was loaded. + + +def show_server_banner(debug: bool, app_import_path: str | None) -> None: + """Show extra startup messages the first time the server is run, + ignoring the reloader. + """ + if is_running_from_reloader(): + return + + if app_import_path is not None: + click.echo(f" * Serving Flask app '{app_import_path}'") + + if debug is not None: + click.echo(f" * Debug mode: {'on' if debug else 'off'}") + + +class CertParamType(click.ParamType): + """Click option type for the ``--cert`` option. Allows either an + existing file, the string ``'adhoc'``, or an import for a + :class:`~ssl.SSLContext` object. + """ + + name = "path" + + def __init__(self) -> None: + self.path_type = click.Path(exists=True, dir_okay=False, resolve_path=True) + + def convert( + self, value: t.Any, param: click.Parameter | None, ctx: click.Context | None + ) -> t.Any: + try: + import ssl + except ImportError: + raise click.BadParameter( + 'Using "--cert" requires Python to be compiled with SSL support.', + ctx, + param, + ) from None + + try: + return self.path_type(value, param, ctx) + except click.BadParameter: + value = click.STRING(value, param, ctx).lower() + + if value == "adhoc": + try: + import cryptography # noqa: F401 + except ImportError: + raise click.BadParameter( + "Using ad-hoc certificates requires the cryptography library.", + ctx, + param, + ) from None + + return value + + obj = import_string(value, silent=True) + + if isinstance(obj, ssl.SSLContext): + return obj + + raise + + +def _validate_key(ctx: click.Context, param: click.Parameter, value: t.Any) -> t.Any: + """The ``--key`` option must be specified when ``--cert`` is a file. + Modifies the ``cert`` param to be a ``(cert, key)`` pair if needed. + """ + cert = ctx.params.get("cert") + is_adhoc = cert == "adhoc" + + try: + import ssl + except ImportError: + is_context = False + else: + is_context = isinstance(cert, ssl.SSLContext) + + if value is not None: + if is_adhoc: + raise click.BadParameter( + 'When "--cert" is "adhoc", "--key" is not used.', ctx, param + ) + + if is_context: + raise click.BadParameter( + 'When "--cert" is an SSLContext object, "--key" is not used.', + ctx, + param, + ) + + if not cert: + raise click.BadParameter('"--cert" must also be specified.', ctx, param) + + ctx.params["cert"] = cert, value + + else: + if cert and not (is_adhoc or is_context): + raise click.BadParameter('Required when using "--cert".', ctx, param) + + return value + + +class SeparatedPathType(click.Path): + """Click option type that accepts a list of values separated by the + OS's path separator (``:``, ``;`` on Windows). Each value is + validated as a :class:`click.Path` type. + """ + + def convert( + self, value: t.Any, param: click.Parameter | None, ctx: click.Context | None + ) -> t.Any: + items = self.split_envvar_value(value) + # can't call no-arg super() inside list comprehension until Python 3.12 + super_convert = super().convert + return [super_convert(item, param, ctx) for item in items] + + +@click.command("run", short_help="Run a development server.") +@click.option("--host", "-h", default="127.0.0.1", help="The interface to bind to.") +@click.option("--port", "-p", default=5000, help="The port to bind to.") +@click.option( + "--cert", + type=CertParamType(), + help="Specify a certificate file to use HTTPS.", + is_eager=True, +) +@click.option( + "--key", + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + callback=_validate_key, + expose_value=False, + help="The key file to use when specifying a certificate.", +) +@click.option( + "--reload/--no-reload", + default=None, + help="Enable or disable the reloader. By default the reloader " + "is active if debug is enabled.", +) +@click.option( + "--debugger/--no-debugger", + default=None, + help="Enable or disable the debugger. By default the debugger " + "is active if debug is enabled.", +) +@click.option( + "--with-threads/--without-threads", + default=True, + help="Enable or disable multithreading.", +) +@click.option( + "--extra-files", + default=None, + type=SeparatedPathType(), + help=( + "Extra files that trigger a reload on change. Multiple paths" + f" are separated by {os.path.pathsep!r}." + ), +) +@click.option( + "--exclude-patterns", + default=None, + type=SeparatedPathType(), + help=( + "Files matching these fnmatch patterns will not trigger a reload" + " on change. Multiple patterns are separated by" + f" {os.path.pathsep!r}." + ), +) +@pass_script_info +def run_command( + info: ScriptInfo, + host: str, + port: int, + reload: bool, + debugger: bool, + with_threads: bool, + cert: ssl.SSLContext | tuple[str, str | None] | t.Literal["adhoc"] | None, + extra_files: list[str] | None, + exclude_patterns: list[str] | None, +) -> None: + """Run a local development server. + + This server is for development purposes only. It does not provide + the stability, security, or performance of production WSGI servers. + + The reloader and debugger are enabled by default with the '--debug' + option. + """ + try: + app: WSGIApplication = info.load_app() # pyright: ignore + except Exception as e: + if is_running_from_reloader(): + # When reloading, print out the error immediately, but raise + # it later so the debugger or server can handle it. + traceback.print_exc() + err = e + + def app( + environ: WSGIEnvironment, start_response: StartResponse + ) -> cabc.Iterable[bytes]: + raise err from None + + else: + # When not reloading, raise the error immediately so the + # command fails. + raise e from None + + debug = get_debug_flag() + + if reload is None: + reload = debug + + if debugger is None: + debugger = debug + + show_server_banner(debug, info.app_import_path) + + run_simple( + host, + port, + app, + use_reloader=reload, + use_debugger=debugger, + threaded=with_threads, + ssl_context=cert, + extra_files=extra_files, + exclude_patterns=exclude_patterns, + ) + + +run_command.params.insert(0, _debug_option) + + +@click.command("shell", short_help="Run a shell in the app context.") +@with_appcontext +def shell_command() -> None: + """Run an interactive Python shell in the context of a given + Flask application. The application will populate the default + namespace of this shell according to its configuration. + + This is useful for executing small snippets of management code + without having to manually configure the application. + """ + import code + + banner = ( + f"Python {sys.version} on {sys.platform}\n" + f"App: {current_app.import_name}\n" + f"Instance: {current_app.instance_path}" + ) + ctx: dict[str, t.Any] = {} + + # Support the regular Python interpreter startup script if someone + # is using it. + startup = os.environ.get("PYTHONSTARTUP") + if startup and os.path.isfile(startup): + with open(startup) as f: + eval(compile(f.read(), startup, "exec"), ctx) + + ctx.update(current_app.make_shell_context()) + + # Site, customize, or startup script can set a hook to call when + # entering interactive mode. The default one sets up readline with + # tab and history completion. + interactive_hook = getattr(sys, "__interactivehook__", None) + + if interactive_hook is not None: + try: + import readline + from rlcompleter import Completer + except ImportError: + pass + else: + # rlcompleter uses __main__.__dict__ by default, which is + # flask.__main__. Use the shell context instead. + readline.set_completer(Completer(ctx).complete) + + interactive_hook() + + code.interact(banner=banner, local=ctx) + + +@click.command("routes", short_help="Show the routes for the app.") +@click.option( + "--sort", + "-s", + type=click.Choice(("endpoint", "methods", "domain", "rule", "match")), + default="endpoint", + help=( + "Method to sort routes by. 'match' is the order that Flask will match routes" + " when dispatching a request." + ), +) +@click.option("--all-methods", is_flag=True, help="Show HEAD and OPTIONS methods.") +@with_appcontext +def routes_command(sort: str, all_methods: bool) -> None: + """Show all registered routes with endpoints and methods.""" + rules = list(current_app.url_map.iter_rules()) + + if not rules: + click.echo("No routes were registered.") + return + + ignored_methods = set() if all_methods else {"HEAD", "OPTIONS"} + host_matching = current_app.url_map.host_matching + has_domain = any(rule.host if host_matching else rule.subdomain for rule in rules) + rows = [] + + for rule in rules: + row = [ + rule.endpoint, + ", ".join(sorted((rule.methods or set()) - ignored_methods)), + ] + + if has_domain: + row.append((rule.host if host_matching else rule.subdomain) or "") + + row.append(rule.rule) + rows.append(row) + + headers = ["Endpoint", "Methods"] + sorts = ["endpoint", "methods"] + + if has_domain: + headers.append("Host" if host_matching else "Subdomain") + sorts.append("domain") + + headers.append("Rule") + sorts.append("rule") + + try: + rows.sort(key=itemgetter(sorts.index(sort))) + except ValueError: + pass + + rows.insert(0, headers) + widths = [max(len(row[i]) for row in rows) for i in range(len(headers))] + rows.insert(1, ["-" * w for w in widths]) + template = " ".join(f"{{{i}:<{w}}}" for i, w in enumerate(widths)) + + for row in rows: + click.echo(template.format(*row)) + + +cli = FlaskGroup( + name="flask", + help="""\ +A general utility script for Flask applications. + +An application to load must be given with the '--app' option, +'FLASK_APP' environment variable, or with a 'wsgi.py' or 'app.py' file +in the current directory. +""", +) + + +def main() -> None: + cli.main() + + +if __name__ == "__main__": + main() diff --git a/src/flask-main/src/flask/config.py b/src/flask-main/src/flask/config.py new file mode 100644 index 0000000..34ef1a5 --- /dev/null +++ b/src/flask-main/src/flask/config.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import errno +import json +import os +import types +import typing as t + +from werkzeug.utils import import_string + +if t.TYPE_CHECKING: + import typing_extensions as te + + from .sansio.app import App + + +T = t.TypeVar("T") + + +class ConfigAttribute(t.Generic[T]): + """Makes an attribute forward to the config""" + + def __init__( + self, name: str, get_converter: t.Callable[[t.Any], T] | None = None + ) -> None: + self.__name__ = name + self.get_converter = get_converter + + @t.overload + def __get__(self, obj: None, owner: None) -> te.Self: ... + + @t.overload + def __get__(self, obj: App, owner: type[App]) -> T: ... + + def __get__(self, obj: App | None, owner: type[App] | None = None) -> T | te.Self: + if obj is None: + return self + + rv = obj.config[self.__name__] + + if self.get_converter is not None: + rv = self.get_converter(rv) + + return rv # type: ignore[no-any-return] + + def __set__(self, obj: App, value: t.Any) -> None: + obj.config[self.__name__] = value + + +class Config(dict): # type: ignore[type-arg] + """Works exactly like a dict but provides ways to fill it from files + or special dictionaries. There are two common patterns to populate the + config. + + Either you can fill the config from a config file:: + + app.config.from_pyfile('yourconfig.cfg') + + Or alternatively you can define the configuration options in the + module that calls :meth:`from_object` or provide an import path to + a module that should be loaded. It is also possible to tell it to + use the same module and with that provide the configuration values + just before the call:: + + DEBUG = True + SECRET_KEY = 'development key' + app.config.from_object(__name__) + + In both cases (loading from any Python file or loading from modules), + only uppercase keys are added to the config. This makes it possible to use + lowercase values in the config file for temporary values that are not added + to the config or to define the config keys in the same file that implements + the application. + + Probably the most interesting way to load configurations is from an + environment variable pointing to a file:: + + app.config.from_envvar('YOURAPPLICATION_SETTINGS') + + In this case before launching the application you have to set this + environment variable to the file you want to use. On Linux and OS X + use the export statement:: + + export YOURAPPLICATION_SETTINGS='/path/to/config/file' + + On windows use `set` instead. + + :param root_path: path to which files are read relative from. When the + config object is created by the application, this is + the application's :attr:`~flask.Flask.root_path`. + :param defaults: an optional dictionary of default values + """ + + def __init__( + self, + root_path: str | os.PathLike[str], + defaults: dict[str, t.Any] | None = None, + ) -> None: + super().__init__(defaults or {}) + self.root_path = root_path + + def from_envvar(self, variable_name: str, silent: bool = False) -> bool: + """Loads a configuration from an environment variable pointing to + a configuration file. This is basically just a shortcut with nicer + error messages for this line of code:: + + app.config.from_pyfile(os.environ['YOURAPPLICATION_SETTINGS']) + + :param variable_name: name of the environment variable + :param silent: set to ``True`` if you want silent failure for missing + files. + :return: ``True`` if the file was loaded successfully. + """ + rv = os.environ.get(variable_name) + if not rv: + if silent: + return False + raise RuntimeError( + f"The environment variable {variable_name!r} is not set" + " and as such configuration could not be loaded. Set" + " this variable and make it point to a configuration" + " file" + ) + return self.from_pyfile(rv, silent=silent) + + def from_prefixed_env( + self, prefix: str = "FLASK", *, loads: t.Callable[[str], t.Any] = json.loads + ) -> bool: + """Load any environment variables that start with ``FLASK_``, + dropping the prefix from the env key for the config key. Values + are passed through a loading function to attempt to convert them + to more specific types than strings. + + Keys are loaded in :func:`sorted` order. + + The default loading function attempts to parse values as any + valid JSON type, including dicts and lists. + + Specific items in nested dicts can be set by separating the + keys with double underscores (``__``). If an intermediate key + doesn't exist, it will be initialized to an empty dict. + + :param prefix: Load env vars that start with this prefix, + separated with an underscore (``_``). + :param loads: Pass each string value to this function and use + the returned value as the config value. If any error is + raised it is ignored and the value remains a string. The + default is :func:`json.loads`. + + .. versionadded:: 2.1 + """ + prefix = f"{prefix}_" + + for key in sorted(os.environ): + if not key.startswith(prefix): + continue + + value = os.environ[key] + key = key.removeprefix(prefix) + + try: + value = loads(value) + except Exception: + # Keep the value as a string if loading failed. + pass + + if "__" not in key: + # A non-nested key, set directly. + self[key] = value + continue + + # Traverse nested dictionaries with keys separated by "__". + current = self + *parts, tail = key.split("__") + + for part in parts: + # If an intermediate dict does not exist, create it. + if part not in current: + current[part] = {} + + current = current[part] + + current[tail] = value + + return True + + def from_pyfile( + self, filename: str | os.PathLike[str], silent: bool = False + ) -> bool: + """Updates the values in the config from a Python file. This function + behaves as if the file was imported as module with the + :meth:`from_object` function. + + :param filename: the filename of the config. This can either be an + absolute filename or a filename relative to the + root path. + :param silent: set to ``True`` if you want silent failure for missing + files. + :return: ``True`` if the file was loaded successfully. + + .. versionadded:: 0.7 + `silent` parameter. + """ + filename = os.path.join(self.root_path, filename) + d = types.ModuleType("config") + d.__file__ = filename + try: + with open(filename, mode="rb") as config_file: + exec(compile(config_file.read(), filename, "exec"), d.__dict__) + except OSError as e: + if silent and e.errno in (errno.ENOENT, errno.EISDIR, errno.ENOTDIR): + return False + e.strerror = f"Unable to load configuration file ({e.strerror})" + raise + self.from_object(d) + return True + + def from_object(self, obj: object | str) -> None: + """Updates the values from the given object. An object can be of one + of the following two types: + + - a string: in this case the object with that name will be imported + - an actual object reference: that object is used directly + + Objects are usually either modules or classes. :meth:`from_object` + loads only the uppercase attributes of the module/class. A ``dict`` + object will not work with :meth:`from_object` because the keys of a + ``dict`` are not attributes of the ``dict`` class. + + Example of module-based configuration:: + + app.config.from_object('yourapplication.default_config') + from yourapplication import default_config + app.config.from_object(default_config) + + Nothing is done to the object before loading. If the object is a + class and has ``@property`` attributes, it needs to be + instantiated before being passed to this method. + + You should not use this function to load the actual configuration but + rather configuration defaults. The actual config should be loaded + with :meth:`from_pyfile` and ideally from a location not within the + package because the package might be installed system wide. + + See :ref:`config-dev-prod` for an example of class-based configuration + using :meth:`from_object`. + + :param obj: an import name or object + """ + if isinstance(obj, str): + obj = import_string(obj) + for key in dir(obj): + if key.isupper(): + self[key] = getattr(obj, key) + + def from_file( + self, + filename: str | os.PathLike[str], + load: t.Callable[[t.IO[t.Any]], t.Mapping[str, t.Any]], + silent: bool = False, + text: bool = True, + ) -> bool: + """Update the values in the config from a file that is loaded + using the ``load`` parameter. The loaded data is passed to the + :meth:`from_mapping` method. + + .. code-block:: python + + import json + app.config.from_file("config.json", load=json.load) + + import tomllib + app.config.from_file("config.toml", load=tomllib.load, text=False) + + :param filename: The path to the data file. This can be an + absolute path or relative to the config root path. + :param load: A callable that takes a file handle and returns a + mapping of loaded data from the file. + :type load: ``Callable[[Reader], Mapping]`` where ``Reader`` + implements a ``read`` method. + :param silent: Ignore the file if it doesn't exist. + :param text: Open the file in text or binary mode. + :return: ``True`` if the file was loaded successfully. + + .. versionchanged:: 2.3 + The ``text`` parameter was added. + + .. versionadded:: 2.0 + """ + filename = os.path.join(self.root_path, filename) + + try: + with open(filename, "r" if text else "rb") as f: + obj = load(f) + except OSError as e: + if silent and e.errno in (errno.ENOENT, errno.EISDIR): + return False + + e.strerror = f"Unable to load configuration file ({e.strerror})" + raise + + return self.from_mapping(obj) + + def from_mapping( + self, mapping: t.Mapping[str, t.Any] | None = None, **kwargs: t.Any + ) -> bool: + """Updates the config like :meth:`update` ignoring items with + non-upper keys. + + :return: Always returns ``True``. + + .. versionadded:: 0.11 + """ + mappings: dict[str, t.Any] = {} + if mapping is not None: + mappings.update(mapping) + mappings.update(kwargs) + for key, value in mappings.items(): + if key.isupper(): + self[key] = value + return True + + def get_namespace( + self, namespace: str, lowercase: bool = True, trim_namespace: bool = True + ) -> dict[str, t.Any]: + """Returns a dictionary containing a subset of configuration options + that match the specified namespace/prefix. Example usage:: + + app.config['IMAGE_STORE_TYPE'] = 'fs' + app.config['IMAGE_STORE_PATH'] = '/var/app/images' + app.config['IMAGE_STORE_BASE_URL'] = 'http://img.website.com' + image_store_config = app.config.get_namespace('IMAGE_STORE_') + + The resulting dictionary `image_store_config` would look like:: + + { + 'type': 'fs', + 'path': '/var/app/images', + 'base_url': 'http://img.website.com' + } + + This is often useful when configuration options map directly to + keyword arguments in functions or class constructors. + + :param namespace: a configuration namespace + :param lowercase: a flag indicating if the keys of the resulting + dictionary should be lowercase + :param trim_namespace: a flag indicating if the keys of the resulting + dictionary should not include the namespace + + .. versionadded:: 0.11 + """ + rv = {} + for k, v in self.items(): + if not k.startswith(namespace): + continue + if trim_namespace: + key = k[len(namespace) :] + else: + key = k + if lowercase: + key = key.lower() + rv[key] = v + return rv + + def __repr__(self) -> str: + return f"<{type(self).__name__} {dict.__repr__(self)}>" diff --git a/src/flask-main/src/flask/ctx.py b/src/flask-main/src/flask/ctx.py new file mode 100644 index 0000000..ba72b17 --- /dev/null +++ b/src/flask-main/src/flask/ctx.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import contextvars +import typing as t +from functools import update_wrapper +from types import TracebackType + +from werkzeug.exceptions import HTTPException +from werkzeug.routing import MapAdapter + +from . import typing as ft +from .globals import _cv_app +from .signals import appcontext_popped +from .signals import appcontext_pushed + +if t.TYPE_CHECKING: + import typing_extensions as te + from _typeshed.wsgi import WSGIEnvironment + + from .app import Flask + from .sessions import SessionMixin + from .wrappers import Request + + +# a singleton sentinel value for parameter defaults +_sentinel = object() + + +class _AppCtxGlobals: + """A plain object. Used as a namespace for storing data during an + application context. + + Creating an app context automatically creates this object, which is + made available as the :data:`.g` proxy. + + .. describe:: 'key' in g + + Check whether an attribute is present. + + .. versionadded:: 0.10 + + .. describe:: iter(g) + + Return an iterator over the attribute names. + + .. versionadded:: 0.10 + """ + + # Define attr methods to let mypy know this is a namespace object + # that has arbitrary attributes. + + def __getattr__(self, name: str) -> t.Any: + try: + return self.__dict__[name] + except KeyError: + raise AttributeError(name) from None + + def __setattr__(self, name: str, value: t.Any) -> None: + self.__dict__[name] = value + + def __delattr__(self, name: str) -> None: + try: + del self.__dict__[name] + except KeyError: + raise AttributeError(name) from None + + def get(self, name: str, default: t.Any | None = None) -> t.Any: + """Get an attribute by name, or a default value. Like + :meth:`dict.get`. + + :param name: Name of attribute to get. + :param default: Value to return if the attribute is not present. + + .. versionadded:: 0.10 + """ + return self.__dict__.get(name, default) + + def pop(self, name: str, default: t.Any = _sentinel) -> t.Any: + """Get and remove an attribute by name. Like :meth:`dict.pop`. + + :param name: Name of attribute to pop. + :param default: Value to return if the attribute is not present, + instead of raising a ``KeyError``. + + .. versionadded:: 0.11 + """ + if default is _sentinel: + return self.__dict__.pop(name) + else: + return self.__dict__.pop(name, default) + + def setdefault(self, name: str, default: t.Any = None) -> t.Any: + """Get the value of an attribute if it is present, otherwise + set and return a default value. Like :meth:`dict.setdefault`. + + :param name: Name of attribute to get. + :param default: Value to set and return if the attribute is not + present. + + .. versionadded:: 0.11 + """ + return self.__dict__.setdefault(name, default) + + def __contains__(self, item: str) -> bool: + return item in self.__dict__ + + def __iter__(self) -> t.Iterator[str]: + return iter(self.__dict__) + + def __repr__(self) -> str: + ctx = _cv_app.get(None) + if ctx is not None: + return f"" + return object.__repr__(self) + + +def after_this_request( + f: ft.AfterRequestCallable[t.Any], +) -> ft.AfterRequestCallable[t.Any]: + """Decorate a function to run after the current request. The behavior is the + same as :meth:`.Flask.after_request`, except it only applies to the current + request, rather than every request. Therefore, it must be used within a + request context, rather than during setup. + + .. code-block:: python + + @app.route("/") + def index(): + @after_this_request + def add_header(response): + response.headers["X-Foo"] = "Parachute" + return response + + return "Hello, World!" + + .. versionadded:: 0.9 + """ + ctx = _cv_app.get(None) + + if ctx is None or not ctx.has_request: + raise RuntimeError( + "'after_this_request' can only be used when a request" + " context is active, such as in a view function." + ) + + ctx._after_request_functions.append(f) + return f + + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + + +def copy_current_request_context(f: F) -> F: + """Decorate a function to run inside the current request context. This can + be used when starting a background task, otherwise it will not see the app + and request objects that were active in the parent. + + .. warning:: + + Due to the following caveats, it is often safer (and simpler) to pass + the data you need when starting the task, rather than using this and + relying on the context objects. + + In order to avoid execution switching partially though reading data, either + read the request body (access ``form``, ``json``, ``data``, etc) before + starting the task, or use a lock. This can be an issue when using threading, + but shouldn't be an issue when using greenlet/gevent or asyncio. + + If the task will access ``session``, be sure to do so in the parent as well + so that the ``Vary: cookie`` header will be set. Modifying ``session`` in + the task should be avoided, as it may execute after the response cookie has + already been written. + + .. code-block:: python + + import gevent + from flask import copy_current_request_context + + @app.route('/') + def index(): + @copy_current_request_context + def do_some_work(): + # do some work here, it can access flask.request or + # flask.session like you would otherwise in the view function. + ... + gevent.spawn(do_some_work) + return 'Regular response' + + .. versionadded:: 0.10 + """ + ctx = _cv_app.get(None) + + if ctx is None: + raise RuntimeError( + "'copy_current_request_context' can only be used when a" + " request context is active, such as in a view function." + ) + + ctx = ctx.copy() + + def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any: + with ctx: + return ctx.app.ensure_sync(f)(*args, **kwargs) + + return update_wrapper(wrapper, f) # type: ignore[return-value] + + +def has_request_context() -> bool: + """Test if an app context is active and if it has request information. + + .. code-block:: python + + from flask import has_request_context, request + + if has_request_context(): + remote_addr = request.remote_addr + + If a request context is active, the :data:`.request` and :data:`.session` + context proxies will available and ``True``, otherwise ``False``. You can + use that to test the data you use, rather than using this function. + + .. code-block:: python + + from flask import request + + if request: + remote_addr = request.remote_addr + + .. versionadded:: 0.7 + """ + return (ctx := _cv_app.get(None)) is not None and ctx.has_request + + +def has_app_context() -> bool: + """Test if an app context is active. Unlike :func:`has_request_context` + this can be true outside a request, such as in a CLI command. + + .. code-block:: python + + from flask import has_app_context, g + + if has_app_context(): + g.cached_data = ... + + If an app context is active, the :data:`.g` and :data:`.current_app` context + proxies will available and ``True``, otherwise ``False``. You can use that + to test the data you use, rather than using this function. + + from flask import g + + if g: + g.cached_data = ... + + .. versionadded:: 0.9 + """ + return _cv_app.get(None) is not None + + +class AppContext: + """An app context contains information about an app, and about the request + when handling a request. A context is pushed at the beginning of each + request and CLI command, and popped at the end. The context is referred to + as a "request context" if it has request information, and an "app context" + if not. + + Do not use this class directly. Use :meth:`.Flask.app_context` to create an + app context if needed during setup, and :meth:`.Flask.test_request_context` + to create a request context if needed during tests. + + When the context is popped, it will evaluate all the teardown functions + registered with :meth:`~flask.Flask.teardown_request` (if handling a + request) then :meth:`.Flask.teardown_appcontext`. + + When using the interactive debugger, the context will be restored so + ``request`` is still accessible. Similarly, the test client can preserve the + context after the request ends. However, teardown functions may already have + closed some resources such as database connections, and will run again when + the restored context is popped. + + :param app: The application this context represents. + :param request: The request data this context represents. + :param session: The session data this context represents. If not given, + loaded from the request on first access. + + .. versionchanged:: 3.2 + Merged with ``RequestContext``. The ``RequestContext`` alias will be + removed in Flask 4.0. + + .. versionchanged:: 3.2 + A combined app and request context is pushed for every request and CLI + command, rather than trying to detect if an app context is already + pushed. + + .. versionchanged:: 3.2 + The session is loaded the first time it is accessed, rather than when + the context is pushed. + """ + + def __init__( + self, + app: Flask, + *, + request: Request | None = None, + session: SessionMixin | None = None, + ) -> None: + self.app = app + """The application represented by this context. Accessed through + :data:`.current_app`. + """ + + self.g: _AppCtxGlobals = app.app_ctx_globals_class() + """The global data for this context. Accessed through :data:`.g`.""" + + self.url_adapter: MapAdapter | None = None + """The URL adapter bound to the request, or the app if not in a request. + May be ``None`` if binding failed. + """ + + self._request: Request | None = request + self._session: SessionMixin | None = session + self._flashes: list[tuple[str, str]] | None = None + self._after_request_functions: list[ft.AfterRequestCallable[t.Any]] = [] + + try: + self.url_adapter = app.create_url_adapter(self._request) + except HTTPException as e: + if self._request is not None: + self._request.routing_exception = e + + self._cv_token: contextvars.Token[AppContext] | None = None + """The previous state to restore when popping.""" + + self._push_count: int = 0 + """Track nested pushes of this context. Cleanup will only run once the + original push has been popped. + """ + + @classmethod + def from_environ(cls, app: Flask, environ: WSGIEnvironment, /) -> te.Self: + """Create an app context with request data from the given WSGI environ. + + :param app: The application this context represents. + :param environ: The request data this context represents. + """ + request = app.request_class(environ) + request.json_module = app.json + return cls(app, request=request) + + @property + def has_request(self) -> bool: + """True if this context was created with request data.""" + return self._request is not None + + def copy(self) -> te.Self: + """Create a new context with the same data objects as this context. See + :func:`.copy_current_request_context`. + + .. versionchanged:: 1.1 + The current session data is used instead of reloading the original data. + + .. versionadded:: 0.10 + """ + return self.__class__( + self.app, + request=self._request, + session=self._session, + ) + + @property + def request(self) -> Request: + """The request object associated with this context. Accessed through + :data:`.request`. Only available in request contexts, otherwise raises + :exc:`RuntimeError`. + """ + if self._request is None: + raise RuntimeError("There is no request in this context.") + + return self._request + + @property + def session(self) -> SessionMixin: + """The session object associated with this context. Accessed through + :data:`.session`. Only available in request contexts, otherwise raises + :exc:`RuntimeError`. + """ + if self._request is None: + raise RuntimeError("There is no request in this context.") + + if self._session is None: + si = self.app.session_interface + self._session = si.open_session(self.app, self.request) + + if self._session is None: + self._session = si.make_null_session(self.app) + + return self._session + + def match_request(self) -> None: + """Apply routing to the current request, storing either the matched + endpoint and args, or a routing exception. + """ + try: + result = self.url_adapter.match(return_rule=True) # type: ignore[union-attr] + except HTTPException as e: + self._request.routing_exception = e # type: ignore[union-attr] + else: + self._request.url_rule, self._request.view_args = result # type: ignore[union-attr] + + def push(self) -> None: + """Push this context so that it is the active context. If this is a + request context, calls :meth:`match_request` to perform routing with + the context active. + + Typically, this is not used directly. Instead, use a ``with`` block + to manage the context. + + In some situations, such as streaming or testing, the context may be + pushed multiple times. It will only trigger matching and signals if it + is not currently pushed. + """ + self._push_count += 1 + + if self._cv_token is not None: + return + + self._cv_token = _cv_app.set(self) + appcontext_pushed.send(self.app, _async_wrapper=self.app.ensure_sync) + + if self._request is not None and self.url_adapter is not None: + self.match_request() + + def pop(self, exc: BaseException | None = None) -> None: + """Pop this context so that it is no longer the active context. Then + call teardown functions and signals. + + Typically, this is not used directly. Instead, use a ``with`` block + to manage the context. + + This context must currently be the active context, otherwise a + :exc:`RuntimeError` is raised. In some situations, such as streaming or + testing, the context may have been pushed multiple times. It will only + trigger cleanup once it has been popped as many times as it was pushed. + Until then, it will remain the active context. + + :param exc: An unhandled exception that was raised while the context was + active. Passed to teardown functions. + + .. versionchanged:: 0.9 + Added the ``exc`` argument. + """ + if self._cv_token is None: + raise RuntimeError(f"Cannot pop this context ({self!r}), it is not pushed.") + + ctx = _cv_app.get(None) + + if ctx is None or self._cv_token is None: + raise RuntimeError( + f"Cannot pop this context ({self!r}), there is no active context." + ) + + if ctx is not self: + raise RuntimeError( + f"Cannot pop this context ({self!r}), it is not the active" + f" context ({ctx!r})." + ) + + self._push_count -= 1 + + if self._push_count > 0: + return + + try: + if self._request is not None: + self.app.do_teardown_request(self, exc) + self._request.close() + finally: + self.app.do_teardown_appcontext(self, exc) + _cv_app.reset(self._cv_token) + self._cv_token = None + appcontext_popped.send(self.app, _async_wrapper=self.app.ensure_sync) + + def __enter__(self) -> te.Self: + self.push() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.pop(exc_value) + + def __repr__(self) -> str: + if self._request is not None: + return ( + f"<{type(self).__name__} {id(self)} of {self.app.name}," + f" {self.request.method} {self.request.url!r}>" + ) + + return f"<{type(self).__name__} {id(self)} of {self.app.name}>" + + +def __getattr__(name: str) -> t.Any: + import warnings + + if name == "RequestContext": + warnings.warn( + "'RequestContext' has merged with 'AppContext', and will be removed" + " in Flask 4.0. Use 'AppContext' instead.", + DeprecationWarning, + stacklevel=2, + ) + return AppContext + + raise AttributeError(name) diff --git a/src/flask-main/src/flask/debughelpers.py b/src/flask-main/src/flask/debughelpers.py new file mode 100644 index 0000000..61884e1 --- /dev/null +++ b/src/flask-main/src/flask/debughelpers.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import typing as t + +from jinja2.loaders import BaseLoader +from werkzeug.routing import RequestRedirect + +from .blueprints import Blueprint +from .globals import _cv_app +from .sansio.app import App + +if t.TYPE_CHECKING: + from .sansio.scaffold import Scaffold + from .wrappers import Request + + +class UnexpectedUnicodeError(AssertionError, UnicodeError): + """Raised in places where we want some better error reporting for + unexpected unicode or binary data. + """ + + +class DebugFilesKeyError(KeyError, AssertionError): + """Raised from request.files during debugging. The idea is that it can + provide a better error message than just a generic KeyError/BadRequest. + """ + + def __init__(self, request: Request, key: str) -> None: + form_matches = request.form.getlist(key) + buf = [ + f"You tried to access the file {key!r} in the request.files" + " dictionary but it does not exist. The mimetype for the" + f" request is {request.mimetype!r} instead of" + " 'multipart/form-data' which means that no file contents" + " were transmitted. To fix this error you should provide" + ' enctype="multipart/form-data" in your form.' + ] + if form_matches: + names = ", ".join(repr(x) for x in form_matches) + buf.append( + "\n\nThe browser instead transmitted some file names. " + f"This was submitted: {names}" + ) + self.msg = "".join(buf) + + def __str__(self) -> str: + return self.msg + + +class FormDataRoutingRedirect(AssertionError): + """This exception is raised in debug mode if a routing redirect + would cause the browser to drop the method or body. This happens + when method is not GET, HEAD or OPTIONS and the status code is not + 307 or 308. + """ + + def __init__(self, request: Request) -> None: + exc = request.routing_exception + assert isinstance(exc, RequestRedirect) + buf = [ + f"A request was sent to '{request.url}', but routing issued" + f" a redirect to the canonical URL '{exc.new_url}'." + ] + + if f"{request.base_url}/" == exc.new_url.partition("?")[0]: + buf.append( + " The URL was defined with a trailing slash. Flask" + " will redirect to the URL with a trailing slash if it" + " was accessed without one." + ) + + buf.append( + " Send requests to the canonical URL, or use 307 or 308 for" + " routing redirects. Otherwise, browsers will drop form" + " data.\n\n" + "This exception is only raised in debug mode." + ) + super().__init__("".join(buf)) + + +def attach_enctype_error_multidict(request: Request) -> None: + """Patch ``request.files.__getitem__`` to raise a descriptive error + about ``enctype=multipart/form-data``. + + :param request: The request to patch. + :meta private: + """ + oldcls = request.files.__class__ + + class newcls(oldcls): # type: ignore[valid-type, misc] + def __getitem__(self, key: str) -> t.Any: + try: + return super().__getitem__(key) + except KeyError as e: + if key not in request.form: + raise + + raise DebugFilesKeyError(request, key).with_traceback( + e.__traceback__ + ) from None + + newcls.__name__ = oldcls.__name__ + newcls.__module__ = oldcls.__module__ + request.files.__class__ = newcls + + +def _dump_loader_info(loader: BaseLoader) -> t.Iterator[str]: + yield f"class: {type(loader).__module__}.{type(loader).__name__}" + for key, value in sorted(loader.__dict__.items()): + if key.startswith("_"): + continue + if isinstance(value, (tuple, list)): + if not all(isinstance(x, str) for x in value): + continue + yield f"{key}:" + for item in value: + yield f" - {item}" + continue + elif not isinstance(value, (str, int, float, bool)): + continue + yield f"{key}: {value!r}" + + +def explain_template_loading_attempts( + app: App, + template: str, + attempts: list[ + tuple[ + BaseLoader, + Scaffold, + tuple[str, str | None, t.Callable[[], bool] | None] | None, + ] + ], +) -> None: + """This should help developers understand what failed""" + info = [f"Locating template {template!r}:"] + total_found = 0 + blueprint = None + + if (ctx := _cv_app.get(None)) is not None and ctx.has_request: + blueprint = ctx.request.blueprint + + for idx, (loader, srcobj, triple) in enumerate(attempts): + if isinstance(srcobj, App): + src_info = f"application {srcobj.import_name!r}" + elif isinstance(srcobj, Blueprint): + src_info = f"blueprint {srcobj.name!r} ({srcobj.import_name})" + else: + src_info = repr(srcobj) + + info.append(f"{idx + 1:5}: trying loader of {src_info}") + + for line in _dump_loader_info(loader): + info.append(f" {line}") + + if triple is None: + detail = "no match" + else: + detail = f"found ({triple[1] or ''!r})" + total_found += 1 + info.append(f" -> {detail}") + + seems_fishy = False + if total_found == 0: + info.append("Error: the template could not be found.") + seems_fishy = True + elif total_found > 1: + info.append("Warning: multiple loaders returned a match for the template.") + seems_fishy = True + + if blueprint is not None and seems_fishy: + info.append( + " The template was looked up from an endpoint that belongs" + f" to the blueprint {blueprint!r}." + ) + info.append(" Maybe you did not place a template in the right folder?") + info.append(" See https://flask.palletsprojects.com/blueprints/#templates") + + app.logger.info("\n".join(info)) diff --git a/src/flask-main/src/flask/globals.py b/src/flask-main/src/flask/globals.py new file mode 100644 index 0000000..f4a7298 --- /dev/null +++ b/src/flask-main/src/flask/globals.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import typing as t +from contextvars import ContextVar + +from werkzeug.local import LocalProxy + +if t.TYPE_CHECKING: # pragma: no cover + from .app import Flask + from .ctx import _AppCtxGlobals + from .ctx import AppContext + from .sessions import SessionMixin + from .wrappers import Request + + T = t.TypeVar("T", covariant=True) + + class ProxyMixin(t.Protocol[T]): + def _get_current_object(self) -> T: ... + + # These subclasses inform type checkers that the proxy objects look like the + # proxied type along with the _get_current_object method. + class FlaskProxy(ProxyMixin[Flask], Flask): ... + + class AppContextProxy(ProxyMixin[AppContext], AppContext): ... + + class _AppCtxGlobalsProxy(ProxyMixin[_AppCtxGlobals], _AppCtxGlobals): ... + + class RequestProxy(ProxyMixin[Request], Request): ... + + class SessionMixinProxy(ProxyMixin[SessionMixin], SessionMixin): ... + + +_no_app_msg = """\ +Working outside of application context. + +Attempted to use functionality that expected a current application to be set. To +solve this, set up an app context using 'with app.app_context()'. See the +documentation on app context for more information.\ +""" +_cv_app: ContextVar[AppContext] = ContextVar("flask.app_ctx") +app_ctx: AppContextProxy = LocalProxy( # type: ignore[assignment] + _cv_app, unbound_message=_no_app_msg +) +current_app: FlaskProxy = LocalProxy( # type: ignore[assignment] + _cv_app, "app", unbound_message=_no_app_msg +) +g: _AppCtxGlobalsProxy = LocalProxy( # type: ignore[assignment] + _cv_app, "g", unbound_message=_no_app_msg +) + +_no_req_msg = """\ +Working outside of request context. + +Attempted to use functionality that expected an active HTTP request. See the +documentation on request context for more information.\ +""" +request: RequestProxy = LocalProxy( # type: ignore[assignment] + _cv_app, "request", unbound_message=_no_req_msg +) +session: SessionMixinProxy = LocalProxy( # type: ignore[assignment] + _cv_app, "session", unbound_message=_no_req_msg +) + + +def __getattr__(name: str) -> t.Any: + import warnings + + if name == "request_ctx": + warnings.warn( + "'request_ctx' has merged with 'app_ctx', and will be removed" + " in Flask 4.0. Use 'app_ctx' instead.", + DeprecationWarning, + stacklevel=2, + ) + return app_ctx + + raise AttributeError(name) diff --git a/src/flask-main/src/flask/helpers.py b/src/flask-main/src/flask/helpers.py new file mode 100644 index 0000000..31167c2 --- /dev/null +++ b/src/flask-main/src/flask/helpers.py @@ -0,0 +1,635 @@ +from __future__ import annotations + +import importlib.util +import os +import sys +import typing as t +from datetime import datetime +from functools import cache +from functools import update_wrapper + +import werkzeug.utils +from werkzeug.exceptions import abort as _wz_abort +from werkzeug.utils import redirect as _wz_redirect +from werkzeug.wrappers import Response as BaseResponse + +from .globals import _cv_app +from .globals import app_ctx +from .globals import current_app +from .globals import request +from .globals import session +from .signals import message_flashed + +if t.TYPE_CHECKING: # pragma: no cover + from .wrappers import Response + + +def get_debug_flag() -> bool: + """Get whether debug mode should be enabled for the app, indicated by the + :envvar:`FLASK_DEBUG` environment variable. The default is ``False``. + """ + val = os.environ.get("FLASK_DEBUG") + return bool(val and val.lower() not in {"0", "false", "no"}) + + +def get_load_dotenv(default: bool = True) -> bool: + """Get whether the user has disabled loading default dotenv files by + setting :envvar:`FLASK_SKIP_DOTENV`. The default is ``True``, load + the files. + + :param default: What to return if the env var isn't set. + """ + val = os.environ.get("FLASK_SKIP_DOTENV") + + if not val: + return default + + return val.lower() in ("0", "false", "no") + + +@t.overload +def stream_with_context( + generator_or_function: t.Iterator[t.AnyStr], +) -> t.Iterator[t.AnyStr]: ... + + +@t.overload +def stream_with_context( + generator_or_function: t.Callable[..., t.Iterator[t.AnyStr]], +) -> t.Callable[[t.Iterator[t.AnyStr]], t.Iterator[t.AnyStr]]: ... + + +def stream_with_context( + generator_or_function: t.Iterator[t.AnyStr] | t.Callable[..., t.Iterator[t.AnyStr]], +) -> t.Iterator[t.AnyStr] | t.Callable[[t.Iterator[t.AnyStr]], t.Iterator[t.AnyStr]]: + """Wrap a response generator function so that it runs inside the current + request context. This keeps :data:`.request`, :data:`.session`, and :data:`.g` + available, even though at the point the generator runs the request context + will typically have ended. + + Use it as a decorator on a generator function: + + .. code-block:: python + + from flask import stream_with_context, request, Response + + @app.get("/stream") + def streamed_response(): + @stream_with_context + def generate(): + yield "Hello " + yield request.args["name"] + yield "!" + + return Response(generate()) + + Or use it as a wrapper around a created generator: + + .. code-block:: python + + from flask import stream_with_context, request, Response + + @app.get("/stream") + def streamed_response(): + def generate(): + yield "Hello " + yield request.args["name"] + yield "!" + + return Response(stream_with_context(generate())) + + .. versionadded:: 0.9 + """ + try: + gen = iter(generator_or_function) # type: ignore[arg-type] + except TypeError: + + def decorator(*args: t.Any, **kwargs: t.Any) -> t.Any: + gen = generator_or_function(*args, **kwargs) # type: ignore[operator] + return stream_with_context(gen) + + return update_wrapper(decorator, generator_or_function) # type: ignore[arg-type] + + def generator() -> t.Iterator[t.AnyStr]: + if (ctx := _cv_app.get(None)) is None: + raise RuntimeError( + "'stream_with_context' can only be used when a request" + " context is active, such as in a view function." + ) + + with ctx: + yield None # type: ignore[misc] + + try: + yield from gen + finally: + # Clean up in case the user wrapped a WSGI iterator. + if hasattr(gen, "close"): + gen.close() + + # Execute the generator to the sentinel value. This captures the current + # context and pushes it to preserve it. Further iteration will yield from + # the original iterator. + wrapped_g = generator() + next(wrapped_g) + return wrapped_g + + +def make_response(*args: t.Any) -> Response: + """Sometimes it is necessary to set additional headers in a view. Because + views do not have to return response objects but can return a value that + is converted into a response object by Flask itself, it becomes tricky to + add headers to it. This function can be called instead of using a return + and you will get a response object which you can use to attach headers. + + If view looked like this and you want to add a new header:: + + def index(): + return render_template('index.html', foo=42) + + You can now do something like this:: + + def index(): + response = make_response(render_template('index.html', foo=42)) + response.headers['X-Parachutes'] = 'parachutes are cool' + return response + + This function accepts the very same arguments you can return from a + view function. This for example creates a response with a 404 error + code:: + + response = make_response(render_template('not_found.html'), 404) + + The other use case of this function is to force the return value of a + view function into a response which is helpful with view + decorators:: + + response = make_response(view_function()) + response.headers['X-Parachutes'] = 'parachutes are cool' + + Internally this function does the following things: + + - if no arguments are passed, it creates a new response argument + - if one argument is passed, :meth:`flask.Flask.make_response` + is invoked with it. + - if more than one argument is passed, the arguments are passed + to the :meth:`flask.Flask.make_response` function as tuple. + + .. versionadded:: 0.6 + """ + if not args: + return current_app.response_class() + if len(args) == 1: + args = args[0] + return current_app.make_response(args) + + +def url_for( + endpoint: str, + *, + _anchor: str | None = None, + _method: str | None = None, + _scheme: str | None = None, + _external: bool | None = None, + **values: t.Any, +) -> str: + """Generate a URL to the given endpoint with the given values. + + This requires an active request or application context, and calls + :meth:`current_app.url_for() `. See that method + for full documentation. + + :param endpoint: The endpoint name associated with the URL to + generate. If this starts with a ``.``, the current blueprint + name (if any) will be used. + :param _anchor: If given, append this as ``#anchor`` to the URL. + :param _method: If given, generate the URL associated with this + method for the endpoint. + :param _scheme: If given, the URL will have this scheme if it is + external. + :param _external: If given, prefer the URL to be internal (False) or + require it to be external (True). External URLs include the + scheme and domain. When not in an active request, URLs are + external by default. + :param values: Values to use for the variable parts of the URL rule. + Unknown keys are appended as query string arguments, like + ``?a=b&c=d``. + + .. versionchanged:: 2.2 + Calls ``current_app.url_for``, allowing an app to override the + behavior. + + .. versionchanged:: 0.10 + The ``_scheme`` parameter was added. + + .. versionchanged:: 0.9 + The ``_anchor`` and ``_method`` parameters were added. + + .. versionchanged:: 0.9 + Calls ``app.handle_url_build_error`` on build errors. + """ + return current_app.url_for( + endpoint, + _anchor=_anchor, + _method=_method, + _scheme=_scheme, + _external=_external, + **values, + ) + + +def redirect( + location: str, code: int = 302, Response: type[BaseResponse] | None = None +) -> BaseResponse: + """Create a redirect response object. + + If :data:`~flask.current_app` is available, it will use its + :meth:`~flask.Flask.redirect` method, otherwise it will use + :func:`werkzeug.utils.redirect`. + + :param location: The URL to redirect to. + :param code: The status code for the redirect. + :param Response: The response class to use. Not used when + ``current_app`` is active, which uses ``app.response_class``. + + .. versionadded:: 2.2 + Calls ``current_app.redirect`` if available instead of always + using Werkzeug's default ``redirect``. + """ + if (ctx := _cv_app.get(None)) is not None: + return ctx.app.redirect(location, code=code) + + return _wz_redirect(location, code=code, Response=Response) + + +def abort(code: int | BaseResponse, *args: t.Any, **kwargs: t.Any) -> t.NoReturn: + """Raise an :exc:`~werkzeug.exceptions.HTTPException` for the given + status code. + + If :data:`~flask.current_app` is available, it will call its + :attr:`~flask.Flask.aborter` object, otherwise it will use + :func:`werkzeug.exceptions.abort`. + + :param code: The status code for the exception, which must be + registered in ``app.aborter``. + :param args: Passed to the exception. + :param kwargs: Passed to the exception. + + .. versionadded:: 2.2 + Calls ``current_app.aborter`` if available instead of always + using Werkzeug's default ``abort``. + """ + if (ctx := _cv_app.get(None)) is not None: + ctx.app.aborter(code, *args, **kwargs) + + _wz_abort(code, *args, **kwargs) + + +def get_template_attribute(template_name: str, attribute: str) -> t.Any: + """Loads a macro (or variable) a template exports. This can be used to + invoke a macro from within Python code. If you for example have a + template named :file:`_cider.html` with the following contents: + + .. sourcecode:: html+jinja + + {% macro hello(name) %}Hello {{ name }}!{% endmacro %} + + You can access this from Python code like this:: + + hello = get_template_attribute('_cider.html', 'hello') + return hello('World') + + .. versionadded:: 0.2 + + :param template_name: the name of the template + :param attribute: the name of the variable of macro to access + """ + return getattr(current_app.jinja_env.get_template(template_name).module, attribute) + + +def flash(message: str, category: str = "message") -> None: + """Flashes a message to the next request. In order to remove the + flashed message from the session and to display it to the user, + the template has to call :func:`get_flashed_messages`. + + .. versionchanged:: 0.3 + `category` parameter added. + + :param message: the message to be flashed. + :param category: the category for the message. The following values + are recommended: ``'message'`` for any kind of message, + ``'error'`` for errors, ``'info'`` for information + messages and ``'warning'`` for warnings. However any + kind of string can be used as category. + """ + # Original implementation: + # + # session.setdefault('_flashes', []).append((category, message)) + # + # This assumed that changes made to mutable structures in the session are + # always in sync with the session object, which is not true for session + # implementations that use external storage for keeping their keys/values. + flashes = session.get("_flashes", []) + flashes.append((category, message)) + session["_flashes"] = flashes + app = current_app._get_current_object() + message_flashed.send( + app, + _async_wrapper=app.ensure_sync, + message=message, + category=category, + ) + + +def get_flashed_messages( + with_categories: bool = False, category_filter: t.Iterable[str] = () +) -> list[str] | list[tuple[str, str]]: + """Pulls all flashed messages from the session and returns them. + Further calls in the same request to the function will return + the same messages. By default just the messages are returned, + but when `with_categories` is set to ``True``, the return value will + be a list of tuples in the form ``(category, message)`` instead. + + Filter the flashed messages to one or more categories by providing those + categories in `category_filter`. This allows rendering categories in + separate html blocks. The `with_categories` and `category_filter` + arguments are distinct: + + * `with_categories` controls whether categories are returned with message + text (``True`` gives a tuple, where ``False`` gives just the message text). + * `category_filter` filters the messages down to only those matching the + provided categories. + + See :doc:`/patterns/flashing` for examples. + + .. versionchanged:: 0.3 + `with_categories` parameter added. + + .. versionchanged:: 0.9 + `category_filter` parameter added. + + :param with_categories: set to ``True`` to also receive categories. + :param category_filter: filter of categories to limit return values. Only + categories in the list will be returned. + """ + flashes = app_ctx._flashes + if flashes is None: + flashes = session.pop("_flashes") if "_flashes" in session else [] + app_ctx._flashes = flashes + if category_filter: + flashes = list(filter(lambda f: f[0] in category_filter, flashes)) + if not with_categories: + return [x[1] for x in flashes] + return flashes + + +def _prepare_send_file_kwargs(**kwargs: t.Any) -> dict[str, t.Any]: + ctx = app_ctx._get_current_object() + + if kwargs.get("max_age") is None: + kwargs["max_age"] = ctx.app.get_send_file_max_age + + kwargs.update( + environ=ctx.request.environ, + use_x_sendfile=ctx.app.config["USE_X_SENDFILE"], + response_class=ctx.app.response_class, + _root_path=ctx.app.root_path, + ) + return kwargs + + +def send_file( + path_or_file: os.PathLike[t.AnyStr] | str | t.IO[bytes], + mimetype: str | None = None, + as_attachment: bool = False, + download_name: str | None = None, + conditional: bool = True, + etag: bool | str = True, + last_modified: datetime | int | float | None = None, + max_age: None | (int | t.Callable[[str | None], int | None]) = None, +) -> Response: + """Send the contents of a file to the client. + + The first argument can be a file path or a file-like object. Paths + are preferred in most cases because Werkzeug can manage the file and + get extra information from the path. Passing a file-like object + requires that the file is opened in binary mode, and is mostly + useful when building a file in memory with :class:`io.BytesIO`. + + Never pass file paths provided by a user. The path is assumed to be + trusted, so a user could craft a path to access a file you didn't + intend. Use :func:`send_from_directory` to safely serve + user-requested paths from within a directory. + + If the WSGI server sets a ``file_wrapper`` in ``environ``, it is + used, otherwise Werkzeug's built-in wrapper is used. Alternatively, + if the HTTP server supports ``X-Sendfile``, configuring Flask with + ``USE_X_SENDFILE = True`` will tell the server to send the given + path, which is much more efficient than reading it in Python. + + :param path_or_file: The path to the file to send, relative to the + current working directory if a relative path is given. + Alternatively, a file-like object opened in binary mode. Make + sure the file pointer is seeked to the start of the data. + :param mimetype: The MIME type to send for the file. If not + provided, it will try to detect it from the file name. + :param as_attachment: Indicate to a browser that it should offer to + save the file instead of displaying it. + :param download_name: The default name browsers will use when saving + the file. Defaults to the passed file name. + :param conditional: Enable conditional and range responses based on + request headers. Requires passing a file path and ``environ``. + :param etag: Calculate an ETag for the file, which requires passing + a file path. Can also be a string to use instead. + :param last_modified: The last modified time to send for the file, + in seconds. If not provided, it will try to detect it from the + file path. + :param max_age: How long the client should cache the file, in + seconds. If set, ``Cache-Control`` will be ``public``, otherwise + it will be ``no-cache`` to prefer conditional caching. + + .. versionchanged:: 2.0 + ``download_name`` replaces the ``attachment_filename`` + parameter. If ``as_attachment=False``, it is passed with + ``Content-Disposition: inline`` instead. + + .. versionchanged:: 2.0 + ``max_age`` replaces the ``cache_timeout`` parameter. + ``conditional`` is enabled and ``max_age`` is not set by + default. + + .. versionchanged:: 2.0 + ``etag`` replaces the ``add_etags`` parameter. It can be a + string to use instead of generating one. + + .. versionchanged:: 2.0 + Passing a file-like object that inherits from + :class:`~io.TextIOBase` will raise a :exc:`ValueError` rather + than sending an empty file. + + .. versionadded:: 2.0 + Moved the implementation to Werkzeug. This is now a wrapper to + pass some Flask-specific arguments. + + .. versionchanged:: 1.1 + ``filename`` may be a :class:`~os.PathLike` object. + + .. versionchanged:: 1.1 + Passing a :class:`~io.BytesIO` object supports range requests. + + .. versionchanged:: 1.0.3 + Filenames are encoded with ASCII instead of Latin-1 for broader + compatibility with WSGI servers. + + .. versionchanged:: 1.0 + UTF-8 filenames as specified in :rfc:`2231` are supported. + + .. versionchanged:: 0.12 + The filename is no longer automatically inferred from file + objects. If you want to use automatic MIME and etag support, + pass a filename via ``filename_or_fp`` or + ``attachment_filename``. + + .. versionchanged:: 0.12 + ``attachment_filename`` is preferred over ``filename`` for MIME + detection. + + .. versionchanged:: 0.9 + ``cache_timeout`` defaults to + :meth:`Flask.get_send_file_max_age`. + + .. versionchanged:: 0.7 + MIME guessing and etag support for file-like objects was + removed because it was unreliable. Pass a filename if you are + able to, otherwise attach an etag yourself. + + .. versionchanged:: 0.5 + The ``add_etags``, ``cache_timeout`` and ``conditional`` + parameters were added. The default behavior is to add etags. + + .. versionadded:: 0.2 + """ + return werkzeug.utils.send_file( # type: ignore[return-value] + **_prepare_send_file_kwargs( + path_or_file=path_or_file, + environ=request.environ, + mimetype=mimetype, + as_attachment=as_attachment, + download_name=download_name, + conditional=conditional, + etag=etag, + last_modified=last_modified, + max_age=max_age, + ) + ) + + +def send_from_directory( + directory: os.PathLike[str] | str, + path: os.PathLike[str] | str, + **kwargs: t.Any, +) -> Response: + """Send a file from within a directory using :func:`send_file`. + + .. code-block:: python + + @app.route("/uploads/") + def download_file(name): + return send_from_directory( + app.config['UPLOAD_FOLDER'], name, as_attachment=True + ) + + This is a secure way to serve files from a folder, such as static + files or uploads. Uses :func:`~werkzeug.security.safe_join` to + ensure the path coming from the client is not maliciously crafted to + point outside the specified directory. + + If the final path does not point to an existing regular file, + raises a 404 :exc:`~werkzeug.exceptions.NotFound` error. + + :param directory: The directory that ``path`` must be located under, + relative to the current application's root path. This *must not* + be a value provided by the client, otherwise it becomes insecure. + :param path: The path to the file to send, relative to + ``directory``. + :param kwargs: Arguments to pass to :func:`send_file`. + + .. versionchanged:: 2.0 + ``path`` replaces the ``filename`` parameter. + + .. versionadded:: 2.0 + Moved the implementation to Werkzeug. This is now a wrapper to + pass some Flask-specific arguments. + + .. versionadded:: 0.5 + """ + return werkzeug.utils.send_from_directory( # type: ignore[return-value] + directory, path, **_prepare_send_file_kwargs(**kwargs) + ) + + +def get_root_path(import_name: str) -> str: + """Find the root path of a package, or the path that contains a + module. If it cannot be found, returns the current working + directory. + + Not to be confused with the value returned by :func:`find_package`. + + :meta private: + """ + # Module already imported and has a file attribute. Use that first. + mod = sys.modules.get(import_name) + + if mod is not None and hasattr(mod, "__file__") and mod.__file__ is not None: + return os.path.dirname(os.path.abspath(mod.__file__)) + + # Next attempt: check the loader. + try: + spec = importlib.util.find_spec(import_name) + + if spec is None: + raise ValueError + except (ImportError, ValueError): + loader = None + else: + loader = spec.loader + + # Loader does not exist or we're referring to an unloaded main + # module or a main module without path (interactive sessions), go + # with the current working directory. + if loader is None: + return os.getcwd() + + if hasattr(loader, "get_filename"): + filepath = loader.get_filename(import_name) # pyright: ignore + else: + # Fall back to imports. + __import__(import_name) + mod = sys.modules[import_name] + filepath = getattr(mod, "__file__", None) + + # If we don't have a file path it might be because it is a + # namespace package. In this case pick the root path from the + # first module that is contained in the package. + if filepath is None: + raise RuntimeError( + "No root path can be found for the provided module" + f" {import_name!r}. This can happen because the module" + " came from an import hook that does not provide file" + " name information or because it's a namespace package." + " In this case the root path needs to be explicitly" + " provided." + ) + + # filepath is import_name.py for a module, or __init__.py for a package. + return os.path.dirname(os.path.abspath(filepath)) # type: ignore[no-any-return] + + +@cache +def _split_blueprint_path(name: str) -> list[str]: + out: list[str] = [name] + + if "." in name: + out.extend(_split_blueprint_path(name.rpartition(".")[0])) + + return out diff --git a/src/flask-main/src/flask/json/__init__.py b/src/flask-main/src/flask/json/__init__.py new file mode 100644 index 0000000..742812f --- /dev/null +++ b/src/flask-main/src/flask/json/__init__.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json as _json +import typing as t + +from ..globals import current_app +from .provider import _default + +if t.TYPE_CHECKING: # pragma: no cover + from ..wrappers import Response + + +def dumps(obj: t.Any, **kwargs: t.Any) -> str: + """Serialize data as JSON. + + If :data:`~flask.current_app` is available, it will use its + :meth:`app.json.dumps() ` + method, otherwise it will use :func:`json.dumps`. + + :param obj: The data to serialize. + :param kwargs: Arguments passed to the ``dumps`` implementation. + + .. versionchanged:: 2.3 + The ``app`` parameter was removed. + + .. versionchanged:: 2.2 + Calls ``current_app.json.dumps``, allowing an app to override + the behavior. + + .. versionchanged:: 2.0.2 + :class:`decimal.Decimal` is supported by converting to a string. + + .. versionchanged:: 2.0 + ``encoding`` will be removed in Flask 2.1. + + .. versionchanged:: 1.0.3 + ``app`` can be passed directly, rather than requiring an app + context for configuration. + """ + if current_app: + return current_app.json.dumps(obj, **kwargs) + + kwargs.setdefault("default", _default) + return _json.dumps(obj, **kwargs) + + +def dump(obj: t.Any, fp: t.IO[str], **kwargs: t.Any) -> None: + """Serialize data as JSON and write to a file. + + If :data:`~flask.current_app` is available, it will use its + :meth:`app.json.dump() ` + method, otherwise it will use :func:`json.dump`. + + :param obj: The data to serialize. + :param fp: A file opened for writing text. Should use the UTF-8 + encoding to be valid JSON. + :param kwargs: Arguments passed to the ``dump`` implementation. + + .. versionchanged:: 2.3 + The ``app`` parameter was removed. + + .. versionchanged:: 2.2 + Calls ``current_app.json.dump``, allowing an app to override + the behavior. + + .. versionchanged:: 2.0 + Writing to a binary file, and the ``encoding`` argument, will be + removed in Flask 2.1. + """ + if current_app: + current_app.json.dump(obj, fp, **kwargs) + else: + kwargs.setdefault("default", _default) + _json.dump(obj, fp, **kwargs) + + +def loads(s: str | bytes, **kwargs: t.Any) -> t.Any: + """Deserialize data as JSON. + + If :data:`~flask.current_app` is available, it will use its + :meth:`app.json.loads() ` + method, otherwise it will use :func:`json.loads`. + + :param s: Text or UTF-8 bytes. + :param kwargs: Arguments passed to the ``loads`` implementation. + + .. versionchanged:: 2.3 + The ``app`` parameter was removed. + + .. versionchanged:: 2.2 + Calls ``current_app.json.loads``, allowing an app to override + the behavior. + + .. versionchanged:: 2.0 + ``encoding`` will be removed in Flask 2.1. The data must be a + string or UTF-8 bytes. + + .. versionchanged:: 1.0.3 + ``app`` can be passed directly, rather than requiring an app + context for configuration. + """ + if current_app: + return current_app.json.loads(s, **kwargs) + + return _json.loads(s, **kwargs) + + +def load(fp: t.IO[t.AnyStr], **kwargs: t.Any) -> t.Any: + """Deserialize data as JSON read from a file. + + If :data:`~flask.current_app` is available, it will use its + :meth:`app.json.load() ` + method, otherwise it will use :func:`json.load`. + + :param fp: A file opened for reading text or UTF-8 bytes. + :param kwargs: Arguments passed to the ``load`` implementation. + + .. versionchanged:: 2.3 + The ``app`` parameter was removed. + + .. versionchanged:: 2.2 + Calls ``current_app.json.load``, allowing an app to override + the behavior. + + .. versionchanged:: 2.2 + The ``app`` parameter will be removed in Flask 2.3. + + .. versionchanged:: 2.0 + ``encoding`` will be removed in Flask 2.1. The file must be text + mode, or binary mode with UTF-8 bytes. + """ + if current_app: + return current_app.json.load(fp, **kwargs) + + return _json.load(fp, **kwargs) + + +def jsonify(*args: t.Any, **kwargs: t.Any) -> Response: + """Serialize the given arguments as JSON, and return a + :class:`~flask.Response` object with the ``application/json`` + mimetype. A dict or list returned from a view will be converted to a + JSON response automatically without needing to call this. + + This requires an active app context, and calls + :meth:`app.json.response() `. + + In debug mode, the output is formatted with indentation to make it + easier to read. This may also be controlled by the provider. + + Either positional or keyword arguments can be given, not both. + If no arguments are given, ``None`` is serialized. + + :param args: A single value to serialize, or multiple values to + treat as a list to serialize. + :param kwargs: Treat as a dict to serialize. + + .. versionchanged:: 2.2 + Calls ``current_app.json.response``, allowing an app to override + the behavior. + + .. versionchanged:: 2.0.2 + :class:`decimal.Decimal` is supported by converting to a string. + + .. versionchanged:: 0.11 + Added support for serializing top-level arrays. This was a + security risk in ancient browsers. See :ref:`security-json`. + + .. versionadded:: 0.2 + """ + return current_app.json.response(*args, **kwargs) # type: ignore[return-value] diff --git a/src/flask-main/src/flask/json/provider.py b/src/flask-main/src/flask/json/provider.py new file mode 100644 index 0000000..f37cb7b --- /dev/null +++ b/src/flask-main/src/flask/json/provider.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import dataclasses +import decimal +import json +import typing as t +import uuid +import weakref +from datetime import date + +from werkzeug.http import http_date + +if t.TYPE_CHECKING: # pragma: no cover + from werkzeug.sansio.response import Response + + from ..sansio.app import App + + +class JSONProvider: + """A standard set of JSON operations for an application. Subclasses + of this can be used to customize JSON behavior or use different + JSON libraries. + + To implement a provider for a specific library, subclass this base + class and implement at least :meth:`dumps` and :meth:`loads`. All + other methods have default implementations. + + To use a different provider, either subclass ``Flask`` and set + :attr:`~flask.Flask.json_provider_class` to a provider class, or set + :attr:`app.json ` to an instance of the class. + + :param app: An application instance. This will be stored as a + :class:`weakref.proxy` on the :attr:`_app` attribute. + + .. versionadded:: 2.2 + """ + + def __init__(self, app: App) -> None: + self._app: App = weakref.proxy(app) + + def dumps(self, obj: t.Any, **kwargs: t.Any) -> str: + """Serialize data as JSON. + + :param obj: The data to serialize. + :param kwargs: May be passed to the underlying JSON library. + """ + raise NotImplementedError + + def dump(self, obj: t.Any, fp: t.IO[str], **kwargs: t.Any) -> None: + """Serialize data as JSON and write to a file. + + :param obj: The data to serialize. + :param fp: A file opened for writing text. Should use the UTF-8 + encoding to be valid JSON. + :param kwargs: May be passed to the underlying JSON library. + """ + fp.write(self.dumps(obj, **kwargs)) + + def loads(self, s: str | bytes, **kwargs: t.Any) -> t.Any: + """Deserialize data as JSON. + + :param s: Text or UTF-8 bytes. + :param kwargs: May be passed to the underlying JSON library. + """ + raise NotImplementedError + + def load(self, fp: t.IO[t.AnyStr], **kwargs: t.Any) -> t.Any: + """Deserialize data as JSON read from a file. + + :param fp: A file opened for reading text or UTF-8 bytes. + :param kwargs: May be passed to the underlying JSON library. + """ + return self.loads(fp.read(), **kwargs) + + def _prepare_response_obj( + self, args: tuple[t.Any, ...], kwargs: dict[str, t.Any] + ) -> t.Any: + if args and kwargs: + raise TypeError("app.json.response() takes either args or kwargs, not both") + + if not args and not kwargs: + return None + + if len(args) == 1: + return args[0] + + return args or kwargs + + def response(self, *args: t.Any, **kwargs: t.Any) -> Response: + """Serialize the given arguments as JSON, and return a + :class:`~flask.Response` object with the ``application/json`` + mimetype. + + The :func:`~flask.json.jsonify` function calls this method for + the current application. + + Either positional or keyword arguments can be given, not both. + If no arguments are given, ``None`` is serialized. + + :param args: A single value to serialize, or multiple values to + treat as a list to serialize. + :param kwargs: Treat as a dict to serialize. + """ + obj = self._prepare_response_obj(args, kwargs) + return self._app.response_class(self.dumps(obj), mimetype="application/json") + + +def _default(o: t.Any) -> t.Any: + if isinstance(o, date): + return http_date(o) + + if isinstance(o, (decimal.Decimal, uuid.UUID)): + return str(o) + + if dataclasses and dataclasses.is_dataclass(o): + return dataclasses.asdict(o) # type: ignore[arg-type] + + if hasattr(o, "__html__"): + return str(o.__html__()) + + raise TypeError(f"Object of type {type(o).__name__} is not JSON serializable") + + +class DefaultJSONProvider(JSONProvider): + """Provide JSON operations using Python's built-in :mod:`json` + library. Serializes the following additional data types: + + - :class:`datetime.datetime` and :class:`datetime.date` are + serialized to :rfc:`822` strings. This is the same as the HTTP + date format. + - :class:`uuid.UUID` is serialized to a string. + - :class:`dataclasses.dataclass` is passed to + :func:`dataclasses.asdict`. + - :class:`~markupsafe.Markup` (or any object with a ``__html__`` + method) will call the ``__html__`` method to get a string. + """ + + default: t.Callable[[t.Any], t.Any] = staticmethod(_default) + """Apply this function to any object that :meth:`json.dumps` does + not know how to serialize. It should return a valid JSON type or + raise a ``TypeError``. + """ + + ensure_ascii = True + """Replace non-ASCII characters with escape sequences. This may be + more compatible with some clients, but can be disabled for better + performance and size. + """ + + sort_keys = True + """Sort the keys in any serialized dicts. This may be useful for + some caching situations, but can be disabled for better performance. + When enabled, keys must all be strings, they are not converted + before sorting. + """ + + compact: bool | None = None + """If ``True``, or ``None`` out of debug mode, the :meth:`response` + output will not add indentation, newlines, or spaces. If ``False``, + or ``None`` in debug mode, it will use a non-compact representation. + """ + + mimetype = "application/json" + """The mimetype set in :meth:`response`.""" + + def dumps(self, obj: t.Any, **kwargs: t.Any) -> str: + """Serialize data as JSON to a string. + + Keyword arguments are passed to :func:`json.dumps`. Sets some + parameter defaults from the :attr:`default`, + :attr:`ensure_ascii`, and :attr:`sort_keys` attributes. + + :param obj: The data to serialize. + :param kwargs: Passed to :func:`json.dumps`. + """ + kwargs.setdefault("default", self.default) + kwargs.setdefault("ensure_ascii", self.ensure_ascii) + kwargs.setdefault("sort_keys", self.sort_keys) + return json.dumps(obj, **kwargs) + + def loads(self, s: str | bytes, **kwargs: t.Any) -> t.Any: + """Deserialize data as JSON from a string or bytes. + + :param s: Text or UTF-8 bytes. + :param kwargs: Passed to :func:`json.loads`. + """ + return json.loads(s, **kwargs) + + def response(self, *args: t.Any, **kwargs: t.Any) -> Response: + """Serialize the given arguments as JSON, and return a + :class:`~flask.Response` object with it. The response mimetype + will be "application/json" and can be changed with + :attr:`mimetype`. + + If :attr:`compact` is ``False`` or debug mode is enabled, the + output will be formatted to be easier to read. + + Either positional or keyword arguments can be given, not both. + If no arguments are given, ``None`` is serialized. + + :param args: A single value to serialize, or multiple values to + treat as a list to serialize. + :param kwargs: Treat as a dict to serialize. + """ + obj = self._prepare_response_obj(args, kwargs) + dump_args: dict[str, t.Any] = {} + + if (self.compact is None and self._app.debug) or self.compact is False: + dump_args.setdefault("indent", 2) + else: + dump_args.setdefault("separators", (",", ":")) + + return self._app.response_class( + f"{self.dumps(obj, **dump_args)}\n", mimetype=self.mimetype + ) diff --git a/src/flask-main/src/flask/json/tag.py b/src/flask-main/src/flask/json/tag.py new file mode 100644 index 0000000..8dc3629 --- /dev/null +++ b/src/flask-main/src/flask/json/tag.py @@ -0,0 +1,327 @@ +""" +Tagged JSON +~~~~~~~~~~~ + +A compact representation for lossless serialization of non-standard JSON +types. :class:`~flask.sessions.SecureCookieSessionInterface` uses this +to serialize the session data, but it may be useful in other places. It +can be extended to support other types. + +.. autoclass:: TaggedJSONSerializer + :members: + +.. autoclass:: JSONTag + :members: + +Let's see an example that adds support for +:class:`~collections.OrderedDict`. Dicts don't have an order in JSON, so +to handle this we will dump the items as a list of ``[key, value]`` +pairs. Subclass :class:`JSONTag` and give it the new key ``' od'`` to +identify the type. The session serializer processes dicts first, so +insert the new tag at the front of the order since ``OrderedDict`` must +be processed before ``dict``. + +.. code-block:: python + + from flask.json.tag import JSONTag + + class TagOrderedDict(JSONTag): + __slots__ = ('serializer',) + key = ' od' + + def check(self, value): + return isinstance(value, OrderedDict) + + def to_json(self, value): + return [[k, self.serializer.tag(v)] for k, v in iteritems(value)] + + def to_python(self, value): + return OrderedDict(value) + + app.session_interface.serializer.register(TagOrderedDict, index=0) +""" + +from __future__ import annotations + +import typing as t +from base64 import b64decode +from base64 import b64encode +from datetime import datetime +from uuid import UUID + +from markupsafe import Markup +from werkzeug.http import http_date +from werkzeug.http import parse_date + +from ..json import dumps +from ..json import loads + + +class JSONTag: + """Base class for defining type tags for :class:`TaggedJSONSerializer`.""" + + __slots__ = ("serializer",) + + #: The tag to mark the serialized object with. If empty, this tag is + #: only used as an intermediate step during tagging. + key: str = "" + + def __init__(self, serializer: TaggedJSONSerializer) -> None: + """Create a tagger for the given serializer.""" + self.serializer = serializer + + def check(self, value: t.Any) -> bool: + """Check if the given value should be tagged by this tag.""" + raise NotImplementedError + + def to_json(self, value: t.Any) -> t.Any: + """Convert the Python object to an object that is a valid JSON type. + The tag will be added later.""" + raise NotImplementedError + + def to_python(self, value: t.Any) -> t.Any: + """Convert the JSON representation back to the correct type. The tag + will already be removed.""" + raise NotImplementedError + + def tag(self, value: t.Any) -> dict[str, t.Any]: + """Convert the value to a valid JSON type and add the tag structure + around it.""" + return {self.key: self.to_json(value)} + + +class TagDict(JSONTag): + """Tag for 1-item dicts whose only key matches a registered tag. + + Internally, the dict key is suffixed with `__`, and the suffix is removed + when deserializing. + """ + + __slots__ = () + key = " di" + + def check(self, value: t.Any) -> bool: + return ( + isinstance(value, dict) + and len(value) == 1 + and next(iter(value)) in self.serializer.tags + ) + + def to_json(self, value: t.Any) -> t.Any: + key = next(iter(value)) + return {f"{key}__": self.serializer.tag(value[key])} + + def to_python(self, value: t.Any) -> t.Any: + key = next(iter(value)) + return {key[:-2]: value[key]} + + +class PassDict(JSONTag): + __slots__ = () + + def check(self, value: t.Any) -> bool: + return isinstance(value, dict) + + def to_json(self, value: t.Any) -> t.Any: + # JSON objects may only have string keys, so don't bother tagging the + # key here. + return {k: self.serializer.tag(v) for k, v in value.items()} + + tag = to_json + + +class TagTuple(JSONTag): + __slots__ = () + key = " t" + + def check(self, value: t.Any) -> bool: + return isinstance(value, tuple) + + def to_json(self, value: t.Any) -> t.Any: + return [self.serializer.tag(item) for item in value] + + def to_python(self, value: t.Any) -> t.Any: + return tuple(value) + + +class PassList(JSONTag): + __slots__ = () + + def check(self, value: t.Any) -> bool: + return isinstance(value, list) + + def to_json(self, value: t.Any) -> t.Any: + return [self.serializer.tag(item) for item in value] + + tag = to_json + + +class TagBytes(JSONTag): + __slots__ = () + key = " b" + + def check(self, value: t.Any) -> bool: + return isinstance(value, bytes) + + def to_json(self, value: t.Any) -> t.Any: + return b64encode(value).decode("ascii") + + def to_python(self, value: t.Any) -> t.Any: + return b64decode(value) + + +class TagMarkup(JSONTag): + """Serialize anything matching the :class:`~markupsafe.Markup` API by + having a ``__html__`` method to the result of that method. Always + deserializes to an instance of :class:`~markupsafe.Markup`.""" + + __slots__ = () + key = " m" + + def check(self, value: t.Any) -> bool: + return callable(getattr(value, "__html__", None)) + + def to_json(self, value: t.Any) -> t.Any: + return str(value.__html__()) + + def to_python(self, value: t.Any) -> t.Any: + return Markup(value) + + +class TagUUID(JSONTag): + __slots__ = () + key = " u" + + def check(self, value: t.Any) -> bool: + return isinstance(value, UUID) + + def to_json(self, value: t.Any) -> t.Any: + return value.hex + + def to_python(self, value: t.Any) -> t.Any: + return UUID(value) + + +class TagDateTime(JSONTag): + __slots__ = () + key = " d" + + def check(self, value: t.Any) -> bool: + return isinstance(value, datetime) + + def to_json(self, value: t.Any) -> t.Any: + return http_date(value) + + def to_python(self, value: t.Any) -> t.Any: + return parse_date(value) + + +class TaggedJSONSerializer: + """Serializer that uses a tag system to compactly represent objects that + are not JSON types. Passed as the intermediate serializer to + :class:`itsdangerous.Serializer`. + + The following extra types are supported: + + * :class:`dict` + * :class:`tuple` + * :class:`bytes` + * :class:`~markupsafe.Markup` + * :class:`~uuid.UUID` + * :class:`~datetime.datetime` + """ + + __slots__ = ("tags", "order") + + #: Tag classes to bind when creating the serializer. Other tags can be + #: added later using :meth:`~register`. + default_tags = [ + TagDict, + PassDict, + TagTuple, + PassList, + TagBytes, + TagMarkup, + TagUUID, + TagDateTime, + ] + + def __init__(self) -> None: + self.tags: dict[str, JSONTag] = {} + self.order: list[JSONTag] = [] + + for cls in self.default_tags: + self.register(cls) + + def register( + self, + tag_class: type[JSONTag], + force: bool = False, + index: int | None = None, + ) -> None: + """Register a new tag with this serializer. + + :param tag_class: tag class to register. Will be instantiated with this + serializer instance. + :param force: overwrite an existing tag. If false (default), a + :exc:`KeyError` is raised. + :param index: index to insert the new tag in the tag order. Useful when + the new tag is a special case of an existing tag. If ``None`` + (default), the tag is appended to the end of the order. + + :raise KeyError: if the tag key is already registered and ``force`` is + not true. + """ + tag = tag_class(self) + key = tag.key + + if key: + if not force and key in self.tags: + raise KeyError(f"Tag '{key}' is already registered.") + + self.tags[key] = tag + + if index is None: + self.order.append(tag) + else: + self.order.insert(index, tag) + + def tag(self, value: t.Any) -> t.Any: + """Convert a value to a tagged representation if necessary.""" + for tag in self.order: + if tag.check(value): + return tag.tag(value) + + return value + + def untag(self, value: dict[str, t.Any]) -> t.Any: + """Convert a tagged representation back to the original type.""" + if len(value) != 1: + return value + + key = next(iter(value)) + + if key not in self.tags: + return value + + return self.tags[key].to_python(value[key]) + + def _untag_scan(self, value: t.Any) -> t.Any: + if isinstance(value, dict): + # untag each item recursively + value = {k: self._untag_scan(v) for k, v in value.items()} + # untag the dict itself + value = self.untag(value) + elif isinstance(value, list): + # untag each item recursively + value = [self._untag_scan(item) for item in value] + + return value + + def dumps(self, value: t.Any) -> str: + """Tag the value and dump it to a compact JSON string.""" + return dumps(self.tag(value), separators=(",", ":")) + + def loads(self, value: str) -> t.Any: + """Load data from a JSON string and deserialized any tagged objects.""" + return self._untag_scan(loads(value)) diff --git a/src/flask-main/src/flask/logging.py b/src/flask-main/src/flask/logging.py new file mode 100644 index 0000000..0cb8f43 --- /dev/null +++ b/src/flask-main/src/flask/logging.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +import sys +import typing as t + +from werkzeug.local import LocalProxy + +from .globals import request + +if t.TYPE_CHECKING: # pragma: no cover + from .sansio.app import App + + +@LocalProxy +def wsgi_errors_stream() -> t.TextIO: + """Find the most appropriate error stream for the application. If a request + is active, log to ``wsgi.errors``, otherwise use ``sys.stderr``. + + If you configure your own :class:`logging.StreamHandler`, you may want to + use this for the stream. If you are using file or dict configuration and + can't import this directly, you can refer to it as + ``ext://flask.logging.wsgi_errors_stream``. + """ + if request: + return request.environ["wsgi.errors"] # type: ignore[no-any-return] + + return sys.stderr + + +def has_level_handler(logger: logging.Logger) -> bool: + """Check if there is a handler in the logging chain that will handle the + given logger's :meth:`effective level <~logging.Logger.getEffectiveLevel>`. + """ + level = logger.getEffectiveLevel() + current = logger + + while current: + if any(handler.level <= level for handler in current.handlers): + return True + + if not current.propagate: + break + + current = current.parent # type: ignore + + return False + + +#: Log messages to :func:`~flask.logging.wsgi_errors_stream` with the format +#: ``[%(asctime)s] %(levelname)s in %(module)s: %(message)s``. +default_handler = logging.StreamHandler(wsgi_errors_stream) # type: ignore +default_handler.setFormatter( + logging.Formatter("[%(asctime)s] %(levelname)s in %(module)s: %(message)s") +) + + +def create_logger(app: App) -> logging.Logger: + """Get the Flask app's logger and configure it if needed. + + The logger name will be the same as + :attr:`app.import_name `. + + When :attr:`~flask.Flask.debug` is enabled, set the logger level to + :data:`logging.DEBUG` if it is not set. + + If there is no handler for the logger's effective level, add a + :class:`~logging.StreamHandler` for + :func:`~flask.logging.wsgi_errors_stream` with a basic format. + """ + logger = logging.getLogger(app.name) + + if app.debug and not logger.level: + logger.setLevel(logging.DEBUG) + + if not has_level_handler(logger): + logger.addHandler(default_handler) + + return logger diff --git a/src/flask-main/src/flask/py.typed b/src/flask-main/src/flask/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/flask-main/src/flask/sansio/README.md b/src/flask-main/src/flask/sansio/README.md new file mode 100644 index 0000000..623ac19 --- /dev/null +++ b/src/flask-main/src/flask/sansio/README.md @@ -0,0 +1,6 @@ +# Sansio + +This folder contains code that can be used by alternative Flask +implementations, for example Quart. The code therefore cannot do any +IO, nor be part of a likely IO path. Finally this code cannot use the +Flask globals. diff --git a/src/flask-main/src/flask/sansio/app.py b/src/flask-main/src/flask/sansio/app.py new file mode 100644 index 0000000..43d2052 --- /dev/null +++ b/src/flask-main/src/flask/sansio/app.py @@ -0,0 +1,1006 @@ +from __future__ import annotations + +import logging +import os +import sys +import typing as t +from datetime import timedelta +from itertools import chain + +from werkzeug.exceptions import Aborter +from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequestKeyError +from werkzeug.routing import BuildError +from werkzeug.routing import Map +from werkzeug.routing import Rule +from werkzeug.sansio.response import Response +from werkzeug.utils import cached_property +from werkzeug.utils import redirect as _wz_redirect + +from .. import typing as ft +from ..config import Config +from ..config import ConfigAttribute +from ..ctx import _AppCtxGlobals +from ..helpers import _split_blueprint_path +from ..helpers import get_debug_flag +from ..json.provider import DefaultJSONProvider +from ..json.provider import JSONProvider +from ..logging import create_logger +from ..templating import DispatchingJinjaLoader +from ..templating import Environment +from .scaffold import _endpoint_from_view_func +from .scaffold import find_package +from .scaffold import Scaffold +from .scaffold import setupmethod + +if t.TYPE_CHECKING: # pragma: no cover + from werkzeug.wrappers import Response as BaseResponse + + from ..testing import FlaskClient + from ..testing import FlaskCliRunner + from .blueprints import Blueprint + +T_shell_context_processor = t.TypeVar( + "T_shell_context_processor", bound=ft.ShellContextProcessorCallable +) +T_teardown = t.TypeVar("T_teardown", bound=ft.TeardownCallable) +T_template_filter = t.TypeVar("T_template_filter", bound=ft.TemplateFilterCallable) +T_template_global = t.TypeVar("T_template_global", bound=ft.TemplateGlobalCallable) +T_template_test = t.TypeVar("T_template_test", bound=ft.TemplateTestCallable) + + +def _make_timedelta(value: timedelta | int | None) -> timedelta | None: + if value is None or isinstance(value, timedelta): + return value + + return timedelta(seconds=value) + + +class App(Scaffold): + """The flask object implements a WSGI application and acts as the central + object. It is passed the name of the module or package of the + application. Once it is created it will act as a central registry for + the view functions, the URL rules, template configuration and much more. + + The name of the package is used to resolve resources from inside the + package or the folder the module is contained in depending on if the + package parameter resolves to an actual python package (a folder with + an :file:`__init__.py` file inside) or a standard module (just a ``.py`` file). + + For more information about resource loading, see :func:`open_resource`. + + Usually you create a :class:`Flask` instance in your main module or + in the :file:`__init__.py` file of your package like this:: + + from flask import Flask + app = Flask(__name__) + + .. admonition:: About the First Parameter + + The idea of the first parameter is to give Flask an idea of what + belongs to your application. This name is used to find resources + on the filesystem, can be used by extensions to improve debugging + information and a lot more. + + So it's important what you provide there. If you are using a single + module, `__name__` is always the correct value. If you however are + using a package, it's usually recommended to hardcode the name of + your package there. + + For example if your application is defined in :file:`yourapplication/app.py` + you should create it with one of the two versions below:: + + app = Flask('yourapplication') + app = Flask(__name__.split('.')[0]) + + Why is that? The application will work even with `__name__`, thanks + to how resources are looked up. However it will make debugging more + painful. Certain extensions can make assumptions based on the + import name of your application. For example the Flask-SQLAlchemy + extension will look for the code in your application that triggered + an SQL query in debug mode. If the import name is not properly set + up, that debugging information is lost. (For example it would only + pick up SQL queries in `yourapplication.app` and not + `yourapplication.views.frontend`) + + .. versionadded:: 0.7 + The `static_url_path`, `static_folder`, and `template_folder` + parameters were added. + + .. versionadded:: 0.8 + The `instance_path` and `instance_relative_config` parameters were + added. + + .. versionadded:: 0.11 + The `root_path` parameter was added. + + .. versionadded:: 1.0 + The ``host_matching`` and ``static_host`` parameters were added. + + .. versionadded:: 1.0 + The ``subdomain_matching`` parameter was added. Subdomain + matching needs to be enabled manually now. Setting + :data:`SERVER_NAME` does not implicitly enable it. + + :param import_name: the name of the application package + :param static_url_path: can be used to specify a different path for the + static files on the web. Defaults to the name + of the `static_folder` folder. + :param static_folder: The folder with static files that is served at + ``static_url_path``. Relative to the application ``root_path`` + or an absolute path. Defaults to ``'static'``. + :param static_host: the host to use when adding the static route. + Defaults to None. Required when using ``host_matching=True`` + with a ``static_folder`` configured. + :param host_matching: set ``url_map.host_matching`` attribute. + Defaults to False. + :param subdomain_matching: consider the subdomain relative to + :data:`SERVER_NAME` when matching routes. Defaults to False. + :param template_folder: the folder that contains the templates that should + be used by the application. Defaults to + ``'templates'`` folder in the root path of the + application. + :param instance_path: An alternative instance path for the application. + By default the folder ``'instance'`` next to the + package or module is assumed to be the instance + path. + :param instance_relative_config: if set to ``True`` relative filenames + for loading the config are assumed to + be relative to the instance path instead + of the application root. + :param root_path: The path to the root of the application files. + This should only be set manually when it can't be detected + automatically, such as for namespace packages. + """ + + #: The class of the object assigned to :attr:`aborter`, created by + #: :meth:`create_aborter`. That object is called by + #: :func:`flask.abort` to raise HTTP errors, and can be + #: called directly as well. + #: + #: Defaults to :class:`werkzeug.exceptions.Aborter`. + #: + #: .. versionadded:: 2.2 + aborter_class = Aborter + + #: The class that is used for the Jinja environment. + #: + #: .. versionadded:: 0.11 + jinja_environment = Environment + + #: The class that is used for the :data:`~flask.g` instance. + #: + #: Example use cases for a custom class: + #: + #: 1. Store arbitrary attributes on flask.g. + #: 2. Add a property for lazy per-request database connectors. + #: 3. Return None instead of AttributeError on unexpected attributes. + #: 4. Raise exception if an unexpected attr is set, a "controlled" flask.g. + #: + #: .. versionadded:: 0.10 + #: Renamed from ``request_globals_class`. + app_ctx_globals_class = _AppCtxGlobals + + #: The class that is used for the ``config`` attribute of this app. + #: Defaults to :class:`~flask.Config`. + #: + #: Example use cases for a custom class: + #: + #: 1. Default values for certain config options. + #: 2. Access to config values through attributes in addition to keys. + #: + #: .. versionadded:: 0.11 + config_class = Config + + #: The testing flag. Set this to ``True`` to enable the test mode of + #: Flask extensions (and in the future probably also Flask itself). + #: For example this might activate test helpers that have an + #: additional runtime cost which should not be enabled by default. + #: + #: If this is enabled and PROPAGATE_EXCEPTIONS is not changed from the + #: default it's implicitly enabled. + #: + #: This attribute can also be configured from the config with the + #: ``TESTING`` configuration key. Defaults to ``False``. + testing = ConfigAttribute[bool]("TESTING") + + #: If a secret key is set, cryptographic components can use this to + #: sign cookies and other things. Set this to a complex random value + #: when you want to use the secure cookie for instance. + #: + #: This attribute can also be configured from the config with the + #: :data:`SECRET_KEY` configuration key. Defaults to ``None``. + secret_key = ConfigAttribute[str | bytes | None]("SECRET_KEY") + + #: A :class:`~datetime.timedelta` which is used to set the expiration + #: date of a permanent session. The default is 31 days which makes a + #: permanent session survive for roughly one month. + #: + #: This attribute can also be configured from the config with the + #: ``PERMANENT_SESSION_LIFETIME`` configuration key. Defaults to + #: ``timedelta(days=31)`` + permanent_session_lifetime = ConfigAttribute[timedelta]( + "PERMANENT_SESSION_LIFETIME", + get_converter=_make_timedelta, # type: ignore[arg-type] + ) + + json_provider_class: type[JSONProvider] = DefaultJSONProvider + """A subclass of :class:`~flask.json.provider.JSONProvider`. An + instance is created and assigned to :attr:`app.json` when creating + the app. + + The default, :class:`~flask.json.provider.DefaultJSONProvider`, uses + Python's built-in :mod:`json` library. A different provider can use + a different JSON library. + + .. versionadded:: 2.2 + """ + + #: Options that are passed to the Jinja environment in + #: :meth:`create_jinja_environment`. Changing these options after + #: the environment is created (accessing :attr:`jinja_env`) will + #: have no effect. + #: + #: .. versionchanged:: 1.1.0 + #: This is a ``dict`` instead of an ``ImmutableDict`` to allow + #: easier configuration. + #: + jinja_options: dict[str, t.Any] = {} + + #: The rule object to use for URL rules created. This is used by + #: :meth:`add_url_rule`. Defaults to :class:`werkzeug.routing.Rule`. + #: + #: .. versionadded:: 0.7 + url_rule_class = Rule + + #: The map object to use for storing the URL rules and routing + #: configuration parameters. Defaults to :class:`werkzeug.routing.Map`. + #: + #: .. versionadded:: 1.1.0 + url_map_class = Map + + #: The :meth:`test_client` method creates an instance of this test + #: client class. Defaults to :class:`~flask.testing.FlaskClient`. + #: + #: .. versionadded:: 0.7 + test_client_class: type[FlaskClient] | None = None + + #: The :class:`~click.testing.CliRunner` subclass, by default + #: :class:`~flask.testing.FlaskCliRunner` that is used by + #: :meth:`test_cli_runner`. Its ``__init__`` method should take a + #: Flask app object as the first argument. + #: + #: .. versionadded:: 1.0 + test_cli_runner_class: type[FlaskCliRunner] | None = None + + default_config: dict[str, t.Any] + response_class: type[Response] + + def __init__( + self, + import_name: str, + static_url_path: str | None = None, + static_folder: str | os.PathLike[str] | None = "static", + static_host: str | None = None, + host_matching: bool = False, + subdomain_matching: bool = False, + template_folder: str | os.PathLike[str] | None = "templates", + instance_path: str | None = None, + instance_relative_config: bool = False, + root_path: str | None = None, + ) -> None: + super().__init__( + import_name=import_name, + static_folder=static_folder, + static_url_path=static_url_path, + template_folder=template_folder, + root_path=root_path, + ) + + if instance_path is None: + instance_path = self.auto_find_instance_path() + elif not os.path.isabs(instance_path): + raise ValueError( + "If an instance path is provided it must be absolute." + " A relative path was given instead." + ) + + #: Holds the path to the instance folder. + #: + #: .. versionadded:: 0.8 + self.instance_path = instance_path + + #: The configuration dictionary as :class:`Config`. This behaves + #: exactly like a regular dictionary but supports additional methods + #: to load a config from files. + self.config = self.make_config(instance_relative_config) + + #: An instance of :attr:`aborter_class` created by + #: :meth:`make_aborter`. This is called by :func:`flask.abort` + #: to raise HTTP errors, and can be called directly as well. + #: + #: .. versionadded:: 2.2 + #: Moved from ``flask.abort``, which calls this object. + self.aborter = self.make_aborter() + + self.json: JSONProvider = self.json_provider_class(self) + """Provides access to JSON methods. Functions in ``flask.json`` + will call methods on this provider when the application context + is active. Used for handling JSON requests and responses. + + An instance of :attr:`json_provider_class`. Can be customized by + changing that attribute on a subclass, or by assigning to this + attribute afterwards. + + The default, :class:`~flask.json.provider.DefaultJSONProvider`, + uses Python's built-in :mod:`json` library. A different provider + can use a different JSON library. + + .. versionadded:: 2.2 + """ + + #: A list of functions that are called by + #: :meth:`handle_url_build_error` when :meth:`.url_for` raises a + #: :exc:`~werkzeug.routing.BuildError`. Each function is called + #: with ``error``, ``endpoint`` and ``values``. If a function + #: returns ``None`` or raises a ``BuildError``, it is skipped. + #: Otherwise, its return value is returned by ``url_for``. + #: + #: .. versionadded:: 0.9 + self.url_build_error_handlers: list[ + t.Callable[[Exception, str, dict[str, t.Any]], str] + ] = [] + + #: A list of functions that are called when the application context + #: is destroyed. Since the application context is also torn down + #: if the request ends this is the place to store code that disconnects + #: from databases. + #: + #: .. versionadded:: 0.9 + self.teardown_appcontext_funcs: list[ft.TeardownCallable] = [] + + #: A list of shell context processor functions that should be run + #: when a shell context is created. + #: + #: .. versionadded:: 0.11 + self.shell_context_processors: list[ft.ShellContextProcessorCallable] = [] + + #: Maps registered blueprint names to blueprint objects. The + #: dict retains the order the blueprints were registered in. + #: Blueprints can be registered multiple times, this dict does + #: not track how often they were attached. + #: + #: .. versionadded:: 0.7 + self.blueprints: dict[str, Blueprint] = {} + + #: a place where extensions can store application specific state. For + #: example this is where an extension could store database engines and + #: similar things. + #: + #: The key must match the name of the extension module. For example in + #: case of a "Flask-Foo" extension in `flask_foo`, the key would be + #: ``'foo'``. + #: + #: .. versionadded:: 0.7 + self.extensions: dict[str, t.Any] = {} + + #: The :class:`~werkzeug.routing.Map` for this instance. You can use + #: this to change the routing converters after the class was created + #: but before any routes are connected. Example:: + #: + #: from werkzeug.routing import BaseConverter + #: + #: class ListConverter(BaseConverter): + #: def to_python(self, value): + #: return value.split(',') + #: def to_url(self, values): + #: return ','.join(super(ListConverter, self).to_url(value) + #: for value in values) + #: + #: app = Flask(__name__) + #: app.url_map.converters['list'] = ListConverter + self.url_map = self.url_map_class(host_matching=host_matching) + + self.subdomain_matching = subdomain_matching + + # tracks internally if the application already handled at least one + # request. + self._got_first_request = False + + def _check_setup_finished(self, f_name: str) -> None: + if self._got_first_request: + raise AssertionError( + f"The setup method '{f_name}' can no longer be called" + " on the application. It has already handled its first" + " request, any changes will not be applied" + " consistently.\n" + "Make sure all imports, decorators, functions, etc." + " needed to set up the application are done before" + " running it." + ) + + @cached_property + def name(self) -> str: + """The name of the application. This is usually the import name + with the difference that it's guessed from the run file if the + import name is main. This name is used as a display name when + Flask needs the name of the application. It can be set and overridden + to change the value. + + .. versionadded:: 0.8 + """ + if self.import_name == "__main__": + fn: str | None = getattr(sys.modules["__main__"], "__file__", None) + if fn is None: + return "__main__" + return os.path.splitext(os.path.basename(fn))[0] + return self.import_name + + @cached_property + def logger(self) -> logging.Logger: + """A standard Python :class:`~logging.Logger` for the app, with + the same name as :attr:`name`. + + In debug mode, the logger's :attr:`~logging.Logger.level` will + be set to :data:`~logging.DEBUG`. + + If there are no handlers configured, a default handler will be + added. See :doc:`/logging` for more information. + + .. versionchanged:: 1.1.0 + The logger takes the same name as :attr:`name` rather than + hard-coding ``"flask.app"``. + + .. versionchanged:: 1.0.0 + Behavior was simplified. The logger is always named + ``"flask.app"``. The level is only set during configuration, + it doesn't check ``app.debug`` each time. Only one format is + used, not different ones depending on ``app.debug``. No + handlers are removed, and a handler is only added if no + handlers are already configured. + + .. versionadded:: 0.3 + """ + return create_logger(self) + + @cached_property + def jinja_env(self) -> Environment: + """The Jinja environment used to load templates. + + The environment is created the first time this property is + accessed. Changing :attr:`jinja_options` after that will have no + effect. + """ + return self.create_jinja_environment() + + def create_jinja_environment(self) -> Environment: + raise NotImplementedError() + + def make_config(self, instance_relative: bool = False) -> Config: + """Used to create the config attribute by the Flask constructor. + The `instance_relative` parameter is passed in from the constructor + of Flask (there named `instance_relative_config`) and indicates if + the config should be relative to the instance path or the root path + of the application. + + .. versionadded:: 0.8 + """ + root_path = self.root_path + if instance_relative: + root_path = self.instance_path + defaults = dict(self.default_config) + defaults["DEBUG"] = get_debug_flag() + return self.config_class(root_path, defaults) + + def make_aborter(self) -> Aborter: + """Create the object to assign to :attr:`aborter`. That object + is called by :func:`flask.abort` to raise HTTP errors, and can + be called directly as well. + + By default, this creates an instance of :attr:`aborter_class`, + which defaults to :class:`werkzeug.exceptions.Aborter`. + + .. versionadded:: 2.2 + """ + return self.aborter_class() + + def auto_find_instance_path(self) -> str: + """Tries to locate the instance path if it was not provided to the + constructor of the application class. It will basically calculate + the path to a folder named ``instance`` next to your main file or + the package. + + .. versionadded:: 0.8 + """ + prefix, package_path = find_package(self.import_name) + if prefix is None: + return os.path.join(package_path, "instance") + return os.path.join(prefix, "var", f"{self.name}-instance") + + def create_global_jinja_loader(self) -> DispatchingJinjaLoader: + """Creates the loader for the Jinja environment. Can be used to + override just the loader and keeping the rest unchanged. It's + discouraged to override this function. Instead one should override + the :meth:`jinja_loader` function instead. + + The global loader dispatches between the loaders of the application + and the individual blueprints. + + .. versionadded:: 0.7 + """ + return DispatchingJinjaLoader(self) + + def select_jinja_autoescape(self, filename: str) -> bool: + """Returns ``True`` if autoescaping should be active for the given + template name. If no template name is given, returns `True`. + + .. versionchanged:: 2.2 + Autoescaping is now enabled by default for ``.svg`` files. + + .. versionadded:: 0.5 + """ + if filename is None: + return True + return filename.endswith((".html", ".htm", ".xml", ".xhtml", ".svg")) + + @property + def debug(self) -> bool: + """Whether debug mode is enabled. When using ``flask run`` to start the + development server, an interactive debugger will be shown for unhandled + exceptions, and the server will be reloaded when code changes. This maps to the + :data:`DEBUG` config key. It may not behave as expected if set late. + + **Do not enable debug mode when deploying in production.** + + Default: ``False`` + """ + return self.config["DEBUG"] # type: ignore[no-any-return] + + @debug.setter + def debug(self, value: bool) -> None: + self.config["DEBUG"] = value + + if self.config["TEMPLATES_AUTO_RELOAD"] is None: + self.jinja_env.auto_reload = value + + @setupmethod + def register_blueprint(self, blueprint: Blueprint, **options: t.Any) -> None: + """Register a :class:`~flask.Blueprint` on the application. Keyword + arguments passed to this method will override the defaults set on the + blueprint. + + Calls the blueprint's :meth:`~flask.Blueprint.register` method after + recording the blueprint in the application's :attr:`blueprints`. + + :param blueprint: The blueprint to register. + :param url_prefix: Blueprint routes will be prefixed with this. + :param subdomain: Blueprint routes will match on this subdomain. + :param url_defaults: Blueprint routes will use these default values for + view arguments. + :param options: Additional keyword arguments are passed to + :class:`~flask.blueprints.BlueprintSetupState`. They can be + accessed in :meth:`~flask.Blueprint.record` callbacks. + + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. + + .. versionadded:: 0.7 + """ + blueprint.register(self, options) + + def iter_blueprints(self) -> t.ValuesView[Blueprint]: + """Iterates over all blueprints by the order they were registered. + + .. versionadded:: 0.11 + """ + return self.blueprints.values() + + @setupmethod + def add_url_rule( + self, + rule: str, + endpoint: str | None = None, + view_func: ft.RouteCallable | None = None, + provide_automatic_options: bool | None = None, + **options: t.Any, + ) -> None: + if endpoint is None: + endpoint = _endpoint_from_view_func(view_func) # type: ignore + options["endpoint"] = endpoint + methods = options.pop("methods", None) + + # if the methods are not given and the view_func object knows its + # methods we can use that instead. If neither exists, we go with + # a tuple of only ``GET`` as default. + if methods is None: + methods = getattr(view_func, "methods", None) or ("GET",) + if isinstance(methods, str): + raise TypeError( + "Allowed methods must be a list of strings, for" + ' example: @app.route(..., methods=["POST"])' + ) + methods = {item.upper() for item in methods} + + # Methods that should always be added + required_methods: set[str] = set(getattr(view_func, "required_methods", ())) + + # starting with Flask 0.8 the view_func object can disable and + # force-enable the automatic options handling. + if provide_automatic_options is None: + provide_automatic_options = getattr( + view_func, "provide_automatic_options", None + ) + + if provide_automatic_options is None: + if "OPTIONS" not in methods and self.config["PROVIDE_AUTOMATIC_OPTIONS"]: + provide_automatic_options = True + required_methods.add("OPTIONS") + else: + provide_automatic_options = False + + # Add the required methods now. + methods |= required_methods + + rule_obj = self.url_rule_class(rule, methods=methods, **options) + rule_obj.provide_automatic_options = provide_automatic_options # type: ignore[attr-defined] + + self.url_map.add(rule_obj) + if view_func is not None: + old_func = self.view_functions.get(endpoint) + if old_func is not None and old_func != view_func: + raise AssertionError( + "View function mapping is overwriting an existing" + f" endpoint function: {endpoint}" + ) + self.view_functions[endpoint] = view_func + + @t.overload + def template_filter(self, name: T_template_filter) -> T_template_filter: ... + @t.overload + def template_filter( + self, name: str | None = None + ) -> t.Callable[[T_template_filter], T_template_filter]: ... + @setupmethod + def template_filter( + self, name: T_template_filter | str | None = None + ) -> T_template_filter | t.Callable[[T_template_filter], T_template_filter]: + """Decorate a function to register it as a custom Jinja filter. The name + is optional. The decorator may be used without parentheses. + + .. code-block:: python + + @app.template_filter("reverse") + def reverse_filter(s): + return reversed(s) + + The :meth:`add_template_filter` method may be used to register a + function later rather than decorating. + + :param name: The name to register the filter as. If not given, uses the + function's name. + """ + if callable(name): + self.add_template_filter(name) + return name + + def decorator(f: T_template_filter) -> T_template_filter: + self.add_template_filter(f, name=name) + return f + + return decorator + + @setupmethod + def add_template_filter( + self, f: ft.TemplateFilterCallable, name: str | None = None + ) -> None: + """Register a function to use as a custom Jinja filter. + + The :meth:`template_filter` decorator can be used to register a function + by decorating instead. + + :param f: The function to register. + :param name: The name to register the filter as. If not given, uses the + function's name. + """ + self.jinja_env.filters[name or f.__name__] = f + + @t.overload + def template_test(self, name: T_template_test) -> T_template_test: ... + @t.overload + def template_test( + self, name: str | None = None + ) -> t.Callable[[T_template_test], T_template_test]: ... + @setupmethod + def template_test( + self, name: T_template_test | str | None = None + ) -> T_template_test | t.Callable[[T_template_test], T_template_test]: + """Decorate a function to register it as a custom Jinja test. The name + is optional. The decorator may be used without parentheses. + + .. code-block:: python + + @app.template_test("prime") + def is_prime_test(n): + if n == 2: + return True + for i in range(2, int(math.ceil(math.sqrt(n))) + 1): + if n % i == 0: + return False + return True + + The :meth:`add_template_test` method may be used to register a function + later rather than decorating. + + :param name: The name to register the filter as. If not given, uses the + function's name. + + .. versionadded:: 0.10 + """ + if callable(name): + self.add_template_test(name) + return name + + def decorator(f: T_template_test) -> T_template_test: + self.add_template_test(f, name=name) + return f + + return decorator + + @setupmethod + def add_template_test( + self, f: ft.TemplateTestCallable, name: str | None = None + ) -> None: + """Register a function to use as a custom Jinja test. + + The :meth:`template_test` decorator can be used to register a function + by decorating instead. + + :param f: The function to register. + :param name: The name to register the test as. If not given, uses the + function's name. + + .. versionadded:: 0.10 + """ + self.jinja_env.tests[name or f.__name__] = f + + @t.overload + def template_global(self, name: T_template_global) -> T_template_global: ... + @t.overload + def template_global( + self, name: str | None = None + ) -> t.Callable[[T_template_global], T_template_global]: ... + @setupmethod + def template_global( + self, name: T_template_global | str | None = None + ) -> T_template_global | t.Callable[[T_template_global], T_template_global]: + """Decorate a function to register it as a custom Jinja global. The name + is optional. The decorator may be used without parentheses. + + .. code-block:: python + + @app.template_global + def double(n): + return 2 * n + + The :meth:`add_template_global` method may be used to register a + function later rather than decorating. + + :param name: The name to register the global as. If not given, uses the + function's name. + + .. versionadded:: 0.10 + """ + if callable(name): + self.add_template_global(name) + return name + + def decorator(f: T_template_global) -> T_template_global: + self.add_template_global(f, name=name) + return f + + return decorator + + @setupmethod + def add_template_global( + self, f: ft.TemplateGlobalCallable, name: str | None = None + ) -> None: + """Register a function to use as a custom Jinja global. + + The :meth:`template_global` decorator can be used to register a function + by decorating instead. + + :param f: The function to register. + :param name: The name to register the global as. If not given, uses the + function's name. + + .. versionadded:: 0.10 + """ + self.jinja_env.globals[name or f.__name__] = f + + @setupmethod + def teardown_appcontext(self, f: T_teardown) -> T_teardown: + """Registers a function to be called when the app context is popped. The + context is popped at the end of a request, CLI command, or manual ``with`` + block. + + .. code-block:: python + + with app.app_context(): + ... + + When the ``with`` block exits (or ``ctx.pop()`` is called), the + teardown functions are called just before the app context is + made inactive. + + When a teardown function was called because of an unhandled + exception it will be passed an error object. If an + :meth:`errorhandler` is registered, it will handle the exception + and the teardown will not receive it. + + Teardown functions must avoid raising exceptions. If they + execute code that might fail they must surround that code with a + ``try``/``except`` block and log any errors. + + The return values of teardown functions are ignored. + + .. versionadded:: 0.9 + """ + self.teardown_appcontext_funcs.append(f) + return f + + @setupmethod + def shell_context_processor( + self, f: T_shell_context_processor + ) -> T_shell_context_processor: + """Registers a shell context processor function. + + .. versionadded:: 0.11 + """ + self.shell_context_processors.append(f) + return f + + def _find_error_handler( + self, e: Exception, blueprints: list[str] + ) -> ft.ErrorHandlerCallable | None: + """Return a registered error handler for an exception in this order: + blueprint handler for a specific code, app handler for a specific code, + blueprint handler for an exception class, app handler for an exception + class, or ``None`` if a suitable handler is not found. + """ + exc_class, code = self._get_exc_class_and_code(type(e)) + names = (*blueprints, None) + + for c in (code, None) if code is not None else (None,): + for name in names: + handler_map = self.error_handler_spec[name][c] + + if not handler_map: + continue + + for cls in exc_class.__mro__: + handler = handler_map.get(cls) + + if handler is not None: + return handler + return None + + def trap_http_exception(self, e: Exception) -> bool: + """Checks if an HTTP exception should be trapped or not. By default + this will return ``False`` for all exceptions except for a bad request + key error if ``TRAP_BAD_REQUEST_ERRORS`` is set to ``True``. It + also returns ``True`` if ``TRAP_HTTP_EXCEPTIONS`` is set to ``True``. + + This is called for all HTTP exceptions raised by a view function. + If it returns ``True`` for any exception the error handler for this + exception is not called and it shows up as regular exception in the + traceback. This is helpful for debugging implicitly raised HTTP + exceptions. + + .. versionchanged:: 1.0 + Bad request errors are not trapped by default in debug mode. + + .. versionadded:: 0.8 + """ + if self.config["TRAP_HTTP_EXCEPTIONS"]: + return True + + trap_bad_request = self.config["TRAP_BAD_REQUEST_ERRORS"] + + # if unset, trap key errors in debug mode + if ( + trap_bad_request is None + and self.debug + and isinstance(e, BadRequestKeyError) + ): + return True + + if trap_bad_request: + return isinstance(e, BadRequest) + + return False + + def should_ignore_error(self, error: BaseException | None) -> bool: + """This is called to figure out if an error should be ignored + or not as far as the teardown system is concerned. If this + function returns ``True`` then the teardown handlers will not be + passed the error. + + .. versionadded:: 0.10 + """ + return False + + def redirect(self, location: str, code: int = 302) -> BaseResponse: + """Create a redirect response object. + + This is called by :func:`flask.redirect`, and can be called + directly as well. + + :param location: The URL to redirect to. + :param code: The status code for the redirect. + + .. versionadded:: 2.2 + Moved from ``flask.redirect``, which calls this method. + """ + return _wz_redirect( + location, + code=code, + Response=self.response_class, # type: ignore[arg-type] + ) + + def inject_url_defaults(self, endpoint: str, values: dict[str, t.Any]) -> None: + """Injects the URL defaults for the given endpoint directly into + the values dictionary passed. This is used internally and + automatically called on URL building. + + .. versionadded:: 0.7 + """ + names: t.Iterable[str | None] = (None,) + + # url_for may be called outside a request context, parse the + # passed endpoint instead of using request.blueprints. + if "." in endpoint: + names = chain( + names, reversed(_split_blueprint_path(endpoint.rpartition(".")[0])) + ) + + for name in names: + if name in self.url_default_functions: + for func in self.url_default_functions[name]: + func(endpoint, values) + + def handle_url_build_error( + self, error: BuildError, endpoint: str, values: dict[str, t.Any] + ) -> str: + """Called by :meth:`.url_for` if a + :exc:`~werkzeug.routing.BuildError` was raised. If this returns + a value, it will be returned by ``url_for``, otherwise the error + will be re-raised. + + Each function in :attr:`url_build_error_handlers` is called with + ``error``, ``endpoint`` and ``values``. If a function returns + ``None`` or raises a ``BuildError``, it is skipped. Otherwise, + its return value is returned by ``url_for``. + + :param error: The active ``BuildError`` being handled. + :param endpoint: The endpoint being built. + :param values: The keyword arguments passed to ``url_for``. + """ + for handler in self.url_build_error_handlers: + try: + rv = handler(error, endpoint, values) + except BuildError as e: + # make error available outside except block + error = e + else: + if rv is not None: + return rv + + # Re-raise if called with an active exception, otherwise raise + # the passed in exception. + if error is sys.exc_info()[1]: + raise + + raise error diff --git a/src/flask-main/src/flask/sansio/blueprints.py b/src/flask-main/src/flask/sansio/blueprints.py new file mode 100644 index 0000000..665816e --- /dev/null +++ b/src/flask-main/src/flask/sansio/blueprints.py @@ -0,0 +1,692 @@ +from __future__ import annotations + +import os +import typing as t +from collections import defaultdict +from functools import update_wrapper + +from .. import typing as ft +from .scaffold import _endpoint_from_view_func +from .scaffold import _sentinel +from .scaffold import Scaffold +from .scaffold import setupmethod + +if t.TYPE_CHECKING: # pragma: no cover + from .app import App + +DeferredSetupFunction = t.Callable[["BlueprintSetupState"], None] +T_after_request = t.TypeVar("T_after_request", bound=ft.AfterRequestCallable[t.Any]) +T_before_request = t.TypeVar("T_before_request", bound=ft.BeforeRequestCallable) +T_error_handler = t.TypeVar("T_error_handler", bound=ft.ErrorHandlerCallable) +T_teardown = t.TypeVar("T_teardown", bound=ft.TeardownCallable) +T_template_context_processor = t.TypeVar( + "T_template_context_processor", bound=ft.TemplateContextProcessorCallable +) +T_template_filter = t.TypeVar("T_template_filter", bound=ft.TemplateFilterCallable) +T_template_global = t.TypeVar("T_template_global", bound=ft.TemplateGlobalCallable) +T_template_test = t.TypeVar("T_template_test", bound=ft.TemplateTestCallable) +T_url_defaults = t.TypeVar("T_url_defaults", bound=ft.URLDefaultCallable) +T_url_value_preprocessor = t.TypeVar( + "T_url_value_preprocessor", bound=ft.URLValuePreprocessorCallable +) + + +class BlueprintSetupState: + """Temporary holder object for registering a blueprint with the + application. An instance of this class is created by the + :meth:`~flask.Blueprint.make_setup_state` method and later passed + to all register callback functions. + """ + + def __init__( + self, + blueprint: Blueprint, + app: App, + options: t.Any, + first_registration: bool, + ) -> None: + #: a reference to the current application + self.app = app + + #: a reference to the blueprint that created this setup state. + self.blueprint = blueprint + + #: a dictionary with all options that were passed to the + #: :meth:`~flask.Flask.register_blueprint` method. + self.options = options + + #: as blueprints can be registered multiple times with the + #: application and not everything wants to be registered + #: multiple times on it, this attribute can be used to figure + #: out if the blueprint was registered in the past already. + self.first_registration = first_registration + + subdomain = self.options.get("subdomain") + if subdomain is None: + subdomain = self.blueprint.subdomain + + #: The subdomain that the blueprint should be active for, ``None`` + #: otherwise. + self.subdomain = subdomain + + url_prefix = self.options.get("url_prefix") + if url_prefix is None: + url_prefix = self.blueprint.url_prefix + #: The prefix that should be used for all URLs defined on the + #: blueprint. + self.url_prefix = url_prefix + + self.name = self.options.get("name", blueprint.name) + self.name_prefix = self.options.get("name_prefix", "") + + #: A dictionary with URL defaults that is added to each and every + #: URL that was defined with the blueprint. + self.url_defaults = dict(self.blueprint.url_values_defaults) + self.url_defaults.update(self.options.get("url_defaults", ())) + + def add_url_rule( + self, + rule: str, + endpoint: str | None = None, + view_func: ft.RouteCallable | None = None, + **options: t.Any, + ) -> None: + """A helper method to register a rule (and optionally a view function) + to the application. The endpoint is automatically prefixed with the + blueprint's name. + """ + if self.url_prefix is not None: + if rule: + rule = "/".join((self.url_prefix.rstrip("/"), rule.lstrip("/"))) + else: + rule = self.url_prefix + options.setdefault("subdomain", self.subdomain) + if endpoint is None: + endpoint = _endpoint_from_view_func(view_func) # type: ignore + defaults = self.url_defaults + if "defaults" in options: + defaults = dict(defaults, **options.pop("defaults")) + + self.app.add_url_rule( + rule, + f"{self.name_prefix}.{self.name}.{endpoint}".lstrip("."), + view_func, + defaults=defaults, + **options, + ) + + +class Blueprint(Scaffold): + """Represents a blueprint, a collection of routes and other + app-related functions that can be registered on a real application + later. + + A blueprint is an object that allows defining application functions + without requiring an application object ahead of time. It uses the + same decorators as :class:`~flask.Flask`, but defers the need for an + application by recording them for later registration. + + Decorating a function with a blueprint creates a deferred function + that is called with :class:`~flask.blueprints.BlueprintSetupState` + when the blueprint is registered on an application. + + See :doc:`/blueprints` for more information. + + :param name: The name of the blueprint. Will be prepended to each + endpoint name. + :param import_name: The name of the blueprint package, usually + ``__name__``. This helps locate the ``root_path`` for the + blueprint. + :param static_folder: A folder with static files that should be + served by the blueprint's static route. The path is relative to + the blueprint's root path. Blueprint static files are disabled + by default. + :param static_url_path: The url to serve static files from. + Defaults to ``static_folder``. If the blueprint does not have + a ``url_prefix``, the app's static route will take precedence, + and the blueprint's static files won't be accessible. + :param template_folder: A folder with templates that should be added + to the app's template search path. The path is relative to the + blueprint's root path. Blueprint templates are disabled by + default. Blueprint templates have a lower precedence than those + in the app's templates folder. + :param url_prefix: A path to prepend to all of the blueprint's URLs, + to make them distinct from the rest of the app's routes. + :param subdomain: A subdomain that blueprint routes will match on by + default. + :param url_defaults: A dict of default values that blueprint routes + will receive by default. + :param root_path: By default, the blueprint will automatically set + this based on ``import_name``. In certain situations this + automatic detection can fail, so the path can be specified + manually instead. + + .. versionchanged:: 1.1.0 + Blueprints have a ``cli`` group to register nested CLI commands. + The ``cli_group`` parameter controls the name of the group under + the ``flask`` command. + + .. versionadded:: 0.7 + """ + + _got_registered_once = False + + def __init__( + self, + name: str, + import_name: str, + static_folder: str | os.PathLike[str] | None = None, + static_url_path: str | None = None, + template_folder: str | os.PathLike[str] | None = None, + url_prefix: str | None = None, + subdomain: str | None = None, + url_defaults: dict[str, t.Any] | None = None, + root_path: str | None = None, + cli_group: str | None = _sentinel, # type: ignore[assignment] + ): + super().__init__( + import_name=import_name, + static_folder=static_folder, + static_url_path=static_url_path, + template_folder=template_folder, + root_path=root_path, + ) + + if not name: + raise ValueError("'name' may not be empty.") + + if "." in name: + raise ValueError("'name' may not contain a dot '.' character.") + + self.name = name + self.url_prefix = url_prefix + self.subdomain = subdomain + self.deferred_functions: list[DeferredSetupFunction] = [] + + if url_defaults is None: + url_defaults = {} + + self.url_values_defaults = url_defaults + self.cli_group = cli_group + self._blueprints: list[tuple[Blueprint, dict[str, t.Any]]] = [] + + def _check_setup_finished(self, f_name: str) -> None: + if self._got_registered_once: + raise AssertionError( + f"The setup method '{f_name}' can no longer be called on the blueprint" + f" '{self.name}'. It has already been registered at least once, any" + " changes will not be applied consistently.\n" + "Make sure all imports, decorators, functions, etc. needed to set up" + " the blueprint are done before registering it." + ) + + @setupmethod + def record(self, func: DeferredSetupFunction) -> None: + """Registers a function that is called when the blueprint is + registered on the application. This function is called with the + state as argument as returned by the :meth:`make_setup_state` + method. + """ + self.deferred_functions.append(func) + + @setupmethod + def record_once(self, func: DeferredSetupFunction) -> None: + """Works like :meth:`record` but wraps the function in another + function that will ensure the function is only called once. If the + blueprint is registered a second time on the application, the + function passed is not called. + """ + + def wrapper(state: BlueprintSetupState) -> None: + if state.first_registration: + func(state) + + self.record(update_wrapper(wrapper, func)) + + def make_setup_state( + self, app: App, options: dict[str, t.Any], first_registration: bool = False + ) -> BlueprintSetupState: + """Creates an instance of :meth:`~flask.blueprints.BlueprintSetupState` + object that is later passed to the register callback functions. + Subclasses can override this to return a subclass of the setup state. + """ + return BlueprintSetupState(self, app, options, first_registration) + + @setupmethod + def register_blueprint(self, blueprint: Blueprint, **options: t.Any) -> None: + """Register a :class:`~flask.Blueprint` on this blueprint. Keyword + arguments passed to this method will override the defaults set + on the blueprint. + + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. + + .. versionadded:: 2.0 + """ + if blueprint is self: + raise ValueError("Cannot register a blueprint on itself") + self._blueprints.append((blueprint, options)) + + def register(self, app: App, options: dict[str, t.Any]) -> None: + """Called by :meth:`Flask.register_blueprint` to register all + views and callbacks registered on the blueprint with the + application. Creates a :class:`.BlueprintSetupState` and calls + each :meth:`record` callback with it. + + :param app: The application this blueprint is being registered + with. + :param options: Keyword arguments forwarded from + :meth:`~Flask.register_blueprint`. + + .. versionchanged:: 2.3 + Nested blueprints now correctly apply subdomains. + + .. versionchanged:: 2.1 + Registering the same blueprint with the same name multiple + times is an error. + + .. versionchanged:: 2.0.1 + Nested blueprints are registered with their dotted name. + This allows different blueprints with the same name to be + nested at different locations. + + .. versionchanged:: 2.0.1 + The ``name`` option can be used to change the (pre-dotted) + name the blueprint is registered with. This allows the same + blueprint to be registered multiple times with unique names + for ``url_for``. + """ + name_prefix = options.get("name_prefix", "") + self_name = options.get("name", self.name) + name = f"{name_prefix}.{self_name}".lstrip(".") + + if name in app.blueprints: + bp_desc = "this" if app.blueprints[name] is self else "a different" + existing_at = f" '{name}'" if self_name != name else "" + + raise ValueError( + f"The name '{self_name}' is already registered for" + f" {bp_desc} blueprint{existing_at}. Use 'name=' to" + f" provide a unique name." + ) + + first_bp_registration = not any(bp is self for bp in app.blueprints.values()) + first_name_registration = name not in app.blueprints + + app.blueprints[name] = self + self._got_registered_once = True + state = self.make_setup_state(app, options, first_bp_registration) + + if self.has_static_folder: + state.add_url_rule( + f"{self.static_url_path}/", + view_func=self.send_static_file, # type: ignore[attr-defined] + endpoint="static", + ) + + # Merge blueprint data into parent. + if first_bp_registration or first_name_registration: + self._merge_blueprint_funcs(app, name) + + for deferred in self.deferred_functions: + deferred(state) + + cli_resolved_group = options.get("cli_group", self.cli_group) + + if self.cli.commands: + if cli_resolved_group is None: + app.cli.commands.update(self.cli.commands) + elif cli_resolved_group is _sentinel: + self.cli.name = name + app.cli.add_command(self.cli) + else: + self.cli.name = cli_resolved_group + app.cli.add_command(self.cli) + + for blueprint, bp_options in self._blueprints: + bp_options = bp_options.copy() + bp_url_prefix = bp_options.get("url_prefix") + bp_subdomain = bp_options.get("subdomain") + + if bp_subdomain is None: + bp_subdomain = blueprint.subdomain + + if state.subdomain is not None and bp_subdomain is not None: + bp_options["subdomain"] = bp_subdomain + "." + state.subdomain + elif bp_subdomain is not None: + bp_options["subdomain"] = bp_subdomain + elif state.subdomain is not None: + bp_options["subdomain"] = state.subdomain + + if bp_url_prefix is None: + bp_url_prefix = blueprint.url_prefix + + if state.url_prefix is not None and bp_url_prefix is not None: + bp_options["url_prefix"] = ( + state.url_prefix.rstrip("/") + "/" + bp_url_prefix.lstrip("/") + ) + elif bp_url_prefix is not None: + bp_options["url_prefix"] = bp_url_prefix + elif state.url_prefix is not None: + bp_options["url_prefix"] = state.url_prefix + + bp_options["name_prefix"] = name + blueprint.register(app, bp_options) + + def _merge_blueprint_funcs(self, app: App, name: str) -> None: + def extend( + bp_dict: dict[ft.AppOrBlueprintKey, list[t.Any]], + parent_dict: dict[ft.AppOrBlueprintKey, list[t.Any]], + ) -> None: + for key, values in bp_dict.items(): + key = name if key is None else f"{name}.{key}" + parent_dict[key].extend(values) + + for key, value in self.error_handler_spec.items(): + key = name if key is None else f"{name}.{key}" + value = defaultdict( + dict, + { + code: {exc_class: func for exc_class, func in code_values.items()} + for code, code_values in value.items() + }, + ) + app.error_handler_spec[key] = value + + for endpoint, func in self.view_functions.items(): + app.view_functions[endpoint] = func + + extend(self.before_request_funcs, app.before_request_funcs) + extend(self.after_request_funcs, app.after_request_funcs) + extend( + self.teardown_request_funcs, + app.teardown_request_funcs, + ) + extend(self.url_default_functions, app.url_default_functions) + extend(self.url_value_preprocessors, app.url_value_preprocessors) + extend(self.template_context_processors, app.template_context_processors) + + @setupmethod + def add_url_rule( + self, + rule: str, + endpoint: str | None = None, + view_func: ft.RouteCallable | None = None, + provide_automatic_options: bool | None = None, + **options: t.Any, + ) -> None: + """Register a URL rule with the blueprint. See :meth:`.Flask.add_url_rule` for + full documentation. + + The URL rule is prefixed with the blueprint's URL prefix. The endpoint name, + used with :func:`url_for`, is prefixed with the blueprint's name. + """ + if endpoint and "." in endpoint: + raise ValueError("'endpoint' may not contain a dot '.' character.") + + if view_func and hasattr(view_func, "__name__") and "." in view_func.__name__: + raise ValueError("'view_func' name may not contain a dot '.' character.") + + self.record( + lambda s: s.add_url_rule( + rule, + endpoint, + view_func, + provide_automatic_options=provide_automatic_options, + **options, + ) + ) + + @t.overload + def app_template_filter(self, name: T_template_filter) -> T_template_filter: ... + @t.overload + def app_template_filter( + self, name: str | None = None + ) -> t.Callable[[T_template_filter], T_template_filter]: ... + @setupmethod + def app_template_filter( + self, name: T_template_filter | str | None = None + ) -> T_template_filter | t.Callable[[T_template_filter], T_template_filter]: + """Decorate a function to register it as a custom Jinja filter. The name + is optional. The decorator may be used without parentheses. + + The :meth:`add_app_template_filter` method may be used to register a + function later rather than decorating. + + The filter is available in all templates, not only those under this + blueprint. Equivalent to :meth:`.Flask.template_filter`. + + :param name: The name to register the filter as. If not given, uses the + function's name. + """ + if callable(name): + self.add_app_template_filter(name) + return name + + def decorator(f: T_template_filter) -> T_template_filter: + self.add_app_template_filter(f, name=name) + return f + + return decorator + + @setupmethod + def add_app_template_filter( + self, f: ft.TemplateFilterCallable, name: str | None = None + ) -> None: + """Register a function to use as a custom Jinja filter. + + The :meth:`app_template_filter` decorator can be used to register a + function by decorating instead. + + The filter is available in all templates, not only those under this + blueprint. Equivalent to :meth:`.Flask.add_template_filter`. + + :param f: The function to register. + :param name: The name to register the filter as. If not given, uses the + function's name. + """ + + def register_template_filter(state: BlueprintSetupState) -> None: + state.app.add_template_filter(f, name=name) + + self.record_once(register_template_filter) + + @t.overload + def app_template_test(self, name: T_template_test) -> T_template_test: ... + @t.overload + def app_template_test( + self, name: str | None = None + ) -> t.Callable[[T_template_test], T_template_test]: ... + @setupmethod + def app_template_test( + self, name: T_template_test | str | None = None + ) -> T_template_test | t.Callable[[T_template_test], T_template_test]: + """Decorate a function to register it as a custom Jinja test. The name + is optional. The decorator may be used without parentheses. + + The :meth:`add_app_template_test` method may be used to register a + function later rather than decorating. + + The test is available in all templates, not only those under this + blueprint. Equivalent to :meth:`.Flask.template_test`. + + :param name: The name to register the filter as. If not given, uses the + function's name. + + .. versionadded:: 0.10 + """ + if callable(name): + self.add_app_template_test(name) + return name + + def decorator(f: T_template_test) -> T_template_test: + self.add_app_template_test(f, name=name) + return f + + return decorator + + @setupmethod + def add_app_template_test( + self, f: ft.TemplateTestCallable, name: str | None = None + ) -> None: + """Register a function to use as a custom Jinja test. + + The :meth:`app_template_test` decorator can be used to register a + function by decorating instead. + + The test is available in all templates, not only those under this + blueprint. Equivalent to :meth:`.Flask.add_template_test`. + + :param f: The function to register. + :param name: The name to register the test as. If not given, uses the + function's name. + + .. versionadded:: 0.10 + """ + + def register_template_test(state: BlueprintSetupState) -> None: + state.app.add_template_test(f, name=name) + + self.record_once(register_template_test) + + @t.overload + def app_template_global(self, name: T_template_global) -> T_template_global: ... + @t.overload + def app_template_global( + self, name: str | None = None + ) -> t.Callable[[T_template_global], T_template_global]: ... + @setupmethod + def app_template_global( + self, name: T_template_global | str | None = None + ) -> T_template_global | t.Callable[[T_template_global], T_template_global]: + """Decorate a function to register it as a custom Jinja global. The name + is optional. The decorator may be used without parentheses. + + The :meth:`add_app_template_global` method may be used to register a + function later rather than decorating. + + The global is available in all templates, not only those under this + blueprint. Equivalent to :meth:`.Flask.template_global`. + + :param name: The name to register the global as. If not given, uses the + function's name. + + .. versionadded:: 0.10 + """ + if callable(name): + self.add_app_template_global(name) + return name + + def decorator(f: T_template_global) -> T_template_global: + self.add_app_template_global(f, name=name) + return f + + return decorator + + @setupmethod + def add_app_template_global( + self, f: ft.TemplateGlobalCallable, name: str | None = None + ) -> None: + """Register a function to use as a custom Jinja global. + + The :meth:`app_template_global` decorator can be used to register a function + by decorating instead. + + The global is available in all templates, not only those under this + blueprint. Equivalent to :meth:`.Flask.add_template_global`. + + :param f: The function to register. + :param name: The name to register the global as. If not given, uses the + function's name. + + .. versionadded:: 0.10 + """ + + def register_template_global(state: BlueprintSetupState) -> None: + state.app.add_template_global(f, name=name) + + self.record_once(register_template_global) + + @setupmethod + def before_app_request(self, f: T_before_request) -> T_before_request: + """Like :meth:`before_request`, but before every request, not only those handled + by the blueprint. Equivalent to :meth:`.Flask.before_request`. + """ + self.record_once( + lambda s: s.app.before_request_funcs.setdefault(None, []).append(f) + ) + return f + + @setupmethod + def after_app_request(self, f: T_after_request) -> T_after_request: + """Like :meth:`after_request`, but after every request, not only those handled + by the blueprint. Equivalent to :meth:`.Flask.after_request`. + """ + self.record_once( + lambda s: s.app.after_request_funcs.setdefault(None, []).append(f) + ) + return f + + @setupmethod + def teardown_app_request(self, f: T_teardown) -> T_teardown: + """Like :meth:`teardown_request`, but after every request, not only those + handled by the blueprint. Equivalent to :meth:`.Flask.teardown_request`. + """ + self.record_once( + lambda s: s.app.teardown_request_funcs.setdefault(None, []).append(f) + ) + return f + + @setupmethod + def app_context_processor( + self, f: T_template_context_processor + ) -> T_template_context_processor: + """Like :meth:`context_processor`, but for templates rendered by every view, not + only by the blueprint. Equivalent to :meth:`.Flask.context_processor`. + """ + self.record_once( + lambda s: s.app.template_context_processors.setdefault(None, []).append(f) + ) + return f + + @setupmethod + def app_errorhandler( + self, code: type[Exception] | int + ) -> t.Callable[[T_error_handler], T_error_handler]: + """Like :meth:`errorhandler`, but for every request, not only those handled by + the blueprint. Equivalent to :meth:`.Flask.errorhandler`. + """ + + def decorator(f: T_error_handler) -> T_error_handler: + def from_blueprint(state: BlueprintSetupState) -> None: + state.app.errorhandler(code)(f) + + self.record_once(from_blueprint) + return f + + return decorator + + @setupmethod + def app_url_value_preprocessor( + self, f: T_url_value_preprocessor + ) -> T_url_value_preprocessor: + """Like :meth:`url_value_preprocessor`, but for every request, not only those + handled by the blueprint. Equivalent to :meth:`.Flask.url_value_preprocessor`. + """ + self.record_once( + lambda s: s.app.url_value_preprocessors.setdefault(None, []).append(f) + ) + return f + + @setupmethod + def app_url_defaults(self, f: T_url_defaults) -> T_url_defaults: + """Like :meth:`url_defaults`, but for every request, not only those handled by + the blueprint. Equivalent to :meth:`.Flask.url_defaults`. + """ + self.record_once( + lambda s: s.app.url_default_functions.setdefault(None, []).append(f) + ) + return f diff --git a/src/flask-main/src/flask/sansio/scaffold.py b/src/flask-main/src/flask/sansio/scaffold.py new file mode 100644 index 0000000..a04c38a --- /dev/null +++ b/src/flask-main/src/flask/sansio/scaffold.py @@ -0,0 +1,792 @@ +from __future__ import annotations + +import importlib.util +import os +import pathlib +import sys +import typing as t +from collections import defaultdict +from functools import update_wrapper + +from jinja2 import BaseLoader +from jinja2 import FileSystemLoader +from werkzeug.exceptions import default_exceptions +from werkzeug.exceptions import HTTPException +from werkzeug.utils import cached_property + +from .. import typing as ft +from ..helpers import get_root_path +from ..templating import _default_template_ctx_processor + +if t.TYPE_CHECKING: # pragma: no cover + from click import Group + +# a singleton sentinel value for parameter defaults +_sentinel = object() + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +T_after_request = t.TypeVar("T_after_request", bound=ft.AfterRequestCallable[t.Any]) +T_before_request = t.TypeVar("T_before_request", bound=ft.BeforeRequestCallable) +T_error_handler = t.TypeVar("T_error_handler", bound=ft.ErrorHandlerCallable) +T_teardown = t.TypeVar("T_teardown", bound=ft.TeardownCallable) +T_template_context_processor = t.TypeVar( + "T_template_context_processor", bound=ft.TemplateContextProcessorCallable +) +T_url_defaults = t.TypeVar("T_url_defaults", bound=ft.URLDefaultCallable) +T_url_value_preprocessor = t.TypeVar( + "T_url_value_preprocessor", bound=ft.URLValuePreprocessorCallable +) +T_route = t.TypeVar("T_route", bound=ft.RouteCallable) + + +def setupmethod(f: F) -> F: + f_name = f.__name__ + + def wrapper_func(self: Scaffold, *args: t.Any, **kwargs: t.Any) -> t.Any: + self._check_setup_finished(f_name) + return f(self, *args, **kwargs) + + return t.cast(F, update_wrapper(wrapper_func, f)) + + +class Scaffold: + """Common behavior shared between :class:`~flask.Flask` and + :class:`~flask.blueprints.Blueprint`. + + :param import_name: The import name of the module where this object + is defined. Usually :attr:`__name__` should be used. + :param static_folder: Path to a folder of static files to serve. + If this is set, a static route will be added. + :param static_url_path: URL prefix for the static route. + :param template_folder: Path to a folder containing template files. + for rendering. If this is set, a Jinja loader will be added. + :param root_path: The path that static, template, and resource files + are relative to. Typically not set, it is discovered based on + the ``import_name``. + + .. versionadded:: 2.0 + """ + + cli: Group + name: str + _static_folder: str | None = None + _static_url_path: str | None = None + + def __init__( + self, + import_name: str, + static_folder: str | os.PathLike[str] | None = None, + static_url_path: str | None = None, + template_folder: str | os.PathLike[str] | None = None, + root_path: str | None = None, + ): + #: The name of the package or module that this object belongs + #: to. Do not change this once it is set by the constructor. + self.import_name = import_name + + self.static_folder = static_folder + self.static_url_path = static_url_path + + #: The path to the templates folder, relative to + #: :attr:`root_path`, to add to the template loader. ``None`` if + #: templates should not be added. + self.template_folder = template_folder + + if root_path is None: + root_path = get_root_path(self.import_name) + + #: Absolute path to the package on the filesystem. Used to look + #: up resources contained in the package. + self.root_path = root_path + + #: A dictionary mapping endpoint names to view functions. + #: + #: To register a view function, use the :meth:`route` decorator. + #: + #: This data structure is internal. It should not be modified + #: directly and its format may change at any time. + self.view_functions: dict[str, ft.RouteCallable] = {} + + #: A data structure of registered error handlers, in the format + #: ``{scope: {code: {class: handler}}}``. The ``scope`` key is + #: the name of a blueprint the handlers are active for, or + #: ``None`` for all requests. The ``code`` key is the HTTP + #: status code for ``HTTPException``, or ``None`` for + #: other exceptions. The innermost dictionary maps exception + #: classes to handler functions. + #: + #: To register an error handler, use the :meth:`errorhandler` + #: decorator. + #: + #: This data structure is internal. It should not be modified + #: directly and its format may change at any time. + self.error_handler_spec: dict[ + ft.AppOrBlueprintKey, + dict[int | None, dict[type[Exception], ft.ErrorHandlerCallable]], + ] = defaultdict(lambda: defaultdict(dict)) + + #: A data structure of functions to call at the beginning of + #: each request, in the format ``{scope: [functions]}``. The + #: ``scope`` key is the name of a blueprint the functions are + #: active for, or ``None`` for all requests. + #: + #: To register a function, use the :meth:`before_request` + #: decorator. + #: + #: This data structure is internal. It should not be modified + #: directly and its format may change at any time. + self.before_request_funcs: dict[ + ft.AppOrBlueprintKey, list[ft.BeforeRequestCallable] + ] = defaultdict(list) + + #: A data structure of functions to call at the end of each + #: request, in the format ``{scope: [functions]}``. The + #: ``scope`` key is the name of a blueprint the functions are + #: active for, or ``None`` for all requests. + #: + #: To register a function, use the :meth:`after_request` + #: decorator. + #: + #: This data structure is internal. It should not be modified + #: directly and its format may change at any time. + self.after_request_funcs: dict[ + ft.AppOrBlueprintKey, list[ft.AfterRequestCallable[t.Any]] + ] = defaultdict(list) + + #: A data structure of functions to call at the end of each + #: request even if an exception is raised, in the format + #: ``{scope: [functions]}``. The ``scope`` key is the name of a + #: blueprint the functions are active for, or ``None`` for all + #: requests. + #: + #: To register a function, use the :meth:`teardown_request` + #: decorator. + #: + #: This data structure is internal. It should not be modified + #: directly and its format may change at any time. + self.teardown_request_funcs: dict[ + ft.AppOrBlueprintKey, list[ft.TeardownCallable] + ] = defaultdict(list) + + #: A data structure of functions to call to pass extra context + #: values when rendering templates, in the format + #: ``{scope: [functions]}``. The ``scope`` key is the name of a + #: blueprint the functions are active for, or ``None`` for all + #: requests. + #: + #: To register a function, use the :meth:`context_processor` + #: decorator. + #: + #: This data structure is internal. It should not be modified + #: directly and its format may change at any time. + self.template_context_processors: dict[ + ft.AppOrBlueprintKey, list[ft.TemplateContextProcessorCallable] + ] = defaultdict(list, {None: [_default_template_ctx_processor]}) + + #: A data structure of functions to call to modify the keyword + #: arguments passed to the view function, in the format + #: ``{scope: [functions]}``. The ``scope`` key is the name of a + #: blueprint the functions are active for, or ``None`` for all + #: requests. + #: + #: To register a function, use the + #: :meth:`url_value_preprocessor` decorator. + #: + #: This data structure is internal. It should not be modified + #: directly and its format may change at any time. + self.url_value_preprocessors: dict[ + ft.AppOrBlueprintKey, + list[ft.URLValuePreprocessorCallable], + ] = defaultdict(list) + + #: A data structure of functions to call to modify the keyword + #: arguments when generating URLs, in the format + #: ``{scope: [functions]}``. The ``scope`` key is the name of a + #: blueprint the functions are active for, or ``None`` for all + #: requests. + #: + #: To register a function, use the :meth:`url_defaults` + #: decorator. + #: + #: This data structure is internal. It should not be modified + #: directly and its format may change at any time. + self.url_default_functions: dict[ + ft.AppOrBlueprintKey, list[ft.URLDefaultCallable] + ] = defaultdict(list) + + def __repr__(self) -> str: + return f"<{type(self).__name__} {self.name!r}>" + + def _check_setup_finished(self, f_name: str) -> None: + raise NotImplementedError + + @property + def static_folder(self) -> str | None: + """The absolute path to the configured static folder. ``None`` + if no static folder is set. + """ + if self._static_folder is not None: + return os.path.join(self.root_path, self._static_folder) + else: + return None + + @static_folder.setter + def static_folder(self, value: str | os.PathLike[str] | None) -> None: + if value is not None: + value = os.fspath(value).rstrip(r"\/") + + self._static_folder = value + + @property + def has_static_folder(self) -> bool: + """``True`` if :attr:`static_folder` is set. + + .. versionadded:: 0.5 + """ + return self.static_folder is not None + + @property + def static_url_path(self) -> str | None: + """The URL prefix that the static route will be accessible from. + + If it was not configured during init, it is derived from + :attr:`static_folder`. + """ + if self._static_url_path is not None: + return self._static_url_path + + if self.static_folder is not None: + basename = os.path.basename(self.static_folder) + return f"/{basename}".rstrip("/") + + return None + + @static_url_path.setter + def static_url_path(self, value: str | None) -> None: + if value is not None: + value = value.rstrip("/") + + self._static_url_path = value + + @cached_property + def jinja_loader(self) -> BaseLoader | None: + """The Jinja loader for this object's templates. By default this + is a class :class:`jinja2.loaders.FileSystemLoader` to + :attr:`template_folder` if it is set. + + .. versionadded:: 0.5 + """ + if self.template_folder is not None: + return FileSystemLoader(os.path.join(self.root_path, self.template_folder)) + else: + return None + + def _method_route( + self, + method: str, + rule: str, + options: dict[str, t.Any], + ) -> t.Callable[[T_route], T_route]: + if "methods" in options: + raise TypeError("Use the 'route' decorator to use the 'methods' argument.") + + return self.route(rule, methods=[method], **options) + + @setupmethod + def get(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]: + """Shortcut for :meth:`route` with ``methods=["GET"]``. + + .. versionadded:: 2.0 + """ + return self._method_route("GET", rule, options) + + @setupmethod + def post(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]: + """Shortcut for :meth:`route` with ``methods=["POST"]``. + + .. versionadded:: 2.0 + """ + return self._method_route("POST", rule, options) + + @setupmethod + def put(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]: + """Shortcut for :meth:`route` with ``methods=["PUT"]``. + + .. versionadded:: 2.0 + """ + return self._method_route("PUT", rule, options) + + @setupmethod + def delete(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]: + """Shortcut for :meth:`route` with ``methods=["DELETE"]``. + + .. versionadded:: 2.0 + """ + return self._method_route("DELETE", rule, options) + + @setupmethod + def patch(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]: + """Shortcut for :meth:`route` with ``methods=["PATCH"]``. + + .. versionadded:: 2.0 + """ + return self._method_route("PATCH", rule, options) + + @setupmethod + def route(self, rule: str, **options: t.Any) -> t.Callable[[T_route], T_route]: + """Decorate a view function to register it with the given URL + rule and options. Calls :meth:`add_url_rule`, which has more + details about the implementation. + + .. code-block:: python + + @app.route("/") + def index(): + return "Hello, World!" + + See :ref:`url-route-registrations`. + + The endpoint name for the route defaults to the name of the view + function if the ``endpoint`` parameter isn't passed. + + The ``methods`` parameter defaults to ``["GET"]``. ``HEAD`` and + ``OPTIONS`` are added automatically. + + :param rule: The URL rule string. + :param options: Extra options passed to the + :class:`~werkzeug.routing.Rule` object. + """ + + def decorator(f: T_route) -> T_route: + endpoint = options.pop("endpoint", None) + self.add_url_rule(rule, endpoint, f, **options) + return f + + return decorator + + @setupmethod + def add_url_rule( + self, + rule: str, + endpoint: str | None = None, + view_func: ft.RouteCallable | None = None, + provide_automatic_options: bool | None = None, + **options: t.Any, + ) -> None: + """Register a rule for routing incoming requests and building + URLs. The :meth:`route` decorator is a shortcut to call this + with the ``view_func`` argument. These are equivalent: + + .. code-block:: python + + @app.route("/") + def index(): + ... + + .. code-block:: python + + def index(): + ... + + app.add_url_rule("/", view_func=index) + + See :ref:`url-route-registrations`. + + The endpoint name for the route defaults to the name of the view + function if the ``endpoint`` parameter isn't passed. An error + will be raised if a function has already been registered for the + endpoint. + + The ``methods`` parameter defaults to ``["GET"]``. ``HEAD`` is + always added automatically, and ``OPTIONS`` is added + automatically by default. + + ``view_func`` does not necessarily need to be passed, but if the + rule should participate in routing an endpoint name must be + associated with a view function at some point with the + :meth:`endpoint` decorator. + + .. code-block:: python + + app.add_url_rule("/", endpoint="index") + + @app.endpoint("index") + def index(): + ... + + If ``view_func`` has a ``required_methods`` attribute, those + methods are added to the passed and automatic methods. If it + has a ``provide_automatic_methods`` attribute, it is used as the + default if the parameter is not passed. + + :param rule: The URL rule string. + :param endpoint: The endpoint name to associate with the rule + and view function. Used when routing and building URLs. + Defaults to ``view_func.__name__``. + :param view_func: The view function to associate with the + endpoint name. + :param provide_automatic_options: Add the ``OPTIONS`` method and + respond to ``OPTIONS`` requests automatically. + :param options: Extra options passed to the + :class:`~werkzeug.routing.Rule` object. + """ + raise NotImplementedError + + @setupmethod + def endpoint(self, endpoint: str) -> t.Callable[[F], F]: + """Decorate a view function to register it for the given + endpoint. Used if a rule is added without a ``view_func`` with + :meth:`add_url_rule`. + + .. code-block:: python + + app.add_url_rule("/ex", endpoint="example") + + @app.endpoint("example") + def example(): + ... + + :param endpoint: The endpoint name to associate with the view + function. + """ + + def decorator(f: F) -> F: + self.view_functions[endpoint] = f + return f + + return decorator + + @setupmethod + def before_request(self, f: T_before_request) -> T_before_request: + """Register a function to run before each request. + + For example, this can be used to open a database connection, or + to load the logged in user from the session. + + .. code-block:: python + + @app.before_request + def load_user(): + if "user_id" in session: + g.user = db.session.get(session["user_id"]) + + The function will be called without any arguments. If it returns + a non-``None`` value, the value is handled as if it was the + return value from the view, and further request handling is + stopped. + + This is available on both app and blueprint objects. When used on an app, this + executes before every request. When used on a blueprint, this executes before + every request that the blueprint handles. To register with a blueprint and + execute before every request, use :meth:`.Blueprint.before_app_request`. + """ + self.before_request_funcs.setdefault(None, []).append(f) + return f + + @setupmethod + def after_request(self, f: T_after_request) -> T_after_request: + """Register a function to run after each request to this object. + + The function is called with the response object, and must return + a response object. This allows the functions to modify or + replace the response before it is sent. + + If a function raises an exception, any remaining + ``after_request`` functions will not be called. Therefore, this + should not be used for actions that must execute, such as to + close resources. Use :meth:`teardown_request` for that. + + This is available on both app and blueprint objects. When used on an app, this + executes after every request. When used on a blueprint, this executes after + every request that the blueprint handles. To register with a blueprint and + execute after every request, use :meth:`.Blueprint.after_app_request`. + """ + self.after_request_funcs.setdefault(None, []).append(f) + return f + + @setupmethod + def teardown_request(self, f: T_teardown) -> T_teardown: + """Register a function to be called when the request context is + popped. Typically, this happens at the end of each request, but + contexts may be pushed manually during testing. + + .. code-block:: python + + with app.test_request_context(): + ... + + When the ``with`` block exits (or ``ctx.pop()`` is called), the + teardown functions are called just before the request context is + made inactive. + + When a teardown function was called because of an unhandled + exception it will be passed an error object. If an + :meth:`errorhandler` is registered, it will handle the exception + and the teardown will not receive it. + + Teardown functions must avoid raising exceptions. If they + execute code that might fail they must surround that code with a + ``try``/``except`` block and log any errors. + + The return values of teardown functions are ignored. + + This is available on both app and blueprint objects. When used on an app, this + executes after every request. When used on a blueprint, this executes after + every request that the blueprint handles. To register with a blueprint and + execute after every request, use :meth:`.Blueprint.teardown_app_request`. + """ + self.teardown_request_funcs.setdefault(None, []).append(f) + return f + + @setupmethod + def context_processor( + self, + f: T_template_context_processor, + ) -> T_template_context_processor: + """Registers a template context processor function. These functions run before + rendering a template. The keys of the returned dict are added as variables + available in the template. + + This is available on both app and blueprint objects. When used on an app, this + is called for every rendered template. When used on a blueprint, this is called + for templates rendered from the blueprint's views. To register with a blueprint + and affect every template, use :meth:`.Blueprint.app_context_processor`. + """ + self.template_context_processors[None].append(f) + return f + + @setupmethod + def url_value_preprocessor( + self, + f: T_url_value_preprocessor, + ) -> T_url_value_preprocessor: + """Register a URL value preprocessor function for all view + functions in the application. These functions will be called before the + :meth:`before_request` functions. + + The function can modify the values captured from the matched url before + they are passed to the view. For example, this can be used to pop a + common language code value and place it in ``g`` rather than pass it to + every view. + + The function is passed the endpoint name and values dict. The return + value is ignored. + + This is available on both app and blueprint objects. When used on an app, this + is called for every request. When used on a blueprint, this is called for + requests that the blueprint handles. To register with a blueprint and affect + every request, use :meth:`.Blueprint.app_url_value_preprocessor`. + """ + self.url_value_preprocessors[None].append(f) + return f + + @setupmethod + def url_defaults(self, f: T_url_defaults) -> T_url_defaults: + """Callback function for URL defaults for all view functions of the + application. It's called with the endpoint and values and should + update the values passed in place. + + This is available on both app and blueprint objects. When used on an app, this + is called for every request. When used on a blueprint, this is called for + requests that the blueprint handles. To register with a blueprint and affect + every request, use :meth:`.Blueprint.app_url_defaults`. + """ + self.url_default_functions[None].append(f) + return f + + @setupmethod + def errorhandler( + self, code_or_exception: type[Exception] | int + ) -> t.Callable[[T_error_handler], T_error_handler]: + """Register a function to handle errors by code or exception class. + + A decorator that is used to register a function given an + error code. Example:: + + @app.errorhandler(404) + def page_not_found(error): + return 'This page does not exist', 404 + + You can also register handlers for arbitrary exceptions:: + + @app.errorhandler(DatabaseError) + def special_exception_handler(error): + return 'Database connection failed', 500 + + This is available on both app and blueprint objects. When used on an app, this + can handle errors from every request. When used on a blueprint, this can handle + errors from requests that the blueprint handles. To register with a blueprint + and affect every request, use :meth:`.Blueprint.app_errorhandler`. + + .. versionadded:: 0.7 + Use :meth:`register_error_handler` instead of modifying + :attr:`error_handler_spec` directly, for application wide error + handlers. + + .. versionadded:: 0.7 + One can now additionally also register custom exception types + that do not necessarily have to be a subclass of the + :class:`~werkzeug.exceptions.HTTPException` class. + + :param code_or_exception: the code as integer for the handler, or + an arbitrary exception + """ + + def decorator(f: T_error_handler) -> T_error_handler: + self.register_error_handler(code_or_exception, f) + return f + + return decorator + + @setupmethod + def register_error_handler( + self, + code_or_exception: type[Exception] | int, + f: ft.ErrorHandlerCallable, + ) -> None: + """Alternative error attach function to the :meth:`errorhandler` + decorator that is more straightforward to use for non decorator + usage. + + .. versionadded:: 0.7 + """ + exc_class, code = self._get_exc_class_and_code(code_or_exception) + self.error_handler_spec[None][code][exc_class] = f + + @staticmethod + def _get_exc_class_and_code( + exc_class_or_code: type[Exception] | int, + ) -> tuple[type[Exception], int | None]: + """Get the exception class being handled. For HTTP status codes + or ``HTTPException`` subclasses, return both the exception and + status code. + + :param exc_class_or_code: Any exception class, or an HTTP status + code as an integer. + """ + exc_class: type[Exception] + + if isinstance(exc_class_or_code, int): + try: + exc_class = default_exceptions[exc_class_or_code] + except KeyError: + raise ValueError( + f"'{exc_class_or_code}' is not a recognized HTTP" + " error code. Use a subclass of HTTPException with" + " that code instead." + ) from None + else: + exc_class = exc_class_or_code + + if isinstance(exc_class, Exception): + raise TypeError( + f"{exc_class!r} is an instance, not a class. Handlers" + " can only be registered for Exception classes or HTTP" + " error codes." + ) + + if not issubclass(exc_class, Exception): + raise ValueError( + f"'{exc_class.__name__}' is not a subclass of Exception." + " Handlers can only be registered for Exception classes" + " or HTTP error codes." + ) + + if issubclass(exc_class, HTTPException): + return exc_class, exc_class.code + else: + return exc_class, None + + +def _endpoint_from_view_func(view_func: ft.RouteCallable) -> str: + """Internal helper that returns the default endpoint for a given + function. This always is the function name. + """ + assert view_func is not None, "expected view func if endpoint is not provided." + return view_func.__name__ + + +def _find_package_path(import_name: str) -> str: + """Find the path that contains the package or module.""" + root_mod_name, _, _ = import_name.partition(".") + + try: + root_spec = importlib.util.find_spec(root_mod_name) + + if root_spec is None: + raise ValueError("not found") + except (ImportError, ValueError): + # ImportError: the machinery told us it does not exist + # ValueError: + # - the module name was invalid + # - the module name is __main__ + # - we raised `ValueError` due to `root_spec` being `None` + return os.getcwd() + + if root_spec.submodule_search_locations: + if root_spec.origin is None or root_spec.origin == "namespace": + # namespace package + package_spec = importlib.util.find_spec(import_name) + + if package_spec is not None and package_spec.submodule_search_locations: + # Pick the path in the namespace that contains the submodule. + package_path = pathlib.Path( + os.path.commonpath(package_spec.submodule_search_locations) + ) + search_location = next( + location + for location in root_spec.submodule_search_locations + if package_path.is_relative_to(location) + ) + else: + # Pick the first path. + search_location = root_spec.submodule_search_locations[0] + + return os.path.dirname(search_location) + else: + # package with __init__.py + return os.path.dirname(os.path.dirname(root_spec.origin)) + else: + # module + return os.path.dirname(root_spec.origin) # type: ignore[type-var, return-value] + + +def find_package(import_name: str) -> tuple[str | None, str]: + """Find the prefix that a package is installed under, and the path + that it would be imported from. + + The prefix is the directory containing the standard directory + hierarchy (lib, bin, etc.). If the package is not installed to the + system (:attr:`sys.prefix`) or a virtualenv (``site-packages``), + ``None`` is returned. + + The path is the entry in :attr:`sys.path` that contains the package + for import. If the package is not installed, it's assumed that the + package was imported from the current working directory. + """ + package_path = _find_package_path(import_name) + py_prefix = os.path.abspath(sys.prefix) + + # installed to the system + if pathlib.PurePath(package_path).is_relative_to(py_prefix): + return py_prefix, package_path + + site_parent, site_folder = os.path.split(package_path) + + # installed to a virtualenv + if site_folder.lower() == "site-packages": + parent, folder = os.path.split(site_parent) + + # Windows (prefix/lib/site-packages) + if folder.lower() == "lib": + return parent, package_path + + # Unix (prefix/lib/pythonX.Y/site-packages) + if os.path.basename(parent).lower() == "lib": + return os.path.dirname(parent), package_path + + # something else (prefix/site-packages) + return site_parent, package_path + + # not installed + return None, package_path diff --git a/src/flask-main/src/flask/sessions.py b/src/flask-main/src/flask/sessions.py new file mode 100644 index 0000000..c1d978d --- /dev/null +++ b/src/flask-main/src/flask/sessions.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +import collections.abc as c +import hashlib +import typing as t +from collections.abc import MutableMapping +from datetime import datetime +from datetime import timezone + +from itsdangerous import BadSignature +from itsdangerous import URLSafeTimedSerializer +from werkzeug.datastructures import CallbackDict + +from .json.tag import TaggedJSONSerializer + +if t.TYPE_CHECKING: # pragma: no cover + import typing_extensions as te + + from .app import Flask + from .wrappers import Request + from .wrappers import Response + + +class SessionMixin(MutableMapping[str, t.Any]): + """Expands a basic dictionary with session attributes.""" + + @property + def permanent(self) -> bool: + """This reflects the ``'_permanent'`` key in the dict.""" + return self.get("_permanent", False) # type: ignore[no-any-return] + + @permanent.setter + def permanent(self, value: bool) -> None: + self["_permanent"] = bool(value) + + #: Some implementations can detect whether a session is newly + #: created, but that is not guaranteed. Use with caution. The mixin + # default is hard-coded ``False``. + new = False + + #: Some implementations can detect changes to the session and set + #: this when that happens. The mixin default is hard coded to + #: ``True``. + modified = True + + #: Some implementations can detect when session data is read or + #: written and set this when that happens. The mixin default is hard + #: coded to ``True``. + accessed = True + + +class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin): + """Base class for sessions based on signed cookies. + + This session backend will set the :attr:`modified` and + :attr:`accessed` attributes. It cannot reliably track whether a + session is new (vs. empty), so :attr:`new` remains hard coded to + ``False``. + """ + + #: When data is changed, this is set to ``True``. Only the session + #: dictionary itself is tracked; if the session contains mutable + #: data (for example a nested dict) then this must be set to + #: ``True`` manually when modifying that data. The session cookie + #: will only be written to the response if this is ``True``. + modified = False + + #: When data is read or written, this is set to ``True``. Used by + # :class:`.SecureCookieSessionInterface` to add a ``Vary: Cookie`` + #: header, which allows caching proxies to cache different pages for + #: different users. + accessed = False + + def __init__( + self, + initial: c.Mapping[str, t.Any] | c.Iterable[tuple[str, t.Any]] | None = None, + ) -> None: + def on_update(self: te.Self) -> None: + self.modified = True + self.accessed = True + + super().__init__(initial, on_update) + + def __getitem__(self, key: str) -> t.Any: + self.accessed = True + return super().__getitem__(key) + + def get(self, key: str, default: t.Any = None) -> t.Any: + self.accessed = True + return super().get(key, default) + + def setdefault(self, key: str, default: t.Any = None) -> t.Any: + self.accessed = True + return super().setdefault(key, default) + + +class NullSession(SecureCookieSession): + """Class used to generate nicer error messages if sessions are not + available. Will still allow read-only access to the empty session + but fail on setting. + """ + + def _fail(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn: + raise RuntimeError( + "The session is unavailable because no secret " + "key was set. Set the secret_key on the " + "application to something unique and secret." + ) + + __setitem__ = __delitem__ = clear = pop = popitem = update = setdefault = _fail # noqa: B950 + del _fail + + +class SessionInterface: + """The basic interface you have to implement in order to replace the + default session interface which uses werkzeug's securecookie + implementation. The only methods you have to implement are + :meth:`open_session` and :meth:`save_session`, the others have + useful defaults which you don't need to change. + + The session object returned by the :meth:`open_session` method has to + provide a dictionary like interface plus the properties and methods + from the :class:`SessionMixin`. We recommend just subclassing a dict + and adding that mixin:: + + class Session(dict, SessionMixin): + pass + + If :meth:`open_session` returns ``None`` Flask will call into + :meth:`make_null_session` to create a session that acts as replacement + if the session support cannot work because some requirement is not + fulfilled. The default :class:`NullSession` class that is created + will complain that the secret key was not set. + + To replace the session interface on an application all you have to do + is to assign :attr:`flask.Flask.session_interface`:: + + app = Flask(__name__) + app.session_interface = MySessionInterface() + + Multiple requests with the same session may be sent and handled + concurrently. When implementing a new session interface, consider + whether reads or writes to the backing store must be synchronized. + There is no guarantee on the order in which the session for each + request is opened or saved, it will occur in the order that requests + begin and end processing. + + .. versionadded:: 0.8 + """ + + #: :meth:`make_null_session` will look here for the class that should + #: be created when a null session is requested. Likewise the + #: :meth:`is_null_session` method will perform a typecheck against + #: this type. + null_session_class = NullSession + + #: A flag that indicates if the session interface is pickle based. + #: This can be used by Flask extensions to make a decision in regards + #: to how to deal with the session object. + #: + #: .. versionadded:: 0.10 + pickle_based = False + + def make_null_session(self, app: Flask) -> NullSession: + """Creates a null session which acts as a replacement object if the + real session support could not be loaded due to a configuration + error. This mainly aids the user experience because the job of the + null session is to still support lookup without complaining but + modifications are answered with a helpful error message of what + failed. + + This creates an instance of :attr:`null_session_class` by default. + """ + return self.null_session_class() + + def is_null_session(self, obj: object) -> bool: + """Checks if a given object is a null session. Null sessions are + not asked to be saved. + + This checks if the object is an instance of :attr:`null_session_class` + by default. + """ + return isinstance(obj, self.null_session_class) + + def get_cookie_name(self, app: Flask) -> str: + """The name of the session cookie. Uses``app.config["SESSION_COOKIE_NAME"]``.""" + return app.config["SESSION_COOKIE_NAME"] # type: ignore[no-any-return] + + def get_cookie_domain(self, app: Flask) -> str | None: + """The value of the ``Domain`` parameter on the session cookie. If not set, + browsers will only send the cookie to the exact domain it was set from. + Otherwise, they will send it to any subdomain of the given value as well. + + Uses the :data:`SESSION_COOKIE_DOMAIN` config. + + .. versionchanged:: 2.3 + Not set by default, does not fall back to ``SERVER_NAME``. + """ + return app.config["SESSION_COOKIE_DOMAIN"] # type: ignore[no-any-return] + + def get_cookie_path(self, app: Flask) -> str: + """Returns the path for which the cookie should be valid. The + default implementation uses the value from the ``SESSION_COOKIE_PATH`` + config var if it's set, and falls back to ``APPLICATION_ROOT`` or + uses ``/`` if it's ``None``. + """ + return app.config["SESSION_COOKIE_PATH"] or app.config["APPLICATION_ROOT"] # type: ignore[no-any-return] + + def get_cookie_httponly(self, app: Flask) -> bool: + """Returns True if the session cookie should be httponly. This + currently just returns the value of the ``SESSION_COOKIE_HTTPONLY`` + config var. + """ + return app.config["SESSION_COOKIE_HTTPONLY"] # type: ignore[no-any-return] + + def get_cookie_secure(self, app: Flask) -> bool: + """Returns True if the cookie should be secure. This currently + just returns the value of the ``SESSION_COOKIE_SECURE`` setting. + """ + return app.config["SESSION_COOKIE_SECURE"] # type: ignore[no-any-return] + + def get_cookie_samesite(self, app: Flask) -> str | None: + """Return ``'Strict'`` or ``'Lax'`` if the cookie should use the + ``SameSite`` attribute. This currently just returns the value of + the :data:`SESSION_COOKIE_SAMESITE` setting. + """ + return app.config["SESSION_COOKIE_SAMESITE"] # type: ignore[no-any-return] + + def get_cookie_partitioned(self, app: Flask) -> bool: + """Returns True if the cookie should be partitioned. By default, uses + the value of :data:`SESSION_COOKIE_PARTITIONED`. + + .. versionadded:: 3.1 + """ + return app.config["SESSION_COOKIE_PARTITIONED"] # type: ignore[no-any-return] + + def get_expiration_time(self, app: Flask, session: SessionMixin) -> datetime | None: + """A helper method that returns an expiration date for the session + or ``None`` if the session is linked to the browser session. The + default implementation returns now + the permanent session + lifetime configured on the application. + """ + if session.permanent: + return datetime.now(timezone.utc) + app.permanent_session_lifetime + return None + + def should_set_cookie(self, app: Flask, session: SessionMixin) -> bool: + """Used by session backends to determine if a ``Set-Cookie`` header + should be set for this session cookie for this response. If the session + has been modified, the cookie is set. If the session is permanent and + the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the cookie is + always set. + + This check is usually skipped if the session was deleted. + + .. versionadded:: 0.11 + """ + + return session.modified or ( + session.permanent and app.config["SESSION_REFRESH_EACH_REQUEST"] + ) + + def open_session(self, app: Flask, request: Request) -> SessionMixin | None: + """This is called at the beginning of each request, after + pushing the request context, before matching the URL. + + This must return an object which implements a dictionary-like + interface as well as the :class:`SessionMixin` interface. + + This will return ``None`` to indicate that loading failed in + some way that is not immediately an error. The request + context will fall back to using :meth:`make_null_session` + in this case. + """ + raise NotImplementedError() + + def save_session( + self, app: Flask, session: SessionMixin, response: Response + ) -> None: + """This is called at the end of each request, after generating + a response, before removing the request context. It is skipped + if :meth:`is_null_session` returns ``True``. + """ + raise NotImplementedError() + + +session_json_serializer = TaggedJSONSerializer() + + +def _lazy_sha1(string: bytes = b"") -> t.Any: + """Don't access ``hashlib.sha1`` until runtime. FIPS builds may not include + SHA-1, in which case the import and use as a default would fail before the + developer can configure something else. + """ + return hashlib.sha1(string) + + +class SecureCookieSessionInterface(SessionInterface): + """The default session interface that stores sessions in signed cookies + through the :mod:`itsdangerous` module. + """ + + #: the salt that should be applied on top of the secret key for the + #: signing of cookie based sessions. + salt = "cookie-session" + #: the hash function to use for the signature. The default is sha1 + digest_method = staticmethod(_lazy_sha1) + #: the name of the itsdangerous supported key derivation. The default + #: is hmac. + key_derivation = "hmac" + #: A python serializer for the payload. The default is a compact + #: JSON derived serializer with support for some extra Python types + #: such as datetime objects or tuples. + serializer = session_json_serializer + session_class = SecureCookieSession + + def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None: + if not app.secret_key: + return None + + keys: list[str | bytes] = [] + + if fallbacks := app.config["SECRET_KEY_FALLBACKS"]: + keys.extend(fallbacks) + + keys.append(app.secret_key) # itsdangerous expects current key at top + return URLSafeTimedSerializer( + keys, # type: ignore[arg-type] + salt=self.salt, + serializer=self.serializer, + signer_kwargs={ + "key_derivation": self.key_derivation, + "digest_method": self.digest_method, + }, + ) + + def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None: + s = self.get_signing_serializer(app) + if s is None: + return None + val = request.cookies.get(self.get_cookie_name(app)) + if not val: + return self.session_class() + max_age = int(app.permanent_session_lifetime.total_seconds()) + try: + data = s.loads(val, max_age=max_age) + return self.session_class(data) + except BadSignature: + return self.session_class() + + def save_session( + self, app: Flask, session: SessionMixin, response: Response + ) -> None: + name = self.get_cookie_name(app) + domain = self.get_cookie_domain(app) + path = self.get_cookie_path(app) + secure = self.get_cookie_secure(app) + partitioned = self.get_cookie_partitioned(app) + samesite = self.get_cookie_samesite(app) + httponly = self.get_cookie_httponly(app) + + # Add a "Vary: Cookie" header if the session was accessed at all. + if session.accessed: + response.vary.add("Cookie") + + # If the session is modified to be empty, remove the cookie. + # If the session is empty, return without setting the cookie. + if not session: + if session.modified: + response.delete_cookie( + name, + domain=domain, + path=path, + secure=secure, + partitioned=partitioned, + samesite=samesite, + httponly=httponly, + ) + response.vary.add("Cookie") + + return + + if not self.should_set_cookie(app, session): + return + + expires = self.get_expiration_time(app, session) + val = self.get_signing_serializer(app).dumps(dict(session)) # type: ignore[union-attr] + response.set_cookie( + name, + val, + expires=expires, + httponly=httponly, + domain=domain, + path=path, + secure=secure, + partitioned=partitioned, + samesite=samesite, + ) + response.vary.add("Cookie") diff --git a/src/flask-main/src/flask/signals.py b/src/flask-main/src/flask/signals.py new file mode 100644 index 0000000..444fda9 --- /dev/null +++ b/src/flask-main/src/flask/signals.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from blinker import Namespace + +# This namespace is only for signals provided by Flask itself. +_signals = Namespace() + +template_rendered = _signals.signal("template-rendered") +before_render_template = _signals.signal("before-render-template") +request_started = _signals.signal("request-started") +request_finished = _signals.signal("request-finished") +request_tearing_down = _signals.signal("request-tearing-down") +got_request_exception = _signals.signal("got-request-exception") +appcontext_tearing_down = _signals.signal("appcontext-tearing-down") +appcontext_pushed = _signals.signal("appcontext-pushed") +appcontext_popped = _signals.signal("appcontext-popped") +message_flashed = _signals.signal("message-flashed") diff --git a/src/flask-main/src/flask/templating.py b/src/flask-main/src/flask/templating.py new file mode 100644 index 0000000..4bb86d5 --- /dev/null +++ b/src/flask-main/src/flask/templating.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import typing as t + +from jinja2 import BaseLoader +from jinja2 import Environment as BaseEnvironment +from jinja2 import Template +from jinja2 import TemplateNotFound + +from .ctx import AppContext +from .globals import app_ctx +from .helpers import stream_with_context +from .signals import before_render_template +from .signals import template_rendered + +if t.TYPE_CHECKING: # pragma: no cover + from .sansio.app import App + from .sansio.scaffold import Scaffold + + +def _default_template_ctx_processor() -> dict[str, t.Any]: + """Default template context processor. Injects `request`, + `session` and `g`. + """ + ctx = app_ctx._get_current_object() + rv: dict[str, t.Any] = {"g": ctx.g} + + if ctx.has_request: + rv["request"] = ctx.request + rv["session"] = ctx.session + + return rv + + +class Environment(BaseEnvironment): + """Works like a regular Jinja environment but has some additional + knowledge of how Flask's blueprint works so that it can prepend the + name of the blueprint to referenced templates if necessary. + """ + + def __init__(self, app: App, **options: t.Any) -> None: + if "loader" not in options: + options["loader"] = app.create_global_jinja_loader() + BaseEnvironment.__init__(self, **options) + self.app = app + + +class DispatchingJinjaLoader(BaseLoader): + """A loader that looks for templates in the application and all + the blueprint folders. + """ + + def __init__(self, app: App) -> None: + self.app = app + + def get_source( + self, environment: BaseEnvironment, template: str + ) -> tuple[str, str | None, t.Callable[[], bool] | None]: + if self.app.config["EXPLAIN_TEMPLATE_LOADING"]: + return self._get_source_explained(environment, template) + return self._get_source_fast(environment, template) + + def _get_source_explained( + self, environment: BaseEnvironment, template: str + ) -> tuple[str, str | None, t.Callable[[], bool] | None]: + attempts = [] + rv: tuple[str, str | None, t.Callable[[], bool] | None] | None + trv: None | (tuple[str, str | None, t.Callable[[], bool] | None]) = None + + for srcobj, loader in self._iter_loaders(template): + try: + rv = loader.get_source(environment, template) + if trv is None: + trv = rv + except TemplateNotFound: + rv = None + attempts.append((loader, srcobj, rv)) + + from .debughelpers import explain_template_loading_attempts + + explain_template_loading_attempts(self.app, template, attempts) + + if trv is not None: + return trv + raise TemplateNotFound(template) + + def _get_source_fast( + self, environment: BaseEnvironment, template: str + ) -> tuple[str, str | None, t.Callable[[], bool] | None]: + for _srcobj, loader in self._iter_loaders(template): + try: + return loader.get_source(environment, template) + except TemplateNotFound: + continue + raise TemplateNotFound(template) + + def _iter_loaders(self, template: str) -> t.Iterator[tuple[Scaffold, BaseLoader]]: + loader = self.app.jinja_loader + if loader is not None: + yield self.app, loader + + for blueprint in self.app.iter_blueprints(): + loader = blueprint.jinja_loader + if loader is not None: + yield blueprint, loader + + def list_templates(self) -> list[str]: + result = set() + loader = self.app.jinja_loader + if loader is not None: + result.update(loader.list_templates()) + + for blueprint in self.app.iter_blueprints(): + loader = blueprint.jinja_loader + if loader is not None: + for template in loader.list_templates(): + result.add(template) + + return list(result) + + +def _render(ctx: AppContext, template: Template, context: dict[str, t.Any]) -> str: + app = ctx.app + app.update_template_context(ctx, context) + before_render_template.send( + app, _async_wrapper=app.ensure_sync, template=template, context=context + ) + rv = template.render(context) + template_rendered.send( + app, _async_wrapper=app.ensure_sync, template=template, context=context + ) + return rv + + +def render_template( + template_name_or_list: str | Template | list[str | Template], + **context: t.Any, +) -> str: + """Render a template by name with the given context. + + :param template_name_or_list: The name of the template to render. If + a list is given, the first name to exist will be rendered. + :param context: The variables to make available in the template. + """ + ctx = app_ctx._get_current_object() + template = ctx.app.jinja_env.get_or_select_template(template_name_or_list) + return _render(ctx, template, context) + + +def render_template_string(source: str, **context: t.Any) -> str: + """Render a template from the given source string with the given + context. + + :param source: The source code of the template to render. + :param context: The variables to make available in the template. + """ + ctx = app_ctx._get_current_object() + template = ctx.app.jinja_env.from_string(source) + return _render(ctx, template, context) + + +def _stream( + ctx: AppContext, template: Template, context: dict[str, t.Any] +) -> t.Iterator[str]: + app = ctx.app + app.update_template_context(ctx, context) + before_render_template.send( + app, _async_wrapper=app.ensure_sync, template=template, context=context + ) + + def generate() -> t.Iterator[str]: + yield from template.generate(context) + template_rendered.send( + app, _async_wrapper=app.ensure_sync, template=template, context=context + ) + + return stream_with_context(generate()) + + +def stream_template( + template_name_or_list: str | Template | list[str | Template], + **context: t.Any, +) -> t.Iterator[str]: + """Render a template by name with the given context as a stream. + This returns an iterator of strings, which can be used as a + streaming response from a view. + + :param template_name_or_list: The name of the template to render. If + a list is given, the first name to exist will be rendered. + :param context: The variables to make available in the template. + + .. versionadded:: 2.2 + """ + ctx = app_ctx._get_current_object() + template = ctx.app.jinja_env.get_or_select_template(template_name_or_list) + return _stream(ctx, template, context) + + +def stream_template_string(source: str, **context: t.Any) -> t.Iterator[str]: + """Render a template from the given source string with the given + context as a stream. This returns an iterator of strings, which can + be used as a streaming response from a view. + + :param source: The source code of the template to render. + :param context: The variables to make available in the template. + + .. versionadded:: 2.2 + """ + ctx = app_ctx._get_current_object() + template = ctx.app.jinja_env.from_string(source) + return _stream(ctx, template, context) diff --git a/src/flask-main/src/flask/testing.py b/src/flask-main/src/flask/testing.py new file mode 100644 index 0000000..68b1ab4 --- /dev/null +++ b/src/flask-main/src/flask/testing.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import importlib.metadata +import typing as t +from contextlib import contextmanager +from contextlib import ExitStack +from copy import copy +from types import TracebackType +from urllib.parse import urlsplit + +import werkzeug.test +from click.testing import CliRunner +from click.testing import Result +from werkzeug.test import Client +from werkzeug.wrappers import Request as BaseRequest + +from .cli import ScriptInfo +from .sessions import SessionMixin + +if t.TYPE_CHECKING: # pragma: no cover + from _typeshed.wsgi import WSGIEnvironment + from werkzeug.test import TestResponse + + from .app import Flask + + +class EnvironBuilder(werkzeug.test.EnvironBuilder): + """An :class:`~werkzeug.test.EnvironBuilder`, that takes defaults from the + application. + + :param app: The Flask application to configure the environment from. + :param path: URL path being requested. + :param base_url: Base URL where the app is being served, which + ``path`` is relative to. If not given, built from + :data:`PREFERRED_URL_SCHEME`, ``subdomain``, + :data:`SERVER_NAME`, and :data:`APPLICATION_ROOT`. + :param subdomain: Subdomain name to append to :data:`SERVER_NAME`. + :param url_scheme: Scheme to use instead of + :data:`PREFERRED_URL_SCHEME`. + :param json: If given, this is serialized as JSON and passed as + ``data``. Also defaults ``content_type`` to + ``application/json``. + :param args: other positional arguments passed to + :class:`~werkzeug.test.EnvironBuilder`. + :param kwargs: other keyword arguments passed to + :class:`~werkzeug.test.EnvironBuilder`. + """ + + def __init__( + self, + app: Flask, + path: str = "/", + base_url: str | None = None, + subdomain: str | None = None, + url_scheme: str | None = None, + *args: t.Any, + **kwargs: t.Any, + ) -> None: + assert not (base_url or subdomain or url_scheme) or ( + base_url is not None + ) != bool(subdomain or url_scheme), ( + 'Cannot pass "subdomain" or "url_scheme" with "base_url".' + ) + + if base_url is None: + http_host = app.config.get("SERVER_NAME") or "localhost" + app_root = app.config["APPLICATION_ROOT"] + + if subdomain: + http_host = f"{subdomain}.{http_host}" + + if url_scheme is None: + url_scheme = app.config["PREFERRED_URL_SCHEME"] + + url = urlsplit(path) + base_url = ( + f"{url.scheme or url_scheme}://{url.netloc or http_host}" + f"/{app_root.lstrip('/')}" + ) + path = url.path + + if url.query: + path = f"{path}?{url.query}" + + self.app = app + super().__init__(path, base_url, *args, **kwargs) + + def json_dumps(self, obj: t.Any, **kwargs: t.Any) -> str: + """Serialize ``obj`` to a JSON-formatted string. + + The serialization will be configured according to the config associated + with this EnvironBuilder's ``app``. + """ + return self.app.json.dumps(obj, **kwargs) + + +_werkzeug_version = "" + + +def _get_werkzeug_version() -> str: + global _werkzeug_version + + if not _werkzeug_version: + _werkzeug_version = importlib.metadata.version("werkzeug") + + return _werkzeug_version + + +class FlaskClient(Client): + """Works like a regular Werkzeug test client, with additional behavior for + Flask. Can defer the cleanup of the request context until the end of a + ``with`` block. For general information about how to use this class refer to + :class:`werkzeug.test.Client`. + + .. versionchanged:: 0.12 + `app.test_client()` includes preset default environment, which can be + set after instantiation of the `app.test_client()` object in + `client.environ_base`. + + Basic usage is outlined in the :doc:`/testing` chapter. + """ + + application: Flask + + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: + super().__init__(*args, **kwargs) + self.preserve_context = False + self._new_contexts: list[t.ContextManager[t.Any]] = [] + self._context_stack = ExitStack() + self.environ_base = { + "REMOTE_ADDR": "127.0.0.1", + "HTTP_USER_AGENT": f"Werkzeug/{_get_werkzeug_version()}", + } + + @contextmanager + def session_transaction( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Iterator[SessionMixin]: + """When used in combination with a ``with`` statement this opens a + session transaction. This can be used to modify the session that + the test client uses. Once the ``with`` block is left the session is + stored back. + + :: + + with client.session_transaction() as session: + session['value'] = 42 + + Internally this is implemented by going through a temporary test + request context and since session handling could depend on + request variables this function accepts the same arguments as + :meth:`~flask.Flask.test_request_context` which are directly + passed through. + """ + if self._cookies is None: + raise TypeError( + "Cookies are disabled. Create a client with 'use_cookies=True'." + ) + + app = self.application + ctx = app.test_request_context(*args, **kwargs) + self._add_cookies_to_wsgi(ctx.request.environ) + + with ctx: + sess = app.session_interface.open_session(app, ctx.request) + + if sess is None: + raise RuntimeError("Session backend did not open a session.") + + yield sess + resp = app.response_class() + + if app.session_interface.is_null_session(sess): + return + + with ctx: + app.session_interface.save_session(app, sess, resp) + + self._update_cookies_from_response( + ctx.request.host.partition(":")[0], + ctx.request.path, + resp.headers.getlist("Set-Cookie"), + ) + + def _copy_environ(self, other: WSGIEnvironment) -> WSGIEnvironment: + out = {**self.environ_base, **other} + + if self.preserve_context: + out["werkzeug.debug.preserve_context"] = self._new_contexts.append + + return out + + def _request_from_builder_args( + self, args: tuple[t.Any, ...], kwargs: dict[str, t.Any] + ) -> BaseRequest: + kwargs["environ_base"] = self._copy_environ(kwargs.get("environ_base", {})) + builder = EnvironBuilder(self.application, *args, **kwargs) + + try: + return builder.get_request() + finally: + builder.close() + + def open( + self, + *args: t.Any, + buffered: bool = False, + follow_redirects: bool = False, + **kwargs: t.Any, + ) -> TestResponse: + if args and isinstance( + args[0], (werkzeug.test.EnvironBuilder, dict, BaseRequest) + ): + if isinstance(args[0], werkzeug.test.EnvironBuilder): + builder = copy(args[0]) + builder.environ_base = self._copy_environ(builder.environ_base or {}) # type: ignore[arg-type] + request = builder.get_request() + elif isinstance(args[0], dict): + request = EnvironBuilder.from_environ( + args[0], app=self.application, environ_base=self._copy_environ({}) + ).get_request() + else: + # isinstance(args[0], BaseRequest) + request = copy(args[0]) + request.environ = self._copy_environ(request.environ) + else: + # request is None + request = self._request_from_builder_args(args, kwargs) + + # Pop any previously preserved contexts. This prevents contexts + # from being preserved across redirects or multiple requests + # within a single block. + self._context_stack.close() + + response = super().open( + request, + buffered=buffered, + follow_redirects=follow_redirects, + ) + response.json_module = self.application.json # type: ignore[assignment] + + # Re-push contexts that were preserved during the request. + for cm in self._new_contexts: + self._context_stack.enter_context(cm) + + self._new_contexts.clear() + return response + + def __enter__(self) -> FlaskClient: + if self.preserve_context: + raise RuntimeError("Cannot nest client invocations") + self.preserve_context = True + return self + + def __exit__( + self, + exc_type: type | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.preserve_context = False + self._context_stack.close() + + +class FlaskCliRunner(CliRunner): + """A :class:`~click.testing.CliRunner` for testing a Flask app's + CLI commands. Typically created using + :meth:`~flask.Flask.test_cli_runner`. See :ref:`testing-cli`. + """ + + def __init__(self, app: Flask, **kwargs: t.Any) -> None: + self.app = app + super().__init__(**kwargs) + + def invoke( # type: ignore + self, cli: t.Any = None, args: t.Any = None, **kwargs: t.Any + ) -> Result: + """Invokes a CLI command in an isolated environment. See + :meth:`CliRunner.invoke ` for + full method documentation. See :ref:`testing-cli` for examples. + + If the ``obj`` argument is not given, passes an instance of + :class:`~flask.cli.ScriptInfo` that knows how to load the Flask + app being tested. + + :param cli: Command object to invoke. Default is the app's + :attr:`~flask.app.Flask.cli` group. + :param args: List of strings to invoke the command with. + + :return: a :class:`~click.testing.Result` object. + """ + if cli is None: + cli = self.app.cli + + if "obj" not in kwargs: + kwargs["obj"] = ScriptInfo(create_app=lambda: self.app) + + return super().invoke(cli, args, **kwargs) diff --git a/src/flask-main/src/flask/typing.py b/src/flask-main/src/flask/typing.py new file mode 100644 index 0000000..5495061 --- /dev/null +++ b/src/flask-main/src/flask/typing.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import collections.abc as cabc +import typing as t + +if t.TYPE_CHECKING: # pragma: no cover + from _typeshed.wsgi import WSGIApplication # noqa: F401 + from werkzeug.datastructures import Headers # noqa: F401 + from werkzeug.sansio.response import Response # noqa: F401 + +# The possible types that are directly convertible or are a Response object. +ResponseValue = t.Union[ + "Response", + str, + bytes, + list[t.Any], + # Only dict is actually accepted, but Mapping allows for TypedDict. + t.Mapping[str, t.Any], + t.Iterator[str], + t.Iterator[bytes], + cabc.AsyncIterable[str], # for Quart, until App is generic. + cabc.AsyncIterable[bytes], +] + +# the possible types for an individual HTTP header +HeaderValue = str | list[str] | tuple[str, ...] + +# the possible types for HTTP headers +HeadersValue = t.Union[ + "Headers", + t.Mapping[str, HeaderValue], + t.Sequence[tuple[str, HeaderValue]], +] + +# The possible types returned by a route function. +ResponseReturnValue = t.Union[ + ResponseValue, + tuple[ResponseValue, HeadersValue], + tuple[ResponseValue, int], + tuple[ResponseValue, int, HeadersValue], + "WSGIApplication", +] + +# Allow any subclass of werkzeug.Response, such as the one from Flask, +# as a callback argument. Using werkzeug.Response directly makes a +# callback annotated with flask.Response fail type checking. +ResponseClass = t.TypeVar("ResponseClass", bound="Response") + +AppOrBlueprintKey = str | None # The App key is None, whereas blueprints are named +AfterRequestCallable = ( + t.Callable[[ResponseClass], ResponseClass] + | t.Callable[[ResponseClass], t.Awaitable[ResponseClass]] +) +BeforeFirstRequestCallable = t.Callable[[], None] | t.Callable[[], t.Awaitable[None]] +BeforeRequestCallable = ( + t.Callable[[], ResponseReturnValue | None] + | t.Callable[[], t.Awaitable[ResponseReturnValue | None]] +) +ShellContextProcessorCallable = t.Callable[[], dict[str, t.Any]] +TeardownCallable = ( + t.Callable[[BaseException | None], None] + | t.Callable[[BaseException | None], t.Awaitable[None]] +) +TemplateContextProcessorCallable = ( + t.Callable[[], dict[str, t.Any]] | t.Callable[[], t.Awaitable[dict[str, t.Any]]] +) +TemplateFilterCallable = t.Callable[..., t.Any] +TemplateGlobalCallable = t.Callable[..., t.Any] +TemplateTestCallable = t.Callable[..., bool] +URLDefaultCallable = t.Callable[[str, dict[str, t.Any]], None] +URLValuePreprocessorCallable = t.Callable[[str | None, dict[str, t.Any] | None], None] + +# This should take Exception, but that either breaks typing the argument +# with a specific exception, or decorating multiple times with different +# exceptions (and using a union type on the argument). +# https://github.com/pallets/flask/issues/4095 +# https://github.com/pallets/flask/issues/4295 +# https://github.com/pallets/flask/issues/4297 +ErrorHandlerCallable = ( + t.Callable[[t.Any], ResponseReturnValue] + | t.Callable[[t.Any], t.Awaitable[ResponseReturnValue]] +) + +RouteCallable = ( + t.Callable[..., ResponseReturnValue] + | t.Callable[..., t.Awaitable[ResponseReturnValue]] +) diff --git a/src/flask-main/src/flask/views.py b/src/flask-main/src/flask/views.py new file mode 100644 index 0000000..53fe976 --- /dev/null +++ b/src/flask-main/src/flask/views.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import typing as t + +from . import typing as ft +from .globals import current_app +from .globals import request + +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +http_method_funcs = frozenset( + ["get", "post", "head", "options", "delete", "put", "trace", "patch"] +) + + +class View: + """Subclass this class and override :meth:`dispatch_request` to + create a generic class-based view. Call :meth:`as_view` to create a + view function that creates an instance of the class with the given + arguments and calls its ``dispatch_request`` method with any URL + variables. + + See :doc:`views` for a detailed guide. + + .. code-block:: python + + class Hello(View): + init_every_request = False + + def dispatch_request(self, name): + return f"Hello, {name}!" + + app.add_url_rule( + "/hello/", view_func=Hello.as_view("hello") + ) + + Set :attr:`methods` on the class to change what methods the view + accepts. + + Set :attr:`decorators` on the class to apply a list of decorators to + the generated view function. Decorators applied to the class itself + will not be applied to the generated view function! + + Set :attr:`init_every_request` to ``False`` for efficiency, unless + you need to store request-global data on ``self``. + """ + + #: The methods this view is registered for. Uses the same default + #: (``["GET", "HEAD", "OPTIONS"]``) as ``route`` and + #: ``add_url_rule`` by default. + methods: t.ClassVar[t.Collection[str] | None] = None + + #: Control whether the ``OPTIONS`` method is handled automatically. + #: Uses the same default (``True``) as ``route`` and + #: ``add_url_rule`` by default. + provide_automatic_options: t.ClassVar[bool | None] = None + + #: A list of decorators to apply, in order, to the generated view + #: function. Remember that ``@decorator`` syntax is applied bottom + #: to top, so the first decorator in the list would be the bottom + #: decorator. + #: + #: .. versionadded:: 0.8 + decorators: t.ClassVar[list[t.Callable[..., t.Any]]] = [] + + #: Create a new instance of this view class for every request by + #: default. If a view subclass sets this to ``False``, the same + #: instance is used for every request. + #: + #: A single instance is more efficient, especially if complex setup + #: is done during init. However, storing data on ``self`` is no + #: longer safe across requests, and :data:`~flask.g` should be used + #: instead. + #: + #: .. versionadded:: 2.2 + init_every_request: t.ClassVar[bool] = True + + def dispatch_request(self) -> ft.ResponseReturnValue: + """The actual view function behavior. Subclasses must override + this and return a valid response. Any variables from the URL + rule are passed as keyword arguments. + """ + raise NotImplementedError() + + @classmethod + def as_view( + cls, name: str, *class_args: t.Any, **class_kwargs: t.Any + ) -> ft.RouteCallable: + """Convert the class into a view function that can be registered + for a route. + + By default, the generated view will create a new instance of the + view class for every request and call its + :meth:`dispatch_request` method. If the view class sets + :attr:`init_every_request` to ``False``, the same instance will + be used for every request. + + Except for ``name``, all other arguments passed to this method + are forwarded to the view class ``__init__`` method. + + .. versionchanged:: 2.2 + Added the ``init_every_request`` class attribute. + """ + if cls.init_every_request: + + def view(**kwargs: t.Any) -> ft.ResponseReturnValue: + self = view.view_class( # type: ignore[attr-defined] + *class_args, **class_kwargs + ) + return current_app.ensure_sync(self.dispatch_request)(**kwargs) # type: ignore[no-any-return] + + else: + self = cls(*class_args, **class_kwargs) # pyright: ignore + + def view(**kwargs: t.Any) -> ft.ResponseReturnValue: + return current_app.ensure_sync(self.dispatch_request)(**kwargs) # type: ignore[no-any-return] + + if cls.decorators: + view.__name__ = name + view.__module__ = cls.__module__ + for decorator in cls.decorators: + view = decorator(view) + + # We attach the view class to the view function for two reasons: + # first of all it allows us to easily figure out what class-based + # view this thing came from, secondly it's also used for instantiating + # the view class so you can actually replace it with something else + # for testing purposes and debugging. + view.view_class = cls # type: ignore + view.__name__ = name + view.__doc__ = cls.__doc__ + view.__module__ = cls.__module__ + view.methods = cls.methods # type: ignore + view.provide_automatic_options = cls.provide_automatic_options # type: ignore + return view + + +class MethodView(View): + """Dispatches request methods to the corresponding instance methods. + For example, if you implement a ``get`` method, it will be used to + handle ``GET`` requests. + + This can be useful for defining a REST API. + + :attr:`methods` is automatically set based on the methods defined on + the class. + + See :doc:`views` for a detailed guide. + + .. code-block:: python + + class CounterAPI(MethodView): + def get(self): + return str(session.get("counter", 0)) + + def post(self): + session["counter"] = session.get("counter", 0) + 1 + return redirect(url_for("counter")) + + app.add_url_rule( + "/counter", view_func=CounterAPI.as_view("counter") + ) + """ + + def __init_subclass__(cls, **kwargs: t.Any) -> None: + super().__init_subclass__(**kwargs) + + if "methods" not in cls.__dict__: + methods = set() + + for base in cls.__bases__: + if getattr(base, "methods", None): + methods.update(base.methods) # type: ignore[attr-defined] + + for key in http_method_funcs: + if hasattr(cls, key): + methods.add(key.upper()) + + if methods: + cls.methods = methods + + def dispatch_request(self, **kwargs: t.Any) -> ft.ResponseReturnValue: + meth = getattr(self, request.method.lower(), None) + + # If the request method is HEAD and we don't have a handler for it + # retry with GET. + if meth is None and request.method == "HEAD": + meth = getattr(self, "get", None) + + assert meth is not None, f"Unimplemented method {request.method!r}" + return current_app.ensure_sync(meth)(**kwargs) # type: ignore[no-any-return] diff --git a/src/flask-main/src/flask/wrappers.py b/src/flask-main/src/flask/wrappers.py new file mode 100644 index 0000000..bab6102 --- /dev/null +++ b/src/flask-main/src/flask/wrappers.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import typing as t + +from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import HTTPException +from werkzeug.wrappers import Request as RequestBase +from werkzeug.wrappers import Response as ResponseBase + +from . import json +from .globals import current_app +from .helpers import _split_blueprint_path + +if t.TYPE_CHECKING: # pragma: no cover + from werkzeug.routing import Rule + + +class Request(RequestBase): + """The request object used by default in Flask. Remembers the + matched endpoint and view arguments. + + It is what ends up as :class:`~flask.request`. If you want to replace + the request object used you can subclass this and set + :attr:`~flask.Flask.request_class` to your subclass. + + The request object is a :class:`~werkzeug.wrappers.Request` subclass and + provides all of the attributes Werkzeug defines plus a few Flask + specific ones. + """ + + json_module: t.Any = json + + #: The internal URL rule that matched the request. This can be + #: useful to inspect which methods are allowed for the URL from + #: a before/after handler (``request.url_rule.methods``) etc. + #: Though if the request's method was invalid for the URL rule, + #: the valid list is available in ``routing_exception.valid_methods`` + #: instead (an attribute of the Werkzeug exception + #: :exc:`~werkzeug.exceptions.MethodNotAllowed`) + #: because the request was never internally bound. + #: + #: .. versionadded:: 0.6 + url_rule: Rule | None = None + + #: A dict of view arguments that matched the request. If an exception + #: happened when matching, this will be ``None``. + view_args: dict[str, t.Any] | None = None + + #: If matching the URL failed, this is the exception that will be + #: raised / was raised as part of the request handling. This is + #: usually a :exc:`~werkzeug.exceptions.NotFound` exception or + #: something similar. + routing_exception: HTTPException | None = None + + _max_content_length: int | None = None + _max_form_memory_size: int | None = None + _max_form_parts: int | None = None + + @property + def max_content_length(self) -> int | None: + """The maximum number of bytes that will be read during this request. If + this limit is exceeded, a 413 :exc:`~werkzeug.exceptions.RequestEntityTooLarge` + error is raised. If it is set to ``None``, no limit is enforced at the + Flask application level. However, if it is ``None`` and the request has + no ``Content-Length`` header and the WSGI server does not indicate that + it terminates the stream, then no data is read to avoid an infinite + stream. + + Each request defaults to the :data:`MAX_CONTENT_LENGTH` config, which + defaults to ``None``. It can be set on a specific ``request`` to apply + the limit to that specific view. This should be set appropriately based + on an application's or view's specific needs. + + .. versionchanged:: 3.1 + This can be set per-request. + + .. versionchanged:: 0.6 + This is configurable through Flask config. + """ + if self._max_content_length is not None: + return self._max_content_length + + if not current_app: + return super().max_content_length + + return current_app.config["MAX_CONTENT_LENGTH"] # type: ignore[no-any-return] + + @max_content_length.setter + def max_content_length(self, value: int | None) -> None: + self._max_content_length = value + + @property + def max_form_memory_size(self) -> int | None: + """The maximum size in bytes any non-file form field may be in a + ``multipart/form-data`` body. If this limit is exceeded, a 413 + :exc:`~werkzeug.exceptions.RequestEntityTooLarge` error is raised. If it + is set to ``None``, no limit is enforced at the Flask application level. + + Each request defaults to the :data:`MAX_FORM_MEMORY_SIZE` config, which + defaults to ``500_000``. It can be set on a specific ``request`` to + apply the limit to that specific view. This should be set appropriately + based on an application's or view's specific needs. + + .. versionchanged:: 3.1 + This is configurable through Flask config. + """ + if self._max_form_memory_size is not None: + return self._max_form_memory_size + + if not current_app: + return super().max_form_memory_size + + return current_app.config["MAX_FORM_MEMORY_SIZE"] # type: ignore[no-any-return] + + @max_form_memory_size.setter + def max_form_memory_size(self, value: int | None) -> None: + self._max_form_memory_size = value + + @property # type: ignore[override] + def max_form_parts(self) -> int | None: + """The maximum number of fields that may be present in a + ``multipart/form-data`` body. If this limit is exceeded, a 413 + :exc:`~werkzeug.exceptions.RequestEntityTooLarge` error is raised. If it + is set to ``None``, no limit is enforced at the Flask application level. + + Each request defaults to the :data:`MAX_FORM_PARTS` config, which + defaults to ``1_000``. It can be set on a specific ``request`` to apply + the limit to that specific view. This should be set appropriately based + on an application's or view's specific needs. + + .. versionchanged:: 3.1 + This is configurable through Flask config. + """ + if self._max_form_parts is not None: + return self._max_form_parts + + if not current_app: + return super().max_form_parts + + return current_app.config["MAX_FORM_PARTS"] # type: ignore[no-any-return] + + @max_form_parts.setter + def max_form_parts(self, value: int | None) -> None: + self._max_form_parts = value + + @property + def endpoint(self) -> str | None: + """The endpoint that matched the request URL. + + This will be ``None`` if matching failed or has not been + performed yet. + + This in combination with :attr:`view_args` can be used to + reconstruct the same URL or a modified URL. + """ + if self.url_rule is not None: + return self.url_rule.endpoint # type: ignore[no-any-return] + + return None + + @property + def blueprint(self) -> str | None: + """The registered name of the current blueprint. + + This will be ``None`` if the endpoint is not part of a + blueprint, or if URL matching failed or has not been performed + yet. + + This does not necessarily match the name the blueprint was + created with. It may have been nested, or registered with a + different name. + """ + endpoint = self.endpoint + + if endpoint is not None and "." in endpoint: + return endpoint.rpartition(".")[0] + + return None + + @property + def blueprints(self) -> list[str]: + """The registered names of the current blueprint upwards through + parent blueprints. + + This will be an empty list if there is no current blueprint, or + if URL matching failed. + + .. versionadded:: 2.0.1 + """ + name = self.blueprint + + if name is None: + return [] + + return _split_blueprint_path(name) + + def _load_form_data(self) -> None: + super()._load_form_data() + + # In debug mode we're replacing the files multidict with an ad-hoc + # subclass that raises a different error for key errors. + if ( + current_app + and current_app.debug + and self.mimetype != "multipart/form-data" + and not self.files + ): + from .debughelpers import attach_enctype_error_multidict + + attach_enctype_error_multidict(self) + + def on_json_loading_failed(self, e: ValueError | None) -> t.Any: + try: + return super().on_json_loading_failed(e) + except BadRequest as ebr: + if current_app and current_app.debug: + raise + + raise BadRequest() from ebr + + +class Response(ResponseBase): + """The response object that is used by default in Flask. Works like the + response object from Werkzeug but is set to have an HTML mimetype by + default. Quite often you don't have to create this object yourself because + :meth:`~flask.Flask.make_response` will take care of that for you. + + If you want to replace the response object used you can subclass this and + set :attr:`~flask.Flask.response_class` to your subclass. + + .. versionchanged:: 1.0 + JSON support is added to the response, like the request. This is useful + when testing to get the test client response data as JSON. + + .. versionchanged:: 1.0 + + Added :attr:`max_cookie_size`. + """ + + default_mimetype: str | None = "text/html" + + json_module = json + + autocorrect_location_header = False + + @property + def max_cookie_size(self) -> int: # type: ignore + """Read-only view of the :data:`MAX_COOKIE_SIZE` config key. + + See :attr:`~werkzeug.wrappers.Response.max_cookie_size` in + Werkzeug's docs. + """ + if current_app: + return current_app.config["MAX_COOKIE_SIZE"] # type: ignore[no-any-return] + + # return Werkzeug's default when not in an app context + return super().max_cookie_size diff --git a/src/flask-main/tests/conftest.py b/src/flask-main/tests/conftest.py new file mode 100644 index 0000000..0414b9e --- /dev/null +++ b/src/flask-main/tests/conftest.py @@ -0,0 +1,129 @@ +import os +import sys + +import pytest +from _pytest import monkeypatch + +from flask import Flask +from flask.globals import app_ctx as _app_ctx + + +@pytest.fixture(scope="session", autouse=True) +def _standard_os_environ(): + """Set up ``os.environ`` at the start of the test session to have + standard values. Returns a list of operations that is used by + :func:`._reset_os_environ` after each test. + """ + mp = monkeypatch.MonkeyPatch() + out = ( + (os.environ, "FLASK_ENV_FILE", monkeypatch.notset), + (os.environ, "FLASK_APP", monkeypatch.notset), + (os.environ, "FLASK_DEBUG", monkeypatch.notset), + (os.environ, "FLASK_RUN_FROM_CLI", monkeypatch.notset), + (os.environ, "WERKZEUG_RUN_MAIN", monkeypatch.notset), + ) + + for _, key, value in out: + if value is monkeypatch.notset: + mp.delenv(key, False) + else: + mp.setenv(key, value) + + yield out + mp.undo() + + +@pytest.fixture(autouse=True) +def _reset_os_environ(monkeypatch, _standard_os_environ): + """Reset ``os.environ`` to the standard environ after each test, + in case a test changed something without cleaning up. + """ + monkeypatch._setitem.extend(_standard_os_environ) + + +@pytest.fixture +def app(): + app = Flask("flask_test", root_path=os.path.dirname(__file__)) + app.config.update( + TESTING=True, + SECRET_KEY="test key", + ) + return app + + +@pytest.fixture +def app_ctx(app): + with app.app_context() as ctx: + yield ctx + + +@pytest.fixture +def req_ctx(app): + with app.test_request_context() as ctx: + yield ctx + + +@pytest.fixture +def client(app): + return app.test_client() + + +@pytest.fixture +def test_apps(monkeypatch): + monkeypatch.syspath_prepend(os.path.join(os.path.dirname(__file__), "test_apps")) + original_modules = set(sys.modules.keys()) + + yield + + # Remove any imports cached during the test. Otherwise "import app" + # will work in the next test even though it's no longer on the path. + for key in sys.modules.keys() - original_modules: + sys.modules.pop(key) + + +@pytest.fixture(autouse=True) +def leak_detector(): + """Fails if any app contexts are still pushed when a test ends. Pops all + contexts so subsequent tests are not affected. + """ + yield + leaks = [] + + while _app_ctx: + leaks.append(_app_ctx._get_current_object()) + _app_ctx.pop() + + assert not leaks + + +@pytest.fixture +def modules_tmp_path(tmp_path, monkeypatch): + """A temporary directory added to sys.path.""" + rv = tmp_path / "modules_tmp" + rv.mkdir() + monkeypatch.syspath_prepend(os.fspath(rv)) + return rv + + +@pytest.fixture +def modules_tmp_path_prefix(modules_tmp_path, monkeypatch): + monkeypatch.setattr(sys, "prefix", os.fspath(modules_tmp_path)) + return modules_tmp_path + + +@pytest.fixture +def site_packages(modules_tmp_path, monkeypatch): + """Create a fake site-packages.""" + py_dir = f"python{sys.version_info.major}.{sys.version_info.minor}" + rv = modules_tmp_path / "lib" / py_dir / "site-packages" + rv.mkdir(parents=True) + monkeypatch.syspath_prepend(os.fspath(rv)) + return rv + + +@pytest.fixture +def purge_module(request): + def inner(name): + request.addfinalizer(lambda: sys.modules.pop(name, None)) + + return inner diff --git a/src/flask-main/tests/static/config.json b/src/flask-main/tests/static/config.json new file mode 100644 index 0000000..4eedab1 --- /dev/null +++ b/src/flask-main/tests/static/config.json @@ -0,0 +1,4 @@ +{ + "TEST_KEY": "foo", + "SECRET_KEY": "config" +} diff --git a/src/flask-main/tests/static/config.toml b/src/flask-main/tests/static/config.toml new file mode 100644 index 0000000..64acdbd --- /dev/null +++ b/src/flask-main/tests/static/config.toml @@ -0,0 +1,2 @@ +TEST_KEY="foo" +SECRET_KEY="config" diff --git a/src/flask-main/tests/static/index.html b/src/flask-main/tests/static/index.html new file mode 100644 index 0000000..de8b69b --- /dev/null +++ b/src/flask-main/tests/static/index.html @@ -0,0 +1 @@ +

Hello World!

diff --git a/src/flask-main/tests/templates/_macro.html b/src/flask-main/tests/templates/_macro.html new file mode 100644 index 0000000..3460ae2 --- /dev/null +++ b/src/flask-main/tests/templates/_macro.html @@ -0,0 +1 @@ +{% macro hello(name) %}Hello {{ name }}!{% endmacro %} diff --git a/src/flask-main/tests/templates/context_template.html b/src/flask-main/tests/templates/context_template.html new file mode 100644 index 0000000..fadf3e5 --- /dev/null +++ b/src/flask-main/tests/templates/context_template.html @@ -0,0 +1 @@ +

{{ value }}|{{ injected_value }} diff --git a/src/flask-main/tests/templates/escaping_template.html b/src/flask-main/tests/templates/escaping_template.html new file mode 100644 index 0000000..dc47644 --- /dev/null +++ b/src/flask-main/tests/templates/escaping_template.html @@ -0,0 +1,6 @@ +{{ text }} +{{ html }} +{% autoescape false %}{{ text }} +{{ html }}{% endautoescape %} +{% autoescape true %}{{ text }} +{{ html }}{% endautoescape %} diff --git a/src/flask-main/tests/templates/mail.txt b/src/flask-main/tests/templates/mail.txt new file mode 100644 index 0000000..d6cb92e --- /dev/null +++ b/src/flask-main/tests/templates/mail.txt @@ -0,0 +1 @@ +{{ foo}} Mail diff --git a/src/flask-main/tests/templates/nested/nested.txt b/src/flask-main/tests/templates/nested/nested.txt new file mode 100644 index 0000000..2c8634f --- /dev/null +++ b/src/flask-main/tests/templates/nested/nested.txt @@ -0,0 +1 @@ +I'm nested diff --git a/src/flask-main/tests/templates/non_escaping_template.txt b/src/flask-main/tests/templates/non_escaping_template.txt new file mode 100644 index 0000000..542864e --- /dev/null +++ b/src/flask-main/tests/templates/non_escaping_template.txt @@ -0,0 +1,8 @@ +{{ text }} +{{ html }} +{% autoescape false %}{{ text }} +{{ html }}{% endautoescape %} +{% autoescape true %}{{ text }} +{{ html }}{% endautoescape %} +{{ text }} +{{ html }} diff --git a/src/flask-main/tests/templates/simple_template.html b/src/flask-main/tests/templates/simple_template.html new file mode 100644 index 0000000..c24612c --- /dev/null +++ b/src/flask-main/tests/templates/simple_template.html @@ -0,0 +1 @@ +

{{ whiskey }}

diff --git a/src/flask-main/tests/templates/template_filter.html b/src/flask-main/tests/templates/template_filter.html new file mode 100644 index 0000000..d51506a --- /dev/null +++ b/src/flask-main/tests/templates/template_filter.html @@ -0,0 +1 @@ +{{ value|super_reverse }} diff --git a/src/flask-main/tests/templates/template_test.html b/src/flask-main/tests/templates/template_test.html new file mode 100644 index 0000000..92d5561 --- /dev/null +++ b/src/flask-main/tests/templates/template_test.html @@ -0,0 +1,3 @@ +{% if value is boolean %} + Success! +{% endif %} diff --git a/src/flask-main/tests/test_appctx.py b/src/flask-main/tests/test_appctx.py new file mode 100644 index 0000000..a537b05 --- /dev/null +++ b/src/flask-main/tests/test_appctx.py @@ -0,0 +1,210 @@ +import pytest + +import flask +from flask.globals import app_ctx + + +def test_basic_url_generation(app): + app.config["SERVER_NAME"] = "localhost" + app.config["PREFERRED_URL_SCHEME"] = "https" + + @app.route("/") + def index(): + pass + + with app.app_context(): + rv = flask.url_for("index") + assert rv == "https://localhost/" + + +def test_url_generation_requires_server_name(app): + with app.app_context(): + with pytest.raises(RuntimeError): + flask.url_for("index") + + +def test_url_generation_without_context_fails(): + with pytest.raises(RuntimeError): + flask.url_for("index") + + +def test_request_context_means_app_context(app): + with app.test_request_context(): + assert flask.current_app._get_current_object() is app + assert not flask.current_app + + +def test_app_context_provides_current_app(app): + with app.app_context(): + assert flask.current_app._get_current_object() is app + assert not flask.current_app + + +def test_app_tearing_down(app): + cleanup_stuff = [] + + @app.teardown_appcontext + def cleanup(exception): + cleanup_stuff.append(exception) + + with app.app_context(): + pass + + assert cleanup_stuff == [None] + + +def test_app_tearing_down_with_previous_exception(app): + cleanup_stuff = [] + + @app.teardown_appcontext + def cleanup(exception): + cleanup_stuff.append(exception) + + try: + raise Exception("dummy") + except Exception: + pass + + with app.app_context(): + pass + + assert cleanup_stuff == [None] + + +def test_app_tearing_down_with_handled_exception_by_except_block(app): + cleanup_stuff = [] + + @app.teardown_appcontext + def cleanup(exception): + cleanup_stuff.append(exception) + + with app.app_context(): + try: + raise Exception("dummy") + except Exception: + pass + + assert cleanup_stuff == [None] + + +def test_app_tearing_down_with_handled_exception_by_app_handler(app, client): + app.config["PROPAGATE_EXCEPTIONS"] = True + cleanup_stuff = [] + + @app.teardown_appcontext + def cleanup(exception): + cleanup_stuff.append(exception) + + @app.route("/") + def index(): + raise Exception("dummy") + + @app.errorhandler(Exception) + def handler(f): + return flask.jsonify(str(f)) + + with app.app_context(): + client.get("/") + + # teardown request context, and with block context + assert cleanup_stuff == [None, None] + + +def test_app_tearing_down_with_unhandled_exception(app, client): + app.config["PROPAGATE_EXCEPTIONS"] = True + cleanup_stuff = [] + + @app.teardown_appcontext + def cleanup(exception): + cleanup_stuff.append(exception) + + @app.route("/") + def index(): + raise ValueError("dummy") + + with pytest.raises(ValueError, match="dummy"): + with app.app_context(): + client.get("/") + + assert len(cleanup_stuff) == 2 + assert isinstance(cleanup_stuff[0], ValueError) + assert str(cleanup_stuff[0]) == "dummy" + # exception propagated, seen by request context and with block context + assert cleanup_stuff[0] is cleanup_stuff[1] + + +def test_app_ctx_globals_methods(app, app_ctx): + # get + assert flask.g.get("foo") is None + assert flask.g.get("foo", "bar") == "bar" + # __contains__ + assert "foo" not in flask.g + flask.g.foo = "bar" + assert "foo" in flask.g + # setdefault + flask.g.setdefault("bar", "the cake is a lie") + flask.g.setdefault("bar", "hello world") + assert flask.g.bar == "the cake is a lie" + # pop + assert flask.g.pop("bar") == "the cake is a lie" + with pytest.raises(KeyError): + flask.g.pop("bar") + assert flask.g.pop("bar", "more cake") == "more cake" + # __iter__ + assert list(flask.g) == ["foo"] + # __repr__ + assert repr(flask.g) == "" + + +def test_custom_app_ctx_globals_class(app): + class CustomRequestGlobals: + def __init__(self): + self.spam = "eggs" + + app.app_ctx_globals_class = CustomRequestGlobals + with app.app_context(): + assert flask.render_template_string("{{ g.spam }}") == "eggs" + + +def test_context_refcounts(app, client): + called = [] + + @app.teardown_request + def teardown_req(error=None): + called.append("request") + + @app.teardown_appcontext + def teardown_app(error=None): + called.append("app") + + @app.route("/") + def index(): + with app_ctx: + pass + + assert flask.request.environ["werkzeug.request"] is not None + return "" + + res = client.get("/") + assert res.status_code == 200 + assert res.data == b"" + assert called == ["request", "app"] + + +def test_clean_pop(app): + app.testing = False + called = [] + + @app.teardown_request + def teardown_req(error=None): + raise ZeroDivisionError + + @app.teardown_appcontext + def teardown_app(error=None): + called.append("TEARDOWN") + + with app.app_context(): + called.append(flask.current_app.name) + + assert called == ["flask_test", "TEARDOWN"] + assert not flask.current_app diff --git a/src/flask-main/tests/test_apps/.env b/src/flask-main/tests/test_apps/.env new file mode 100644 index 0000000..0890b61 --- /dev/null +++ b/src/flask-main/tests/test_apps/.env @@ -0,0 +1,4 @@ +FOO=env +SPAM=1 +EGGS=2 +HAM=火腿 diff --git a/src/flask-main/tests/test_apps/.flaskenv b/src/flask-main/tests/test_apps/.flaskenv new file mode 100644 index 0000000..59f96af --- /dev/null +++ b/src/flask-main/tests/test_apps/.flaskenv @@ -0,0 +1,3 @@ +FOO=flaskenv +BAR=bar +EGGS=0 diff --git a/src/flask-main/tests/test_apps/blueprintapp/__init__.py b/src/flask-main/tests/test_apps/blueprintapp/__init__.py new file mode 100644 index 0000000..ad594cf --- /dev/null +++ b/src/flask-main/tests/test_apps/blueprintapp/__init__.py @@ -0,0 +1,9 @@ +from flask import Flask + +app = Flask(__name__) +app.config["DEBUG"] = True +from blueprintapp.apps.admin import admin # noqa: E402 +from blueprintapp.apps.frontend import frontend # noqa: E402 + +app.register_blueprint(admin) +app.register_blueprint(frontend) diff --git a/src/flask-main/tests/test_apps/blueprintapp/apps/__init__.py b/src/flask-main/tests/test_apps/blueprintapp/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/flask-main/tests/test_apps/blueprintapp/apps/admin/__init__.py b/src/flask-main/tests/test_apps/blueprintapp/apps/admin/__init__.py new file mode 100644 index 0000000..b197fad --- /dev/null +++ b/src/flask-main/tests/test_apps/blueprintapp/apps/admin/__init__.py @@ -0,0 +1,20 @@ +from flask import Blueprint +from flask import render_template + +admin = Blueprint( + "admin", + __name__, + url_prefix="/admin", + template_folder="templates", + static_folder="static", +) + + +@admin.route("/") +def index(): + return render_template("admin/index.html") + + +@admin.route("/index2") +def index2(): + return render_template("./admin/index.html") diff --git a/src/flask-main/tests/test_apps/blueprintapp/apps/admin/static/css/test.css b/src/flask-main/tests/test_apps/blueprintapp/apps/admin/static/css/test.css new file mode 100644 index 0000000..b9f564d --- /dev/null +++ b/src/flask-main/tests/test_apps/blueprintapp/apps/admin/static/css/test.css @@ -0,0 +1 @@ +/* nested file */ diff --git a/src/flask-main/tests/test_apps/blueprintapp/apps/admin/static/test.txt b/src/flask-main/tests/test_apps/blueprintapp/apps/admin/static/test.txt new file mode 100644 index 0000000..f220d22 --- /dev/null +++ b/src/flask-main/tests/test_apps/blueprintapp/apps/admin/static/test.txt @@ -0,0 +1 @@ +Admin File diff --git a/src/flask-main/tests/test_apps/blueprintapp/apps/admin/templates/admin/index.html b/src/flask-main/tests/test_apps/blueprintapp/apps/admin/templates/admin/index.html new file mode 100644 index 0000000..eeec199 --- /dev/null +++ b/src/flask-main/tests/test_apps/blueprintapp/apps/admin/templates/admin/index.html @@ -0,0 +1 @@ +Hello from the Admin diff --git a/src/flask-main/tests/test_apps/blueprintapp/apps/frontend/__init__.py b/src/flask-main/tests/test_apps/blueprintapp/apps/frontend/__init__.py new file mode 100644 index 0000000..7cc5cd8 --- /dev/null +++ b/src/flask-main/tests/test_apps/blueprintapp/apps/frontend/__init__.py @@ -0,0 +1,14 @@ +from flask import Blueprint +from flask import render_template + +frontend = Blueprint("frontend", __name__, template_folder="templates") + + +@frontend.route("/") +def index(): + return render_template("frontend/index.html") + + +@frontend.route("/missing") +def missing_template(): + return render_template("missing_template.html") diff --git a/src/flask-main/tests/test_apps/blueprintapp/apps/frontend/templates/frontend/index.html b/src/flask-main/tests/test_apps/blueprintapp/apps/frontend/templates/frontend/index.html new file mode 100644 index 0000000..a062d71 --- /dev/null +++ b/src/flask-main/tests/test_apps/blueprintapp/apps/frontend/templates/frontend/index.html @@ -0,0 +1 @@ +Hello from the Frontend diff --git a/src/flask-main/tests/test_apps/cliapp/__init__.py b/src/flask-main/tests/test_apps/cliapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/flask-main/tests/test_apps/cliapp/app.py b/src/flask-main/tests/test_apps/cliapp/app.py new file mode 100644 index 0000000..017ce28 --- /dev/null +++ b/src/flask-main/tests/test_apps/cliapp/app.py @@ -0,0 +1,3 @@ +from flask import Flask + +testapp = Flask("testapp") diff --git a/src/flask-main/tests/test_apps/cliapp/factory.py b/src/flask-main/tests/test_apps/cliapp/factory.py new file mode 100644 index 0000000..1d27396 --- /dev/null +++ b/src/flask-main/tests/test_apps/cliapp/factory.py @@ -0,0 +1,13 @@ +from flask import Flask + + +def create_app(): + return Flask("app") + + +def create_app2(foo, bar): + return Flask("_".join(["app2", foo, bar])) + + +def no_app(): + pass diff --git a/src/flask-main/tests/test_apps/cliapp/importerrorapp.py b/src/flask-main/tests/test_apps/cliapp/importerrorapp.py new file mode 100644 index 0000000..2c96c9b --- /dev/null +++ b/src/flask-main/tests/test_apps/cliapp/importerrorapp.py @@ -0,0 +1,5 @@ +from flask import Flask + +raise ImportError() + +testapp = Flask("testapp") diff --git a/src/flask-main/tests/test_apps/cliapp/inner1/__init__.py b/src/flask-main/tests/test_apps/cliapp/inner1/__init__.py new file mode 100644 index 0000000..8330f6e --- /dev/null +++ b/src/flask-main/tests/test_apps/cliapp/inner1/__init__.py @@ -0,0 +1,3 @@ +from flask import Flask + +application = Flask(__name__) diff --git a/src/flask-main/tests/test_apps/cliapp/inner1/inner2/__init__.py b/src/flask-main/tests/test_apps/cliapp/inner1/inner2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/flask-main/tests/test_apps/cliapp/inner1/inner2/flask.py b/src/flask-main/tests/test_apps/cliapp/inner1/inner2/flask.py new file mode 100644 index 0000000..d7562aa --- /dev/null +++ b/src/flask-main/tests/test_apps/cliapp/inner1/inner2/flask.py @@ -0,0 +1,3 @@ +from flask import Flask + +app = Flask(__name__) diff --git a/src/flask-main/tests/test_apps/cliapp/message.txt b/src/flask-main/tests/test_apps/cliapp/message.txt new file mode 100644 index 0000000..fc2b2cf --- /dev/null +++ b/src/flask-main/tests/test_apps/cliapp/message.txt @@ -0,0 +1 @@ +So long, and thanks for all the fish. diff --git a/src/flask-main/tests/test_apps/cliapp/multiapp.py b/src/flask-main/tests/test_apps/cliapp/multiapp.py new file mode 100644 index 0000000..4ed0f32 --- /dev/null +++ b/src/flask-main/tests/test_apps/cliapp/multiapp.py @@ -0,0 +1,4 @@ +from flask import Flask + +app1 = Flask("app1") +app2 = Flask("app2") diff --git a/src/flask-main/tests/test_apps/helloworld/hello.py b/src/flask-main/tests/test_apps/helloworld/hello.py new file mode 100644 index 0000000..71a2f90 --- /dev/null +++ b/src/flask-main/tests/test_apps/helloworld/hello.py @@ -0,0 +1,8 @@ +from flask import Flask + +app = Flask(__name__) + + +@app.route("/") +def hello(): + return "Hello World!" diff --git a/src/flask-main/tests/test_apps/helloworld/wsgi.py b/src/flask-main/tests/test_apps/helloworld/wsgi.py new file mode 100644 index 0000000..ab2d6e9 --- /dev/null +++ b/src/flask-main/tests/test_apps/helloworld/wsgi.py @@ -0,0 +1 @@ +from hello import app # noqa: F401 diff --git a/src/flask-main/tests/test_apps/subdomaintestmodule/__init__.py b/src/flask-main/tests/test_apps/subdomaintestmodule/__init__.py new file mode 100644 index 0000000..b4ce4b1 --- /dev/null +++ b/src/flask-main/tests/test_apps/subdomaintestmodule/__init__.py @@ -0,0 +1,3 @@ +from flask import Module + +mod = Module(__name__, "foo", subdomain="foo") diff --git a/src/flask-main/tests/test_apps/subdomaintestmodule/static/hello.txt b/src/flask-main/tests/test_apps/subdomaintestmodule/static/hello.txt new file mode 100644 index 0000000..12e23c1 --- /dev/null +++ b/src/flask-main/tests/test_apps/subdomaintestmodule/static/hello.txt @@ -0,0 +1 @@ +Hello Subdomain diff --git a/src/flask-main/tests/test_async.py b/src/flask-main/tests/test_async.py new file mode 100644 index 0000000..f52b049 --- /dev/null +++ b/src/flask-main/tests/test_async.py @@ -0,0 +1,145 @@ +import asyncio + +import pytest + +from flask import Blueprint +from flask import Flask +from flask import request +from flask.views import MethodView +from flask.views import View + +pytest.importorskip("asgiref") + + +class AppError(Exception): + pass + + +class BlueprintError(Exception): + pass + + +class AsyncView(View): + methods = ["GET", "POST"] + + async def dispatch_request(self): + await asyncio.sleep(0) + return request.method + + +class AsyncMethodView(MethodView): + async def get(self): + await asyncio.sleep(0) + return "GET" + + async def post(self): + await asyncio.sleep(0) + return "POST" + + +@pytest.fixture(name="async_app") +def _async_app(): + app = Flask(__name__) + + @app.route("/", methods=["GET", "POST"]) + @app.route("/home", methods=["GET", "POST"]) + async def index(): + await asyncio.sleep(0) + return request.method + + @app.errorhandler(AppError) + async def handle(_): + return "", 412 + + @app.route("/error") + async def error(): + raise AppError() + + blueprint = Blueprint("bp", __name__) + + @blueprint.route("/", methods=["GET", "POST"]) + async def bp_index(): + await asyncio.sleep(0) + return request.method + + @blueprint.errorhandler(BlueprintError) + async def bp_handle(_): + return "", 412 + + @blueprint.route("/error") + async def bp_error(): + raise BlueprintError() + + app.register_blueprint(blueprint, url_prefix="/bp") + + app.add_url_rule("/view", view_func=AsyncView.as_view("view")) + app.add_url_rule("/methodview", view_func=AsyncMethodView.as_view("methodview")) + + return app + + +@pytest.mark.parametrize("path", ["/", "/home", "/bp/", "/view", "/methodview"]) +def test_async_route(path, async_app): + test_client = async_app.test_client() + response = test_client.get(path) + assert b"GET" in response.get_data() + response = test_client.post(path) + assert b"POST" in response.get_data() + + +@pytest.mark.parametrize("path", ["/error", "/bp/error"]) +def test_async_error_handler(path, async_app): + test_client = async_app.test_client() + response = test_client.get(path) + assert response.status_code == 412 + + +def test_async_before_after_request(): + app_before_called = False + app_after_called = False + bp_before_called = False + bp_after_called = False + + app = Flask(__name__) + + @app.route("/") + def index(): + return "" + + @app.before_request + async def before(): + nonlocal app_before_called + app_before_called = True + + @app.after_request + async def after(response): + nonlocal app_after_called + app_after_called = True + return response + + blueprint = Blueprint("bp", __name__) + + @blueprint.route("/") + def bp_index(): + return "" + + @blueprint.before_request + async def bp_before(): + nonlocal bp_before_called + bp_before_called = True + + @blueprint.after_request + async def bp_after(response): + nonlocal bp_after_called + bp_after_called = True + return response + + app.register_blueprint(blueprint, url_prefix="/bp") + + test_client = app.test_client() + test_client.get("/") + assert app_before_called + assert app_after_called + test_client.get("/bp/") + assert bp_before_called + assert bp_after_called diff --git a/src/flask-main/tests/test_basic.py b/src/flask-main/tests/test_basic.py new file mode 100644 index 0000000..c372a91 --- /dev/null +++ b/src/flask-main/tests/test_basic.py @@ -0,0 +1,1944 @@ +import gc +import re +import typing as t +import uuid +import warnings +import weakref +from contextlib import nullcontext +from datetime import datetime +from datetime import timezone +from platform import python_implementation + +import pytest +import werkzeug.serving +from markupsafe import Markup +from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import Forbidden +from werkzeug.exceptions import NotFound +from werkzeug.http import parse_date +from werkzeug.routing import BuildError +from werkzeug.routing import RequestRedirect + +import flask + +require_cpython_gc = pytest.mark.skipif( + python_implementation() != "CPython", + reason="Requires CPython GC behavior", +) + + +def test_options_work(app, client): + @app.route("/", methods=["GET", "POST"]) + def index(): + return "Hello World" + + rv = client.open("/", method="OPTIONS") + assert sorted(rv.allow) == ["GET", "HEAD", "OPTIONS", "POST"] + assert rv.data == b"" + + +def test_options_on_multiple_rules(app, client): + @app.route("/", methods=["GET", "POST"]) + def index(): + return "Hello World" + + @app.route("/", methods=["PUT"]) + def index_put(): + return "Aha!" + + rv = client.open("/", method="OPTIONS") + assert sorted(rv.allow) == ["GET", "HEAD", "OPTIONS", "POST", "PUT"] + + +@pytest.mark.parametrize("method", ["get", "post", "put", "delete", "patch"]) +def test_method_route(app, client, method): + method_route = getattr(app, method) + client_method = getattr(client, method) + + @method_route("/") + def hello(): + return "Hello" + + assert client_method("/").data == b"Hello" + + +def test_method_route_no_methods(app): + with pytest.raises(TypeError): + app.get("/", methods=["GET", "POST"]) + + +def test_provide_automatic_options_attr(): + app = flask.Flask(__name__) + + def index(): + return "Hello World!" + + index.provide_automatic_options = False + app.route("/")(index) + rv = app.test_client().open("/", method="OPTIONS") + assert rv.status_code == 405 + + app = flask.Flask(__name__) + + def index2(): + return "Hello World!" + + index2.provide_automatic_options = True + app.route("/", methods=["OPTIONS"])(index2) + rv = app.test_client().open("/", method="OPTIONS") + assert sorted(rv.allow) == ["OPTIONS"] + + +def test_provide_automatic_options_kwarg(app, client): + def index(): + return flask.request.method + + def more(): + return flask.request.method + + app.add_url_rule("/", view_func=index, provide_automatic_options=False) + app.add_url_rule( + "/more", + view_func=more, + methods=["GET", "POST"], + provide_automatic_options=False, + ) + assert client.get("/").data == b"GET" + + rv = client.post("/") + assert rv.status_code == 405 + assert sorted(rv.allow) == ["GET", "HEAD"] + + rv = client.open("/", method="OPTIONS") + assert rv.status_code == 405 + + rv = client.head("/") + assert rv.status_code == 200 + assert not rv.data # head truncates + assert client.post("/more").data == b"POST" + assert client.get("/more").data == b"GET" + + rv = client.delete("/more") + assert rv.status_code == 405 + assert sorted(rv.allow) == ["GET", "HEAD", "POST"] + + rv = client.open("/more", method="OPTIONS") + assert rv.status_code == 405 + + +def test_request_dispatching(app, client): + @app.route("/") + def index(): + return flask.request.method + + @app.route("/more", methods=["GET", "POST"]) + def more(): + return flask.request.method + + assert client.get("/").data == b"GET" + rv = client.post("/") + assert rv.status_code == 405 + assert sorted(rv.allow) == ["GET", "HEAD", "OPTIONS"] + rv = client.head("/") + assert rv.status_code == 200 + assert not rv.data # head truncates + assert client.post("/more").data == b"POST" + assert client.get("/more").data == b"GET" + rv = client.delete("/more") + assert rv.status_code == 405 + assert sorted(rv.allow) == ["GET", "HEAD", "OPTIONS", "POST"] + + +def test_disallow_string_for_allowed_methods(app): + with pytest.raises(TypeError): + app.add_url_rule("/", methods="GET POST", endpoint="test") + + +def test_url_mapping(app, client): + random_uuid4 = "7eb41166-9ebf-4d26-b771-ea3f54f8b383" + + def index(): + return flask.request.method + + def more(): + return flask.request.method + + def options(): + return random_uuid4 + + app.add_url_rule("/", "index", index) + app.add_url_rule("/more", "more", more, methods=["GET", "POST"]) + + # Issue 1288: Test that automatic options are not added + # when non-uppercase 'options' in methods + app.add_url_rule("/options", "options", options, methods=["options"]) + + assert client.get("/").data == b"GET" + rv = client.post("/") + assert rv.status_code == 405 + assert sorted(rv.allow) == ["GET", "HEAD", "OPTIONS"] + rv = client.head("/") + assert rv.status_code == 200 + assert not rv.data # head truncates + assert client.post("/more").data == b"POST" + assert client.get("/more").data == b"GET" + rv = client.delete("/more") + assert rv.status_code == 405 + assert sorted(rv.allow) == ["GET", "HEAD", "OPTIONS", "POST"] + rv = client.open("/options", method="OPTIONS") + assert rv.status_code == 200 + assert random_uuid4 in rv.data.decode("utf-8") + + +def test_werkzeug_routing(app, client): + from werkzeug.routing import Rule + from werkzeug.routing import Submount + + app.url_map.add( + Submount("/foo", [Rule("/bar", endpoint="bar"), Rule("/", endpoint="index")]) + ) + + def bar(): + return "bar" + + def index(): + return "index" + + app.view_functions["bar"] = bar + app.view_functions["index"] = index + + assert client.get("/foo/").data == b"index" + assert client.get("/foo/bar").data == b"bar" + + +def test_endpoint_decorator(app, client): + from werkzeug.routing import Rule + from werkzeug.routing import Submount + + app.url_map.add( + Submount("/foo", [Rule("/bar", endpoint="bar"), Rule("/", endpoint="index")]) + ) + + @app.endpoint("bar") + def bar(): + return "bar" + + @app.endpoint("index") + def index(): + return "index" + + assert client.get("/foo/").data == b"index" + assert client.get("/foo/bar").data == b"bar" + + +def test_session(app, client): + @app.route("/set", methods=["POST"]) + def set(): + assert not flask.session.accessed + assert not flask.session.modified + flask.session["value"] = flask.request.form["value"] + assert flask.session.accessed + assert flask.session.modified + return "value set" + + @app.route("/get") + def get(): + assert not flask.session.accessed + assert not flask.session.modified + v = flask.session.get("value", "None") + assert flask.session.accessed + assert not flask.session.modified + return v + + assert client.post("/set", data={"value": "42"}).data == b"value set" + assert client.get("/get").data == b"42" + + +def test_session_path(app, client): + app.config.update(APPLICATION_ROOT="/foo") + + @app.route("/") + def index(): + flask.session["testing"] = 42 + return "Hello World" + + rv = client.get("/", "http://example.com:8080/foo") + assert "path=/foo" in rv.headers["set-cookie"].lower() + + +def test_session_using_application_root(app, client): + class PrefixPathMiddleware: + def __init__(self, app, prefix): + self.app = app + self.prefix = prefix + + def __call__(self, environ, start_response): + environ["SCRIPT_NAME"] = self.prefix + return self.app(environ, start_response) + + app.wsgi_app = PrefixPathMiddleware(app.wsgi_app, "/bar") + app.config.update(APPLICATION_ROOT="/bar") + + @app.route("/") + def index(): + flask.session["testing"] = 42 + return "Hello World" + + rv = client.get("/", "http://example.com:8080/") + assert "path=/bar" in rv.headers["set-cookie"].lower() + + +def test_session_using_session_settings(app, client): + app.config.update( + SERVER_NAME="www.example.com:8080", + APPLICATION_ROOT="/test", + SESSION_COOKIE_DOMAIN=".example.com", + SESSION_COOKIE_HTTPONLY=False, + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_PARTITIONED=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_PATH="/", + ) + + @app.route("/") + def index(): + flask.session["testing"] = 42 + return "Hello World" + + @app.route("/clear") + def clear(): + flask.session.pop("testing", None) + return "Goodbye World" + + rv = client.get("/", "http://www.example.com:8080/test/") + cookie = rv.headers["set-cookie"].lower() + # or condition for Werkzeug < 2.3 + assert "domain=example.com" in cookie or "domain=.example.com" in cookie + assert "path=/" in cookie + assert "secure" in cookie + assert "httponly" not in cookie + assert "samesite" in cookie + assert "partitioned" in cookie + + rv = client.get("/clear", "http://www.example.com:8080/test/") + cookie = rv.headers["set-cookie"].lower() + assert "session=;" in cookie + # or condition for Werkzeug < 2.3 + assert "domain=example.com" in cookie or "domain=.example.com" in cookie + assert "path=/" in cookie + assert "secure" in cookie + assert "samesite" in cookie + assert "partitioned" in cookie + + +def test_session_using_samesite_attribute(app, client): + @app.route("/") + def index(): + flask.session["testing"] = 42 + return "Hello World" + + app.config.update(SESSION_COOKIE_SAMESITE="invalid") + + with pytest.raises(ValueError): + client.get("/") + + app.config.update(SESSION_COOKIE_SAMESITE=None) + rv = client.get("/") + cookie = rv.headers["set-cookie"].lower() + assert "samesite" not in cookie + + app.config.update(SESSION_COOKIE_SAMESITE="Strict") + rv = client.get("/") + cookie = rv.headers["set-cookie"].lower() + assert "samesite=strict" in cookie + + app.config.update(SESSION_COOKIE_SAMESITE="Lax") + rv = client.get("/") + cookie = rv.headers["set-cookie"].lower() + assert "samesite=lax" in cookie + + +def test_missing_session(app): + app.secret_key = None + + def expect_exception(f, *args, **kwargs): + e = pytest.raises(RuntimeError, f, *args, **kwargs) + assert e.value.args and "session is unavailable" in e.value.args[0] + + with app.test_request_context(): + assert flask.session.get("missing_key") is None + expect_exception(flask.session.__setitem__, "foo", 42) + expect_exception(flask.session.pop, "foo") + + +def test_session_secret_key_fallbacks(app, client) -> None: + @app.post("/") + def set_session() -> str: + flask.session["a"] = 1 + return "" + + @app.get("/") + def get_session() -> dict[str, t.Any]: + return dict(flask.session) + + # Set session with initial secret key, and two valid expiring keys + app.secret_key, app.config["SECRET_KEY_FALLBACKS"] = ( + "0 key", + ["-1 key", "-2 key"], + ) + client.post() + assert client.get().json == {"a": 1} + # Change secret key, session can't be loaded and appears empty + app.secret_key = "? key" + assert client.get().json == {} + # Rotate the valid keys, session can be loaded + app.secret_key, app.config["SECRET_KEY_FALLBACKS"] = ( + "+1 key", + ["0 key", "-1 key"], + ) + assert client.get().json == {"a": 1} + + +def test_session_expiration(app, client): + permanent = True + + @app.route("/") + def index(): + flask.session["test"] = 42 + flask.session.permanent = permanent + return "" + + @app.route("/test") + def test(): + return str(flask.session.permanent) + + rv = client.get("/") + assert "set-cookie" in rv.headers + match = re.search(r"(?i)\bexpires=([^;]+)", rv.headers["set-cookie"]) + expires = parse_date(match.group()) + expected = datetime.now(timezone.utc) + app.permanent_session_lifetime + assert expires.year == expected.year + assert expires.month == expected.month + assert expires.day == expected.day + + rv = client.get("/test") + assert rv.data == b"True" + + permanent = False + rv = client.get("/") + assert "set-cookie" in rv.headers + match = re.search(r"\bexpires=([^;]+)", rv.headers["set-cookie"]) + assert match is None + + +def test_session_stored_last(app, client): + @app.after_request + def modify_session(response): + flask.session["foo"] = 42 + return response + + @app.route("/") + def dump_session_contents(): + return repr(flask.session.get("foo")) + + assert client.get("/").data == b"None" + assert client.get("/").data == b"42" + + +def test_session_special_types(app, client): + now = datetime.now(timezone.utc).replace(microsecond=0) + the_uuid = uuid.uuid4() + + @app.route("/") + def dump_session_contents(): + flask.session["t"] = (1, 2, 3) + flask.session["b"] = b"\xff" + flask.session["m"] = Markup("") + flask.session["u"] = the_uuid + flask.session["d"] = now + flask.session["t_tag"] = {" t": "not-a-tuple"} + flask.session["di_t_tag"] = {" t__": "not-a-tuple"} + flask.session["di_tag"] = {" di": "not-a-dict"} + return "", 204 + + with client: + client.get("/") + s = flask.session + assert s["t"] == (1, 2, 3) + assert type(s["b"]) is bytes # noqa: E721 + assert s["b"] == b"\xff" + assert type(s["m"]) is Markup # noqa: E721 + assert s["m"] == Markup("") + assert s["u"] == the_uuid + assert s["d"] == now + assert s["t_tag"] == {" t": "not-a-tuple"} + assert s["di_t_tag"] == {" t__": "not-a-tuple"} + assert s["di_tag"] == {" di": "not-a-dict"} + + +def test_session_cookie_setting(app): + is_permanent = True + + @app.route("/bump") + def bump(): + rv = flask.session["foo"] = flask.session.get("foo", 0) + 1 + flask.session.permanent = is_permanent + return str(rv) + + @app.route("/read") + def read(): + return str(flask.session.get("foo", 0)) + + def run_test(expect_header): + with app.test_client() as c: + assert c.get("/bump").data == b"1" + assert c.get("/bump").data == b"2" + assert c.get("/bump").data == b"3" + + rv = c.get("/read") + set_cookie = rv.headers.get("set-cookie") + assert (set_cookie is not None) == expect_header + assert rv.data == b"3" + + is_permanent = True + app.config["SESSION_REFRESH_EACH_REQUEST"] = True + run_test(expect_header=True) + + is_permanent = True + app.config["SESSION_REFRESH_EACH_REQUEST"] = False + run_test(expect_header=False) + + is_permanent = False + app.config["SESSION_REFRESH_EACH_REQUEST"] = True + run_test(expect_header=False) + + is_permanent = False + app.config["SESSION_REFRESH_EACH_REQUEST"] = False + run_test(expect_header=False) + + +def test_session_vary_cookie(app, client): + @app.route("/set") + def set_session(): + flask.session["test"] = "test" + return "" + + @app.route("/get") + def get(): + return flask.session.get("test") + + @app.route("/getitem") + def getitem(): + return flask.session["test"] + + @app.route("/setdefault") + def setdefault(): + return flask.session.setdefault("test", "default") + + @app.route("/clear") + def clear(): + flask.session.clear() + return "" + + @app.route("/vary-cookie-header-set") + def vary_cookie_header_set(): + response = flask.Response() + response.vary.add("Cookie") + flask.session["test"] = "test" + return response + + @app.route("/vary-header-set") + def vary_header_set(): + response = flask.Response() + response.vary.update(("Accept-Encoding", "Accept-Language")) + flask.session["test"] = "test" + return response + + @app.route("/no-vary-header") + def no_vary_header(): + return "" + + def expect(path, header_value="Cookie"): + rv = client.get(path) + + if header_value: + # The 'Vary' key should exist in the headers only once. + assert len(rv.headers.get_all("Vary")) == 1 + assert rv.headers["Vary"] == header_value + else: + assert "Vary" not in rv.headers + + expect("/set") + expect("/get") + expect("/getitem") + expect("/setdefault") + expect("/clear") + expect("/vary-cookie-header-set") + expect("/vary-header-set", "Accept-Encoding, Accept-Language, Cookie") + expect("/no-vary-header", None) + + +def test_session_refresh_vary(app, client): + @app.get("/login") + def login(): + flask.session["user_id"] = 1 + flask.session.permanent = True + return "" + + @app.get("/ignored") + def ignored(): + return "" + + rv = client.get("/login") + assert rv.headers["Vary"] == "Cookie" + rv = client.get("/ignored") + assert rv.headers["Vary"] == "Cookie" + + +def test_flashes(app, req_ctx): + assert not flask.session.modified + flask.flash("Zap") + flask.session.modified = False + flask.flash("Zip") + assert flask.session.modified + assert list(flask.get_flashed_messages()) == ["Zap", "Zip"] + + +def test_extended_flashing(app): + # Be sure app.testing=True below, else tests can fail silently. + # + # Specifically, if app.testing is not set to True, the AssertionErrors + # in the view functions will cause a 500 response to the test client + # instead of propagating exceptions. + + @app.route("/") + def index(): + flask.flash("Hello World") + flask.flash("Hello World", "error") + flask.flash(Markup("Testing"), "warning") + return "" + + @app.route("/test/") + def test(): + messages = flask.get_flashed_messages() + assert list(messages) == [ + "Hello World", + "Hello World", + Markup("Testing"), + ] + return "" + + @app.route("/test_with_categories/") + def test_with_categories(): + messages = flask.get_flashed_messages(with_categories=True) + assert len(messages) == 3 + assert list(messages) == [ + ("message", "Hello World"), + ("error", "Hello World"), + ("warning", Markup("Testing")), + ] + return "" + + @app.route("/test_filter/") + def test_filter(): + messages = flask.get_flashed_messages( + category_filter=["message"], with_categories=True + ) + assert list(messages) == [("message", "Hello World")] + return "" + + @app.route("/test_filters/") + def test_filters(): + messages = flask.get_flashed_messages( + category_filter=["message", "warning"], with_categories=True + ) + assert list(messages) == [ + ("message", "Hello World"), + ("warning", Markup("Testing")), + ] + return "" + + @app.route("/test_filters_without_returning_categories/") + def test_filters2(): + messages = flask.get_flashed_messages(category_filter=["message", "warning"]) + assert len(messages) == 2 + assert messages[0] == "Hello World" + assert messages[1] == Markup("Testing") + return "" + + # Create new test client on each test to clean flashed messages. + + client = app.test_client() + client.get("/") + client.get("/test_with_categories/") + + client = app.test_client() + client.get("/") + client.get("/test_filter/") + + client = app.test_client() + client.get("/") + client.get("/test_filters/") + + client = app.test_client() + client.get("/") + client.get("/test_filters_without_returning_categories/") + + +def test_request_processing(app, client): + evts = [] + + @app.before_request + def before_request(): + evts.append("before") + + @app.after_request + def after_request(response): + response.data += b"|after" + evts.append("after") + return response + + @app.route("/") + def index(): + assert "before" in evts + assert "after" not in evts + return "request" + + assert "after" not in evts + rv = client.get("/").data + assert "after" in evts + assert rv == b"request|after" + + +def test_request_preprocessing_early_return(app, client): + evts = [] + + @app.before_request + def before_request1(): + evts.append(1) + + @app.before_request + def before_request2(): + evts.append(2) + return "hello" + + @app.before_request + def before_request3(): + evts.append(3) + return "bye" + + @app.route("/") + def index(): + evts.append("index") + return "damnit" + + rv = client.get("/").data.strip() + assert rv == b"hello" + assert evts == [1, 2] + + +def test_after_request_processing(app, client): + @app.route("/") + def index(): + @flask.after_this_request + def foo(response): + response.headers["X-Foo"] = "a header" + return response + + return "Test" + + resp = client.get("/") + assert resp.status_code == 200 + assert resp.headers["X-Foo"] == "a header" + + +def test_teardown_request_handler(app, client): + called = [] + + @app.teardown_request + def teardown_request(exc): + called.append(True) + return "Ignored" + + @app.route("/") + def root(): + return "Response" + + rv = client.get("/") + assert rv.status_code == 200 + assert b"Response" in rv.data + assert len(called) == 1 + + +def test_teardown_request_handler_debug_mode(app, client): + called = [] + + @app.teardown_request + def teardown_request(exc): + called.append(True) + return "Ignored" + + @app.route("/") + def root(): + return "Response" + + rv = client.get("/") + assert rv.status_code == 200 + assert b"Response" in rv.data + assert len(called) == 1 + + +def test_teardown_request_handler_error(app, client): + called = [] + app.testing = False + + @app.teardown_request + def teardown_request1(exc): + assert type(exc) is ZeroDivisionError + called.append(True) + # This raises a new error and blows away sys.exc_info(), so we can + # test that all teardown_requests get passed the same original + # exception. + try: + raise TypeError() + except Exception: + pass + + @app.teardown_request + def teardown_request2(exc): + assert type(exc) is ZeroDivisionError + called.append(True) + # This raises a new error and blows away sys.exc_info(), so we can + # test that all teardown_requests get passed the same original + # exception. + try: + raise TypeError() + except Exception: + pass + + @app.route("/") + def fails(): + raise ZeroDivisionError + + rv = client.get("/") + assert rv.status_code == 500 + assert b"Internal Server Error" in rv.data + assert len(called) == 2 + + +def test_before_after_request_order(app, client): + called = [] + + @app.before_request + def before1(): + called.append(1) + + @app.before_request + def before2(): + called.append(2) + + @app.after_request + def after1(response): + called.append(4) + return response + + @app.after_request + def after2(response): + called.append(3) + return response + + @app.teardown_request + def finish1(exc): + called.append(6) + + @app.teardown_request + def finish2(exc): + called.append(5) + + @app.route("/") + def index(): + return "42" + + rv = client.get("/") + assert rv.data == b"42" + assert called == [1, 2, 3, 4, 5, 6] + + +def test_error_handling(app, client): + app.testing = False + + @app.errorhandler(404) + def not_found(e): + return "not found", 404 + + @app.errorhandler(500) + def internal_server_error(e): + return "internal server error", 500 + + @app.errorhandler(Forbidden) + def forbidden(e): + return "forbidden", 403 + + @app.route("/") + def index(): + flask.abort(404) + + @app.route("/error") + def error(): + raise ZeroDivisionError + + @app.route("/forbidden") + def error2(): + flask.abort(403) + + rv = client.get("/") + assert rv.status_code == 404 + assert rv.data == b"not found" + rv = client.get("/error") + assert rv.status_code == 500 + assert b"internal server error" == rv.data + rv = client.get("/forbidden") + assert rv.status_code == 403 + assert b"forbidden" == rv.data + + +def test_error_handling_processing(app, client): + app.testing = False + + @app.errorhandler(500) + def internal_server_error(e): + return "internal server error", 500 + + @app.route("/") + def broken_func(): + raise ZeroDivisionError + + @app.after_request + def after_request(resp): + resp.mimetype = "text/x-special" + return resp + + resp = client.get("/") + assert resp.mimetype == "text/x-special" + assert resp.data == b"internal server error" + + +def test_baseexception_error_handling(app, client): + app.testing = False + + @app.route("/") + def broken_func(): + raise KeyboardInterrupt() + + with pytest.raises(KeyboardInterrupt): + client.get("/") + + +def test_before_request_and_routing_errors(app, client): + @app.before_request + def attach_something(): + flask.g.something = "value" + + @app.errorhandler(404) + def return_something(error): + return flask.g.something, 404 + + rv = client.get("/") + assert rv.status_code == 404 + assert rv.data == b"value" + + +def test_user_error_handling(app, client): + class MyException(Exception): + pass + + @app.errorhandler(MyException) + def handle_my_exception(e): + assert isinstance(e, MyException) + return "42" + + @app.route("/") + def index(): + raise MyException() + + assert client.get("/").data == b"42" + + +def test_http_error_subclass_handling(app, client): + class ForbiddenSubclass(Forbidden): + pass + + @app.errorhandler(ForbiddenSubclass) + def handle_forbidden_subclass(e): + assert isinstance(e, ForbiddenSubclass) + return "banana" + + @app.errorhandler(403) + def handle_403(e): + assert not isinstance(e, ForbiddenSubclass) + assert isinstance(e, Forbidden) + return "apple" + + @app.route("/1") + def index1(): + raise ForbiddenSubclass() + + @app.route("/2") + def index2(): + flask.abort(403) + + @app.route("/3") + def index3(): + raise Forbidden() + + assert client.get("/1").data == b"banana" + assert client.get("/2").data == b"apple" + assert client.get("/3").data == b"apple" + + +def test_errorhandler_precedence(app, client): + class E1(Exception): + pass + + class E2(Exception): + pass + + class E3(E1, E2): + pass + + @app.errorhandler(E2) + def handle_e2(e): + return "E2" + + @app.errorhandler(Exception) + def handle_exception(e): + return "Exception" + + @app.route("/E1") + def raise_e1(): + raise E1 + + @app.route("/E3") + def raise_e3(): + raise E3 + + rv = client.get("/E1") + assert rv.data == b"Exception" + + rv = client.get("/E3") + assert rv.data == b"E2" + + +@pytest.mark.parametrize( + ("debug", "trap", "expect_key", "expect_abort"), + [(False, None, True, True), (True, None, False, True), (False, True, False, False)], +) +def test_trap_bad_request_key_error(app, client, debug, trap, expect_key, expect_abort): + app.config["DEBUG"] = debug + app.config["TRAP_BAD_REQUEST_ERRORS"] = trap + + @app.route("/key") + def fail(): + flask.request.form["missing_key"] + + @app.route("/abort") + def allow_abort(): + flask.abort(400) + + if expect_key: + rv = client.get("/key") + assert rv.status_code == 400 + assert b"missing_key" not in rv.data + else: + with pytest.raises(KeyError) as exc_info: + client.get("/key") + + assert exc_info.errisinstance(BadRequest) + assert "missing_key" in exc_info.value.get_description() + + if expect_abort: + rv = client.get("/abort") + assert rv.status_code == 400 + else: + with pytest.raises(BadRequest): + client.get("/abort") + + +def test_trapping_of_all_http_exceptions(app, client): + app.config["TRAP_HTTP_EXCEPTIONS"] = True + + @app.route("/fail") + def fail(): + flask.abort(404) + + with pytest.raises(NotFound): + client.get("/fail") + + +def test_error_handler_after_processor_error(app, client): + app.testing = False + + @app.before_request + def before_request(): + if _trigger == "before": + raise ZeroDivisionError + + @app.after_request + def after_request(response): + if _trigger == "after": + raise ZeroDivisionError + + return response + + @app.route("/") + def index(): + return "Foo" + + @app.errorhandler(500) + def internal_server_error(e): + return "Hello Server Error", 500 + + for _trigger in "before", "after": + rv = client.get("/") + assert rv.status_code == 500 + assert rv.data == b"Hello Server Error" + + +def test_enctype_debug_helper(app, client): + from flask.debughelpers import DebugFilesKeyError + + app.debug = True + + @app.route("/fail", methods=["POST"]) + def index(): + return flask.request.files["foo"].filename + + with pytest.raises(DebugFilesKeyError) as e: + client.post("/fail", data={"foo": "index.txt"}) + assert "no file contents were transmitted" in str(e.value) + assert "This was submitted: 'index.txt'" in str(e.value) + + +def test_response_types(app, client): + @app.route("/text") + def from_text(): + return "Hällo Wörld" + + @app.route("/bytes") + def from_bytes(): + return "Hällo Wörld".encode() + + @app.route("/full_tuple") + def from_full_tuple(): + return ( + "Meh", + 400, + {"X-Foo": "Testing", "Content-Type": "text/plain; charset=utf-8"}, + ) + + @app.route("/text_headers") + def from_text_headers(): + return "Hello", {"X-Foo": "Test", "Content-Type": "text/plain; charset=utf-8"} + + @app.route("/text_status") + def from_text_status(): + return "Hi, status!", 400 + + @app.route("/response_headers") + def from_response_headers(): + return ( + flask.Response( + "Hello world", 404, {"Content-Type": "text/html", "X-Foo": "Baz"} + ), + {"Content-Type": "text/plain", "X-Foo": "Bar", "X-Bar": "Foo"}, + ) + + @app.route("/response_status") + def from_response_status(): + return app.response_class("Hello world", 400), 500 + + @app.route("/wsgi") + def from_wsgi(): + return NotFound() + + @app.route("/dict") + def from_dict(): + return {"foo": "bar"}, 201 + + @app.route("/list") + def from_list(): + return ["foo", "bar"], 201 + + assert client.get("/text").data == "Hällo Wörld".encode() + assert client.get("/bytes").data == "Hällo Wörld".encode() + + rv = client.get("/full_tuple") + assert rv.data == b"Meh" + assert rv.headers["X-Foo"] == "Testing" + assert rv.status_code == 400 + assert rv.mimetype == "text/plain" + + rv = client.get("/text_headers") + assert rv.data == b"Hello" + assert rv.headers["X-Foo"] == "Test" + assert rv.status_code == 200 + assert rv.mimetype == "text/plain" + + rv = client.get("/text_status") + assert rv.data == b"Hi, status!" + assert rv.status_code == 400 + assert rv.mimetype == "text/html" + + rv = client.get("/response_headers") + assert rv.data == b"Hello world" + assert rv.content_type == "text/plain" + assert rv.headers.getlist("X-Foo") == ["Bar"] + assert rv.headers["X-Bar"] == "Foo" + assert rv.status_code == 404 + + rv = client.get("/response_status") + assert rv.data == b"Hello world" + assert rv.status_code == 500 + + rv = client.get("/wsgi") + assert b"Not Found" in rv.data + assert rv.status_code == 404 + + rv = client.get("/dict") + assert rv.json == {"foo": "bar"} + assert rv.status_code == 201 + + rv = client.get("/list") + assert rv.json == ["foo", "bar"] + assert rv.status_code == 201 + + +def test_response_type_errors(): + app = flask.Flask(__name__) + app.testing = True + + @app.route("/none") + def from_none(): + pass + + @app.route("/small_tuple") + def from_small_tuple(): + return ("Hello",) + + @app.route("/large_tuple") + def from_large_tuple(): + return "Hello", 234, {"X-Foo": "Bar"}, "???" + + @app.route("/bad_type") + def from_bad_type(): + return True + + @app.route("/bad_wsgi") + def from_bad_wsgi(): + return lambda: None + + c = app.test_client() + + with pytest.raises(TypeError) as e: + c.get("/none") + + assert "returned None" in str(e.value) + assert "from_none" in str(e.value) + + with pytest.raises(TypeError) as e: + c.get("/small_tuple") + + assert "tuple must have the form" in str(e.value) + + with pytest.raises(TypeError): + c.get("/large_tuple") + + with pytest.raises(TypeError) as e: + c.get("/bad_type") + + assert "it was a bool" in str(e.value) + + with pytest.raises(TypeError): + c.get("/bad_wsgi") + + +def test_make_response(app, req_ctx): + rv = flask.make_response() + assert rv.status_code == 200 + assert rv.data == b"" + assert rv.mimetype == "text/html" + + rv = flask.make_response("Awesome") + assert rv.status_code == 200 + assert rv.data == b"Awesome" + assert rv.mimetype == "text/html" + + rv = flask.make_response("W00t", 404) + assert rv.status_code == 404 + assert rv.data == b"W00t" + assert rv.mimetype == "text/html" + + rv = flask.make_response(c for c in "Hello") + assert rv.status_code == 200 + assert rv.data == b"Hello" + assert rv.mimetype == "text/html" + + +def test_make_response_with_response_instance(app, req_ctx): + rv = flask.make_response(flask.jsonify({"msg": "W00t"}), 400) + assert rv.status_code == 400 + assert rv.data == b'{"msg":"W00t"}\n' + assert rv.mimetype == "application/json" + + rv = flask.make_response(flask.Response(""), 400) + assert rv.status_code == 400 + assert rv.data == b"" + assert rv.mimetype == "text/html" + + rv = flask.make_response( + flask.Response("", headers={"Content-Type": "text/html"}), + 400, + [("X-Foo", "bar")], + ) + assert rv.status_code == 400 + assert rv.headers["Content-Type"] == "text/html" + assert rv.headers["X-Foo"] == "bar" + + +@pytest.mark.parametrize("compact", [True, False]) +def test_jsonify_no_prettyprint(app, compact): + app.json.compact = compact + rv = app.json.response({"msg": {"submsg": "W00t"}, "msg2": "foobar"}) + data = rv.data.strip() + assert (b" " not in data) is compact + assert (b"\n" not in data) is compact + + +def test_jsonify_mimetype(app, req_ctx): + app.json.mimetype = "application/vnd.api+json" + msg = {"msg": {"submsg": "W00t"}} + rv = flask.make_response(flask.jsonify(msg), 200) + assert rv.mimetype == "application/vnd.api+json" + + +def test_json_dump_dataclass(app, req_ctx): + from dataclasses import make_dataclass + + Data = make_dataclass("Data", [("name", str)]) + value = app.json.dumps(Data("Flask")) + value = app.json.loads(value) + assert value == {"name": "Flask"} + + +def test_jsonify_args_and_kwargs_check(app, req_ctx): + with pytest.raises(TypeError) as e: + flask.jsonify("fake args", kwargs="fake") + assert "args or kwargs" in str(e.value) + + +def test_url_generation(app, req_ctx): + @app.route("/hello/", methods=["POST"]) + def hello(): + pass + + assert flask.url_for("hello", name="test x") == "/hello/test%20x" + assert ( + flask.url_for("hello", name="test x", _external=True) + == "http://localhost/hello/test%20x" + ) + + +def test_build_error_handler(app): + # Test base case, a URL which results in a BuildError. + with app.test_request_context(): + pytest.raises(BuildError, flask.url_for, "spam") + + # Verify the error is re-raised if not the current exception. + try: + with app.test_request_context(): + flask.url_for("spam") + except BuildError as err: + error = err + try: + raise RuntimeError("Test case where BuildError is not current.") + except RuntimeError: + pytest.raises(BuildError, app.handle_url_build_error, error, "spam", {}) + + # Test a custom handler. + def handler(error, endpoint, values): + # Just a test. + return "/test_handler/" + + app.url_build_error_handlers.append(handler) + with app.test_request_context(): + assert flask.url_for("spam") == "/test_handler/" + + +def test_build_error_handler_reraise(app): + # Test a custom handler which reraises the BuildError + def handler_raises_build_error(error, endpoint, values): + raise error + + app.url_build_error_handlers.append(handler_raises_build_error) + + with app.test_request_context(): + pytest.raises(BuildError, flask.url_for, "not.existing") + + +def test_url_for_passes_special_values_to_build_error_handler(app): + @app.url_build_error_handlers.append + def handler(error, endpoint, values): + assert values == { + "_external": False, + "_anchor": None, + "_method": None, + "_scheme": None, + } + return "handled" + + with app.test_request_context(): + flask.url_for("/") + + +def test_static_files(app, client): + rv = client.get("/static/index.html") + assert rv.status_code == 200 + assert rv.data.strip() == b"

Hello World!

" + with app.test_request_context(): + assert flask.url_for("static", filename="index.html") == "/static/index.html" + rv.close() + + +def test_static_url_path(): + app = flask.Flask(__name__, static_url_path="/foo") + app.testing = True + rv = app.test_client().get("/foo/index.html") + assert rv.status_code == 200 + rv.close() + + with app.test_request_context(): + assert flask.url_for("static", filename="index.html") == "/foo/index.html" + + +def test_static_url_path_with_ending_slash(): + app = flask.Flask(__name__, static_url_path="/foo/") + app.testing = True + rv = app.test_client().get("/foo/index.html") + assert rv.status_code == 200 + rv.close() + + with app.test_request_context(): + assert flask.url_for("static", filename="index.html") == "/foo/index.html" + + +def test_static_url_empty_path(app): + app = flask.Flask(__name__, static_folder="", static_url_path="") + rv = app.test_client().open("/static/index.html", method="GET") + assert rv.status_code == 200 + rv.close() + + +def test_static_url_empty_path_default(app): + app = flask.Flask(__name__, static_folder="") + rv = app.test_client().open("/static/index.html", method="GET") + assert rv.status_code == 200 + rv.close() + + +def test_static_folder_with_pathlib_path(app): + from pathlib import Path + + app = flask.Flask(__name__, static_folder=Path("static")) + rv = app.test_client().open("/static/index.html", method="GET") + assert rv.status_code == 200 + rv.close() + + +def test_static_folder_with_ending_slash(): + app = flask.Flask(__name__, static_folder="static/") + + @app.route("/") + def catch_all(path): + return path + + rv = app.test_client().get("/catch/all") + assert rv.data == b"catch/all" + + +def test_static_route_with_host_matching(): + app = flask.Flask(__name__, host_matching=True, static_host="example.com") + c = app.test_client() + rv = c.get("http://example.com/static/index.html") + assert rv.status_code == 200 + rv.close() + with app.test_request_context(): + rv = flask.url_for("static", filename="index.html", _external=True) + assert rv == "http://example.com/static/index.html" + # Providing static_host without host_matching=True should error. + with pytest.raises(AssertionError): + flask.Flask(__name__, static_host="example.com") + # Providing host_matching=True with static_folder + # but without static_host should error. + with pytest.raises(AssertionError): + flask.Flask(__name__, host_matching=True) + # Providing host_matching=True without static_host + # but with static_folder=None should not error. + flask.Flask(__name__, host_matching=True, static_folder=None) + + +def test_request_locals(): + assert repr(flask.g) == "" + assert not flask.g + + +@pytest.mark.parametrize( + ("subdomain_matching", "host_matching", "expect_base", "expect_abc", "expect_xyz"), + [ + (False, False, "default", "default", "default"), + (True, False, "default", "abc", ""), + (False, True, "default", "abc", "default"), + ], +) +def test_server_name_matching( + subdomain_matching: bool, + host_matching: bool, + expect_base: str, + expect_abc: str, + expect_xyz: str, +) -> None: + app = flask.Flask( + __name__, + subdomain_matching=subdomain_matching, + host_matching=host_matching, + static_host="example.test" if host_matching else None, + ) + app.config["SERVER_NAME"] = "example.test" + + @app.route("/", defaults={"name": "default"}, host="") + @app.route("/", subdomain="", host=".example.test") + def index(name: str) -> str: + return name + + client = app.test_client() + + r = client.get(base_url="http://example.test") + assert r.text == expect_base + + r = client.get(base_url="http://abc.example.test") + assert r.text == expect_abc + + with pytest.warns() if subdomain_matching else nullcontext(): + r = client.get(base_url="http://xyz.other.test") + + assert r.text == expect_xyz + + +def test_server_name_subdomain(): + app = flask.Flask(__name__, subdomain_matching=True) + client = app.test_client() + + @app.route("/") + def index(): + return "default" + + @app.route("/", subdomain="foo") + def subdomain(): + return "subdomain" + + app.config["SERVER_NAME"] = "dev.local:5000" + rv = client.get("/") + assert rv.data == b"default" + + rv = client.get("/", "http://dev.local:5000") + assert rv.data == b"default" + + rv = client.get("/", "https://dev.local:5000") + assert rv.data == b"default" + + app.config["SERVER_NAME"] = "dev.local:443" + rv = client.get("/", "https://dev.local") + + # Werkzeug 1.0 fixes matching https scheme with 443 port + if rv.status_code != 404: + assert rv.data == b"default" + + app.config["SERVER_NAME"] = "dev.local" + rv = client.get("/", "https://dev.local") + assert rv.data == b"default" + + # suppress Werkzeug 0.15 warning about name mismatch + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", "Current server name", UserWarning, "flask.app" + ) + rv = client.get("/", "http://foo.localhost") + assert rv.status_code == 404 + + rv = client.get("/", "http://foo.dev.local") + assert rv.data == b"subdomain" + + +@pytest.mark.parametrize("key", ["TESTING", "PROPAGATE_EXCEPTIONS", "DEBUG", None]) +def test_exception_propagation(app, client, key): + app.testing = False + + @app.route("/") + def index(): + raise ZeroDivisionError + + if key is not None: + app.config[key] = True + + with pytest.raises(ZeroDivisionError): + client.get("/") + else: + assert client.get("/").status_code == 500 + + +@pytest.mark.parametrize("debug", [True, False]) +@pytest.mark.parametrize("use_debugger", [True, False]) +@pytest.mark.parametrize("use_reloader", [True, False]) +@pytest.mark.parametrize("propagate_exceptions", [None, True, False]) +def test_werkzeug_passthrough_errors( + monkeypatch, debug, use_debugger, use_reloader, propagate_exceptions, app +): + rv = {} + + # Mocks werkzeug.serving.run_simple method + def run_simple_mock(*args, **kwargs): + rv["passthrough_errors"] = kwargs.get("passthrough_errors") + + monkeypatch.setattr(werkzeug.serving, "run_simple", run_simple_mock) + app.config["PROPAGATE_EXCEPTIONS"] = propagate_exceptions + app.run(debug=debug, use_debugger=use_debugger, use_reloader=use_reloader) + + +def test_url_processors(app, client): + @app.url_defaults + def add_language_code(endpoint, values): + if flask.g.lang_code is not None and app.url_map.is_endpoint_expecting( + endpoint, "lang_code" + ): + values.setdefault("lang_code", flask.g.lang_code) + + @app.url_value_preprocessor + def pull_lang_code(endpoint, values): + flask.g.lang_code = values.pop("lang_code", None) + + @app.route("//") + def index(): + return flask.url_for("about") + + @app.route("//about") + def about(): + return flask.url_for("something_else") + + @app.route("/foo") + def something_else(): + return flask.url_for("about", lang_code="en") + + assert client.get("/de/").data == b"/de/about" + assert client.get("/de/about").data == b"/foo" + assert client.get("/foo").data == b"/en/about" + + +def test_inject_blueprint_url_defaults(app): + bp = flask.Blueprint("foo", __name__, template_folder="template") + + @bp.url_defaults + def bp_defaults(endpoint, values): + values["page"] = "login" + + @bp.route("/") + def view(page): + pass + + app.register_blueprint(bp) + + values = dict() + app.inject_url_defaults("foo.view", values) + expected = dict(page="login") + assert values == expected + + with app.test_request_context("/somepage"): + url = flask.url_for("foo.view") + expected = "/login" + assert url == expected + + +def test_nonascii_pathinfo(app, client): + @app.route("/киртест") + def index(): + return "Hello World!" + + rv = client.get("/киртест") + assert rv.data == b"Hello World!" + + +def test_no_setup_after_first_request(app, client): + app.debug = True + + @app.route("/") + def index(): + return "Awesome" + + assert client.get("/").data == b"Awesome" + + with pytest.raises(AssertionError) as exc_info: + app.add_url_rule("/foo", endpoint="late") + + assert "setup method 'add_url_rule'" in str(exc_info.value) + + +def test_routing_redirect_debugging(monkeypatch, app, client): + app.config["DEBUG"] = True + + @app.route("/user/", methods=["GET", "POST"]) + def user(): + return flask.request.form["status"] + + # default redirect code preserves form data + rv = client.post("/user", data={"status": "success"}, follow_redirects=True) + assert rv.data == b"success" + + # 301 and 302 raise error + monkeypatch.setattr(RequestRedirect, "code", 301) + + with client, pytest.raises(AssertionError) as exc_info: + client.post("/user", data={"status": "error"}, follow_redirects=True) + + assert "canonical URL 'http://localhost/user/'" in str(exc_info.value) + + +def test_route_decorator_custom_endpoint(app, client): + app.debug = True + + @app.route("/foo/") + def foo(): + return flask.request.endpoint + + @app.route("/bar/", endpoint="bar") + def for_bar(): + return flask.request.endpoint + + @app.route("/bar/123", endpoint="123") + def for_bar_foo(): + return flask.request.endpoint + + with app.test_request_context(): + assert flask.url_for("foo") == "/foo/" + assert flask.url_for("bar") == "/bar/" + assert flask.url_for("123") == "/bar/123" + + assert client.get("/foo/").data == b"foo" + assert client.get("/bar/").data == b"bar" + assert client.get("/bar/123").data == b"123" + + +def test_get_method_on_g(app_ctx): + assert flask.g.get("x") is None + assert flask.g.get("x", 11) == 11 + flask.g.x = 42 + assert flask.g.get("x") == 42 + assert flask.g.x == 42 + + +def test_g_iteration_protocol(app_ctx): + flask.g.foo = 23 + flask.g.bar = 42 + assert "foo" in flask.g + assert "foos" not in flask.g + assert sorted(flask.g) == ["bar", "foo"] + + +def test_subdomain_basic_support(): + app = flask.Flask(__name__, subdomain_matching=True) + app.config["SERVER_NAME"] = "localhost.localdomain" + client = app.test_client() + + @app.route("/") + def normal_index(): + return "normal index" + + @app.route("/", subdomain="test") + def test_index(): + return "test index" + + rv = client.get("/", "http://localhost.localdomain/") + assert rv.data == b"normal index" + + rv = client.get("/", "http://test.localhost.localdomain/") + assert rv.data == b"test index" + + +def test_subdomain_matching(): + app = flask.Flask(__name__, subdomain_matching=True) + client = app.test_client() + app.config["SERVER_NAME"] = "localhost.localdomain" + + @app.route("/", subdomain="") + def index(user): + return f"index for {user}" + + rv = client.get("/", "http://mitsuhiko.localhost.localdomain/") + assert rv.data == b"index for mitsuhiko" + + +def test_subdomain_matching_with_ports(): + app = flask.Flask(__name__, subdomain_matching=True) + app.config["SERVER_NAME"] = "localhost.localdomain:3000" + client = app.test_client() + + @app.route("/", subdomain="") + def index(user): + return f"index for {user}" + + rv = client.get("/", "http://mitsuhiko.localhost.localdomain:3000/") + assert rv.data == b"index for mitsuhiko" + + +@pytest.mark.parametrize("matching", (False, True)) +def test_subdomain_matching_other_name(matching): + app = flask.Flask(__name__, subdomain_matching=matching) + app.config["SERVER_NAME"] = "localhost.localdomain:3000" + client = app.test_client() + + @app.route("/") + def index(): + return "", 204 + + # suppress Werkzeug 0.15 warning about name mismatch + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", "Current server name", UserWarning, "flask.app" + ) + # ip address can't match name + rv = client.get("/", "http://127.0.0.1:3000/") + assert rv.status_code == 404 if matching else 204 + + # allow all subdomains if matching is disabled + rv = client.get("/", "http://www.localhost.localdomain:3000/") + assert rv.status_code == 404 if matching else 204 + + +def test_multi_route_rules(app, client): + @app.route("/") + @app.route("//") + def index(test="a"): + return test + + rv = client.open("/") + assert rv.data == b"a" + rv = client.open("/b/") + assert rv.data == b"b" + + +def test_multi_route_class_views(app, client): + class View: + def __init__(self, app): + app.add_url_rule("/", "index", self.index) + app.add_url_rule("//", "index", self.index) + + def index(self, test="a"): + return test + + _ = View(app) + rv = client.open("/") + assert rv.data == b"a" + rv = client.open("/b/") + assert rv.data == b"b" + + +def test_run_defaults(monkeypatch, app): + rv = {} + + # Mocks werkzeug.serving.run_simple method + def run_simple_mock(*args, **kwargs): + rv["result"] = "running..." + + monkeypatch.setattr(werkzeug.serving, "run_simple", run_simple_mock) + app.run() + assert rv["result"] == "running..." + + +def test_run_server_port(monkeypatch, app): + rv = {} + + # Mocks werkzeug.serving.run_simple method + def run_simple_mock(hostname, port, application, *args, **kwargs): + rv["result"] = f"running on {hostname}:{port} ..." + + monkeypatch.setattr(werkzeug.serving, "run_simple", run_simple_mock) + hostname, port = "localhost", 8000 + app.run(hostname, port, debug=True) + assert rv["result"] == f"running on {hostname}:{port} ..." + + +@pytest.mark.parametrize( + "host,port,server_name,expect_host,expect_port", + ( + (None, None, "pocoo.org:8080", "pocoo.org", 8080), + ("localhost", None, "pocoo.org:8080", "localhost", 8080), + (None, 80, "pocoo.org:8080", "pocoo.org", 80), + ("localhost", 80, "pocoo.org:8080", "localhost", 80), + ("localhost", 0, "localhost:8080", "localhost", 0), + (None, None, "localhost:8080", "localhost", 8080), + (None, None, "localhost:0", "localhost", 0), + ), +) +def test_run_from_config( + monkeypatch, host, port, server_name, expect_host, expect_port, app +): + def run_simple_mock(hostname, port, *args, **kwargs): + assert hostname == expect_host + assert port == expect_port + + monkeypatch.setattr(werkzeug.serving, "run_simple", run_simple_mock) + app.config["SERVER_NAME"] = server_name + app.run(host, port) + + +def test_max_cookie_size(app, client, recwarn): + app.config["MAX_COOKIE_SIZE"] = 100 + + # outside app context, default to Werkzeug static value, + # which is also the default config + response = flask.Response() + default = flask.Flask.default_config["MAX_COOKIE_SIZE"] + assert response.max_cookie_size == default + + # inside app context, use app config + with app.app_context(): + assert flask.Response().max_cookie_size == 100 + + @app.route("/") + def index(): + r = flask.Response("", status=204) + r.set_cookie("foo", "bar" * 100) + return r + + client.get("/") + assert len(recwarn) == 1 + w = recwarn.pop() + assert "cookie is too large" in str(w.message) + + app.config["MAX_COOKIE_SIZE"] = 0 + + client.get("/") + assert len(recwarn) == 0 + + +@require_cpython_gc +def test_app_freed_on_zero_refcount(): + # A Flask instance should not create a reference cycle that prevents CPython + # from freeing it when all external references to it are released (see #3761). + gc.disable() + try: + app = flask.Flask(__name__) + assert app.view_functions["static"] + weak = weakref.ref(app) + assert weak() is not None + del app + assert weak() is None + finally: + gc.enable() diff --git a/src/flask-main/tests/test_blueprints.py b/src/flask-main/tests/test_blueprints.py new file mode 100644 index 0000000..ed1683c --- /dev/null +++ b/src/flask-main/tests/test_blueprints.py @@ -0,0 +1,1127 @@ +import pytest +from jinja2 import TemplateNotFound +from werkzeug.http import parse_cache_control_header + +import flask + + +def test_blueprint_specific_error_handling(app, client): + frontend = flask.Blueprint("frontend", __name__) + backend = flask.Blueprint("backend", __name__) + sideend = flask.Blueprint("sideend", __name__) + + @frontend.errorhandler(403) + def frontend_forbidden(e): + return "frontend says no", 403 + + @frontend.route("/frontend-no") + def frontend_no(): + flask.abort(403) + + @backend.errorhandler(403) + def backend_forbidden(e): + return "backend says no", 403 + + @backend.route("/backend-no") + def backend_no(): + flask.abort(403) + + @sideend.route("/what-is-a-sideend") + def sideend_no(): + flask.abort(403) + + app.register_blueprint(frontend) + app.register_blueprint(backend) + app.register_blueprint(sideend) + + @app.errorhandler(403) + def app_forbidden(e): + return "application itself says no", 403 + + assert client.get("/frontend-no").data == b"frontend says no" + assert client.get("/backend-no").data == b"backend says no" + assert client.get("/what-is-a-sideend").data == b"application itself says no" + + +def test_blueprint_specific_user_error_handling(app, client): + class MyDecoratorException(Exception): + pass + + class MyFunctionException(Exception): + pass + + blue = flask.Blueprint("blue", __name__) + + @blue.errorhandler(MyDecoratorException) + def my_decorator_exception_handler(e): + assert isinstance(e, MyDecoratorException) + return "boom" + + def my_function_exception_handler(e): + assert isinstance(e, MyFunctionException) + return "bam" + + blue.register_error_handler(MyFunctionException, my_function_exception_handler) + + @blue.route("/decorator") + def blue_deco_test(): + raise MyDecoratorException() + + @blue.route("/function") + def blue_func_test(): + raise MyFunctionException() + + app.register_blueprint(blue) + + assert client.get("/decorator").data == b"boom" + assert client.get("/function").data == b"bam" + + +def test_blueprint_app_error_handling(app, client): + errors = flask.Blueprint("errors", __name__) + + @errors.app_errorhandler(403) + def forbidden_handler(e): + return "you shall not pass", 403 + + @app.route("/forbidden") + def app_forbidden(): + flask.abort(403) + + forbidden_bp = flask.Blueprint("forbidden_bp", __name__) + + @forbidden_bp.route("/nope") + def bp_forbidden(): + flask.abort(403) + + app.register_blueprint(errors) + app.register_blueprint(forbidden_bp) + + assert client.get("/forbidden").data == b"you shall not pass" + assert client.get("/nope").data == b"you shall not pass" + + +@pytest.mark.parametrize( + ("prefix", "rule", "url"), + ( + ("", "/", "/"), + ("/", "", "/"), + ("/", "/", "/"), + ("/foo", "", "/foo"), + ("/foo/", "", "/foo/"), + ("", "/bar", "/bar"), + ("/foo/", "/bar", "/foo/bar"), + ("/foo/", "bar", "/foo/bar"), + ("/foo", "/bar", "/foo/bar"), + ("/foo/", "//bar", "/foo/bar"), + ("/foo//", "/bar", "/foo/bar"), + ), +) +def test_blueprint_prefix_slash(app, client, prefix, rule, url): + bp = flask.Blueprint("test", __name__, url_prefix=prefix) + + @bp.route(rule) + def index(): + return "", 204 + + app.register_blueprint(bp) + assert client.get(url).status_code == 204 + + +def test_blueprint_url_defaults(app, client): + bp = flask.Blueprint("test", __name__) + + @bp.route("/foo", defaults={"baz": 42}) + def foo(bar, baz): + return f"{bar}/{baz:d}" + + @bp.route("/bar") + def bar(bar): + return str(bar) + + app.register_blueprint(bp, url_prefix="/1", url_defaults={"bar": 23}) + app.register_blueprint(bp, name="test2", url_prefix="/2", url_defaults={"bar": 19}) + + assert client.get("/1/foo").data == b"23/42" + assert client.get("/2/foo").data == b"19/42" + assert client.get("/1/bar").data == b"23" + assert client.get("/2/bar").data == b"19" + + +def test_blueprint_url_processors(app, client): + bp = flask.Blueprint("frontend", __name__, url_prefix="/") + + @bp.url_defaults + def add_language_code(endpoint, values): + values.setdefault("lang_code", flask.g.lang_code) + + @bp.url_value_preprocessor + def pull_lang_code(endpoint, values): + flask.g.lang_code = values.pop("lang_code") + + @bp.route("/") + def index(): + return flask.url_for(".about") + + @bp.route("/about") + def about(): + return flask.url_for(".index") + + app.register_blueprint(bp) + + assert client.get("/de/").data == b"/de/about" + assert client.get("/de/about").data == b"/de/" + + +def test_templates_and_static(test_apps): + from blueprintapp import app + + client = app.test_client() + + rv = client.get("/") + assert rv.data == b"Hello from the Frontend" + rv = client.get("/admin/") + assert rv.data == b"Hello from the Admin" + rv = client.get("/admin/index2") + assert rv.data == b"Hello from the Admin" + rv = client.get("/admin/static/test.txt") + assert rv.data.strip() == b"Admin File" + rv.close() + rv = client.get("/admin/static/css/test.css") + assert rv.data.strip() == b"/* nested file */" + rv.close() + + # try/finally, in case other tests use this app for Blueprint tests. + max_age_default = app.config["SEND_FILE_MAX_AGE_DEFAULT"] + try: + expected_max_age = 3600 + if app.config["SEND_FILE_MAX_AGE_DEFAULT"] == expected_max_age: + expected_max_age = 7200 + app.config["SEND_FILE_MAX_AGE_DEFAULT"] = expected_max_age + rv = client.get("/admin/static/css/test.css") + cc = parse_cache_control_header(rv.headers["Cache-Control"]) + assert cc.max_age == expected_max_age + rv.close() + finally: + app.config["SEND_FILE_MAX_AGE_DEFAULT"] = max_age_default + + with app.test_request_context(): + assert ( + flask.url_for("admin.static", filename="test.txt") + == "/admin/static/test.txt" + ) + + with app.test_request_context(): + with pytest.raises(TemplateNotFound) as e: + flask.render_template("missing.html") + assert e.value.name == "missing.html" + + with flask.Flask(__name__).test_request_context(): + assert flask.render_template("nested/nested.txt") == "I'm nested" + + +def test_default_static_max_age(app): + class MyBlueprint(flask.Blueprint): + def get_send_file_max_age(self, filename): + return 100 + + blueprint = MyBlueprint("blueprint", __name__, static_folder="static") + app.register_blueprint(blueprint) + + # try/finally, in case other tests use this app for Blueprint tests. + max_age_default = app.config["SEND_FILE_MAX_AGE_DEFAULT"] + try: + with app.test_request_context(): + unexpected_max_age = 3600 + if app.config["SEND_FILE_MAX_AGE_DEFAULT"] == unexpected_max_age: + unexpected_max_age = 7200 + app.config["SEND_FILE_MAX_AGE_DEFAULT"] = unexpected_max_age + rv = blueprint.send_static_file("index.html") + cc = parse_cache_control_header(rv.headers["Cache-Control"]) + assert cc.max_age == 100 + rv.close() + finally: + app.config["SEND_FILE_MAX_AGE_DEFAULT"] = max_age_default + + +def test_templates_list(test_apps): + from blueprintapp import app + + templates = sorted(app.jinja_env.list_templates()) + assert templates == ["admin/index.html", "frontend/index.html"] + + +def test_dotted_name_not_allowed(app, client): + with pytest.raises(ValueError): + flask.Blueprint("app.ui", __name__) + + +def test_empty_name_not_allowed(app, client): + with pytest.raises(ValueError): + flask.Blueprint("", __name__) + + +def test_dotted_names_from_app(app, client): + test = flask.Blueprint("test", __name__) + + @app.route("/") + def app_index(): + return flask.url_for("test.index") + + @test.route("/test/") + def index(): + return flask.url_for("app_index") + + app.register_blueprint(test) + + rv = client.get("/") + assert rv.data == b"/test/" + + +def test_empty_url_defaults(app, client): + bp = flask.Blueprint("bp", __name__) + + @bp.route("/", defaults={"page": 1}) + @bp.route("/page/") + def something(page): + return str(page) + + app.register_blueprint(bp) + + assert client.get("/").data == b"1" + assert client.get("/page/2").data == b"2" + + +def test_route_decorator_custom_endpoint(app, client): + bp = flask.Blueprint("bp", __name__) + + @bp.route("/foo") + def foo(): + return flask.request.endpoint + + @bp.route("/bar", endpoint="bar") + def foo_bar(): + return flask.request.endpoint + + @bp.route("/bar/123", endpoint="123") + def foo_bar_foo(): + return flask.request.endpoint + + @bp.route("/bar/foo") + def bar_foo(): + return flask.request.endpoint + + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.request.endpoint + + assert client.get("/").data == b"index" + assert client.get("/py/foo").data == b"bp.foo" + assert client.get("/py/bar").data == b"bp.bar" + assert client.get("/py/bar/123").data == b"bp.123" + assert client.get("/py/bar/foo").data == b"bp.bar_foo" + + +def test_route_decorator_custom_endpoint_with_dots(app, client): + bp = flask.Blueprint("bp", __name__) + + with pytest.raises(ValueError): + bp.route("/", endpoint="a.b")(lambda: "") + + with pytest.raises(ValueError): + bp.add_url_rule("/", endpoint="a.b") + + def view(): + return "" + + view.__name__ = "a.b" + + with pytest.raises(ValueError): + bp.add_url_rule("/", view_func=view) + + +def test_endpoint_decorator(app, client): + from werkzeug.routing import Rule + + app.url_map.add(Rule("/foo", endpoint="bar")) + + bp = flask.Blueprint("bp", __name__) + + @bp.endpoint("bar") + def foobar(): + return flask.request.endpoint + + app.register_blueprint(bp, url_prefix="/bp_prefix") + + assert client.get("/foo").data == b"bar" + assert client.get("/bp_prefix/bar").status_code == 404 + + +def test_template_filter(app): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_filter() + def my_reverse(s): + return s[::-1] + + @bp.app_template_filter + def my_reverse_2(s): + return s[::-1] + + @bp.app_template_filter("my_reverse_custom_name_3") + def my_reverse_3(s): + return s[::-1] + + @bp.app_template_filter(name="my_reverse_custom_name_4") + def my_reverse_4(s): + return s[::-1] + + app.register_blueprint(bp, url_prefix="/py") + assert "my_reverse" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse"] == my_reverse + assert app.jinja_env.filters["my_reverse"]("abcd") == "dcba" + + assert "my_reverse_2" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse_2"] == my_reverse_2 + assert app.jinja_env.filters["my_reverse_2"]("abcd") == "dcba" + + assert "my_reverse_custom_name_3" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse_custom_name_3"] == my_reverse_3 + assert app.jinja_env.filters["my_reverse_custom_name_3"]("abcd") == "dcba" + + assert "my_reverse_custom_name_4" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse_custom_name_4"] == my_reverse_4 + assert app.jinja_env.filters["my_reverse_custom_name_4"]("abcd") == "dcba" + + +def test_add_template_filter(app): + bp = flask.Blueprint("bp", __name__) + + def my_reverse(s): + return s[::-1] + + bp.add_app_template_filter(my_reverse) + app.register_blueprint(bp, url_prefix="/py") + assert "my_reverse" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse"] == my_reverse + assert app.jinja_env.filters["my_reverse"]("abcd") == "dcba" + + +def test_template_filter_with_name(app): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_filter("strrev") + def my_reverse(s): + return s[::-1] + + app.register_blueprint(bp, url_prefix="/py") + assert "strrev" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["strrev"] == my_reverse + assert app.jinja_env.filters["strrev"]("abcd") == "dcba" + + +def test_add_template_filter_with_name(app): + bp = flask.Blueprint("bp", __name__) + + def my_reverse(s): + return s[::-1] + + bp.add_app_template_filter(my_reverse, "strrev") + app.register_blueprint(bp, url_prefix="/py") + assert "strrev" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["strrev"] == my_reverse + assert app.jinja_env.filters["strrev"]("abcd") == "dcba" + + +def test_template_filter_with_template(app, client): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_filter() + def super_reverse(s): + return s[::-1] + + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_template_filter_after_route_with_template(app, client): + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_filter() + def super_reverse(s): + return s[::-1] + + app.register_blueprint(bp, url_prefix="/py") + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_add_template_filter_with_template(app, client): + bp = flask.Blueprint("bp", __name__) + + def super_reverse(s): + return s[::-1] + + bp.add_app_template_filter(super_reverse) + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_template_filter_with_name_and_template(app, client): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_filter("super_reverse") + def my_reverse(s): + return s[::-1] + + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_add_template_filter_with_name_and_template(app, client): + bp = flask.Blueprint("bp", __name__) + + def my_reverse(s): + return s[::-1] + + bp.add_app_template_filter(my_reverse, "super_reverse") + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_template_test(app): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_test() + def is_boolean(value): + return isinstance(value, bool) + + @bp.app_template_test + def boolean_2(value): + return isinstance(value, bool) + + @bp.app_template_test("my_boolean_custom_name") + def boolean_3(value): + return isinstance(value, bool) + + @bp.app_template_test(name="my_boolean_custom_name_2") + def boolean_4(value): + return isinstance(value, bool) + + app.register_blueprint(bp, url_prefix="/py") + assert "is_boolean" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["is_boolean"] == is_boolean + assert app.jinja_env.tests["is_boolean"](False) + + assert "boolean_2" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["boolean_2"] == boolean_2 + assert app.jinja_env.tests["boolean_2"](False) + + assert "my_boolean_custom_name" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["my_boolean_custom_name"] == boolean_3 + assert app.jinja_env.tests["my_boolean_custom_name"](False) + + assert "my_boolean_custom_name_2" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["my_boolean_custom_name_2"] == boolean_4 + assert app.jinja_env.tests["my_boolean_custom_name_2"](False) + + +def test_add_template_test(app): + bp = flask.Blueprint("bp", __name__) + + def is_boolean(value): + return isinstance(value, bool) + + bp.add_app_template_test(is_boolean) + app.register_blueprint(bp, url_prefix="/py") + assert "is_boolean" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["is_boolean"] == is_boolean + assert app.jinja_env.tests["is_boolean"](False) + + +def test_template_test_with_name(app): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_test("boolean") + def is_boolean(value): + return isinstance(value, bool) + + app.register_blueprint(bp, url_prefix="/py") + assert "boolean" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["boolean"] == is_boolean + assert app.jinja_env.tests["boolean"](False) + + +def test_add_template_test_with_name(app): + bp = flask.Blueprint("bp", __name__) + + def is_boolean(value): + return isinstance(value, bool) + + bp.add_app_template_test(is_boolean, "boolean") + app.register_blueprint(bp, url_prefix="/py") + assert "boolean" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["boolean"] == is_boolean + assert app.jinja_env.tests["boolean"](False) + + +def test_template_test_with_template(app, client): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_test() + def boolean(value): + return isinstance(value, bool) + + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_template_test_after_route_with_template(app, client): + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_test() + def boolean(value): + return isinstance(value, bool) + + app.register_blueprint(bp, url_prefix="/py") + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_add_template_test_with_template(app, client): + bp = flask.Blueprint("bp", __name__) + + def boolean(value): + return isinstance(value, bool) + + bp.add_app_template_test(boolean) + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_template_test_with_name_and_template(app, client): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_test("boolean") + def is_boolean(value): + return isinstance(value, bool) + + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_add_template_test_with_name_and_template(app, client): + bp = flask.Blueprint("bp", __name__) + + def is_boolean(value): + return isinstance(value, bool) + + bp.add_app_template_test(is_boolean, "boolean") + app.register_blueprint(bp, url_prefix="/py") + + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_context_processing(app, client): + answer_bp = flask.Blueprint("answer_bp", __name__) + + def template_string(): + return flask.render_template_string( + "{% if notanswer %}{{ notanswer }} is not the answer. {% endif %}" + "{% if answer %}{{ answer }} is the answer.{% endif %}" + ) + + # App global context processor + @answer_bp.app_context_processor + def not_answer_context_processor(): + return {"notanswer": 43} + + # Blueprint local context processor + @answer_bp.context_processor + def answer_context_processor(): + return {"answer": 42} + + # Setup endpoints for testing + @answer_bp.route("/bp") + def bp_page(): + return template_string() + + @app.route("/") + def app_page(): + return template_string() + + # Register the blueprint + app.register_blueprint(answer_bp) + + app_page_bytes = client.get("/").data + answer_page_bytes = client.get("/bp").data + + assert b"43" in app_page_bytes + assert b"42" not in app_page_bytes + + assert b"42" in answer_page_bytes + assert b"43" in answer_page_bytes + + +def test_template_global(app): + bp = flask.Blueprint("bp", __name__) + + @bp.app_template_global() + def get_answer(): + return 42 + + @bp.app_template_global + def get_stuff_1(): + return "get_stuff_1" + + @bp.app_template_global("my_get_stuff_custom_name_2") + def get_stuff_2(): + return "get_stuff_2" + + @bp.app_template_global(name="my_get_stuff_custom_name_3") + def get_stuff_3(): + return "get_stuff_3" + + # Make sure the function is not in the jinja_env already + assert "get_answer" not in app.jinja_env.globals.keys() + app.register_blueprint(bp) + + # Tests + assert "get_answer" in app.jinja_env.globals.keys() + assert app.jinja_env.globals["get_answer"] is get_answer + assert app.jinja_env.globals["get_answer"]() == 42 + + assert "get_stuff_1" in app.jinja_env.globals.keys() + assert app.jinja_env.globals["get_stuff_1"] == get_stuff_1 + assert app.jinja_env.globals["get_stuff_1"](), "get_stuff_1" + + assert "my_get_stuff_custom_name_2" in app.jinja_env.globals.keys() + assert app.jinja_env.globals["my_get_stuff_custom_name_2"] == get_stuff_2 + assert app.jinja_env.globals["my_get_stuff_custom_name_2"](), "get_stuff_2" + + assert "my_get_stuff_custom_name_3" in app.jinja_env.globals.keys() + assert app.jinja_env.globals["my_get_stuff_custom_name_3"] == get_stuff_3 + assert app.jinja_env.globals["my_get_stuff_custom_name_3"](), "get_stuff_3" + + with app.app_context(): + rv = flask.render_template_string("{{ get_answer() }}") + assert rv == "42" + + rv = flask.render_template_string("{{ get_stuff_1() }}") + assert rv == "get_stuff_1" + + rv = flask.render_template_string("{{ my_get_stuff_custom_name_2() }}") + assert rv == "get_stuff_2" + + rv = flask.render_template_string("{{ my_get_stuff_custom_name_3() }}") + assert rv == "get_stuff_3" + + +def test_request_processing(app, client): + bp = flask.Blueprint("bp", __name__) + evts = [] + + @bp.before_request + def before_bp(): + evts.append("before") + + @bp.after_request + def after_bp(response): + response.data += b"|after" + evts.append("after") + return response + + @bp.teardown_request + def teardown_bp(exc): + evts.append("teardown") + + # Setup routes for testing + @bp.route("/bp") + def bp_endpoint(): + return "request" + + app.register_blueprint(bp) + + assert evts == [] + rv = client.get("/bp") + assert rv.data == b"request|after" + assert evts == ["before", "after", "teardown"] + + +def test_app_request_processing(app, client): + bp = flask.Blueprint("bp", __name__) + evts = [] + + @bp.before_app_request + def before_app(): + evts.append("before") + + @bp.after_app_request + def after_app(response): + response.data += b"|after" + evts.append("after") + return response + + @bp.teardown_app_request + def teardown_app(exc): + evts.append("teardown") + + app.register_blueprint(bp) + + # Setup routes for testing + @app.route("/") + def bp_endpoint(): + return "request" + + # before first request + assert evts == [] + + # first request + resp = client.get("/").data + assert resp == b"request|after" + assert evts == ["before", "after", "teardown"] + + # second request + resp = client.get("/").data + assert resp == b"request|after" + assert evts == ["before", "after", "teardown"] * 2 + + +def test_app_url_processors(app, client): + bp = flask.Blueprint("bp", __name__) + + # Register app-wide url defaults and preprocessor on blueprint + @bp.app_url_defaults + def add_language_code(endpoint, values): + values.setdefault("lang_code", flask.g.lang_code) + + @bp.app_url_value_preprocessor + def pull_lang_code(endpoint, values): + flask.g.lang_code = values.pop("lang_code") + + # Register route rules at the app level + @app.route("//") + def index(): + return flask.url_for("about") + + @app.route("//about") + def about(): + return flask.url_for("index") + + app.register_blueprint(bp) + + assert client.get("/de/").data == b"/de/about" + assert client.get("/de/about").data == b"/de/" + + +def test_nested_blueprint(app, client): + parent = flask.Blueprint("parent", __name__) + child = flask.Blueprint("child", __name__) + grandchild = flask.Blueprint("grandchild", __name__) + + @parent.errorhandler(403) + def forbidden(e): + return "Parent no", 403 + + @parent.route("/") + def parent_index(): + return "Parent yes" + + @parent.route("/no") + def parent_no(): + flask.abort(403) + + @child.route("/") + def child_index(): + return "Child yes" + + @child.route("/no") + def child_no(): + flask.abort(403) + + @grandchild.errorhandler(403) + def grandchild_forbidden(e): + return "Grandchild no", 403 + + @grandchild.route("/") + def grandchild_index(): + return "Grandchild yes" + + @grandchild.route("/no") + def grandchild_no(): + flask.abort(403) + + child.register_blueprint(grandchild, url_prefix="/grandchild") + parent.register_blueprint(child, url_prefix="/child") + app.register_blueprint(parent, url_prefix="/parent") + + assert client.get("/parent/").data == b"Parent yes" + assert client.get("/parent/child/").data == b"Child yes" + assert client.get("/parent/child/grandchild/").data == b"Grandchild yes" + assert client.get("/parent/no").data == b"Parent no" + assert client.get("/parent/child/no").data == b"Parent no" + assert client.get("/parent/child/grandchild/no").data == b"Grandchild no" + + +def test_nested_callback_order(app, client): + parent = flask.Blueprint("parent", __name__) + child = flask.Blueprint("child", __name__) + + @app.before_request + def app_before1(): + flask.g.setdefault("seen", []).append("app_1") + + @app.teardown_request + def app_teardown1(e=None): + assert flask.g.seen.pop() == "app_1" + + @app.before_request + def app_before2(): + flask.g.setdefault("seen", []).append("app_2") + + @app.teardown_request + def app_teardown2(e=None): + assert flask.g.seen.pop() == "app_2" + + @app.context_processor + def app_ctx(): + return dict(key="app") + + @parent.before_request + def parent_before1(): + flask.g.setdefault("seen", []).append("parent_1") + + @parent.teardown_request + def parent_teardown1(e=None): + assert flask.g.seen.pop() == "parent_1" + + @parent.before_request + def parent_before2(): + flask.g.setdefault("seen", []).append("parent_2") + + @parent.teardown_request + def parent_teardown2(e=None): + assert flask.g.seen.pop() == "parent_2" + + @parent.context_processor + def parent_ctx(): + return dict(key="parent") + + @child.before_request + def child_before1(): + flask.g.setdefault("seen", []).append("child_1") + + @child.teardown_request + def child_teardown1(e=None): + assert flask.g.seen.pop() == "child_1" + + @child.before_request + def child_before2(): + flask.g.setdefault("seen", []).append("child_2") + + @child.teardown_request + def child_teardown2(e=None): + assert flask.g.seen.pop() == "child_2" + + @child.context_processor + def child_ctx(): + return dict(key="child") + + @child.route("/a") + def a(): + return ", ".join(flask.g.seen) + + @child.route("/b") + def b(): + return flask.render_template_string("{{ key }}") + + parent.register_blueprint(child) + app.register_blueprint(parent) + assert ( + client.get("/a").data == b"app_1, app_2, parent_1, parent_2, child_1, child_2" + ) + assert client.get("/b").data == b"child" + + +@pytest.mark.parametrize( + "parent_init, child_init, parent_registration, child_registration", + [ + ("/parent", "/child", None, None), + ("/parent", None, None, "/child"), + (None, None, "/parent", "/child"), + ("/other", "/something", "/parent", "/child"), + ], +) +def test_nesting_url_prefixes( + parent_init, + child_init, + parent_registration, + child_registration, + app, + client, +) -> None: + parent = flask.Blueprint("parent", __name__, url_prefix=parent_init) + child = flask.Blueprint("child", __name__, url_prefix=child_init) + + @child.route("/") + def index(): + return "index" + + parent.register_blueprint(child, url_prefix=child_registration) + app.register_blueprint(parent, url_prefix=parent_registration) + + response = client.get("/parent/child/") + assert response.status_code == 200 + + +def test_nesting_subdomains(app, client) -> None: + app.subdomain_matching = True + app.config["SERVER_NAME"] = "example.test" + client.allow_subdomain_redirects = True + + parent = flask.Blueprint("parent", __name__) + child = flask.Blueprint("child", __name__) + + @child.route("/child/") + def index(): + return "child" + + parent.register_blueprint(child) + app.register_blueprint(parent, subdomain="api") + + response = client.get("/child/", base_url="http://api.example.test") + assert response.status_code == 200 + + +def test_child_and_parent_subdomain(app, client) -> None: + app.subdomain_matching = True + app.config["SERVER_NAME"] = "example.test" + client.allow_subdomain_redirects = True + + parent = flask.Blueprint("parent", __name__) + child = flask.Blueprint("child", __name__, subdomain="api") + + @child.route("/") + def index(): + return "child" + + parent.register_blueprint(child) + app.register_blueprint(parent, subdomain="parent") + + response = client.get("/", base_url="http://api.parent.example.test") + assert response.status_code == 200 + + response = client.get("/", base_url="http://parent.example.test") + assert response.status_code == 404 + + +def test_unique_blueprint_names(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + bp2 = flask.Blueprint("bp", __name__) + + app.register_blueprint(bp) + + with pytest.raises(ValueError): + app.register_blueprint(bp) # same bp, same name, error + + app.register_blueprint(bp, name="again") # same bp, different name, ok + + with pytest.raises(ValueError): + app.register_blueprint(bp2) # different bp, same name, error + + app.register_blueprint(bp2, name="alt") # different bp, different name, ok + + +def test_self_registration(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + with pytest.raises(ValueError): + bp.register_blueprint(bp) + + +def test_blueprint_renaming(app, client) -> None: + bp = flask.Blueprint("bp", __name__) + bp2 = flask.Blueprint("bp2", __name__) + + @bp.get("/") + def index(): + return flask.request.endpoint + + @bp.get("/error") + def error(): + flask.abort(403) + + @bp.errorhandler(403) + def forbidden(_: Exception): + return "Error", 403 + + @bp2.get("/") + def index2(): + return flask.request.endpoint + + bp.register_blueprint(bp2, url_prefix="/a", name="sub") + app.register_blueprint(bp, url_prefix="/a") + app.register_blueprint(bp, url_prefix="/b", name="alt") + + assert client.get("/a/").data == b"bp.index" + assert client.get("/b/").data == b"alt.index" + assert client.get("/a/a/").data == b"bp.sub.index2" + assert client.get("/b/a/").data == b"alt.sub.index2" + assert client.get("/a/error").data == b"Error" + assert client.get("/b/error").data == b"Error" diff --git a/src/flask-main/tests/test_cli.py b/src/flask-main/tests/test_cli.py new file mode 100644 index 0000000..a5aab50 --- /dev/null +++ b/src/flask-main/tests/test_cli.py @@ -0,0 +1,702 @@ +# This file was part of Flask-CLI and was modified under the terms of +# its Revised BSD License. Copyright © 2015 CERN. +import importlib.metadata +import os +import platform +import ssl +import sys +import types +from functools import partial +from pathlib import Path + +import click +import pytest +from _pytest.monkeypatch import notset +from click.testing import CliRunner + +from flask import Blueprint +from flask import current_app +from flask import Flask +from flask.cli import AppGroup +from flask.cli import find_best_app +from flask.cli import FlaskGroup +from flask.cli import get_version +from flask.cli import load_dotenv +from flask.cli import locate_app +from flask.cli import NoAppException +from flask.cli import prepare_import +from flask.cli import run_command +from flask.cli import ScriptInfo +from flask.cli import with_appcontext + +cwd = Path.cwd() +test_path = (Path(__file__) / ".." / "test_apps").resolve() + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_cli_name(test_apps): + """Make sure the CLI object's name is the app's name and not the app itself""" + from cliapp.app import testapp + + assert testapp.cli.name == testapp.name + + +def test_find_best_app(test_apps): + class Module: + app = Flask("appname") + + assert find_best_app(Module) == Module.app + + class Module: + application = Flask("appname") + + assert find_best_app(Module) == Module.application + + class Module: + myapp = Flask("appname") + + assert find_best_app(Module) == Module.myapp + + class Module: + @staticmethod + def create_app(): + return Flask("appname") + + app = find_best_app(Module) + assert isinstance(app, Flask) + assert app.name == "appname" + + class Module: + @staticmethod + def create_app(**kwargs): + return Flask("appname") + + app = find_best_app(Module) + assert isinstance(app, Flask) + assert app.name == "appname" + + class Module: + @staticmethod + def make_app(): + return Flask("appname") + + app = find_best_app(Module) + assert isinstance(app, Flask) + assert app.name == "appname" + + class Module: + myapp = Flask("appname1") + + @staticmethod + def create_app(): + return Flask("appname2") + + assert find_best_app(Module) == Module.myapp + + class Module: + myapp = Flask("appname1") + + @staticmethod + def create_app(): + return Flask("appname2") + + assert find_best_app(Module) == Module.myapp + + class Module: + pass + + pytest.raises(NoAppException, find_best_app, Module) + + class Module: + myapp1 = Flask("appname1") + myapp2 = Flask("appname2") + + pytest.raises(NoAppException, find_best_app, Module) + + class Module: + @staticmethod + def create_app(foo, bar): + return Flask("appname2") + + pytest.raises(NoAppException, find_best_app, Module) + + class Module: + @staticmethod + def create_app(): + raise TypeError("bad bad factory!") + + pytest.raises(TypeError, find_best_app, Module) + + +@pytest.mark.parametrize( + "value,path,result", + ( + ("test", cwd, "test"), + ("test.py", cwd, "test"), + ("a/test", cwd / "a", "test"), + ("test/__init__.py", cwd, "test"), + ("test/__init__", cwd, "test"), + # nested package + ( + test_path / "cliapp" / "inner1" / "__init__", + test_path, + "cliapp.inner1", + ), + ( + test_path / "cliapp" / "inner1" / "inner2", + test_path, + "cliapp.inner1.inner2", + ), + # dotted name + ("test.a.b", cwd, "test.a.b"), + (test_path / "cliapp.app", test_path, "cliapp.app"), + # not a Python file, will be caught during import + (test_path / "cliapp" / "message.txt", test_path, "cliapp.message.txt"), + ), +) +def test_prepare_import(request, value, path, result): + """Expect the correct path to be set and the correct import and app names + to be returned. + + :func:`prepare_exec_for_file` has a side effect where the parent directory + of the given import is added to :data:`sys.path`. This is reset after the + test runs. + """ + original_path = sys.path[:] + + def reset_path(): + sys.path[:] = original_path + + request.addfinalizer(reset_path) + + assert prepare_import(value) == result + assert sys.path[0] == str(path) + + +@pytest.mark.parametrize( + "iname,aname,result", + ( + ("cliapp.app", None, "testapp"), + ("cliapp.app", "testapp", "testapp"), + ("cliapp.factory", None, "app"), + ("cliapp.factory", "create_app", "app"), + ("cliapp.factory", "create_app()", "app"), + ("cliapp.factory", 'create_app2("foo", "bar")', "app2_foo_bar"), + # trailing comma space + ("cliapp.factory", 'create_app2("foo", "bar", )', "app2_foo_bar"), + # strip whitespace + ("cliapp.factory", " create_app () ", "app"), + ), +) +def test_locate_app(test_apps, iname, aname, result): + assert locate_app(iname, aname).name == result + + +@pytest.mark.parametrize( + "iname,aname", + ( + ("notanapp.py", None), + ("cliapp/app", None), + ("cliapp.app", "notanapp"), + # not enough arguments + ("cliapp.factory", 'create_app2("foo")'), + # invalid identifier + ("cliapp.factory", "create_app("), + # no app returned + ("cliapp.factory", "no_app"), + # nested import error + ("cliapp.importerrorapp", None), + # not a Python file + ("cliapp.message.txt", None), + ), +) +def test_locate_app_raises(test_apps, iname, aname): + with pytest.raises(NoAppException): + locate_app(iname, aname) + + +def test_locate_app_suppress_raise(test_apps): + app = locate_app("notanapp.py", None, raise_if_not_found=False) + assert app is None + + # only direct import error is suppressed + with pytest.raises(NoAppException): + locate_app("cliapp.importerrorapp", None, raise_if_not_found=False) + + +def test_get_version(test_apps, capsys): + class MockCtx: + resilient_parsing = False + color = None + + def exit(self): + return + + ctx = MockCtx() + get_version(ctx, None, "test") + out, err = capsys.readouterr() + assert f"Python {platform.python_version()}" in out + assert f"Flask {importlib.metadata.version('flask')}" in out + assert f"Werkzeug {importlib.metadata.version('werkzeug')}" in out + + +def test_scriptinfo(test_apps, monkeypatch): + obj = ScriptInfo(app_import_path="cliapp.app:testapp") + app = obj.load_app() + assert app.name == "testapp" + assert obj.load_app() is app + + # import app with module's absolute path + cli_app_path = str(test_path / "cliapp" / "app.py") + + obj = ScriptInfo(app_import_path=cli_app_path) + app = obj.load_app() + assert app.name == "testapp" + assert obj.load_app() is app + obj = ScriptInfo(app_import_path=f"{cli_app_path}:testapp") + app = obj.load_app() + assert app.name == "testapp" + assert obj.load_app() is app + + def create_app(): + return Flask("createapp") + + obj = ScriptInfo(create_app=create_app) + app = obj.load_app() + assert app.name == "createapp" + assert obj.load_app() is app + + obj = ScriptInfo() + pytest.raises(NoAppException, obj.load_app) + + # import app from wsgi.py in current directory + monkeypatch.chdir(test_path / "helloworld") + obj = ScriptInfo() + app = obj.load_app() + assert app.name == "hello" + + # import app from app.py in current directory + monkeypatch.chdir(test_path / "cliapp") + obj = ScriptInfo() + app = obj.load_app() + assert app.name == "testapp" + + +def test_app_cli_has_app_context(app, runner): + def _param_cb(ctx, param, value): + # current_app should be available in parameter callbacks + return bool(current_app) + + @app.cli.command() + @click.argument("value", callback=_param_cb) + def check(value): + app = click.get_current_context().obj.load_app() + # the loaded app should be the same as current_app + same_app = current_app._get_current_object() is app + return same_app, value + + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["check", "x"], standalone_mode=False) + assert result.return_value == (True, True) + + +def test_with_appcontext(runner): + @click.command() + @with_appcontext + def testcmd(): + click.echo(current_app.name) + + obj = ScriptInfo(create_app=lambda: Flask("testapp")) + + result = runner.invoke(testcmd, obj=obj) + assert result.exit_code == 0 + assert result.output == "testapp\n" + + +def test_appgroup_app_context(runner): + @click.group(cls=AppGroup) + def cli(): + pass + + @cli.command() + def test(): + click.echo(current_app.name) + + @cli.group() + def subgroup(): + pass + + @subgroup.command() + def test2(): + click.echo(current_app.name) + + obj = ScriptInfo(create_app=lambda: Flask("testappgroup")) + + result = runner.invoke(cli, ["test"], obj=obj) + assert result.exit_code == 0 + assert result.output == "testappgroup\n" + + result = runner.invoke(cli, ["subgroup", "test2"], obj=obj) + assert result.exit_code == 0 + assert result.output == "testappgroup\n" + + +def test_flaskgroup_app_context(runner): + def create_app(): + return Flask("flaskgroup") + + @click.group(cls=FlaskGroup, create_app=create_app) + def cli(**params): + pass + + @cli.command() + def test(): + click.echo(current_app.name) + + result = runner.invoke(cli, ["test"]) + assert result.exit_code == 0 + assert result.output == "flaskgroup\n" + + +@pytest.mark.parametrize("set_debug_flag", (True, False)) +def test_flaskgroup_debug(runner, set_debug_flag): + def create_app(): + app = Flask("flaskgroup") + app.debug = True + return app + + @click.group(cls=FlaskGroup, create_app=create_app, set_debug_flag=set_debug_flag) + def cli(**params): + pass + + @cli.command() + def test(): + click.echo(str(current_app.debug)) + + result = runner.invoke(cli, ["test"]) + assert result.exit_code == 0 + assert result.output == f"{not set_debug_flag}\n" + + +def test_flaskgroup_nested(app, runner): + cli = click.Group("cli") + flask_group = FlaskGroup(name="flask", create_app=lambda: app) + cli.add_command(flask_group) + + @flask_group.command() + def show(): + click.echo(current_app.name) + + result = runner.invoke(cli, ["flask", "show"]) + assert result.output == "flask_test\n" + + +def test_no_command_echo_loading_error(): + from flask.cli import cli + + try: + runner = CliRunner(mix_stderr=False) + except (DeprecationWarning, TypeError): + # Click >= 8.2 + runner = CliRunner() + + result = runner.invoke(cli, ["missing"]) + assert result.exit_code == 2 + assert "FLASK_APP" in result.stderr + assert "Usage:" in result.stderr + + +def test_help_echo_loading_error(): + from flask.cli import cli + + try: + runner = CliRunner(mix_stderr=False) + except (DeprecationWarning, TypeError): + # Click >= 8.2 + runner = CliRunner() + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "FLASK_APP" in result.stderr + assert "Usage:" in result.stdout + + +def test_help_echo_exception(): + def create_app(): + raise Exception("oh no") + + cli = FlaskGroup(create_app=create_app) + + try: + runner = CliRunner(mix_stderr=False) + except (DeprecationWarning, TypeError): + # Click >= 8.2 + runner = CliRunner() + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Exception: oh no" in result.stderr + assert "Usage:" in result.stdout + + +class TestRoutes: + @pytest.fixture + def app(self): + app = Flask(__name__) + app.add_url_rule( + "/get_post//", + methods=["GET", "POST"], + endpoint="yyy_get_post", + ) + app.add_url_rule("/zzz_post", methods=["POST"], endpoint="aaa_post") + return app + + @pytest.fixture + def invoke(self, app, runner): + cli = FlaskGroup(create_app=lambda: app) + return partial(runner.invoke, cli) + + def expect_order(self, order, output): + # skip the header and match the start of each row + for expect, line in zip(order, output.splitlines()[2:], strict=False): + # do this instead of startswith for nicer pytest output + assert line[: len(expect)] == expect + + def test_simple(self, invoke): + result = invoke(["routes"]) + assert result.exit_code == 0 + self.expect_order(["aaa_post", "static", "yyy_get_post"], result.output) + + def test_sort(self, app, invoke): + default_output = invoke(["routes"]).output + endpoint_output = invoke(["routes", "-s", "endpoint"]).output + assert default_output == endpoint_output + self.expect_order( + ["static", "yyy_get_post", "aaa_post"], + invoke(["routes", "-s", "methods"]).output, + ) + self.expect_order( + ["yyy_get_post", "static", "aaa_post"], + invoke(["routes", "-s", "rule"]).output, + ) + match_order = [r.endpoint for r in app.url_map.iter_rules()] + self.expect_order(match_order, invoke(["routes", "-s", "match"]).output) + + def test_all_methods(self, invoke): + output = invoke(["routes"]).output + assert "GET, HEAD, OPTIONS, POST" not in output + output = invoke(["routes", "--all-methods"]).output + assert "GET, HEAD, OPTIONS, POST" in output + + def test_no_routes(self, runner): + app = Flask(__name__, static_folder=None) + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes"]) + assert result.exit_code == 0 + assert "No routes were registered." in result.output + + def test_subdomain(self, runner): + app = Flask(__name__, static_folder=None) + app.add_url_rule("/a", subdomain="a", endpoint="a") + app.add_url_rule("/b", subdomain="b", endpoint="b") + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes"]) + assert result.exit_code == 0 + assert "Subdomain" in result.output + + def test_host(self, runner): + app = Flask(__name__, static_folder=None, host_matching=True) + app.add_url_rule("/a", host="a", endpoint="a") + app.add_url_rule("/b", host="b", endpoint="b") + cli = FlaskGroup(create_app=lambda: app) + result = runner.invoke(cli, ["routes"]) + assert result.exit_code == 0 + assert "Host" in result.output + + +def dotenv_not_available(): + try: + import dotenv # noqa: F401 + except ImportError: + return True + + return False + + +need_dotenv = pytest.mark.skipif( + dotenv_not_available(), reason="dotenv is not installed" +) + + +@need_dotenv +def test_load_dotenv(monkeypatch): + # can't use monkeypatch.delitem since the keys don't exist yet + for item in ("FOO", "BAR", "SPAM", "HAM"): + monkeypatch._setitem.append((os.environ, item, notset)) + + monkeypatch.setenv("EGGS", "3") + monkeypatch.chdir(test_path) + assert load_dotenv() + assert Path.cwd() == test_path + # .flaskenv doesn't overwrite .env + assert os.environ["FOO"] == "env" + # set only in .flaskenv + assert os.environ["BAR"] == "bar" + # set only in .env + assert os.environ["SPAM"] == "1" + # set manually, files don't overwrite + assert os.environ["EGGS"] == "3" + # test env file encoding + assert os.environ["HAM"] == "火腿" + # Non existent file should not load + assert not load_dotenv("non-existent-file", load_defaults=False) + + +@need_dotenv +def test_dotenv_path(monkeypatch): + for item in ("FOO", "BAR", "EGGS"): + monkeypatch._setitem.append((os.environ, item, notset)) + + load_dotenv(test_path / ".flaskenv") + assert Path.cwd() == cwd + assert "FOO" in os.environ + + +def test_dotenv_optional(monkeypatch): + monkeypatch.setitem(sys.modules, "dotenv", None) + monkeypatch.chdir(test_path) + load_dotenv() + assert "FOO" not in os.environ + + +@need_dotenv +def test_disable_dotenv_from_env(monkeypatch, runner): + monkeypatch.chdir(test_path) + monkeypatch.setitem(os.environ, "FLASK_SKIP_DOTENV", "1") + runner.invoke(FlaskGroup()) + assert "FOO" not in os.environ + + +def test_run_cert_path(): + # no key + with pytest.raises(click.BadParameter): + run_command.make_context("run", ["--cert", __file__]) + + # no cert + with pytest.raises(click.BadParameter): + run_command.make_context("run", ["--key", __file__]) + + # cert specified first + ctx = run_command.make_context("run", ["--cert", __file__, "--key", __file__]) + assert ctx.params["cert"] == (__file__, __file__) + + # key specified first + ctx = run_command.make_context("run", ["--key", __file__, "--cert", __file__]) + assert ctx.params["cert"] == (__file__, __file__) + + +def test_run_cert_adhoc(monkeypatch): + monkeypatch.setitem(sys.modules, "cryptography", None) + + # cryptography not installed + with pytest.raises(click.BadParameter): + run_command.make_context("run", ["--cert", "adhoc"]) + + # cryptography installed + monkeypatch.setitem(sys.modules, "cryptography", types.ModuleType("cryptography")) + ctx = run_command.make_context("run", ["--cert", "adhoc"]) + assert ctx.params["cert"] == "adhoc" + + # no key with adhoc + with pytest.raises(click.BadParameter): + run_command.make_context("run", ["--cert", "adhoc", "--key", __file__]) + + +def test_run_cert_import(monkeypatch): + monkeypatch.setitem(sys.modules, "not_here", None) + + # ImportError + with pytest.raises(click.BadParameter): + run_command.make_context("run", ["--cert", "not_here"]) + + with pytest.raises(click.BadParameter): + run_command.make_context("run", ["--cert", "flask"]) + + # SSLContext + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + + monkeypatch.setitem(sys.modules, "ssl_context", ssl_context) + ctx = run_command.make_context("run", ["--cert", "ssl_context"]) + assert ctx.params["cert"] is ssl_context + + # no --key with SSLContext + with pytest.raises(click.BadParameter): + run_command.make_context("run", ["--cert", "ssl_context", "--key", __file__]) + + +def test_run_cert_no_ssl(monkeypatch): + monkeypatch.setitem(sys.modules, "ssl", None) + + with pytest.raises(click.BadParameter): + run_command.make_context("run", ["--cert", "not_here"]) + + +def test_cli_blueprints(app): + """Test blueprint commands register correctly to the application""" + custom = Blueprint("custom", __name__, cli_group="customized") + nested = Blueprint("nested", __name__) + merged = Blueprint("merged", __name__, cli_group=None) + late = Blueprint("late", __name__) + + @custom.cli.command("custom") + def custom_command(): + click.echo("custom_result") + + @nested.cli.command("nested") + def nested_command(): + click.echo("nested_result") + + @merged.cli.command("merged") + def merged_command(): + click.echo("merged_result") + + @late.cli.command("late") + def late_command(): + click.echo("late_result") + + app.register_blueprint(custom) + app.register_blueprint(nested) + app.register_blueprint(merged) + app.register_blueprint(late, cli_group="late_registration") + + app_runner = app.test_cli_runner() + + result = app_runner.invoke(args=["customized", "custom"]) + assert "custom_result" in result.output + + result = app_runner.invoke(args=["nested", "nested"]) + assert "nested_result" in result.output + + result = app_runner.invoke(args=["merged"]) + assert "merged_result" in result.output + + result = app_runner.invoke(args=["late_registration", "late"]) + assert "late_result" in result.output + + +def test_cli_empty(app): + """If a Blueprint's CLI group is empty, do not register it.""" + bp = Blueprint("blue", __name__, cli_group="blue") + app.register_blueprint(bp) + + result = app.test_cli_runner().invoke(args=["blue", "--help"]) + assert result.exit_code == 2, f"Unexpected success:\n\n{result.output}" + + +def test_run_exclude_patterns(): + ctx = run_command.make_context("run", ["--exclude-patterns", __file__]) + assert ctx.params["exclude_patterns"] == [__file__] diff --git a/src/flask-main/tests/test_config.py b/src/flask-main/tests/test_config.py new file mode 100644 index 0000000..e5b1906 --- /dev/null +++ b/src/flask-main/tests/test_config.py @@ -0,0 +1,250 @@ +import json +import os + +import pytest + +import flask + +# config keys used for the TestConfig +TEST_KEY = "foo" +SECRET_KEY = "config" + + +def common_object_test(app): + assert app.secret_key == "config" + assert app.config["TEST_KEY"] == "foo" + assert "TestConfig" not in app.config + + +def test_config_from_pyfile(): + app = flask.Flask(__name__) + app.config.from_pyfile(f"{__file__.rsplit('.', 1)[0]}.py") + common_object_test(app) + + +def test_config_from_object(): + app = flask.Flask(__name__) + app.config.from_object(__name__) + common_object_test(app) + + +def test_config_from_file_json(): + app = flask.Flask(__name__) + current_dir = os.path.dirname(os.path.abspath(__file__)) + app.config.from_file(os.path.join(current_dir, "static", "config.json"), json.load) + common_object_test(app) + + +def test_config_from_file_toml(): + tomllib = pytest.importorskip("tomllib", reason="tomllib added in 3.11") + app = flask.Flask(__name__) + current_dir = os.path.dirname(os.path.abspath(__file__)) + app.config.from_file( + os.path.join(current_dir, "static", "config.toml"), tomllib.load, text=False + ) + common_object_test(app) + + +def test_from_prefixed_env(monkeypatch): + monkeypatch.setenv("FLASK_STRING", "value") + monkeypatch.setenv("FLASK_BOOL", "true") + monkeypatch.setenv("FLASK_INT", "1") + monkeypatch.setenv("FLASK_FLOAT", "1.2") + monkeypatch.setenv("FLASK_LIST", "[1, 2]") + monkeypatch.setenv("FLASK_DICT", '{"k": "v"}') + monkeypatch.setenv("NOT_FLASK_OTHER", "other") + + app = flask.Flask(__name__) + app.config.from_prefixed_env() + + assert app.config["STRING"] == "value" + assert app.config["BOOL"] is True + assert app.config["INT"] == 1 + assert app.config["FLOAT"] == 1.2 + assert app.config["LIST"] == [1, 2] + assert app.config["DICT"] == {"k": "v"} + assert "OTHER" not in app.config + + +def test_from_prefixed_env_custom_prefix(monkeypatch): + monkeypatch.setenv("FLASK_A", "a") + monkeypatch.setenv("NOT_FLASK_A", "b") + + app = flask.Flask(__name__) + app.config.from_prefixed_env("NOT_FLASK") + + assert app.config["A"] == "b" + + +def test_from_prefixed_env_nested(monkeypatch): + monkeypatch.setenv("FLASK_EXIST__ok", "other") + monkeypatch.setenv("FLASK_EXIST__inner__ik", "2") + monkeypatch.setenv("FLASK_EXIST__new__more", '{"k": false}') + monkeypatch.setenv("FLASK_NEW__K", "v") + + app = flask.Flask(__name__) + app.config["EXIST"] = {"ok": "value", "flag": True, "inner": {"ik": 1}} + app.config.from_prefixed_env() + + if os.name != "nt": + assert app.config["EXIST"] == { + "ok": "other", + "flag": True, + "inner": {"ik": 2}, + "new": {"more": {"k": False}}, + } + else: + # Windows env var keys are always uppercase. + assert app.config["EXIST"] == { + "ok": "value", + "OK": "other", + "flag": True, + "inner": {"ik": 1}, + "INNER": {"IK": 2}, + "NEW": {"MORE": {"k": False}}, + } + + assert app.config["NEW"] == {"K": "v"} + + +def test_config_from_mapping(): + app = flask.Flask(__name__) + app.config.from_mapping({"SECRET_KEY": "config", "TEST_KEY": "foo"}) + common_object_test(app) + + app = flask.Flask(__name__) + app.config.from_mapping([("SECRET_KEY", "config"), ("TEST_KEY", "foo")]) + common_object_test(app) + + app = flask.Flask(__name__) + app.config.from_mapping(SECRET_KEY="config", TEST_KEY="foo") + common_object_test(app) + + app = flask.Flask(__name__) + app.config.from_mapping(SECRET_KEY="config", TEST_KEY="foo", skip_key="skip") + common_object_test(app) + + app = flask.Flask(__name__) + with pytest.raises(TypeError): + app.config.from_mapping({}, {}) + + +def test_config_from_class(): + class Base: + TEST_KEY = "foo" + + class Test(Base): + SECRET_KEY = "config" + + app = flask.Flask(__name__) + app.config.from_object(Test) + common_object_test(app) + + +def test_config_from_envvar(monkeypatch): + monkeypatch.setattr("os.environ", {}) + app = flask.Flask(__name__) + + with pytest.raises(RuntimeError) as e: + app.config.from_envvar("FOO_SETTINGS") + + assert "'FOO_SETTINGS' is not set" in str(e.value) + assert not app.config.from_envvar("FOO_SETTINGS", silent=True) + + monkeypatch.setattr( + "os.environ", {"FOO_SETTINGS": f"{__file__.rsplit('.', 1)[0]}.py"} + ) + assert app.config.from_envvar("FOO_SETTINGS") + common_object_test(app) + + +def test_config_from_envvar_missing(monkeypatch): + monkeypatch.setattr("os.environ", {"FOO_SETTINGS": "missing.cfg"}) + app = flask.Flask(__name__) + with pytest.raises(IOError) as e: + app.config.from_envvar("FOO_SETTINGS") + msg = str(e.value) + assert msg.startswith( + "[Errno 2] Unable to load configuration file (No such file or directory):" + ) + assert msg.endswith("missing.cfg'") + assert not app.config.from_envvar("FOO_SETTINGS", silent=True) + + +def test_config_missing(): + app = flask.Flask(__name__) + with pytest.raises(IOError) as e: + app.config.from_pyfile("missing.cfg") + msg = str(e.value) + assert msg.startswith( + "[Errno 2] Unable to load configuration file (No such file or directory):" + ) + assert msg.endswith("missing.cfg'") + assert not app.config.from_pyfile("missing.cfg", silent=True) + + +def test_config_missing_file(): + app = flask.Flask(__name__) + with pytest.raises(IOError) as e: + app.config.from_file("missing.json", load=json.load) + msg = str(e.value) + assert msg.startswith( + "[Errno 2] Unable to load configuration file (No such file or directory):" + ) + assert msg.endswith("missing.json'") + assert not app.config.from_file("missing.json", load=json.load, silent=True) + + +def test_custom_config_class(): + class Config(flask.Config): + pass + + class Flask(flask.Flask): + config_class = Config + + app = Flask(__name__) + assert isinstance(app.config, Config) + app.config.from_object(__name__) + common_object_test(app) + + +def test_session_lifetime(): + app = flask.Flask(__name__) + app.config["PERMANENT_SESSION_LIFETIME"] = 42 + assert app.permanent_session_lifetime.seconds == 42 + + +def test_get_namespace(): + app = flask.Flask(__name__) + app.config["FOO_OPTION_1"] = "foo option 1" + app.config["FOO_OPTION_2"] = "foo option 2" + app.config["BAR_STUFF_1"] = "bar stuff 1" + app.config["BAR_STUFF_2"] = "bar stuff 2" + foo_options = app.config.get_namespace("FOO_") + assert 2 == len(foo_options) + assert "foo option 1" == foo_options["option_1"] + assert "foo option 2" == foo_options["option_2"] + bar_options = app.config.get_namespace("BAR_", lowercase=False) + assert 2 == len(bar_options) + assert "bar stuff 1" == bar_options["STUFF_1"] + assert "bar stuff 2" == bar_options["STUFF_2"] + foo_options = app.config.get_namespace("FOO_", trim_namespace=False) + assert 2 == len(foo_options) + assert "foo option 1" == foo_options["foo_option_1"] + assert "foo option 2" == foo_options["foo_option_2"] + bar_options = app.config.get_namespace( + "BAR_", lowercase=False, trim_namespace=False + ) + assert 2 == len(bar_options) + assert "bar stuff 1" == bar_options["BAR_STUFF_1"] + assert "bar stuff 2" == bar_options["BAR_STUFF_2"] + + +@pytest.mark.parametrize("encoding", ["utf-8", "iso-8859-15", "latin-1"]) +def test_from_pyfile_weird_encoding(tmp_path, encoding): + f = tmp_path / "my_config.py" + f.write_text(f'# -*- coding: {encoding} -*-\nTEST_VALUE = "föö"\n', encoding) + app = flask.Flask(__name__) + app.config.from_pyfile(os.fspath(f)) + value = app.config["TEST_VALUE"] + assert value == "föö" diff --git a/src/flask-main/tests/test_converters.py b/src/flask-main/tests/test_converters.py new file mode 100644 index 0000000..d94a765 --- /dev/null +++ b/src/flask-main/tests/test_converters.py @@ -0,0 +1,42 @@ +from werkzeug.routing import BaseConverter + +from flask import request +from flask import session +from flask import url_for + + +def test_custom_converters(app, client): + class ListConverter(BaseConverter): + def to_python(self, value): + return value.split(",") + + def to_url(self, value): + base_to_url = super().to_url + return ",".join(base_to_url(x) for x in value) + + app.url_map.converters["list"] = ListConverter + + @app.route("/") + def index(args): + return "|".join(args) + + assert client.get("/1,2,3").data == b"1|2|3" + + with app.test_request_context(): + assert url_for("index", args=[4, 5, 6]) == "/4,5,6" + + +def test_context_available(app, client): + class ContextConverter(BaseConverter): + def to_python(self, value): + assert request is not None + assert session is not None + return value + + app.url_map.converters["ctx"] = ContextConverter + + @app.get("/") + def index(name): + return name + + assert client.get("/admin").data == b"admin" diff --git a/src/flask-main/tests/test_helpers.py b/src/flask-main/tests/test_helpers.py new file mode 100644 index 0000000..efd22aa --- /dev/null +++ b/src/flask-main/tests/test_helpers.py @@ -0,0 +1,383 @@ +import io +import os + +import pytest +import werkzeug.exceptions + +import flask +from flask.helpers import get_debug_flag + + +class FakePath: + """Fake object to represent a ``PathLike object``. + + This represents a ``pathlib.Path`` object in python 3. + See: https://www.python.org/dev/peps/pep-0519/ + """ + + def __init__(self, path): + self.path = path + + def __fspath__(self): + return self.path + + +class PyBytesIO: + def __init__(self, *args, **kwargs): + self._io = io.BytesIO(*args, **kwargs) + + def __getattr__(self, name): + return getattr(self._io, name) + + +class TestSendfile: + def test_send_file(self, app, req_ctx): + rv = flask.send_file("static/index.html") + assert rv.direct_passthrough + assert rv.mimetype == "text/html" + + with app.open_resource("static/index.html") as f: + rv.direct_passthrough = False + assert rv.data == f.read() + + rv.close() + + def test_static_file(self, app, req_ctx): + # Default max_age is None. + + # Test with static file handler. + rv = app.send_static_file("index.html") + assert rv.cache_control.max_age is None + rv.close() + + # Test with direct use of send_file. + rv = flask.send_file("static/index.html") + assert rv.cache_control.max_age is None + rv.close() + + app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 3600 + + # Test with static file handler. + rv = app.send_static_file("index.html") + assert rv.cache_control.max_age == 3600 + rv.close() + + # Test with direct use of send_file. + rv = flask.send_file("static/index.html") + assert rv.cache_control.max_age == 3600 + rv.close() + + # Test with pathlib.Path. + rv = app.send_static_file(FakePath("index.html")) + assert rv.cache_control.max_age == 3600 + rv.close() + + class StaticFileApp(flask.Flask): + def get_send_file_max_age(self, filename): + return 10 + + app = StaticFileApp(__name__) + + with app.test_request_context(): + # Test with static file handler. + rv = app.send_static_file("index.html") + assert rv.cache_control.max_age == 10 + rv.close() + + # Test with direct use of send_file. + rv = flask.send_file("static/index.html") + assert rv.cache_control.max_age == 10 + rv.close() + + def test_send_from_directory(self, app, req_ctx): + app.root_path = os.path.join( + os.path.dirname(__file__), "test_apps", "subdomaintestmodule" + ) + rv = flask.send_from_directory("static", "hello.txt") + rv.direct_passthrough = False + assert rv.data.strip() == b"Hello Subdomain" + rv.close() + + +class TestUrlFor: + def test_url_for_with_anchor(self, app, req_ctx): + @app.route("/") + def index(): + return "42" + + assert flask.url_for("index", _anchor="x y") == "/#x%20y" + + def test_url_for_with_scheme(self, app, req_ctx): + @app.route("/") + def index(): + return "42" + + assert ( + flask.url_for("index", _external=True, _scheme="https") + == "https://localhost/" + ) + + def test_url_for_with_scheme_not_external(self, app, req_ctx): + app.add_url_rule("/", endpoint="index") + + # Implicit external with scheme. + url = flask.url_for("index", _scheme="https") + assert url == "https://localhost/" + + # Error when external=False with scheme + with pytest.raises(ValueError): + flask.url_for("index", _scheme="https", _external=False) + + def test_url_for_with_alternating_schemes(self, app, req_ctx): + @app.route("/") + def index(): + return "42" + + assert flask.url_for("index", _external=True) == "http://localhost/" + assert ( + flask.url_for("index", _external=True, _scheme="https") + == "https://localhost/" + ) + assert flask.url_for("index", _external=True) == "http://localhost/" + + def test_url_with_method(self, app, req_ctx): + from flask.views import MethodView + + class MyView(MethodView): + def get(self, id=None): + if id is None: + return "List" + return f"Get {id:d}" + + def post(self): + return "Create" + + myview = MyView.as_view("myview") + app.add_url_rule("/myview/", methods=["GET"], view_func=myview) + app.add_url_rule("/myview/", methods=["GET"], view_func=myview) + app.add_url_rule("/myview/create", methods=["POST"], view_func=myview) + + assert flask.url_for("myview", _method="GET") == "/myview/" + assert flask.url_for("myview", id=42, _method="GET") == "/myview/42" + assert flask.url_for("myview", _method="POST") == "/myview/create" + + def test_url_for_with_self(self, app, req_ctx): + @app.route("/") + def index(self): + return "42" + + assert flask.url_for("index", self="2") == "/2" + + +def test_redirect_no_app(): + response = flask.redirect("https://localhost", 307) + assert response.location == "https://localhost" + assert response.status_code == 307 + + +def test_redirect_with_app(app): + def redirect(location, code=302): + raise ValueError + + app.redirect = redirect + + with app.app_context(), pytest.raises(ValueError): + flask.redirect("other") + + +def test_abort_no_app(): + with pytest.raises(werkzeug.exceptions.Unauthorized): + flask.abort(401) + + with pytest.raises(LookupError): + flask.abort(900) + + +def test_app_aborter_class(): + class MyAborter(werkzeug.exceptions.Aborter): + pass + + class MyFlask(flask.Flask): + aborter_class = MyAborter + + app = MyFlask(__name__) + assert isinstance(app.aborter, MyAborter) + + +def test_abort_with_app(app): + class My900Error(werkzeug.exceptions.HTTPException): + code = 900 + + app.aborter.mapping[900] = My900Error + + with app.app_context(), pytest.raises(My900Error): + flask.abort(900) + + +class TestNoImports: + """Test Flasks are created without import. + + Avoiding ``__import__`` helps create Flask instances where there are errors + at import time. Those runtime errors will be apparent to the user soon + enough, but tools which build Flask instances meta-programmatically benefit + from a Flask which does not ``__import__``. Instead of importing to + retrieve file paths or metadata on a module or package, use the pkgutil and + imp modules in the Python standard library. + """ + + def test_name_with_import_error(self, modules_tmp_path): + (modules_tmp_path / "importerror.py").write_text("raise NotImplementedError()") + try: + flask.Flask("importerror") + except NotImplementedError: + AssertionError("Flask(import_name) is importing import_name.") + + +class TestStreaming: + def test_streaming_with_context(self, app, client): + @app.route("/") + def index(): + def generate(): + yield "Hello " + yield flask.request.args["name"] + yield "!" + + return flask.Response(flask.stream_with_context(generate())) + + rv = client.get("/?name=World") + assert rv.data == b"Hello World!" + + def test_streaming_with_context_as_decorator(self, app, client): + @app.route("/") + def index(): + @flask.stream_with_context + def generate(hello): + yield hello + yield flask.request.args["name"] + yield "!" + + return flask.Response(generate("Hello ")) + + rv = client.get("/?name=World") + assert rv.data == b"Hello World!" + + def test_streaming_with_context_and_custom_close(self, app, client): + called = [] + + class Wrapper: + def __init__(self, gen): + self._gen = gen + + def __iter__(self): + return self + + def close(self): + called.append(42) + + def __next__(self): + return next(self._gen) + + next = __next__ + + @app.route("/") + def index(): + def generate(): + yield "Hello " + yield flask.request.args["name"] + yield "!" + + return flask.Response(flask.stream_with_context(Wrapper(generate()))) + + rv = client.get("/?name=World") + assert rv.data == b"Hello World!" + assert called == [42] + + def test_stream_keeps_session(self, app, client): + @app.route("/") + def index(): + flask.session["test"] = "flask" + + @flask.stream_with_context + def gen(): + yield flask.session["test"] + + return flask.Response(gen()) + + rv = client.get("/") + assert rv.data == b"flask" + + def test_async_view(self, app, client): + @app.route("/") + async def index(): + flask.session["test"] = "flask" + + @flask.stream_with_context + def gen(): + yield flask.session["test"] + + return flask.Response(gen()) + + # response is closed without reading stream + client.get().close() + # response stream is read + assert client.get().text == "flask" + + # same as above, but with client context preservation + with client: + client.get().close() + + with client: + assert client.get().text == "flask" + + +class TestHelpers: + @pytest.mark.parametrize( + ("debug", "expect"), + [ + ("", False), + ("0", False), + ("False", False), + ("No", False), + ("True", True), + ], + ) + def test_get_debug_flag(self, monkeypatch, debug, expect): + monkeypatch.setenv("FLASK_DEBUG", debug) + assert get_debug_flag() == expect + + def test_make_response(self): + app = flask.Flask(__name__) + with app.test_request_context(): + rv = flask.helpers.make_response() + assert rv.status_code == 200 + assert rv.mimetype == "text/html" + + rv = flask.helpers.make_response("Hello") + assert rv.status_code == 200 + assert rv.data == b"Hello" + assert rv.mimetype == "text/html" + + +@pytest.mark.parametrize("mode", ("r", "rb", "rt")) +def test_open_resource(mode): + app = flask.Flask(__name__) + + with app.open_resource("static/index.html", mode) as f: + assert "

Hello World!

" in str(f.read()) + + +@pytest.mark.parametrize("mode", ("w", "x", "a", "r+")) +def test_open_resource_exceptions(mode): + app = flask.Flask(__name__) + + with pytest.raises(ValueError): + app.open_resource("static/index.html", mode) + + +@pytest.mark.parametrize("encoding", ("utf-8", "utf-16-le")) +def test_open_resource_with_encoding(tmp_path, encoding): + app = flask.Flask(__name__, root_path=os.fspath(tmp_path)) + (tmp_path / "test").write_text("test", encoding=encoding) + + with app.open_resource("test", mode="rt", encoding=encoding) as f: + assert f.read() == "test" diff --git a/src/flask-main/tests/test_instance_config.py b/src/flask-main/tests/test_instance_config.py new file mode 100644 index 0000000..835a878 --- /dev/null +++ b/src/flask-main/tests/test_instance_config.py @@ -0,0 +1,111 @@ +import os + +import pytest + +import flask + + +def test_explicit_instance_paths(modules_tmp_path): + with pytest.raises(ValueError, match=".*must be absolute"): + flask.Flask(__name__, instance_path="instance") + + app = flask.Flask(__name__, instance_path=os.fspath(modules_tmp_path)) + assert app.instance_path == os.fspath(modules_tmp_path) + + +def test_uninstalled_module_paths(modules_tmp_path, purge_module): + (modules_tmp_path / "config_module_app.py").write_text( + "import os\n" + "import flask\n" + "here = os.path.abspath(os.path.dirname(__file__))\n" + "app = flask.Flask(__name__)\n" + ) + purge_module("config_module_app") + + from config_module_app import app + + assert app.instance_path == os.fspath(modules_tmp_path / "instance") + + +def test_uninstalled_package_paths(modules_tmp_path, purge_module): + app = modules_tmp_path / "config_package_app" + app.mkdir() + (app / "__init__.py").write_text( + "import os\n" + "import flask\n" + "here = os.path.abspath(os.path.dirname(__file__))\n" + "app = flask.Flask(__name__)\n" + ) + purge_module("config_package_app") + + from config_package_app import app + + assert app.instance_path == os.fspath(modules_tmp_path / "instance") + + +def test_uninstalled_namespace_paths(tmp_path, monkeypatch, purge_module): + def create_namespace(package): + project = tmp_path / f"project-{package}" + monkeypatch.syspath_prepend(os.fspath(project)) + ns = project / "namespace" / package + ns.mkdir(parents=True) + (ns / "__init__.py").write_text("import flask\napp = flask.Flask(__name__)\n") + return project + + _ = create_namespace("package1") + project2 = create_namespace("package2") + purge_module("namespace.package2") + purge_module("namespace") + + from namespace.package2 import app + + assert app.instance_path == os.fspath(project2 / "instance") + + +def test_installed_module_paths( + modules_tmp_path, modules_tmp_path_prefix, purge_module, site_packages +): + (site_packages / "site_app.py").write_text( + "import flask\napp = flask.Flask(__name__)\n" + ) + purge_module("site_app") + + from site_app import app + + assert app.instance_path == os.fspath( + modules_tmp_path / "var" / "site_app-instance" + ) + + +def test_installed_package_paths( + modules_tmp_path, modules_tmp_path_prefix, purge_module, monkeypatch +): + installed_path = modules_tmp_path / "path" + installed_path.mkdir() + monkeypatch.syspath_prepend(installed_path) + + app = installed_path / "installed_package" + app.mkdir() + (app / "__init__.py").write_text("import flask\napp = flask.Flask(__name__)\n") + purge_module("installed_package") + + from installed_package import app + + assert app.instance_path == os.fspath( + modules_tmp_path / "var" / "installed_package-instance" + ) + + +def test_prefix_package_paths( + modules_tmp_path, modules_tmp_path_prefix, purge_module, site_packages +): + app = site_packages / "site_package" + app.mkdir() + (app / "__init__.py").write_text("import flask\napp = flask.Flask(__name__)\n") + purge_module("site_package") + + import site_package + + assert site_package.app.instance_path == os.fspath( + modules_tmp_path / "var" / "site_package-instance" + ) diff --git a/src/flask-main/tests/test_json.py b/src/flask-main/tests/test_json.py new file mode 100644 index 0000000..1e2b27d --- /dev/null +++ b/src/flask-main/tests/test_json.py @@ -0,0 +1,346 @@ +import datetime +import decimal +import io +import uuid + +import pytest +from werkzeug.http import http_date + +import flask +from flask import json +from flask.json.provider import DefaultJSONProvider + + +@pytest.mark.parametrize("debug", (True, False)) +def test_bad_request_debug_message(app, client, debug): + app.config["DEBUG"] = debug + app.config["TRAP_BAD_REQUEST_ERRORS"] = False + + @app.route("/json", methods=["POST"]) + def post_json(): + flask.request.get_json() + return None + + rv = client.post("/json", data=None, content_type="application/json") + assert rv.status_code == 400 + contains = b"Failed to decode JSON object" in rv.data + assert contains == debug + + +def test_json_bad_requests(app, client): + @app.route("/json", methods=["POST"]) + def return_json(): + return flask.jsonify(foo=str(flask.request.get_json())) + + rv = client.post("/json", data="malformed", content_type="application/json") + assert rv.status_code == 400 + + +def test_json_custom_mimetypes(app, client): + @app.route("/json", methods=["POST"]) + def return_json(): + return flask.request.get_json() + + rv = client.post("/json", data='"foo"', content_type="application/x+json") + assert rv.data == b"foo" + + +@pytest.mark.parametrize( + "test_value,expected", [(True, '"\\u2603"'), (False, '"\u2603"')] +) +def test_json_as_unicode(test_value, expected, app, app_ctx): + app.json.ensure_ascii = test_value + rv = app.json.dumps("\N{SNOWMAN}") + assert rv == expected + + +def test_json_dump_to_file(app, app_ctx): + test_data = {"name": "Flask"} + out = io.StringIO() + + flask.json.dump(test_data, out) + out.seek(0) + rv = flask.json.load(out) + assert rv == test_data + + +@pytest.mark.parametrize( + "test_value", [0, -1, 1, 23, 3.14, "s", "longer string", True, False, None] +) +def test_jsonify_basic_types(test_value, app, client): + url = "/jsonify_basic_types" + app.add_url_rule(url, url, lambda x=test_value: flask.jsonify(x)) + rv = client.get(url) + assert rv.mimetype == "application/json" + assert flask.json.loads(rv.data) == test_value + + +def test_jsonify_dicts(app, client): + d = { + "a": 0, + "b": 23, + "c": 3.14, + "d": "t", + "e": "Hi", + "f": True, + "g": False, + "h": ["test list", 10, False], + "i": {"test": "dict"}, + } + + @app.route("/kw") + def return_kwargs(): + return flask.jsonify(**d) + + @app.route("/dict") + def return_dict(): + return flask.jsonify(d) + + for url in "/kw", "/dict": + rv = client.get(url) + assert rv.mimetype == "application/json" + assert flask.json.loads(rv.data) == d + + +def test_jsonify_arrays(app, client): + """Test jsonify of lists and args unpacking.""" + a_list = [ + 0, + 42, + 3.14, + "t", + "hello", + True, + False, + ["test list", 2, False], + {"test": "dict"}, + ] + + @app.route("/args_unpack") + def return_args_unpack(): + return flask.jsonify(*a_list) + + @app.route("/array") + def return_array(): + return flask.jsonify(a_list) + + for url in "/args_unpack", "/array": + rv = client.get(url) + assert rv.mimetype == "application/json" + assert flask.json.loads(rv.data) == a_list + + +@pytest.mark.parametrize( + "value", [datetime.datetime(1973, 3, 11, 6, 30, 45), datetime.date(1975, 1, 5)] +) +def test_jsonify_datetime(app, client, value): + @app.route("/") + def index(): + return flask.jsonify(value=value) + + r = client.get() + assert r.json["value"] == http_date(value) + + +class FixedOffset(datetime.tzinfo): + """Fixed offset in hours east from UTC. + + This is a slight adaptation of the ``FixedOffset`` example found in + https://docs.python.org/2.7/library/datetime.html. + """ + + def __init__(self, hours, name): + self.__offset = datetime.timedelta(hours=hours) + self.__name = name + + def utcoffset(self, dt): + return self.__offset + + def tzname(self, dt): + return self.__name + + def dst(self, dt): + return datetime.timedelta() + + +@pytest.mark.parametrize("tz", (("UTC", 0), ("PST", -8), ("KST", 9))) +def test_jsonify_aware_datetimes(tz): + """Test if aware datetime.datetime objects are converted into GMT.""" + tzinfo = FixedOffset(hours=tz[1], name=tz[0]) + dt = datetime.datetime(2017, 1, 1, 12, 34, 56, tzinfo=tzinfo) + gmt = FixedOffset(hours=0, name="GMT") + expected = dt.astimezone(gmt).strftime('"%a, %d %b %Y %H:%M:%S %Z"') + assert flask.json.dumps(dt) == expected + + +def test_jsonify_uuid_types(app, client): + """Test jsonify with uuid.UUID types""" + + test_uuid = uuid.UUID(bytes=b"\xde\xad\xbe\xef" * 4) + url = "/uuid_test" + app.add_url_rule(url, url, lambda: flask.jsonify(x=test_uuid)) + + rv = client.get(url) + + rv_x = flask.json.loads(rv.data)["x"] + assert rv_x == str(test_uuid) + rv_uuid = uuid.UUID(rv_x) + assert rv_uuid == test_uuid + + +def test_json_decimal(): + rv = flask.json.dumps(decimal.Decimal("0.003")) + assert rv == '"0.003"' + + +def test_json_attr(app, client): + @app.route("/add", methods=["POST"]) + def add(): + json = flask.request.get_json() + return str(json["a"] + json["b"]) + + rv = client.post( + "/add", + data=flask.json.dumps({"a": 1, "b": 2}), + content_type="application/json", + ) + assert rv.data == b"3" + + +def test_tojson_filter(app, req_ctx): + # The tojson filter is tested in Jinja, this confirms that it's + # using Flask's dumps. + rv = flask.render_template_string( + "const data = {{ data|tojson }};", + data={"name": "", "time": datetime.datetime(2021, 2, 1, 7, 15)}, + ) + assert rv == ( + 'const data = {"name": "\\u003c/script\\u003e",' + ' "time": "Mon, 01 Feb 2021 07:15:00 GMT"};' + ) + + +def test_json_customization(app, client): + class X: # noqa: B903, for Python2 compatibility + def __init__(self, val): + self.val = val + + def default(o): + if isinstance(o, X): + return f"<{o.val}>" + + return DefaultJSONProvider.default(o) + + class CustomProvider(DefaultJSONProvider): + def object_hook(self, obj): + if len(obj) == 1 and "_foo" in obj: + return X(obj["_foo"]) + + return obj + + def loads(self, s, **kwargs): + kwargs.setdefault("object_hook", self.object_hook) + return super().loads(s, **kwargs) + + app.json = CustomProvider(app) + app.json.default = default + + @app.route("/", methods=["POST"]) + def index(): + return flask.json.dumps(flask.request.get_json()["x"]) + + rv = client.post( + "/", + data=flask.json.dumps({"x": {"_foo": 42}}), + content_type="application/json", + ) + assert rv.data == b'"<42>"' + + +def _has_encoding(name): + try: + import codecs + + codecs.lookup(name) + return True + except LookupError: + return False + + +def test_json_key_sorting(app, client): + app.debug = True + assert app.json.sort_keys + d = dict.fromkeys(range(20), "foo") + + @app.route("/") + def index(): + return flask.jsonify(values=d) + + rv = client.get("/") + lines = [x.strip() for x in rv.data.strip().decode("utf-8").splitlines()] + sorted_by_str = [ + "{", + '"values": {', + '"0": "foo",', + '"1": "foo",', + '"10": "foo",', + '"11": "foo",', + '"12": "foo",', + '"13": "foo",', + '"14": "foo",', + '"15": "foo",', + '"16": "foo",', + '"17": "foo",', + '"18": "foo",', + '"19": "foo",', + '"2": "foo",', + '"3": "foo",', + '"4": "foo",', + '"5": "foo",', + '"6": "foo",', + '"7": "foo",', + '"8": "foo",', + '"9": "foo"', + "}", + "}", + ] + sorted_by_int = [ + "{", + '"values": {', + '"0": "foo",', + '"1": "foo",', + '"2": "foo",', + '"3": "foo",', + '"4": "foo",', + '"5": "foo",', + '"6": "foo",', + '"7": "foo",', + '"8": "foo",', + '"9": "foo",', + '"10": "foo",', + '"11": "foo",', + '"12": "foo",', + '"13": "foo",', + '"14": "foo",', + '"15": "foo",', + '"16": "foo",', + '"17": "foo",', + '"18": "foo",', + '"19": "foo"', + "}", + "}", + ] + + try: + assert lines == sorted_by_int + except AssertionError: + assert lines == sorted_by_str + + +def test_html_method(): + class ObjectWithHTML: + def __html__(self): + return "

test

" + + result = json.dumps(ObjectWithHTML()) + assert result == '"

test

"' diff --git a/src/flask-main/tests/test_json_tag.py b/src/flask-main/tests/test_json_tag.py new file mode 100644 index 0000000..677160a --- /dev/null +++ b/src/flask-main/tests/test_json_tag.py @@ -0,0 +1,86 @@ +from datetime import datetime +from datetime import timezone +from uuid import uuid4 + +import pytest +from markupsafe import Markup + +from flask.json.tag import JSONTag +from flask.json.tag import TaggedJSONSerializer + + +@pytest.mark.parametrize( + "data", + ( + {" t": (1, 2, 3)}, + {" t__": b"a"}, + {" di": " di"}, + {"x": (1, 2, 3), "y": 4}, + (1, 2, 3), + [(1, 2, 3)], + b"\xff", + Markup(""), + uuid4(), + datetime.now(tz=timezone.utc).replace(microsecond=0), + ), +) +def test_dump_load_unchanged(data): + s = TaggedJSONSerializer() + assert s.loads(s.dumps(data)) == data + + +def test_duplicate_tag(): + class TagDict(JSONTag): + key = " d" + + s = TaggedJSONSerializer() + pytest.raises(KeyError, s.register, TagDict) + s.register(TagDict, force=True, index=0) + assert isinstance(s.tags[" d"], TagDict) + assert isinstance(s.order[0], TagDict) + + +def test_custom_tag(): + class Foo: # noqa: B903, for Python2 compatibility + def __init__(self, data): + self.data = data + + class TagFoo(JSONTag): + __slots__ = () + key = " f" + + def check(self, value): + return isinstance(value, Foo) + + def to_json(self, value): + return self.serializer.tag(value.data) + + def to_python(self, value): + return Foo(value) + + s = TaggedJSONSerializer() + s.register(TagFoo) + assert s.loads(s.dumps(Foo("bar"))).data == "bar" + + +def test_tag_interface(): + t = JSONTag(None) + pytest.raises(NotImplementedError, t.check, None) + pytest.raises(NotImplementedError, t.to_json, None) + pytest.raises(NotImplementedError, t.to_python, None) + + +def test_tag_order(): + class Tag1(JSONTag): + key = " 1" + + class Tag2(JSONTag): + key = " 2" + + s = TaggedJSONSerializer() + + s.register(Tag1, index=-1) + assert isinstance(s.order[-2], Tag1) + + s.register(Tag2, index=None) + assert isinstance(s.order[-1], Tag2) diff --git a/src/flask-main/tests/test_logging.py b/src/flask-main/tests/test_logging.py new file mode 100644 index 0000000..a5f0463 --- /dev/null +++ b/src/flask-main/tests/test_logging.py @@ -0,0 +1,98 @@ +import logging +import sys +from io import StringIO + +import pytest + +from flask.logging import default_handler +from flask.logging import has_level_handler +from flask.logging import wsgi_errors_stream + + +@pytest.fixture(autouse=True) +def reset_logging(pytestconfig): + root_handlers = logging.root.handlers[:] + logging.root.handlers = [] + root_level = logging.root.level + + logger = logging.getLogger("flask_test") + logger.handlers = [] + logger.setLevel(logging.NOTSET) + + logging_plugin = pytestconfig.pluginmanager.unregister(name="logging-plugin") + + yield + + logging.root.handlers[:] = root_handlers + logging.root.setLevel(root_level) + + logger.handlers = [] + logger.setLevel(logging.NOTSET) + + if logging_plugin: + pytestconfig.pluginmanager.register(logging_plugin, "logging-plugin") + + +def test_logger(app): + assert app.logger.name == "flask_test" + assert app.logger.level == logging.NOTSET + assert app.logger.handlers == [default_handler] + + +def test_logger_debug(app): + app.debug = True + assert app.logger.level == logging.DEBUG + assert app.logger.handlers == [default_handler] + + +def test_existing_handler(app): + logging.root.addHandler(logging.StreamHandler()) + assert app.logger.level == logging.NOTSET + assert not app.logger.handlers + + +def test_wsgi_errors_stream(app, client): + @app.route("/") + def index(): + app.logger.error("test") + return "" + + stream = StringIO() + client.get("/", errors_stream=stream) + assert "ERROR in test_logging: test" in stream.getvalue() + + assert wsgi_errors_stream._get_current_object() is sys.stderr + + with app.test_request_context(errors_stream=stream): + assert wsgi_errors_stream._get_current_object() is stream + + +def test_has_level_handler(): + logger = logging.getLogger("flask.app") + assert not has_level_handler(logger) + + handler = logging.StreamHandler() + logging.root.addHandler(handler) + assert has_level_handler(logger) + + logger.propagate = False + assert not has_level_handler(logger) + logger.propagate = True + + handler.setLevel(logging.ERROR) + assert not has_level_handler(logger) + + +def test_log_view_exception(app, client): + @app.route("/") + def index(): + raise Exception("test") + + app.testing = False + stream = StringIO() + rv = client.get("/", errors_stream=stream) + assert rv.status_code == 500 + assert rv.data + err = stream.getvalue() + assert "Exception on / [GET]" in err + assert "Exception: test" in err diff --git a/src/flask-main/tests/test_regression.py b/src/flask-main/tests/test_regression.py new file mode 100644 index 0000000..0ddcf97 --- /dev/null +++ b/src/flask-main/tests/test_regression.py @@ -0,0 +1,30 @@ +import flask + + +def test_aborting(app): + class Foo(Exception): + whatever = 42 + + @app.errorhandler(Foo) + def handle_foo(e): + return str(e.whatever) + + @app.route("/") + def index(): + raise flask.abort(flask.redirect(flask.url_for("test"))) + + @app.route("/test") + def test(): + raise Foo() + + with app.test_client() as c: + rv = c.get("/") + location_parts = rv.headers["Location"].rpartition("/") + + if location_parts[0]: + # For older Werkzeug that used absolute redirects. + assert location_parts[0] == "http://localhost" + + assert location_parts[2] == "test" + rv = c.get("/test") + assert rv.data == b"42" diff --git a/src/flask-main/tests/test_reqctx.py b/src/flask-main/tests/test_reqctx.py new file mode 100644 index 0000000..78561f5 --- /dev/null +++ b/src/flask-main/tests/test_reqctx.py @@ -0,0 +1,326 @@ +import warnings + +import pytest + +import flask +from flask.globals import app_ctx +from flask.sessions import SecureCookieSessionInterface +from flask.sessions import SessionInterface + +try: + from greenlet import greenlet +except ImportError: + greenlet = None + + +def test_teardown_on_pop(app): + buffer = [] + + @app.teardown_request + def end_of_request(exception): + buffer.append(exception) + + ctx = app.test_request_context() + ctx.push() + assert buffer == [] + ctx.pop() + assert buffer == [None] + + +def test_teardown_with_previous_exception(app): + buffer = [] + + @app.teardown_request + def end_of_request(exception): + buffer.append(exception) + + try: + raise Exception("dummy") + except Exception: + pass + + with app.test_request_context(): + assert buffer == [] + assert buffer == [None] + + +def test_teardown_with_handled_exception(app): + buffer = [] + + @app.teardown_request + def end_of_request(exception): + buffer.append(exception) + + with app.test_request_context(): + assert buffer == [] + try: + raise Exception("dummy") + except Exception: + pass + assert buffer == [None] + + +def test_proper_test_request_context(app): + app.config.update(SERVER_NAME="localhost.localdomain:5000") + + @app.route("/") + def index(): + return None + + @app.route("/", subdomain="foo") + def sub(): + return None + + with app.test_request_context("/"): + assert ( + flask.url_for("index", _external=True) + == "http://localhost.localdomain:5000/" + ) + + with app.test_request_context("/"): + assert ( + flask.url_for("sub", _external=True) + == "http://foo.localhost.localdomain:5000/" + ) + + # suppress Werkzeug 0.15 warning about name mismatch + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", "Current server name", UserWarning, "flask.app" + ) + with app.test_request_context( + "/", environ_overrides={"HTTP_HOST": "localhost"} + ): + pass + + app.config.update(SERVER_NAME="localhost") + with app.test_request_context("/", environ_overrides={"SERVER_NAME": "localhost"}): + pass + + app.config.update(SERVER_NAME="localhost:80") + with app.test_request_context( + "/", environ_overrides={"SERVER_NAME": "localhost:80"} + ): + pass + + +def test_context_binding(app): + @app.route("/") + def index(): + return f"Hello {flask.request.args['name']}!" + + @app.route("/meh") + def meh(): + return flask.request.url + + with app.test_request_context("/?name=World"): + assert index() == "Hello World!" + with app.test_request_context("/meh"): + assert meh() == "http://localhost/meh" + assert not flask.request + + +def test_context_test(app): + assert not flask.request + assert not flask.has_request_context() + ctx = app.test_request_context() + ctx.push() + try: + assert flask.request + assert flask.has_request_context() + finally: + ctx.pop() + + +def test_manual_context_binding(app): + @app.route("/") + def index(): + return f"Hello {flask.request.args['name']}!" + + ctx = app.test_request_context("/?name=World") + ctx.push() + assert index() == "Hello World!" + ctx.pop() + with pytest.raises(RuntimeError): + index() + + +@pytest.mark.skipif(greenlet is None, reason="greenlet not installed") +class TestGreenletContextCopying: + def test_greenlet_context_copying(self, app, client): + greenlets = [] + + @app.route("/") + def index(): + flask.session["fizz"] = "buzz" + ctx = app_ctx.copy() + + def g(): + assert not flask.request + assert not flask.current_app + with ctx: + assert flask.request + assert flask.current_app == app + assert flask.request.path == "/" + assert flask.request.args["foo"] == "bar" + assert flask.session.get("fizz") == "buzz" + assert not flask.request + return 42 + + greenlets.append(greenlet(g)) + return "Hello World!" + + rv = client.get("/?foo=bar") + assert rv.data == b"Hello World!" + + result = greenlets[0].run() + assert result == 42 + + def test_greenlet_context_copying_api(self, app, client): + greenlets = [] + + @app.route("/") + def index(): + flask.session["fizz"] = "buzz" + + @flask.copy_current_request_context + def g(): + assert flask.request + assert flask.current_app == app + assert flask.request.path == "/" + assert flask.request.args["foo"] == "bar" + assert flask.session.get("fizz") == "buzz" + return 42 + + greenlets.append(greenlet(g)) + return "Hello World!" + + rv = client.get("/?foo=bar") + assert rv.data == b"Hello World!" + + result = greenlets[0].run() + assert result == 42 + + +def test_session_error_pops_context(): + class SessionError(Exception): + pass + + class FailingSessionInterface(SessionInterface): + def open_session(self, app, request): + raise SessionError() + + class CustomFlask(flask.Flask): + session_interface = FailingSessionInterface() + + app = CustomFlask(__name__) + + @app.route("/") + def index(): + # shouldn't get here + AssertionError() + + response = app.test_client().get("/") + assert response.status_code == 500 + assert not flask.request + assert not flask.current_app + + +def test_session_dynamic_cookie_name(): + # This session interface will use a cookie with a different name if the + # requested url ends with the string "dynamic_cookie" + class PathAwareSessionInterface(SecureCookieSessionInterface): + def get_cookie_name(self, app): + if flask.request.url.endswith("dynamic_cookie"): + return "dynamic_cookie_name" + else: + return super().get_cookie_name(app) + + class CustomFlask(flask.Flask): + session_interface = PathAwareSessionInterface() + + app = CustomFlask(__name__) + app.secret_key = "secret_key" + + @app.route("/set", methods=["POST"]) + def set(): + flask.session["value"] = flask.request.form["value"] + return "value set" + + @app.route("/get") + def get(): + v = flask.session.get("value", "None") + return v + + @app.route("/set_dynamic_cookie", methods=["POST"]) + def set_dynamic_cookie(): + flask.session["value"] = flask.request.form["value"] + return "value set" + + @app.route("/get_dynamic_cookie") + def get_dynamic_cookie(): + v = flask.session.get("value", "None") + return v + + test_client = app.test_client() + + # first set the cookie in both /set urls but each with a different value + assert test_client.post("/set", data={"value": "42"}).data == b"value set" + assert ( + test_client.post("/set_dynamic_cookie", data={"value": "616"}).data + == b"value set" + ) + + # now check that the relevant values come back - meaning that different + # cookies are being used for the urls that end with "dynamic cookie" + assert test_client.get("/get").data == b"42" + assert test_client.get("/get_dynamic_cookie").data == b"616" + + +def test_bad_environ_raises_bad_request(): + app = flask.Flask(__name__) + + from flask.testing import EnvironBuilder + + builder = EnvironBuilder(app) + environ = builder.get_environ() + + # use a non-printable character in the Host - this is key to this test + environ["HTTP_HOST"] = "\x8a" + + with app.request_context(environ) as ctx: + response = app.full_dispatch_request(ctx) + + assert response.status_code == 400 + + +def test_environ_for_valid_idna_completes(): + app = flask.Flask(__name__) + + @app.route("/") + def index(): + return "Hello World!" + + from flask.testing import EnvironBuilder + + builder = EnvironBuilder(app) + environ = builder.get_environ() + + # these characters are all IDNA-compatible + environ["HTTP_HOST"] = "ąśźäüжŠßя.com" + + with app.request_context(environ) as ctx: + response = app.full_dispatch_request(ctx) + + assert response.status_code == 200 + + +def test_normal_environ_completes(): + app = flask.Flask(__name__) + + @app.route("/") + def index(): + return "Hello World!" + + response = app.test_client().get("/", headers={"host": "xn--on-0ia.com"}) + assert response.status_code == 200 diff --git a/src/flask-main/tests/test_request.py b/src/flask-main/tests/test_request.py new file mode 100644 index 0000000..3e95ab3 --- /dev/null +++ b/src/flask-main/tests/test_request.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from flask import Flask +from flask import Request +from flask import request +from flask.testing import FlaskClient + + +def test_max_content_length(app: Flask, client: FlaskClient) -> None: + app.config["MAX_CONTENT_LENGTH"] = 50 + + @app.post("/") + def index(): + request.form["myfile"] + AssertionError() + + @app.errorhandler(413) + def catcher(error): + return "42" + + rv = client.post("/", data={"myfile": "foo" * 50}) + assert rv.data == b"42" + + +def test_limit_config(app: Flask): + app.config["MAX_CONTENT_LENGTH"] = 100 + app.config["MAX_FORM_MEMORY_SIZE"] = 50 + app.config["MAX_FORM_PARTS"] = 3 + r = Request({}) + + # no app context, use Werkzeug defaults + assert r.max_content_length is None + assert r.max_form_memory_size == 500_000 + assert r.max_form_parts == 1_000 + + # in app context, use config + with app.app_context(): + assert r.max_content_length == 100 + assert r.max_form_memory_size == 50 + assert r.max_form_parts == 3 + + # regardless of app context, use override + r.max_content_length = 90 + r.max_form_memory_size = 30 + r.max_form_parts = 4 + + assert r.max_content_length == 90 + assert r.max_form_memory_size == 30 + assert r.max_form_parts == 4 + + with app.app_context(): + assert r.max_content_length == 90 + assert r.max_form_memory_size == 30 + assert r.max_form_parts == 4 + + +def test_trusted_hosts_config(app: Flask) -> None: + app.config["TRUSTED_HOSTS"] = ["example.test", ".other.test"] + + @app.get("/") + def index() -> str: + return "" + + client = app.test_client() + r = client.get(base_url="http://example.test") + assert r.status_code == 200 + r = client.get(base_url="http://a.other.test") + assert r.status_code == 200 + r = client.get(base_url="http://bad.test") + assert r.status_code == 400 diff --git a/src/flask-main/tests/test_session_interface.py b/src/flask-main/tests/test_session_interface.py new file mode 100644 index 0000000..5564be7 --- /dev/null +++ b/src/flask-main/tests/test_session_interface.py @@ -0,0 +1,28 @@ +import flask +from flask.globals import app_ctx +from flask.sessions import SessionInterface + + +def test_open_session_with_endpoint(): + """If request.endpoint (or other URL matching behavior) is needed + while loading the session, RequestContext.match_request() can be + called manually. + """ + + class MySessionInterface(SessionInterface): + def save_session(self, app, session, response): + pass + + def open_session(self, app, request): + app_ctx.match_request() + assert request.endpoint is not None + + app = flask.Flask(__name__) + app.session_interface = MySessionInterface() + + @app.get("/") + def index(): + return "Hello, World!" + + response = app.test_client().get("/") + assert response.status_code == 200 diff --git a/src/flask-main/tests/test_signals.py b/src/flask-main/tests/test_signals.py new file mode 100644 index 0000000..32ab333 --- /dev/null +++ b/src/flask-main/tests/test_signals.py @@ -0,0 +1,181 @@ +import flask + + +def test_template_rendered(app, client): + @app.route("/") + def index(): + return flask.render_template("simple_template.html", whiskey=42) + + recorded = [] + + def record(sender, template, context): + recorded.append((template, context)) + + flask.template_rendered.connect(record, app) + try: + client.get("/") + assert len(recorded) == 1 + template, context = recorded[0] + assert template.name == "simple_template.html" + assert context["whiskey"] == 42 + finally: + flask.template_rendered.disconnect(record, app) + + +def test_before_render_template(): + app = flask.Flask(__name__) + + @app.route("/") + def index(): + return flask.render_template("simple_template.html", whiskey=42) + + recorded = [] + + def record(sender, template, context): + context["whiskey"] = 43 + recorded.append((template, context)) + + flask.before_render_template.connect(record, app) + try: + rv = app.test_client().get("/") + assert len(recorded) == 1 + template, context = recorded[0] + assert template.name == "simple_template.html" + assert context["whiskey"] == 43 + assert rv.data == b"

43

" + finally: + flask.before_render_template.disconnect(record, app) + + +def test_request_signals(): + app = flask.Flask(__name__) + calls = [] + + def before_request_signal(sender): + calls.append("before-signal") + + def after_request_signal(sender, response): + assert response.data == b"stuff" + calls.append("after-signal") + + @app.before_request + def before_request_handler(): + calls.append("before-handler") + + @app.after_request + def after_request_handler(response): + calls.append("after-handler") + response.data = "stuff" + return response + + @app.route("/") + def index(): + calls.append("handler") + return "ignored anyway" + + flask.request_started.connect(before_request_signal, app) + flask.request_finished.connect(after_request_signal, app) + + try: + rv = app.test_client().get("/") + assert rv.data == b"stuff" + + assert calls == [ + "before-signal", + "before-handler", + "handler", + "after-handler", + "after-signal", + ] + finally: + flask.request_started.disconnect(before_request_signal, app) + flask.request_finished.disconnect(after_request_signal, app) + + +def test_request_exception_signal(): + app = flask.Flask(__name__) + recorded = [] + + @app.route("/") + def index(): + raise ZeroDivisionError + + def record(sender, exception): + recorded.append(exception) + + flask.got_request_exception.connect(record, app) + try: + assert app.test_client().get("/").status_code == 500 + assert len(recorded) == 1 + assert isinstance(recorded[0], ZeroDivisionError) + finally: + flask.got_request_exception.disconnect(record, app) + + +def test_appcontext_signals(app, client): + recorded = [] + + def record_push(sender, **kwargs): + recorded.append("push") + + def record_pop(sender, **kwargs): + recorded.append("pop") + + @app.route("/") + def index(): + return "Hello" + + flask.appcontext_pushed.connect(record_push, app) + flask.appcontext_popped.connect(record_pop, app) + try: + rv = client.get("/") + assert rv.data == b"Hello" + assert recorded == ["push", "pop"] + finally: + flask.appcontext_pushed.disconnect(record_push, app) + flask.appcontext_popped.disconnect(record_pop, app) + + +def test_flash_signal(app): + @app.route("/") + def index(): + flask.flash("This is a flash message", category="notice") + return flask.redirect("/other") + + recorded = [] + + def record(sender, message, category): + recorded.append((message, category)) + + flask.message_flashed.connect(record, app) + try: + client = app.test_client() + with client.session_transaction(): + client.get("/") + assert len(recorded) == 1 + message, category = recorded[0] + assert message == "This is a flash message" + assert category == "notice" + finally: + flask.message_flashed.disconnect(record, app) + + +def test_appcontext_tearing_down_signal(app, client): + app.testing = False + recorded = [] + + def record_teardown(sender, exc): + recorded.append(exc) + + @app.route("/") + def index(): + raise ZeroDivisionError + + flask.appcontext_tearing_down.connect(record_teardown, app) + try: + rv = client.get("/") + assert rv.status_code == 500 + assert len(recorded) == 1 + assert isinstance(recorded[0], ZeroDivisionError) + finally: + flask.appcontext_tearing_down.disconnect(record_teardown, app) diff --git a/src/flask-main/tests/test_subclassing.py b/src/flask-main/tests/test_subclassing.py new file mode 100644 index 0000000..3b9fe31 --- /dev/null +++ b/src/flask-main/tests/test_subclassing.py @@ -0,0 +1,21 @@ +from io import StringIO + +import flask + + +def test_suppressed_exception_logging(): + class SuppressedFlask(flask.Flask): + def log_exception(self, ctx, exc_info): + pass + + out = StringIO() + app = SuppressedFlask(__name__) + + @app.route("/") + def index(): + raise Exception("test") + + rv = app.test_client().get("/", errors_stream=out) + assert rv.status_code == 500 + assert b"Internal Server Error" in rv.data + assert not out.getvalue() diff --git a/src/flask-main/tests/test_templating.py b/src/flask-main/tests/test_templating.py new file mode 100644 index 0000000..85549df --- /dev/null +++ b/src/flask-main/tests/test_templating.py @@ -0,0 +1,532 @@ +import logging + +import pytest +import werkzeug.serving +from jinja2 import TemplateNotFound +from markupsafe import Markup + +import flask + + +def test_context_processing(app, client): + @app.context_processor + def context_processor(): + return {"injected_value": 42} + + @app.route("/") + def index(): + return flask.render_template("context_template.html", value=23) + + rv = client.get("/") + assert rv.data == b"

23|42" + + +def test_original_win(app, client): + @app.route("/") + def index(): + return flask.render_template_string("{{ config }}", config=42) + + rv = client.get("/") + assert rv.data == b"42" + + +def test_simple_stream(app, client): + @app.route("/") + def index(): + return flask.stream_template_string("{{ config }}", config=42) + + rv = client.get("/") + assert rv.data == b"42" + + +def test_request_less_rendering(app, app_ctx): + app.config["WORLD_NAME"] = "Special World" + + @app.context_processor + def context_processor(): + return dict(foo=42) + + rv = flask.render_template_string("Hello {{ config.WORLD_NAME }} {{ foo }}") + assert rv == "Hello Special World 42" + + +def test_standard_context(app, client): + @app.route("/") + def index(): + flask.g.foo = 23 + flask.session["test"] = "aha" + return flask.render_template_string( + """ + {{ request.args.foo }} + {{ g.foo }} + {{ config.DEBUG }} + {{ session.test }} + """ + ) + + rv = client.get("/?foo=42") + assert rv.data.split() == [b"42", b"23", b"False", b"aha"] + + +def test_escaping(app, client): + text = "

Hello World!" + + @app.route("/") + def index(): + return flask.render_template( + "escaping_template.html", text=text, html=Markup(text) + ) + + lines = client.get("/").data.splitlines() + assert lines == [ + b"<p>Hello World!", + b"

Hello World!", + b"

Hello World!", + b"

Hello World!", + b"<p>Hello World!", + b"

Hello World!", + ] + + +def test_no_escaping(app, client): + text = "

Hello World!" + + @app.route("/") + def index(): + return flask.render_template( + "non_escaping_template.txt", text=text, html=Markup(text) + ) + + lines = client.get("/").data.splitlines() + assert lines == [ + b"

Hello World!", + b"

Hello World!", + b"

Hello World!", + b"

Hello World!", + b"<p>Hello World!", + b"

Hello World!", + b"

Hello World!", + b"

Hello World!", + ] + + +def test_escaping_without_template_filename(app, client, req_ctx): + assert flask.render_template_string("{{ foo }}", foo="") == "<test>" + assert flask.render_template("mail.txt", foo="") == " Mail" + + +def test_macros(app, req_ctx): + macro = flask.get_template_attribute("_macro.html", "hello") + assert macro("World") == "Hello World!" + + +def test_template_filter(app): + @app.template_filter() + def my_reverse(s): + return s[::-1] + + assert "my_reverse" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse"] == my_reverse + assert app.jinja_env.filters["my_reverse"]("abcd") == "dcba" + + @app.template_filter + def my_reverse_2(s): + return s[::-1] + + assert "my_reverse_2" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse_2"] == my_reverse_2 + assert app.jinja_env.filters["my_reverse_2"]("abcd") == "dcba" + + @app.template_filter("my_reverse_custom_name_3") + def my_reverse_3(s): + return s[::-1] + + assert "my_reverse_custom_name_3" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse_custom_name_3"] == my_reverse_3 + assert app.jinja_env.filters["my_reverse_custom_name_3"]("abcd") == "dcba" + + @app.template_filter(name="my_reverse_custom_name_4") + def my_reverse_4(s): + return s[::-1] + + assert "my_reverse_custom_name_4" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse_custom_name_4"] == my_reverse_4 + assert app.jinja_env.filters["my_reverse_custom_name_4"]("abcd") == "dcba" + + +def test_add_template_filter(app): + def my_reverse(s): + return s[::-1] + + app.add_template_filter(my_reverse) + assert "my_reverse" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["my_reverse"] == my_reverse + assert app.jinja_env.filters["my_reverse"]("abcd") == "dcba" + + +def test_template_filter_with_name(app): + @app.template_filter("strrev") + def my_reverse(s): + return s[::-1] + + assert "strrev" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["strrev"] == my_reverse + assert app.jinja_env.filters["strrev"]("abcd") == "dcba" + + +def test_add_template_filter_with_name(app): + def my_reverse(s): + return s[::-1] + + app.add_template_filter(my_reverse, "strrev") + assert "strrev" in app.jinja_env.filters.keys() + assert app.jinja_env.filters["strrev"] == my_reverse + assert app.jinja_env.filters["strrev"]("abcd") == "dcba" + + +def test_template_filter_with_template(app, client): + @app.template_filter() + def super_reverse(s): + return s[::-1] + + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_add_template_filter_with_template(app, client): + def super_reverse(s): + return s[::-1] + + app.add_template_filter(super_reverse) + + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_template_filter_with_name_and_template(app, client): + @app.template_filter("super_reverse") + def my_reverse(s): + return s[::-1] + + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_add_template_filter_with_name_and_template(app, client): + def my_reverse(s): + return s[::-1] + + app.add_template_filter(my_reverse, "super_reverse") + + @app.route("/") + def index(): + return flask.render_template("template_filter.html", value="abcd") + + rv = client.get("/") + assert rv.data == b"dcba" + + +def test_template_test(app): + @app.template_test() + def boolean(value): + return isinstance(value, bool) + + assert "boolean" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["boolean"] == boolean + assert app.jinja_env.tests["boolean"](False) + + @app.template_test + def boolean_2(value): + return isinstance(value, bool) + + assert "boolean_2" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["boolean_2"] == boolean_2 + assert app.jinja_env.tests["boolean_2"](False) + + @app.template_test("my_boolean_custom_name") + def boolean_3(value): + return isinstance(value, bool) + + assert "my_boolean_custom_name" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["my_boolean_custom_name"] == boolean_3 + assert app.jinja_env.tests["my_boolean_custom_name"](False) + + @app.template_test(name="my_boolean_custom_name_2") + def boolean_4(value): + return isinstance(value, bool) + + assert "my_boolean_custom_name_2" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["my_boolean_custom_name_2"] == boolean_4 + assert app.jinja_env.tests["my_boolean_custom_name_2"](False) + + +def test_add_template_test(app): + def boolean(value): + return isinstance(value, bool) + + app.add_template_test(boolean) + assert "boolean" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["boolean"] == boolean + assert app.jinja_env.tests["boolean"](False) + + +def test_template_test_with_name(app): + @app.template_test("boolean") + def is_boolean(value): + return isinstance(value, bool) + + assert "boolean" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["boolean"] == is_boolean + assert app.jinja_env.tests["boolean"](False) + + +def test_add_template_test_with_name(app): + def is_boolean(value): + return isinstance(value, bool) + + app.add_template_test(is_boolean, "boolean") + assert "boolean" in app.jinja_env.tests.keys() + assert app.jinja_env.tests["boolean"] == is_boolean + assert app.jinja_env.tests["boolean"](False) + + +def test_template_test_with_template(app, client): + @app.template_test() + def boolean(value): + return isinstance(value, bool) + + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_add_template_test_with_template(app, client): + def boolean(value): + return isinstance(value, bool) + + app.add_template_test(boolean) + + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_template_test_with_name_and_template(app, client): + @app.template_test("boolean") + def is_boolean(value): + return isinstance(value, bool) + + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_add_template_test_with_name_and_template(app, client): + def is_boolean(value): + return isinstance(value, bool) + + app.add_template_test(is_boolean, "boolean") + + @app.route("/") + def index(): + return flask.render_template("template_test.html", value=False) + + rv = client.get("/") + assert b"Success!" in rv.data + + +def test_add_template_global(app, app_ctx): + @app.template_global() + def get_stuff(): + return 42 + + assert "get_stuff" in app.jinja_env.globals.keys() + assert app.jinja_env.globals["get_stuff"] == get_stuff + assert app.jinja_env.globals["get_stuff"](), 42 + + rv = flask.render_template_string("{{ get_stuff() }}") + assert rv == "42" + + @app.template_global + def get_stuff_1(): + return "get_stuff_1" + + assert "get_stuff_1" in app.jinja_env.globals.keys() + assert app.jinja_env.globals["get_stuff_1"] == get_stuff_1 + assert app.jinja_env.globals["get_stuff_1"](), "get_stuff_1" + + rv = flask.render_template_string("{{ get_stuff_1() }}") + assert rv == "get_stuff_1" + + @app.template_global("my_get_stuff_custom_name_2") + def get_stuff_2(): + return "get_stuff_2" + + assert "my_get_stuff_custom_name_2" in app.jinja_env.globals.keys() + assert app.jinja_env.globals["my_get_stuff_custom_name_2"] == get_stuff_2 + assert app.jinja_env.globals["my_get_stuff_custom_name_2"](), "get_stuff_2" + + rv = flask.render_template_string("{{ my_get_stuff_custom_name_2() }}") + assert rv == "get_stuff_2" + + @app.template_global(name="my_get_stuff_custom_name_3") + def get_stuff_3(): + return "get_stuff_3" + + assert "my_get_stuff_custom_name_3" in app.jinja_env.globals.keys() + assert app.jinja_env.globals["my_get_stuff_custom_name_3"] == get_stuff_3 + assert app.jinja_env.globals["my_get_stuff_custom_name_3"](), "get_stuff_3" + + rv = flask.render_template_string("{{ my_get_stuff_custom_name_3() }}") + assert rv == "get_stuff_3" + + +def test_custom_template_loader(client): + class MyFlask(flask.Flask): + def create_global_jinja_loader(self): + from jinja2 import DictLoader + + return DictLoader({"index.html": "Hello Custom World!"}) + + app = MyFlask(__name__) + + @app.route("/") + def index(): + return flask.render_template("index.html") + + c = app.test_client() + rv = c.get("/") + assert rv.data == b"Hello Custom World!" + + +def test_iterable_loader(app, client): + @app.context_processor + def context_processor(): + return {"whiskey": "Jameson"} + + @app.route("/") + def index(): + return flask.render_template( + [ + "no_template.xml", # should skip this one + "simple_template.html", # should render this + "context_template.html", + ], + value=23, + ) + + rv = client.get("/") + assert rv.data == b"

Jameson

" + + +def test_templates_auto_reload(app): + # debug is False, config option is None + assert app.debug is False + assert app.config["TEMPLATES_AUTO_RELOAD"] is None + assert app.jinja_env.auto_reload is False + # debug is False, config option is False + app = flask.Flask(__name__) + app.config["TEMPLATES_AUTO_RELOAD"] = False + assert app.debug is False + assert app.jinja_env.auto_reload is False + # debug is False, config option is True + app = flask.Flask(__name__) + app.config["TEMPLATES_AUTO_RELOAD"] = True + assert app.debug is False + assert app.jinja_env.auto_reload is True + # debug is True, config option is None + app = flask.Flask(__name__) + app.config["DEBUG"] = True + assert app.config["TEMPLATES_AUTO_RELOAD"] is None + assert app.jinja_env.auto_reload is True + # debug is True, config option is False + app = flask.Flask(__name__) + app.config["DEBUG"] = True + app.config["TEMPLATES_AUTO_RELOAD"] = False + assert app.jinja_env.auto_reload is False + # debug is True, config option is True + app = flask.Flask(__name__) + app.config["DEBUG"] = True + app.config["TEMPLATES_AUTO_RELOAD"] = True + assert app.jinja_env.auto_reload is True + + +def test_templates_auto_reload_debug_run(app, monkeypatch): + def run_simple_mock(*args, **kwargs): + pass + + monkeypatch.setattr(werkzeug.serving, "run_simple", run_simple_mock) + + app.run() + assert not app.jinja_env.auto_reload + + app.run(debug=True) + assert app.jinja_env.auto_reload + + +def test_template_loader_debugging(test_apps, monkeypatch): + from blueprintapp import app + + called = [] + + class _TestHandler(logging.Handler): + def handle(self, record): + called.append(True) + text = str(record.msg) + assert "1: trying loader of application 'blueprintapp'" in text + assert ( + "2: trying loader of blueprint 'admin' (blueprintapp.apps.admin)" + ) in text + assert ( + "trying loader of blueprint 'frontend' (blueprintapp.apps.frontend)" + ) in text + assert "Error: the template could not be found" in text + assert ( + "looked up from an endpoint that belongs to the blueprint 'frontend'" + ) in text + assert "See https://flask.palletsprojects.com/blueprints/#templates" in text + + with app.test_client() as c: + monkeypatch.setitem(app.config, "EXPLAIN_TEMPLATE_LOADING", True) + monkeypatch.setattr( + logging.getLogger("blueprintapp"), "handlers", [_TestHandler()] + ) + + with pytest.raises(TemplateNotFound) as excinfo: + c.get("/missing") + + assert "missing_template.html" in str(excinfo.value) + + assert len(called) == 1 + + +def test_custom_jinja_env(): + class CustomEnvironment(flask.templating.Environment): + pass + + class CustomFlask(flask.Flask): + jinja_environment = CustomEnvironment + + app = CustomFlask(__name__) + assert isinstance(app.jinja_env, CustomEnvironment) diff --git a/src/flask-main/tests/test_testing.py b/src/flask-main/tests/test_testing.py new file mode 100644 index 0000000..e14f27c --- /dev/null +++ b/src/flask-main/tests/test_testing.py @@ -0,0 +1,385 @@ +import importlib.metadata + +import click +import pytest + +import flask +from flask import appcontext_popped +from flask.cli import ScriptInfo +from flask.globals import _cv_app +from flask.json import jsonify +from flask.testing import EnvironBuilder +from flask.testing import FlaskCliRunner + + +def test_environ_defaults_from_config(app, client): + app.config["SERVER_NAME"] = "example.com:1234" + app.config["APPLICATION_ROOT"] = "/foo" + + @app.route("/") + def index(): + return flask.request.url + + ctx = app.test_request_context() + assert ctx.request.url == "http://example.com:1234/foo/" + + rv = client.get("/") + assert rv.data == b"http://example.com:1234/foo/" + + +def test_environ_defaults(app, client, app_ctx, req_ctx): + @app.route("/") + def index(): + return flask.request.url + + ctx = app.test_request_context() + assert ctx.request.url == "http://localhost/" + with client: + rv = client.get("/") + assert rv.data == b"http://localhost/" + + +def test_environ_base_default(app, client): + @app.route("/") + def index(): + flask.g.remote_addr = flask.request.remote_addr + flask.g.user_agent = flask.request.user_agent.string + return "" + + with client: + client.get("/") + assert flask.g.remote_addr == "127.0.0.1" + assert flask.g.user_agent == ( + f"Werkzeug/{importlib.metadata.version('werkzeug')}" + ) + + +def test_environ_base_modified(app, client): + @app.route("/") + def index(): + flask.g.remote_addr = flask.request.remote_addr + flask.g.user_agent = flask.request.user_agent.string + return "" + + client.environ_base["REMOTE_ADDR"] = "192.168.0.22" + client.environ_base["HTTP_USER_AGENT"] = "Foo" + + with client: + client.get("/") + assert flask.g.remote_addr == "192.168.0.22" + assert flask.g.user_agent == "Foo" + + +def test_client_open_environ(app, client, request): + @app.route("/index") + def index(): + return flask.request.remote_addr + + builder = EnvironBuilder(app, path="/index", method="GET") + request.addfinalizer(builder.close) + + rv = client.open(builder) + assert rv.data == b"127.0.0.1" + + environ = builder.get_environ() + client.environ_base["REMOTE_ADDR"] = "127.0.0.2" + rv = client.open(environ) + assert rv.data == b"127.0.0.2" + + +def test_specify_url_scheme(app, client): + @app.route("/") + def index(): + return flask.request.url + + ctx = app.test_request_context(url_scheme="https") + assert ctx.request.url == "https://localhost/" + + rv = client.get("/", url_scheme="https") + assert rv.data == b"https://localhost/" + + +def test_path_is_url(app): + eb = EnvironBuilder(app, "https://example.com/") + assert eb.url_scheme == "https" + assert eb.host == "example.com" + assert eb.script_root == "" + assert eb.path == "/" + + +def test_environbuilder_json_dumps(app): + """EnvironBuilder.json_dumps() takes settings from the app.""" + app.json.ensure_ascii = False + eb = EnvironBuilder(app, json="\u20ac") + assert eb.input_stream.read().decode("utf8") == '"\u20ac"' + + +def test_blueprint_with_subdomain(): + app = flask.Flask(__name__, subdomain_matching=True) + app.config["SERVER_NAME"] = "example.com:1234" + app.config["APPLICATION_ROOT"] = "/foo" + client = app.test_client() + + bp = flask.Blueprint("company", __name__, subdomain="xxx") + + @bp.route("/") + def index(): + return flask.request.url + + app.register_blueprint(bp) + + ctx = app.test_request_context("/", subdomain="xxx") + assert ctx.request.url == "http://xxx.example.com:1234/foo/" + + with ctx: + assert ctx.request.blueprint == bp.name + + rv = client.get("/", subdomain="xxx") + assert rv.data == b"http://xxx.example.com:1234/foo/" + + +def test_redirect_session(app, client, app_ctx): + @app.route("/redirect") + def index(): + flask.session["redirect"] = True + return flask.redirect("/target") + + @app.route("/target") + def get_session(): + flask.session["target"] = True + return "" + + with client: + client.get("/redirect", follow_redirects=True) + assert flask.session["redirect"] is True + assert flask.session["target"] is True + + +def test_session_transactions(app, client): + @app.route("/") + def index(): + return str(flask.session["foo"]) + + with client: + with client.session_transaction() as sess: + assert len(sess) == 0 + sess["foo"] = [42] + assert len(sess) == 1 + rv = client.get("/") + assert rv.data == b"[42]" + with client.session_transaction() as sess: + assert len(sess) == 1 + assert sess["foo"] == [42] + + +def test_session_transactions_no_null_sessions(): + app = flask.Flask(__name__) + + with app.test_client() as c: + with pytest.raises(RuntimeError) as e: + with c.session_transaction(): + pass + assert "Session backend did not open a session" in str(e.value) + + +def test_session_transactions_keep_context(app, client, req_ctx): + client.get("/") + req = flask.request._get_current_object() + assert req is not None + with client.session_transaction(): + assert req is flask.request._get_current_object() + + +def test_session_transaction_needs_cookies(app): + c = app.test_client(use_cookies=False) + + with pytest.raises(TypeError, match="Cookies are disabled."): + with c.session_transaction(): + pass + + +def test_test_client_context_binding(app, client): + app.testing = False + + @app.route("/") + def index(): + flask.g.value = 42 + return "Hello World!" + + @app.route("/other") + def other(): + raise ZeroDivisionError + + with client: + resp = client.get("/") + assert flask.g.value == 42 + assert resp.data == b"Hello World!" + assert resp.status_code == 200 + + with client: + resp = client.get("/other") + assert not hasattr(flask.g, "value") + assert b"Internal Server Error" in resp.data + assert resp.status_code == 500 + flask.g.value = 23 + + with pytest.raises(RuntimeError): + flask.g.value # noqa: B018 + + +def test_reuse_client(client): + c = client + + with c: + assert client.get("/").status_code == 404 + + with c: + assert client.get("/").status_code == 404 + + +def test_full_url_request(app, client): + @app.route("/action", methods=["POST"]) + def action(): + return "x" + + with client: + rv = client.post("http://domain.com/action?vodka=42", data={"gin": 43}) + assert rv.status_code == 200 + assert "gin" in flask.request.form + assert "vodka" in flask.request.args + + +def test_json_request_and_response(app, client): + @app.route("/echo", methods=["POST"]) + def echo(): + return jsonify(flask.request.get_json()) + + with client: + json_data = {"drink": {"gin": 1, "tonic": True}, "price": 10} + rv = client.post("/echo", json=json_data) + + # Request should be in JSON + assert flask.request.is_json + assert flask.request.get_json() == json_data + + # Response should be in JSON + assert rv.status_code == 200 + assert rv.is_json + assert rv.get_json() == json_data + + +def test_client_json_no_app_context(app, client): + @app.route("/hello", methods=["POST"]) + def hello(): + return f"Hello, {flask.request.json['name']}!" + + class Namespace: + count = 0 + + def add(self, app): + self.count += 1 + + ns = Namespace() + + with appcontext_popped.connected_to(ns.add, app): + rv = client.post("/hello", json={"name": "Flask"}) + + assert rv.get_data(as_text=True) == "Hello, Flask!" + assert ns.count == 1 + + +def test_subdomain(): + app = flask.Flask(__name__, subdomain_matching=True) + app.config["SERVER_NAME"] = "example.com" + client = app.test_client() + + @app.route("/", subdomain="") + def view(company_id): + return company_id + + with app.test_request_context(): + url = flask.url_for("view", company_id="xxx") + + with client: + response = client.get(url) + + assert 200 == response.status_code + assert b"xxx" == response.data + + +def test_nosubdomain(app, client): + app.config["SERVER_NAME"] = "example.com" + + @app.route("/") + def view(company_id): + return company_id + + with app.test_request_context(): + url = flask.url_for("view", company_id="xxx") + + with client: + response = client.get(url) + + assert 200 == response.status_code + assert b"xxx" == response.data + + +def test_cli_runner_class(app): + runner = app.test_cli_runner() + assert isinstance(runner, FlaskCliRunner) + + class SubRunner(FlaskCliRunner): + pass + + app.test_cli_runner_class = SubRunner + runner = app.test_cli_runner() + assert isinstance(runner, SubRunner) + + +def test_cli_invoke(app): + @app.cli.command("hello") + def hello_command(): + click.echo("Hello, World!") + + runner = app.test_cli_runner() + # invoke with command name + result = runner.invoke(args=["hello"]) + assert "Hello" in result.output + # invoke with command object + result = runner.invoke(hello_command) + assert "Hello" in result.output + + +def test_cli_custom_obj(app): + class NS: + called = False + + def create_app(): + NS.called = True + return app + + @app.cli.command("hello") + def hello_command(): + click.echo("Hello, World!") + + script_info = ScriptInfo(create_app=create_app) + runner = app.test_cli_runner() + runner.invoke(hello_command, obj=script_info) + assert NS.called + + +def test_client_pop_all_preserved(app, req_ctx, client): + @app.route("/") + def index(): + # stream_with_context pushes a third context, preserved by response + return flask.stream_with_context("hello") + + # req_ctx fixture pushed an initial context + with client: + # request pushes a second request context, preserved by client + rv = client.get("/") + + # close the response, releasing the context held by stream_with_context + rv.close() + # only req_ctx fixture should still be pushed + assert _cv_app.get(None) is req_ctx diff --git a/src/flask-main/tests/test_user_error_handler.py b/src/flask-main/tests/test_user_error_handler.py new file mode 100644 index 0000000..79c5a73 --- /dev/null +++ b/src/flask-main/tests/test_user_error_handler.py @@ -0,0 +1,295 @@ +import pytest +from werkzeug.exceptions import Forbidden +from werkzeug.exceptions import HTTPException +from werkzeug.exceptions import InternalServerError +from werkzeug.exceptions import NotFound + +import flask + + +def test_error_handler_no_match(app, client): + class CustomException(Exception): + pass + + @app.errorhandler(CustomException) + def custom_exception_handler(e): + assert isinstance(e, CustomException) + return "custom" + + with pytest.raises(TypeError) as exc_info: + app.register_error_handler(CustomException(), None) + + assert "CustomException() is an instance, not a class." in str(exc_info.value) + + with pytest.raises(ValueError) as exc_info: + app.register_error_handler(list, None) + + assert "'list' is not a subclass of Exception." in str(exc_info.value) + + @app.errorhandler(500) + def handle_500(e): + assert isinstance(e, InternalServerError) + + if e.original_exception is not None: + return f"wrapped {type(e.original_exception).__name__}" + + return "direct" + + with pytest.raises(ValueError) as exc_info: + app.register_error_handler(999, None) + + assert "Use a subclass of HTTPException" in str(exc_info.value) + + @app.route("/custom") + def custom_test(): + raise CustomException() + + @app.route("/keyerror") + def key_error(): + raise KeyError() + + @app.route("/abort") + def do_abort(): + flask.abort(500) + + app.testing = False + assert client.get("/custom").data == b"custom" + assert client.get("/keyerror").data == b"wrapped KeyError" + assert client.get("/abort").data == b"direct" + + +def test_error_handler_subclass(app): + class ParentException(Exception): + pass + + class ChildExceptionUnregistered(ParentException): + pass + + class ChildExceptionRegistered(ParentException): + pass + + @app.errorhandler(ParentException) + def parent_exception_handler(e): + assert isinstance(e, ParentException) + return "parent" + + @app.errorhandler(ChildExceptionRegistered) + def child_exception_handler(e): + assert isinstance(e, ChildExceptionRegistered) + return "child-registered" + + @app.route("/parent") + def parent_test(): + raise ParentException() + + @app.route("/child-unregistered") + def unregistered_test(): + raise ChildExceptionUnregistered() + + @app.route("/child-registered") + def registered_test(): + raise ChildExceptionRegistered() + + c = app.test_client() + + assert c.get("/parent").data == b"parent" + assert c.get("/child-unregistered").data == b"parent" + assert c.get("/child-registered").data == b"child-registered" + + +def test_error_handler_http_subclass(app): + class ForbiddenSubclassRegistered(Forbidden): + pass + + class ForbiddenSubclassUnregistered(Forbidden): + pass + + @app.errorhandler(403) + def code_exception_handler(e): + assert isinstance(e, Forbidden) + return "forbidden" + + @app.errorhandler(ForbiddenSubclassRegistered) + def subclass_exception_handler(e): + assert isinstance(e, ForbiddenSubclassRegistered) + return "forbidden-registered" + + @app.route("/forbidden") + def forbidden_test(): + raise Forbidden() + + @app.route("/forbidden-registered") + def registered_test(): + raise ForbiddenSubclassRegistered() + + @app.route("/forbidden-unregistered") + def unregistered_test(): + raise ForbiddenSubclassUnregistered() + + c = app.test_client() + + assert c.get("/forbidden").data == b"forbidden" + assert c.get("/forbidden-unregistered").data == b"forbidden" + assert c.get("/forbidden-registered").data == b"forbidden-registered" + + +def test_error_handler_blueprint(app): + bp = flask.Blueprint("bp", __name__) + + @bp.errorhandler(500) + def bp_exception_handler(e): + return "bp-error" + + @bp.route("/error") + def bp_test(): + raise InternalServerError() + + @app.errorhandler(500) + def app_exception_handler(e): + return "app-error" + + @app.route("/error") + def app_test(): + raise InternalServerError() + + app.register_blueprint(bp, url_prefix="/bp") + + c = app.test_client() + + assert c.get("/error").data == b"app-error" + assert c.get("/bp/error").data == b"bp-error" + + +def test_default_error_handler(): + bp = flask.Blueprint("bp", __name__) + + @bp.errorhandler(HTTPException) + def bp_exception_handler(e): + assert isinstance(e, HTTPException) + assert isinstance(e, NotFound) + return "bp-default" + + @bp.errorhandler(Forbidden) + def bp_forbidden_handler(e): + assert isinstance(e, Forbidden) + return "bp-forbidden" + + @bp.route("/undefined") + def bp_registered_test(): + raise NotFound() + + @bp.route("/forbidden") + def bp_forbidden_test(): + raise Forbidden() + + app = flask.Flask(__name__) + + @app.errorhandler(HTTPException) + def catchall_exception_handler(e): + assert isinstance(e, HTTPException) + assert isinstance(e, NotFound) + return "default" + + @app.errorhandler(Forbidden) + def catchall_forbidden_handler(e): + assert isinstance(e, Forbidden) + return "forbidden" + + @app.route("/forbidden") + def forbidden(): + raise Forbidden() + + @app.route("/slash/") + def slash(): + return "slash" + + app.register_blueprint(bp, url_prefix="/bp") + + c = app.test_client() + assert c.get("/bp/undefined").data == b"bp-default" + assert c.get("/bp/forbidden").data == b"bp-forbidden" + assert c.get("/undefined").data == b"default" + assert c.get("/forbidden").data == b"forbidden" + # Don't handle RequestRedirect raised when adding slash. + assert c.get("/slash", follow_redirects=True).data == b"slash" + + +class TestGenericHandlers: + """Test how very generic handlers are dispatched to.""" + + class Custom(Exception): + pass + + @pytest.fixture() + def app(self, app): + @app.route("/custom") + def do_custom(): + raise self.Custom() + + @app.route("/error") + def do_error(): + raise KeyError() + + @app.route("/abort") + def do_abort(): + flask.abort(500) + + @app.route("/raise") + def do_raise(): + raise InternalServerError() + + app.config["PROPAGATE_EXCEPTIONS"] = False + return app + + def report_error(self, e): + original = getattr(e, "original_exception", None) + + if original is not None: + return f"wrapped {type(original).__name__}" + + return f"direct {type(e).__name__}" + + @pytest.mark.parametrize("to_handle", (InternalServerError, 500)) + def test_handle_class_or_code(self, app, client, to_handle): + """``InternalServerError`` and ``500`` are aliases, they should + have the same behavior. Both should only receive + ``InternalServerError``, which might wrap another error. + """ + + @app.errorhandler(to_handle) + def handle_500(e): + assert isinstance(e, InternalServerError) + return self.report_error(e) + + assert client.get("/custom").data == b"wrapped Custom" + assert client.get("/error").data == b"wrapped KeyError" + assert client.get("/abort").data == b"direct InternalServerError" + assert client.get("/raise").data == b"direct InternalServerError" + + def test_handle_generic_http(self, app, client): + """``HTTPException`` should only receive ``HTTPException`` + subclasses. It will receive ``404`` routing exceptions. + """ + + @app.errorhandler(HTTPException) + def handle_http(e): + assert isinstance(e, HTTPException) + return str(e.code) + + assert client.get("/error").data == b"500" + assert client.get("/abort").data == b"500" + assert client.get("/not-found").data == b"404" + + def test_handle_generic(self, app, client): + """Generic ``Exception`` will handle all exceptions directly, + including ``HTTPExceptions``. + """ + + @app.errorhandler(Exception) + def handle_exception(e): + return self.report_error(e) + + assert client.get("/custom").data == b"direct Custom" + assert client.get("/error").data == b"direct KeyError" + assert client.get("/abort").data == b"direct InternalServerError" + assert client.get("/not-found").data == b"direct NotFound" diff --git a/src/flask-main/tests/test_views.py b/src/flask-main/tests/test_views.py new file mode 100644 index 0000000..eab5eda --- /dev/null +++ b/src/flask-main/tests/test_views.py @@ -0,0 +1,260 @@ +import pytest +from werkzeug.http import parse_set_header + +import flask.views + + +def common_test(app): + c = app.test_client() + + assert c.get("/").data == b"GET" + assert c.post("/").data == b"POST" + assert c.put("/").status_code == 405 + meths = parse_set_header(c.open("/", method="OPTIONS").headers["Allow"]) + assert sorted(meths) == ["GET", "HEAD", "OPTIONS", "POST"] + + +def test_basic_view(app): + class Index(flask.views.View): + methods = ["GET", "POST"] + + def dispatch_request(self): + return flask.request.method + + app.add_url_rule("/", view_func=Index.as_view("index")) + common_test(app) + + +def test_method_based_view(app): + class Index(flask.views.MethodView): + def get(self): + return "GET" + + def post(self): + return "POST" + + app.add_url_rule("/", view_func=Index.as_view("index")) + + common_test(app) + + +def test_view_patching(app): + class Index(flask.views.MethodView): + def get(self): + raise ZeroDivisionError + + def post(self): + raise ZeroDivisionError + + class Other(Index): + def get(self): + return "GET" + + def post(self): + return "POST" + + view = Index.as_view("index") + view.view_class = Other + app.add_url_rule("/", view_func=view) + common_test(app) + + +def test_view_inheritance(app, client): + class Index(flask.views.MethodView): + def get(self): + return "GET" + + def post(self): + return "POST" + + class BetterIndex(Index): + def delete(self): + return "DELETE" + + app.add_url_rule("/", view_func=BetterIndex.as_view("index")) + + meths = parse_set_header(client.open("/", method="OPTIONS").headers["Allow"]) + assert sorted(meths) == ["DELETE", "GET", "HEAD", "OPTIONS", "POST"] + + +def test_view_decorators(app, client): + def add_x_parachute(f): + def new_function(*args, **kwargs): + resp = flask.make_response(f(*args, **kwargs)) + resp.headers["X-Parachute"] = "awesome" + return resp + + return new_function + + class Index(flask.views.View): + decorators = [add_x_parachute] + + def dispatch_request(self): + return "Awesome" + + app.add_url_rule("/", view_func=Index.as_view("index")) + rv = client.get("/") + assert rv.headers["X-Parachute"] == "awesome" + assert rv.data == b"Awesome" + + +def test_view_provide_automatic_options_attr(): + app = flask.Flask(__name__) + + class Index1(flask.views.View): + provide_automatic_options = False + + def dispatch_request(self): + return "Hello World!" + + app.add_url_rule("/", view_func=Index1.as_view("index")) + c = app.test_client() + rv = c.open("/", method="OPTIONS") + assert rv.status_code == 405 + + app = flask.Flask(__name__) + + class Index2(flask.views.View): + methods = ["OPTIONS"] + provide_automatic_options = True + + def dispatch_request(self): + return "Hello World!" + + app.add_url_rule("/", view_func=Index2.as_view("index")) + c = app.test_client() + rv = c.open("/", method="OPTIONS") + assert sorted(rv.allow) == ["OPTIONS"] + + app = flask.Flask(__name__) + + class Index3(flask.views.View): + def dispatch_request(self): + return "Hello World!" + + app.add_url_rule("/", view_func=Index3.as_view("index")) + c = app.test_client() + rv = c.open("/", method="OPTIONS") + assert "OPTIONS" in rv.allow + + +def test_implicit_head(app, client): + class Index(flask.views.MethodView): + def get(self): + return flask.Response("Blub", headers={"X-Method": flask.request.method}) + + app.add_url_rule("/", view_func=Index.as_view("index")) + rv = client.get("/") + assert rv.data == b"Blub" + assert rv.headers["X-Method"] == "GET" + rv = client.head("/") + assert rv.data == b"" + assert rv.headers["X-Method"] == "HEAD" + + +def test_explicit_head(app, client): + class Index(flask.views.MethodView): + def get(self): + return "GET" + + def head(self): + return flask.Response("", headers={"X-Method": "HEAD"}) + + app.add_url_rule("/", view_func=Index.as_view("index")) + rv = client.get("/") + assert rv.data == b"GET" + rv = client.head("/") + assert rv.data == b"" + assert rv.headers["X-Method"] == "HEAD" + + +def test_endpoint_override(app): + app.debug = True + + class Index(flask.views.View): + methods = ["GET", "POST"] + + def dispatch_request(self): + return flask.request.method + + app.add_url_rule("/", view_func=Index.as_view("index")) + + with pytest.raises(AssertionError): + app.add_url_rule("/", view_func=Index.as_view("index")) + + # But these tests should still pass. We just log a warning. + common_test(app) + + +def test_methods_var_inheritance(app, client): + class BaseView(flask.views.MethodView): + methods = ["GET", "PROPFIND"] + + class ChildView(BaseView): + def get(self): + return "GET" + + def propfind(self): + return "PROPFIND" + + app.add_url_rule("/", view_func=ChildView.as_view("index")) + + assert client.get("/").data == b"GET" + assert client.open("/", method="PROPFIND").data == b"PROPFIND" + assert ChildView.methods == {"PROPFIND", "GET"} + + +def test_multiple_inheritance(app, client): + class GetView(flask.views.MethodView): + def get(self): + return "GET" + + class DeleteView(flask.views.MethodView): + def delete(self): + return "DELETE" + + class GetDeleteView(GetView, DeleteView): + pass + + app.add_url_rule("/", view_func=GetDeleteView.as_view("index")) + + assert client.get("/").data == b"GET" + assert client.delete("/").data == b"DELETE" + assert sorted(GetDeleteView.methods) == ["DELETE", "GET"] + + +def test_remove_method_from_parent(app, client): + class GetView(flask.views.MethodView): + def get(self): + return "GET" + + class OtherView(flask.views.MethodView): + def post(self): + return "POST" + + class View(GetView, OtherView): + methods = ["GET"] + + app.add_url_rule("/", view_func=View.as_view("index")) + + assert client.get("/").data == b"GET" + assert client.post("/").status_code == 405 + assert sorted(View.methods) == ["GET"] + + +def test_init_once(app, client): + n = 0 + + class CountInit(flask.views.View): + init_every_request = False + + def __init__(self): + nonlocal n + n += 1 + + def dispatch_request(self): + return str(n) + + app.add_url_rule("/", view_func=CountInit.as_view("index")) + assert client.get("/").data == b"1" + assert client.get("/").data == b"1" diff --git a/src/flask-main/tests/type_check/typing_app_decorators.py b/src/flask-main/tests/type_check/typing_app_decorators.py new file mode 100644 index 0000000..0e25a30 --- /dev/null +++ b/src/flask-main/tests/type_check/typing_app_decorators.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from flask import Flask +from flask import Response + +app = Flask(__name__) + + +@app.after_request +def after_sync(response: Response) -> Response: + return Response() + + +@app.after_request +async def after_async(response: Response) -> Response: + return Response() + + +@app.before_request +def before_sync() -> None: ... + + +@app.before_request +async def before_async() -> None: ... + + +@app.teardown_appcontext +def teardown_sync(exc: BaseException | None) -> None: ... + + +@app.teardown_appcontext +async def teardown_async(exc: BaseException | None) -> None: ... diff --git a/src/flask-main/tests/type_check/typing_error_handler.py b/src/flask-main/tests/type_check/typing_error_handler.py new file mode 100644 index 0000000..ec9c886 --- /dev/null +++ b/src/flask-main/tests/type_check/typing_error_handler.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from http import HTTPStatus + +from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import NotFound + +from flask import Flask + +app = Flask(__name__) + + +@app.errorhandler(400) +@app.errorhandler(HTTPStatus.BAD_REQUEST) +@app.errorhandler(BadRequest) +def handle_400(e: BadRequest) -> str: + return "" + + +@app.errorhandler(ValueError) +def handle_custom(e: ValueError) -> str: + return "" + + +@app.errorhandler(ValueError) +def handle_accept_base(e: Exception) -> str: + return "" + + +@app.errorhandler(BadRequest) +@app.errorhandler(404) +def handle_multiple(e: BadRequest | NotFound) -> str: + return "" diff --git a/src/flask-main/tests/type_check/typing_route.py b/src/flask-main/tests/type_check/typing_route.py new file mode 100644 index 0000000..8bc271b --- /dev/null +++ b/src/flask-main/tests/type_check/typing_route.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import typing as t +from http import HTTPStatus + +from flask import Flask +from flask import jsonify +from flask import stream_template +from flask.templating import render_template +from flask.views import View +from flask.wrappers import Response + +app = Flask(__name__) + + +@app.route("/str") +def hello_str() -> str: + return "

Hello, World!

" + + +@app.route("/bytes") +def hello_bytes() -> bytes: + return b"

Hello, World!

" + + +@app.route("/json") +def hello_json() -> Response: + return jsonify("Hello, World!") + + +@app.route("/json/dict") +def hello_json_dict() -> dict[str, t.Any]: + return {"response": "Hello, World!"} + + +@app.route("/json/dict") +def hello_json_list() -> list[t.Any]: + return [{"message": "Hello"}, {"message": "World"}] + + +class StatusJSON(t.TypedDict): + status: str + + +@app.route("/typed-dict") +def typed_dict() -> StatusJSON: + return {"status": "ok"} + + +@app.route("/generator") +def hello_generator() -> t.Generator[str, None, None]: + def show() -> t.Generator[str, None, None]: + for x in range(100): + yield f"data:{x}\n\n" + + return show() + + +@app.route("/generator-expression") +def hello_generator_expression() -> t.Iterator[bytes]: + return (f"data:{x}\n\n".encode() for x in range(100)) + + +@app.route("/iterator") +def hello_iterator() -> t.Iterator[str]: + return iter([f"data:{x}\n\n" for x in range(100)]) + + +@app.route("/status") +@app.route("/status/") +def tuple_status(code: int = 200) -> tuple[str, int]: + return "hello", code + + +@app.route("/status-enum") +def tuple_status_enum() -> tuple[str, int]: + return "hello", HTTPStatus.OK + + +@app.route("/headers") +def tuple_headers() -> tuple[str, dict[str, str]]: + return "Hello, World!", {"Content-Type": "text/plain"} + + +@app.route("/template") +@app.route("/template/") +def return_template(name: str | None = None) -> str: + return render_template("index.html", name=name) + + +@app.route("/template") +def return_template_stream() -> t.Iterator[str]: + return stream_template("index.html", name="Hello") + + +@app.route("/async") +async def async_route() -> str: + return "Hello" + + +class RenderTemplateView(View): + def __init__(self: RenderTemplateView, template_name: str) -> None: + self.template_name = template_name + + def dispatch_request(self: RenderTemplateView) -> str: + return render_template(self.template_name) + + +app.add_url_rule( + "/about", + view_func=RenderTemplateView.as_view("about_page", template_name="about.html"), +) diff --git a/src/flask-main/uv.lock b/src/flask-main/uv.lock new file mode 100644 index 0000000..8d6b5f3 --- /dev/null +++ b/src/flask-main/uv.lock @@ -0,0 +1,1666 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "asgiref" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "flask" +version = "3.2.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] + +[package.optional-dependencies] +async = [ + { name = "asgiref" }, +] +dotenv = [ + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, +] +docs = [ + { name = "pallets-sphinx-themes" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-tabs" }, + { name = "sphinxcontrib-log-cabinet" }, +] +docs-auto = [ + { name = "sphinx-autobuild", version = "2024.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx-autobuild", version = "2025.8.25", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +gha-update = [ + { name = "gha-update", marker = "python_full_version >= '3.12'" }, +] +pre-commit = [ + { name = "pre-commit" }, + { name = "pre-commit-uv" }, +] +tests = [ + { name = "asgiref" }, + { name = "greenlet" }, + { name = "pytest" }, + { name = "python-dotenv" }, +] +typing = [ + { name = "asgiref" }, + { name = "cryptography" }, + { name = "mypy" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "python-dotenv" }, + { name = "types-contextvars" }, + { name = "types-dataclasses" }, +] + +[package.metadata] +requires-dist = [ + { name = "asgiref", marker = "extra == 'async'", specifier = ">=3.2" }, + { name = "blinker", specifier = ">=1.9.0" }, + { name = "click", specifier = ">=8.1.3" }, + { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "jinja2", specifier = ">=3.1.2" }, + { name = "markupsafe", specifier = ">=2.1.1" }, + { name = "python-dotenv", marker = "extra == 'dotenv'" }, + { name = "werkzeug", specifier = ">=3.1.0" }, +] +provides-extras = ["async", "dotenv"] + +[package.metadata.requires-dev] +dev = [ + { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, +] +docs = [ + { name = "pallets-sphinx-themes" }, + { name = "sphinx" }, + { name = "sphinx-tabs" }, + { name = "sphinxcontrib-log-cabinet" }, +] +docs-auto = [{ name = "sphinx-autobuild" }] +gha-update = [{ name = "gha-update", marker = "python_full_version >= '3.12'" }] +pre-commit = [ + { name = "pre-commit" }, + { name = "pre-commit-uv" }, +] +tests = [ + { name = "asgiref" }, + { name = "greenlet" }, + { name = "pytest" }, + { name = "python-dotenv" }, +] +typing = [ + { name = "asgiref" }, + { name = "cryptography" }, + { name = "mypy" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "python-dotenv" }, + { name = "types-contextvars" }, + { name = "types-dataclasses" }, +] + +[[package]] +name = "gha-update" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "httpx", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e8/eb710e08998a22b314cc068f14805cfdd12e3934a5496c8916c5a164c65a/gha_update-0.2.0.tar.gz", hash = "sha256:328ee0db09346ad13ee90646698cea2ec1f9035964ddd7c2a728a91034c3f4b0", size = 4756, upload-time = "2025-07-14T03:13:33.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/29/a0e42b0b80d614aa82929f65cce0d51443eea296802b2094cadc5660321b/gha_update-0.2.0-py3-none-any.whl", hash = "sha256:ec5641bf23f71baa1232fc61b3059fb08456e1b78150d1e9c1bab69b37046e49", size = 5323, upload-time = "2025-07-14T03:13:31.932Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" }, + { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, + { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, + { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, + { url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" }, + { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, + { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, + { url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" }, + { url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" }, + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" }, + { url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, + { url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "h11", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "python_full_version >= '3.12'" }, + { name = "certifi", marker = "python_full_version >= '3.12'" }, + { name = "httpcore", marker = "python_full_version >= '3.12'" }, + { name = "idna", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pallets-sphinx-themes" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-notfound-page" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/08/c57dd89e45dbc976930200a2cb7826ed76f3c9791454a9fcd1cde3f17177/pallets_sphinx_themes-2.3.0.tar.gz", hash = "sha256:6293ced11a1d5d3de7268af1acd60428732b5a9e6051a47a596c6d9a083e60d9", size = 21029, upload-time = "2024-10-24T18:52:38.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7d/a4aa06e452e031559dcfb066e035d2615ebfa6148e93514d7c36030004c1/pallets_sphinx_themes-2.3.0-py3-none-any.whl", hash = "sha256:7ed13de3743c462c2804e2aa63d96cc9ffa82cb76d0251cea03de9bcd9f8dbec", size = 24745, upload-time = "2024-10-24T18:52:37.265Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" }, +] + +[[package]] +name = "pre-commit-uv" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pre-commit" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/42/84372bc99a841bfdd8b182a50186471a7f5e873d8e8bcec0d0cb6dabcbb0/pre_commit_uv-4.2.0.tar.gz", hash = "sha256:c32bb1d90235507726eee2aeef2be5fdab431a6f1906e3f1addb0a4e99b369d1", size = 6912, upload-time = "2025-10-09T19:30:48.354Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/9f/ec8491f6b3022489a4d36ce372214c10a34f90b425aa61ff2e0a8dc5b9d5/pre_commit_uv-4.2.0-py3-none-any.whl", hash = "sha256:cc1b56641e6c62d90a4d8b4f0af6f2610f1c397ce81af024e768c0f33715cb81", size = 5650, upload-time = "2025-10-09T19:30:47.257Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/7b/c0e1333b61d41c69e59e5366e727b18c4992688caf0de1be10b3e5265f6b/pyproject_api-1.10.0.tar.gz", hash = "sha256:40c6f2d82eebdc4afee61c773ed208c04c19db4c4a60d97f8d7be3ebc0bbb330", size = 22785, upload-time = "2025-10-09T19:12:27.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/cc/cecf97be298bee2b2a37dd360618c819a2a7fd95251d8e480c1f0eb88f3b/pyproject_api-1.10.0-py3-none-any.whl", hash = "sha256:8757c41a79c0f4ab71b99abed52b97ecf66bd20b04fa59da43b5840bac105a09", size = 13218, upload-time = "2025-10-09T19:12:24.428Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.407" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "roman-numerals-py" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2024.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "starlette", marker = "python_full_version < '3.11'" }, + { name = "uvicorn", marker = "python_full_version < '3.11'" }, + { name = "watchfiles", marker = "python_full_version < '3.11'" }, + { name = "websockets", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/c0/eba125db38c84d3c74717008fd3cb5000b68cd7e2cbafd1349c6a38c3d3b/sphinx_autobuild-2024.10.3-py3-none-any.whl", hash = "sha256:158e16c36f9d633e613c9aaf81c19b0fc458ca78b112533b20dafcda430d60fa", size = 11908, upload-time = "2024-10-02T23:15:28.739Z" }, +] + +[[package]] +name = "sphinx-autobuild" +version = "2025.8.25" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "starlette", marker = "python_full_version >= '3.11'" }, + { name = "uvicorn", marker = "python_full_version >= '3.11'" }, + { name = "watchfiles", marker = "python_full_version >= '3.11'" }, + { name = "websockets", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl", hash = "sha256:b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a", size = 12535, upload-time = "2025-08-25T18:44:54.164Z" }, +] + +[[package]] +name = "sphinx-notfound-page" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/b2/67603444a8ee97b4a8ea71b0a9d6bab1727ed65e362c87e02f818ee57b8a/sphinx_notfound_page-1.1.0.tar.gz", hash = "sha256:913e1754370bb3db201d9300d458a8b8b5fb22e9246a816643a819a9ea2b8067", size = 7392, upload-time = "2025-01-28T18:45:02.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d4/019fe439c840a7966012bbb95ccbdd81c5c10271749706793b43beb05145/sphinx_notfound_page-1.1.0-py3-none-any.whl", hash = "sha256:835dc76ff7914577a1f58d80a2c8418fb6138c0932c8da8adce4d9096fbcd389", size = 8167, upload-time = "2025-01-28T18:45:00.465Z" }, +] + +[[package]] +name = "sphinx-tabs" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/a9a91995cb365e589f413b77fc75f1c0e9b4ac61bfa8da52a779ad855cc0/sphinx-tabs-3.4.7.tar.gz", hash = "sha256:991ad4a424ff54119799ba1491701aa8130dd43509474aef45a81c42d889784d", size = 15891, upload-time = "2024-10-08T13:37:27.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/c6/f47505b564b918a3ba60c1e99232d4942c4a7e44ecaae603e829e3d05dae/sphinx_tabs-3.4.7-py3-none-any.whl", hash = "sha256:c12d7a36fd413b369e9e9967a0a4015781b71a9c393575419834f19204bd1915", size = 9727, upload-time = "2024-10-08T13:37:26.192Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-log-cabinet" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/26/0687391e10c605a4d0c7ebe118c57c51ecc687128bcdae5803d9b96def81/sphinxcontrib-log-cabinet-1.0.1.tar.gz", hash = "sha256:103b2e62df4e57abb943bea05ee9c2beb7da922222c8b77314ffd6ab9901c558", size = 4072, upload-time = "2019-07-05T23:22:34.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e7/dbfc155c1b4c429a9a8149032a56bfb7bab4efabc656abb24ab4619c715d/sphinxcontrib_log_cabinet-1.0.1-py2.py3-none-any.whl", hash = "sha256:3decc888e8e453d1912cd95d50efb0794a4670a214efa65e71a7de277dcfe2cd", size = 4887, upload-time = "2019-07-05T23:22:32.969Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "tox" +version = "4.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/bf/0e4dbd42724cbae25959f0e34c95d0c730df03ab03f54d52accd9abfc614/tox-4.32.0.tar.gz", hash = "sha256:1ad476b5f4d3679455b89a992849ffc3367560bbc7e9495ee8a3963542e7c8ff", size = 203330, upload-time = "2025-10-24T18:03:38.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/cc/e09c0d663a004945f82beecd4f147053567910479314e8d01ba71e5d5dea/tox-4.32.0-py3-none-any.whl", hash = "sha256:451e81dc02ba8d1ed20efd52ee409641ae4b5d5830e008af10fe8823ef1bd551", size = 175905, upload-time = "2025-10-24T18:03:36.337Z" }, +] + +[[package]] +name = "tox-uv" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tox" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/90/06752775b8cfadba8856190f5beae9f552547e0f287e0246677972107375/tox_uv-1.29.0.tar.gz", hash = "sha256:30fa9e6ad507df49d3c6a2f88894256bcf90f18e240a00764da6ecab1db24895", size = 23427, upload-time = "2025-10-09T20:40:27.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/17/221d62937c4130b044bb437caac4181e7e13d5536bbede65264db1f0ac9f/tox_uv-1.29.0-py3-none-any.whl", hash = "sha256:b1d251286edeeb4bc4af1e24c8acfdd9404700143c2199ccdbb4ea195f7de6cc", size = 17254, upload-time = "2025-10-09T20:40:25.885Z" }, +] + +[[package]] +name = "types-contextvars" +version = "2.4.7.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/20/6a0271fe78050f15eaad21f94a4efebbddcfd0cadac0d35b056e8d32b40f/types-contextvars-2.4.7.3.tar.gz", hash = "sha256:a15a1624c709d04974900ea4f8c4fc2676941bf7d4771a9c9c4ac3daa0e0060d", size = 3166, upload-time = "2023-07-07T09:16:39.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/5e/770b3dd271a925b4428ee17664536d037695698eb764b4c5ed6fe815169b/types_contextvars-2.4.7.3-py3-none-any.whl", hash = "sha256:bcd8e97a5b58e76d20f5cc161ba39b29b60ac46dcc6edf3e23c1d33f99b34351", size = 2752, upload-time = "2023-07-07T09:16:37.855Z" }, +] + +[[package]] +name = "types-dataclasses" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/6a/dec8fbc818b1e716cb2d9424f1ea0f6f3b1443460eb6a70d00d9d8527360/types-dataclasses-0.6.6.tar.gz", hash = "sha256:4b5a2fcf8e568d5a1974cd69010e320e1af8251177ec968de7b9bb49aa49f7b9", size = 2884, upload-time = "2022-06-30T09:49:21.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/85/23ab2bbc280266af5bf22ded4e070946d1694d1721ced90666b649eaa795/types_dataclasses-0.6.6-py3-none-any.whl", hash = "sha256:a0a1ab5324ba30363a15c9daa0f053ae4fff914812a1ebd8ad84a08e5349574d", size = 2868, upload-time = "2022-06-30T09:49:19.977Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uv" +version = "0.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/42/18d158032c594110e7dab012931a3cd6a09f4dec801854dc877a3c6e865a/uv-0.9.10.tar.gz", hash = "sha256:84128ca76a665fe0584bff6ef913741d5615a4187c1aaed81739d519e669cfbd", size = 3738676, upload-time = "2025-11-17T17:02:36.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/d2/68f07f3b96539b986d36a6f2b03f9f8484373f7d8f05949221be1a1f9993/uv-0.9.10-py3-none-linux_armv6l.whl", hash = "sha256:a02b27e00afe0d7908fbb9ec61ad5338508e02356138a8a718af239fe773dd2e", size = 20522030, upload-time = "2025-11-17T17:01:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/763da49b0dfafb5465376e29e176f56c3f1ca79a38ef59983fe5d62dd371/uv-0.9.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c2ddc0bca2e4b7e31623a17d60ea5f8d1ee4ff3ee27ddbf884e089e57cd0c93", size = 19627118, upload-time = "2025-11-17T17:01:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e8/5b5b5a719830ee5505bddf35082f6dc41569dab8fb6761626e5034c27748/uv-0.9.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8c8969d54c9f603107445a23f36fba03a0dfa4c75b3ada2faf94ed371f26f3a4", size = 18174169, upload-time = "2025-11-17T17:01:54.034Z" }, + { url = "https://files.pythonhosted.org/packages/35/6b/4c074cab3bf483a172bec905e1696269b0222d73fd8dc890388da8b110c9/uv-0.9.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c9a326f04b670f1af2d9fa90e50af57a9a872a8dc90bb377d0c27b2030438ffc", size = 19950337, upload-time = "2025-11-17T17:01:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/45/9d/a8930ce9f5247629335de32dd97f19b955f86fb7524ea268f17181c3bccc/uv-0.9.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:55ecddf19314f7da5b8dea6bcfcc86f5769dd7d22106c731e8e6cfbf3fa6b98d", size = 20123165, upload-time = "2025-11-17T17:01:58.668Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4b/3b136c1effa53ab84bc72f66cbc3aca0013fad6581d9c03c2989635b3be1/uv-0.9.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:763cf33c7c5ab490323fab667b24f66412c3c3ca58e86b56e13fc0109d031a1b", size = 21024525, upload-time = "2025-11-17T17:02:01.022Z" }, + { url = "https://files.pythonhosted.org/packages/64/f8/7aab6deb6362431edd670c2885d802ced2cb045fd4f73df462c9c8b05f32/uv-0.9.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5e7a86eb5edbb9064b1401522eb10c5bd25ff56c04cd0ed253d0cd088a448bef", size = 22578222, upload-time = "2025-11-17T17:02:03.333Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/658647132d2c2396bd484b9ee42ab9a0a3f439a14b59e91c48b66b6b7403/uv-0.9.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c133e34feca0823d952a1d323d1d47f2b35e13154de5ca293d188f5b8792a62", size = 22205447, upload-time = "2025-11-17T17:02:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/80/22/b4c766d855aaefd3797c6e296af4b006d560b78cec5f3041902cd6d7abce/uv-0.9.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:92d4b58ef810eaf5edf95c607d613ff240ab903ee599af1d687f891ab64b4129", size = 21247210, upload-time = "2025-11-17T17:02:08.312Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0c/4b705b613237519e8640baaa1fe4809ddef2482ad7b82ea807c71e5ddb5d/uv-0.9.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21981bc859802c94d4b8f026b8734d39e8146baac703f1e3eab2e2d87d65ca8c", size = 21389310, upload-time = "2025-11-17T17:02:11.117Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/81df7494107d8473a754fea74e51c8727192a71c11fc4c92aa901e36a255/uv-0.9.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:110fd2062dccc4825f96cef0007f6bbb6f5da117af50415bed6f6dcd5e1e3603", size = 20062308, upload-time = "2025-11-17T17:02:14.437Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b5/0919100138d2cdb2f4f058a0e7c6fc059aad1914b1f6637c7fcfe2e670d9/uv-0.9.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b0a0c4594871de9e8b12c9e10982dc83e2414b712a34e84860fac9fbc8510c5a", size = 21182524, upload-time = "2025-11-17T17:02:16.844Z" }, + { url = "https://files.pythonhosted.org/packages/43/f5/deb8be34797f71ae72f690da26b388682cc478bda630822fff7bc90e80f3/uv-0.9.10-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:516d1c5549912696ba68376d2652661ed327199a9ec15763f7baa29bc75b35ec", size = 20084575, upload-time = "2025-11-17T17:02:19.174Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/4e51d384669103a9ba8ede1ecf1c99c932148bdb365794ac586f90e5296f/uv-0.9.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:780a7af4a7dfb0894a8d367b5c3a48f926480f465a34aa4a8633d6e4f521e517", size = 20495061, upload-time = "2025-11-17T17:02:21.978Z" }, + { url = "https://files.pythonhosted.org/packages/78/9f/c16fb7e0290744a22987ffb9700c69fe1e459e199e04f3e881ce05d032e4/uv-0.9.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4e256d3cc542f73435b27a3d0bf2a6b0f9d2dd6dd5c8df1a8028834deb896819", size = 21601257, upload-time = "2025-11-17T17:02:24.646Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f9/ea01841fe2660add4b8fbc2b3a34e8d4f812d9bdaeeeead5470e1142b4be/uv-0.9.10-py3-none-win32.whl", hash = "sha256:dfcc3cd5765158e82a0f52066462e378887aac01b347b588907fe3290af92356", size = 19338075, upload-time = "2025-11-17T17:02:28.007Z" }, + { url = "https://files.pythonhosted.org/packages/43/c3/72dbdf10ea0e70ca47248aaa25122df9dfa7a7e85e96ba6031cafc67cb02/uv-0.9.10-py3-none-win_amd64.whl", hash = "sha256:9ee9027eb98cf5a99d9a3d5ddab7048745e2e49d572869044f66772e17f57792", size = 21367277, upload-time = "2025-11-17T17:02:30.672Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/b38be12b524dbd28dcdc99cfd88f6f3f792823258b96efb6d691025872cb/uv-0.9.10-py3-none-win_arm64.whl", hash = "sha256:d4903e73f5e281ce5c3def60014bef93d2d1a148901653e222470ce54987f4a1", size = 19770409, upload-time = "2025-11-17T17:02:34.057Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, + { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, + { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] diff --git a/src/flask.zip b/src/flask.zip new file mode 100644 index 0000000..6ab639e Binary files /dev/null and b/src/flask.zip differ