diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d871650..3f67aa6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -21,5 +21,14 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v6 + - name: Run linter + run: uv run ruff check . + + - name: Run format + run: uv run ruff format . --check + + - name: Run type check + run: uv run mypy . + - name: Run tests run: uv run pytest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57ea215..4750801 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,14 @@ To run tests with all supported Python versions: $ uv run tox ``` +## Linter, format & type check + +```console +$ uv run ruff check . --fix +$ uv run ruff format . +$ uv run mypy . +``` + ## Docs To build the docs, use [Sphinx](https://www.sphinx-doc.org/en/): diff --git a/docs/conf.py b/docs/conf.py index d30996f..a3a91b7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ # -- Project information ----------------------------------------------------- project = "Flask Simple Login" -copyright = "2020, Maely Brandão" +copyright = "2020, Maely Brandão" # noqa: A001 author = "Maely Brandão" @@ -66,4 +66,4 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = [] +html_static_path: list[str] = [] diff --git a/example/manage.py b/example/manage.py index 8b45638..ba3bfa8 100644 --- a/example/manage.py +++ b/example/manage.py @@ -4,21 +4,22 @@ import click from flask import Flask, jsonify, render_template -from flask_simplelogin import Message, SimpleLogin, login_required from werkzeug.security import check_password_hash, generate_password_hash +from flask_simplelogin import Message, SimpleLogin, login_required # [ -- Utils -- ] def validate_login(user): - db_users = json.load(open("users.json")) + with open("users.json") as handler: + db_users = json.load(handler) + if not db_users.get(user["username"]): return False + stored_password = db_users[user["username"]]["password"] - if check_password_hash(stored_password, user["password"]): - return True - return False + return check_password_hash(stored_password, user["password"]) def create_user(**data): @@ -33,11 +34,16 @@ def create_user(**data): # Here you insert the `data` in your users database # for this simple example we are recording in a json file - db_users = json.load(open("users.json")) + with open("users.json") as handler: + db_users = json.load(handler) + # add the new created user to json db_users[data["username"]] = data + # commit changes to database - json.dump(db_users, open("users.json", "w")) + with open("users.json", "w") as handler: + json.dump(db_users, handler) + return data diff --git a/example/simple_app.py b/example/simple_app.py index cc7a35a..40a0cc1 100644 --- a/example/simple_app.py +++ b/example/simple_app.py @@ -1,5 +1,6 @@ from flask import Flask, jsonify, render_template from flask.views import MethodView + from flask_simplelogin import SimpleLogin, get_username, login_required my_users = { diff --git a/flask_simplelogin/__init__.py b/flask_simplelogin/__init__.py index 622fa42..c96bf99 100644 --- a/flask_simplelogin/__init__.py +++ b/flask_simplelogin/__init__.py @@ -7,13 +7,15 @@ import logging import os from functools import wraps +from typing import Callable, Iterable, Mapping, TypedDict +from urllib.parse import urljoin, urlparse from uuid import uuid4 -from urllib.parse import urlparse, urljoin from warnings import warn from flask import ( - abort, Blueprint, + Flask, + abort, current_app, flash, redirect, @@ -22,28 +24,37 @@ session, url_for, ) -from flask_wtf import FlaskForm +from flask.typing import ResponseReturnValue +from flask_wtf import FlaskForm, Form # type: ignore from wtforms import PasswordField, StringField from wtforms.validators import DataRequired - logger = logging.getLogger(__name__) +class User(TypedDict): + username: str | None + password: str | None + + +Validator = Callable[[str | None], str | None] +LoginChecker = Callable[[User], bool] + + class Message: - def __init__(self, text, category="primary"): + def __init__(self, text: str, category: str = "primary"): self.text = text self.category = category @classmethod - def from_current_app(cls, label): + def from_current_app(cls, label: str) -> "Message": """Helper to get messages from Flask's current_app""" return current_app.extensions["simplelogin"].messages.get(label) - def __str__(self): + def __str__(self) -> str: return self.text - def format(self, *args, **kwargs): + def format(self, *args, **kwargs) -> str: return self.text.format(*args, **kwargs) @@ -56,9 +67,8 @@ class LoginForm(FlaskForm): password = PasswordField("password", validators=[DataRequired()]) -def default_login_checker(user): - """user must be a dictionary here default is - checking username/password +def default_login_checker(user: User) -> bool: + """User must be a dictionary here default is checking username/password if login is ok returns True else False :param user: dict {'username':'', 'password': ''} @@ -71,28 +81,33 @@ def default_login_checker(user): the_password = os.environ.get( "SIMPLELOGIN_PASSWORD", current_app.config.get("SIMPLELOGIN_PASSWORD", "secret") ) - if username == the_username and password == the_password: - return True - return False + return username == the_username and password == the_password -def is_logged_in(username=None): - """Checks if user is logged in if `username` - is passed check if specified user is logged in - username can be a list""" +def is_logged_in(username: str | Iterable[str] | None = None) -> bool: + """Checks if user is logged in if `username` is passed check if specified + user is logged in username can be a list""" if username: - if not isinstance(username, (list, tuple)): - username = [username] - return "simple_logged_in" in session and get_username() in username + if isinstance(username, str): + username = (username,) + got = get_username() + return ( + "simple_logged_in" in session and isinstance(got, str) and got in username + ) return "simple_logged_in" in session -def get_username(): +def get_username() -> str | None: """Get current logged in username""" return session.get("simple_username") -def login_required(function=None, username=None, basic=False, must=None): +def login_required( + function: Callable | None = None, + username: str | Iterable[str] | None = None, + basic: bool = False, + must: Iterable[Validator] | None = None, +): """Decorate views to require login @login_required @login_required() @@ -108,32 +123,40 @@ def login_required(function=None, username=None, basic=False, must=None): 'try login_required(username="foo")' ) - def check(validators): + def check( + validators: Validator | Iterable[Validator] | None, + ) -> tuple[str, int] | None: """Return in the first validation error, else return None""" if validators is None: - return + return None - if not isinstance(validators, (list, tuple)): - validators = [validators] + if callable(validators): + validators = (validators,) for validator in validators: error = validator(get_username()) if error is not None: return Message.from_current_app("auth_error").format(error), 403 - def dispatch(fun, *args, **kwargs): + return None + + def dispatch( + fun: Callable[..., ResponseReturnValue], *args, **kwargs + ) -> ResponseReturnValue: if basic and request.is_json: return dispatch_basic_auth(fun, *args, **kwargs) if is_logged_in(username=username): return check(must) or fun(*args, **kwargs) elif is_logged_in(): - return Message.from_current_app("access_denied"), 403 + return Message.from_current_app("access_denied").text, 403 else: SimpleLogin.flash("login_required") return redirect(url_for("simplelogin.login", next=request.path)) - def dispatch_basic_auth(fun, *args, **kwargs): + def dispatch_basic_auth( + fun: Callable[..., ResponseReturnValue], *args, **kwargs + ) -> ResponseReturnValue: simplelogin = current_app.extensions["simplelogin"] auth_response = simplelogin.basic_auth() if auth_response is True: @@ -144,7 +167,7 @@ def dispatch_basic_auth(fun, *args, **kwargs): if function: @wraps(function) - def simple_decorator(*args, **kwargs): + def simple_decorator(*args, **kwargs) -> ResponseReturnValue: """This is for when decorator is @login_required""" return dispatch(function, *args, **kwargs) @@ -154,7 +177,7 @@ def decorator(f): """This is for when decorator is @login_required(...)""" @wraps(f) - def wrap(*args, **kwargs): + def wrap(*args, **kwargs) -> ResponseReturnValue: return dispatch(f, *args, **kwargs) return wrap @@ -162,6 +185,13 @@ def wrap(*args, **kwargs): return decorator +class SimpleLoginNotInitializedError(Exception): + def __init__(self, *args, **kwargs) -> None: + super().__init__( + "SimpleLogin is not initialized, use `SimpleLogin.init_app(app)`" + ) + + class SimpleLogin: """Simple Flask Login""" @@ -176,7 +206,7 @@ class SimpleLogin: } @staticmethod - def flash(label, *args, **kwargs): + def flash(label: str, *args, **kwargs) -> None: msg = Message.from_current_app(label) if not msg: return @@ -186,17 +216,23 @@ def flash(label, *args, **kwargs): else: flash(msg.text, msg.category) - def __init__(self, app=None, login_checker=None, login_form=None, messages=None): + def __init__( + self, + app: Flask | None = None, + login_checker: LoginChecker | None = None, + login_form: Form = None, + messages: Mapping[str, Message] | None = None, + ): self.config = { "blueprint": "simplelogin", "login_url": "/login/", "logout_url": "/logout/", "home_url": "/", } - self.app = None + self.app: Flask | None = None self._login_checker = login_checker or default_login_checker self._login_form = login_form or LoginForm - self.on_logout_callbacks = [] + self.on_logout_callbacks: list[Callable] = [] if app is not None: self.init_app( app=app, @@ -205,7 +241,7 @@ def __init__(self, app=None, login_checker=None, login_form=None, messages=None) messages=messages, ) - def login_checker(self, f): + def login_checker(self, f: LoginChecker) -> LoginChecker: """To set login_checker as decorator: @simple.login_checher def foo(user): ... @@ -213,7 +249,13 @@ def foo(user): ... self._login_checker = f return f - def init_app(self, app, login_checker=None, login_form=None, messages=None): + def init_app( + self, + app: Flask, + login_checker: LoginChecker | None = None, + login_form: Form | None = None, + messages: Mapping[str, Message] | None = None, + ) -> None: if login_checker: self._login_checker = login_checker @@ -221,8 +263,8 @@ def init_app(self, app, login_checker=None, login_form=None, messages=None): self._login_form = login_form if messages and isinstance(messages, dict): - cleaned = {k: v for k, v in messages.items() if k in self.messages.keys()} - for key in cleaned.keys(): + cleaned = {k: v for k, v in messages.items() if k in self.messages} + for key in cleaned: if isinstance(cleaned[key], str): cleaned[key] = Message(cleaned[key]) self.messages.update(cleaned) @@ -233,7 +275,7 @@ def init_app(self, app, login_checker=None, login_form=None, messages=None): self._register_views() self._register_extras() - def _register(self, app): + def _register(self, app: Flask) -> None: if not hasattr(app, "extensions"): app.extensions = {} @@ -243,7 +285,10 @@ def _register(self, app): app.extensions["simplelogin"] = self self.app = app - def _load_config(self): + def _load_config(self) -> None: + if self.app is None: + raise SimpleLoginNotInitializedError + config = self.app.config.get_namespace( namespace="SIMPLELOGIN_", lowercase=True, trim_namespace=True ) @@ -263,7 +308,10 @@ def _load_config(self): self.config.update(dict((key, value) for key, value in config.items() if value)) - def _set_default_secret(self): + def _set_default_secret(self) -> None: + if self.app is None: + raise SimpleLoginNotInitializedError + if self.app.config.get("SECRET_KEY") is None: secret_key = str(uuid4()) logger.warning( @@ -274,7 +322,12 @@ def _set_default_secret(self): ) self.app.config["SECRET_KEY"] = secret_key - def _register_views(self): + def _register_views(self) -> None: + if not self.app: + raise SimpleLoginNotInitializedError( + "Please, call `SimpleLogin.init_app(app)`" + ) + self.blueprint = Blueprint( self.config["blueprint"], __name__, template_folder="templates" ) @@ -295,11 +348,16 @@ def _register_views(self): self.app.register_blueprint(self.blueprint) - def _register_extras(self): + def _register_extras(self) -> None: + if self.app is None: + raise SimpleLoginNotInitializedError + self.app.add_template_global(is_logged_in) self.app.add_template_global(get_username) - def basic_auth(self, response=None): + def basic_auth( + self, response: ResponseReturnValue | None = None + ) -> ResponseReturnValue | bool: """Support basic_auth via /login or login_required(basic=True)""" auth = request.authorization if auth and self._login_checker( @@ -313,7 +371,10 @@ def basic_auth(self, response=None): headers = {"WWW-Authenticate": 'Basic realm="Login Required"'} return "Invalid credentials", 401, headers - def login(self): + def login(self) -> ResponseReturnValue: + if self.app is None: + raise SimpleLoginNotInitializedError + destiny = request.args.get( "next", default=request.form.get("next", default=self.config.get("home_url", "/")), @@ -322,7 +383,7 @@ def login(self): host_url = urlparse(request.host_url) redirect_url = urlparse(urljoin(request.host_url, destiny)) if ( - not host_url.netloc == redirect_url.netloc + host_url.netloc != redirect_url.netloc and redirect_url.netloc not in self.app.config.get("ALLOWED_HOSTS", []) ): return abort(400, "Invalid next url, can only redirect to the same host") @@ -331,9 +392,11 @@ def login(self): self.flash("is_logged_in") return redirect(destiny) + # recommended to use `login_required(basic=True)` instead this if request.is_json: - # recommended to use `login_required(basic=True)` instead this - return self.basic_auth(redirect(destiny)) + resp = self.basic_auth(redirect(destiny)) + if not isinstance(resp, bool): + return resp # should not happen since we passed a response form = self._login_form() ret_code = 200 @@ -349,11 +412,11 @@ def login(self): return render_template("login.html", form=form, next=destiny), ret_code - def register_on_logout_callback(self, callback): + def register_on_logout_callback(self, callback: Callable) -> None: """Register a callback to be called on logout""" self.on_logout_callbacks.append(callback) - def logout(self): + def logout(self) -> ResponseReturnValue: session.clear() self.flash("logout") diff --git a/pyproject.toml b/pyproject.toml index 953f174..20f7bf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ Repository = "https://github.com/flask-extensions/Flask-SimpleLogin" [dependency-groups] dev = [ "ipdb>=0.13.13,<0.14", + "mypy>=1.17.1", "pytest>=8.3.3,<9", "pytest-cov>=5.0.0,<6", "pytest-flask>=1.3.0,<2", @@ -37,6 +38,7 @@ dev = [ "pytest-ruff>=0.4.1,<0.5", "tox>=4.21.2,<5", "tox-uv>=1.27.0,<2", + "types-wtforms>=3.2.1.20250809", ] docs = [ "recommonmark>=0.7.1,<0.8", @@ -47,6 +49,9 @@ docs = [ [tool.uv] +[tool.ruff.lint] +select = ["A", "E", "F", "I", "N", "SIM", "TID"] + [[tool.uv.index]] name = "pypi-public" url = "https://pypi.org/simple/" diff --git a/tests/conftest.py b/tests/conftest.py index 166e2b0..6c44256 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,10 @@ import pytest from flask import Flask, jsonify, render_template, session from flask.views import MethodView -from flask_simplelogin import SimpleLogin, login_required, get_username from itsdangerous import URLSafeTimedSerializer +from flask_simplelogin import SimpleLogin, get_username, login_required + @pytest.fixture def app(): diff --git a/tests/test_app.py b/tests/test_app.py index d56f811..9c6c7ad 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,5 +1,5 @@ from base64 import b64encode -from unittest.mock import call, Mock +from unittest.mock import Mock, call from flask import url_for diff --git a/tests/test_config.py b/tests/test_config.py index 929720f..785e594 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ import pytest from flask import Flask -from flask_simplelogin import SimpleLogin, Message + +from flask_simplelogin import Message, SimpleLogin class Settings(dict): diff --git a/uv.lock b/uv.lock index 6ee4f36..7fa6726 100644 --- a/uv.lock +++ b/uv.lock @@ -336,6 +336,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "ipdb" }, + { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-flask" }, @@ -343,6 +344,7 @@ dev = [ { name = "pytest-ruff" }, { name = "tox" }, { name = "tox-uv" }, + { name = "types-wtforms" }, ] docs = [ { name = "recommonmark" }, @@ -361,6 +363,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "ipdb", specifier = ">=0.13.13,<0.14" }, + { name = "mypy", specifier = ">=1.17.1" }, { name = "pytest", specifier = ">=8.3.3,<9" }, { name = "pytest-cov", specifier = ">=5.0.0,<6" }, { name = "pytest-flask", specifier = ">=1.3.0,<2" }, @@ -368,6 +371,7 @@ dev = [ { name = "pytest-ruff", specifier = ">=0.4.1,<0.5" }, { name = "tox", specifier = ">=4.21.2,<5" }, { name = "tox-uv", specifier = ">=1.27.0,<2" }, + { name = "types-wtforms", specifier = ">=3.2.1.20250809" }, ] docs = [ { name = "recommonmark", specifier = ">=0.7.1,<0.8" }, @@ -546,6 +550,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, ] +[[package]] +name = "mypy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", version = "2.2.1", source = { registry = "https://pypi.org/simple/" }, marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/a9/3d7aa83955617cdf02f94e50aab5c830d205cfa4320cf124ff64acce3a8e/mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972", size = 11003299, upload-time = "2025-07-31T07:54:06.425Z" }, + { url = "https://files.pythonhosted.org/packages/83/e8/72e62ff837dd5caaac2b4a5c07ce769c8e808a00a65e5d8f94ea9c6f20ab/mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7", size = 10125451, upload-time = "2025-07-31T07:53:52.974Z" }, + { url = "https://files.pythonhosted.org/packages/7d/10/f3f3543f6448db11881776f26a0ed079865926b0c841818ee22de2c6bbab/mypy-1.17.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a580f8a70c69e4a75587bd925d298434057fe2a428faaf927ffe6e4b9a98df", size = 11916211, upload-time = "2025-07-31T07:53:18.879Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/63e83ed551282d67bb3f7fea2cd5561b08d2bb6eb287c096539feb5ddbc5/mypy-1.17.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd86bb649299f09d987a2eebb4d52d10603224500792e1bee18303bbcc1ce390", size = 12652687, upload-time = "2025-07-31T07:53:30.544Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/68f2eeef11facf597143e85b694a161868b3b006a5fbad50e09ea117ef24/mypy-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a76906f26bd8d51ea9504966a9c25419f2e668f012e0bdf3da4ea1526c534d94", size = 12896322, upload-time = "2025-07-31T07:53:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/a3/87/8e3e9c2c8bd0d7e071a89c71be28ad088aaecbadf0454f46a540bda7bca6/mypy-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:e79311f2d904ccb59787477b7bd5d26f3347789c06fcd7656fa500875290264b", size = 9507962, upload-time = "2025-07-31T07:53:08.431Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/eadc80c4e0a70db1c08921dcc220357ba8ab2faecb4392e3cebeb10edbfa/mypy-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ad37544be07c5d7fba814eb370e006df58fed8ad1ef33ed1649cb1889ba6ff58", size = 10921009, upload-time = "2025-07-31T07:53:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c1/c869d8c067829ad30d9bdae051046561552516cfb3a14f7f0347b7d973ee/mypy-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:064e2ff508e5464b4bd807a7c1625bc5047c5022b85c70f030680e18f37273a5", size = 10047482, upload-time = "2025-07-31T07:53:26.151Z" }, + { url = "https://files.pythonhosted.org/packages/98/b9/803672bab3fe03cee2e14786ca056efda4bb511ea02dadcedde6176d06d0/mypy-1.17.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70401bbabd2fa1aa7c43bb358f54037baf0586f41e83b0ae67dd0534fc64edfd", size = 11832883, upload-time = "2025-07-31T07:53:47.948Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/fcdac695beca66800918c18697b48833a9a6701de288452b6715a98cfee1/mypy-1.17.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e92bdc656b7757c438660f775f872a669b8ff374edc4d18277d86b63edba6b8b", size = 12566215, upload-time = "2025-07-31T07:54:04.031Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/a932da3d3dace99ee8eb2043b6ab03b6768c36eb29a02f98f46c18c0da0e/mypy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c1fdf4abb29ed1cb091cf432979e162c208a5ac676ce35010373ff29247bcad5", size = 12751956, upload-time = "2025-07-31T07:53:36.263Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/6438a429e0f2f5cab8bc83e53dbebfa666476f40ee322e13cac5e64b79e7/mypy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:ff2933428516ab63f961644bc49bc4cbe42bbffb2cd3b71cc7277c07d16b1a8b", size = 9507307, upload-time = "2025-07-31T07:53:59.734Z" }, + { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" }, + { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" }, + { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037, upload-time = "2025-07-31T07:54:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550, upload-time = "2025-07-31T07:53:41.307Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963, upload-time = "2025-07-31T07:53:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189, upload-time = "2025-07-31T07:54:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322, upload-time = "2025-07-31T07:53:10.551Z" }, + { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879, upload-time = "2025-07-31T07:52:56.683Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, +] + +[[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 = "packaging" version = "25.0" @@ -564,6 +622,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, ] +[[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 = "pexpect" version = "4.9.0" @@ -1075,6 +1142,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "types-wtforms" +version = "3.2.1.20250809" +source = { registry = "https://pypi.org/simple/" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/f7/72ca8564a59f118dd5ce5b6afcdbb38c297d04950dcaf49873dae8c6ff45/types_wtforms-3.2.1.20250809.tar.gz", hash = "sha256:9108399333be3bde66179f69a610a5d05c25485024f698d5adc3f6a9df20d029", size = 17194, upload-time = "2025-08-09T03:17:24.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/5a/ecedecb8a1ccff7ea936fb77a359f8295da36fb7e7985cb4fc147110a091/types_wtforms-3.2.1.20250809-py3-none-any.whl", hash = "sha256:d254cf027c6725e21ad50044da692d57586dd29f38abd3a6b4ec10993f6741cc", size = 24294, upload-time = "2025-08-09T03:17:24.008Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1"