From 318a3d03087e1102b856381fc004acb0d781bb5f Mon Sep 17 00:00:00 2001 From: 99 Date: Thu, 2 Apr 2026 17:36:58 +0800 Subject: [PATCH 1/2] refactor(api): tighten login and wrapper typing (#34447) --- .../console/app/workflow_draft_variable.py | 4 +- api/controllers/console/app/wraps.py | 54 +++++-- .../rag_pipeline_draft_variable.py | 5 +- api/controllers/service_api/wraps.py | 140 ++++++++---------- api/dify_app.py | 11 +- api/extensions/ext_login.py | 39 ++++- api/libs/login.py | 27 ++-- .../console/test_workspace_account.py | 2 +- .../console/test_workspace_members.py | 2 +- .../unit_tests/extensions/test_ext_login.py | 17 +++ api/tests/unit_tests/libs/test_login.py | 49 +++++- 11 files changed, 229 insertions(+), 121 deletions(-) create mode 100644 api/tests/unit_tests/extensions/test_ext_login.py diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py index 366f1453608..f6d076320c7 100644 --- a/api/controllers/console/app/workflow_draft_variable.py +++ b/api/controllers/console/app/workflow_draft_variable.py @@ -193,7 +193,7 @@ workflow_draft_variable_list_model = console_ns.model( ) -def _api_prerequisite(f: Callable[..., Any]) -> Callable[..., Any]: +def _api_prerequisite[**P, R](f: Callable[P, R]) -> Callable[P, R | Response]: """Common prerequisites for all draft workflow variable APIs. It ensures the following conditions are satisfied: @@ -210,7 +210,7 @@ def _api_prerequisite(f: Callable[..., Any]) -> Callable[..., Any]: @edit_permission_required @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @wraps(f) - def wrapper(*args: Any, **kwargs: Any): + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | Response: return f(*args, **kwargs) return wrapper diff --git a/api/controllers/console/app/wraps.py b/api/controllers/console/app/wraps.py index bd6f019eac1..c9cf08072ad 100644 --- a/api/controllers/console/app/wraps.py +++ b/api/controllers/console/app/wraps.py @@ -1,6 +1,6 @@ from collections.abc import Callable from functools import wraps -from typing import Any +from typing import overload from sqlalchemy import select @@ -23,14 +23,30 @@ def _load_app_model_with_trial(app_id: str) -> App | None: return app_model -def get_app_model( - view: Callable[..., Any] | None = None, +@overload +def get_app_model[**P, R]( + view: Callable[P, R], *, mode: AppMode | list[AppMode] | None = None, -) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: - def decorator(view_func: Callable[..., Any]) -> Callable[..., Any]: +) -> Callable[P, R]: ... + + +@overload +def get_app_model[**P, R]( + view: None = None, + *, + mode: AppMode | list[AppMode] | None = None, +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + + +def get_app_model[**P, R]( + view: Callable[P, R] | None = None, + *, + mode: AppMode | list[AppMode] | None = None, +) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: + def decorator(view_func: Callable[P, R]) -> Callable[P, R]: @wraps(view_func) - def decorated_view(*args: Any, **kwargs: Any): + def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R: if not kwargs.get("app_id"): raise ValueError("missing app_id in path parameters") @@ -68,14 +84,30 @@ def get_app_model( return decorator(view) -def get_app_model_with_trial( - view: Callable[..., Any] | None = None, +@overload +def get_app_model_with_trial[**P, R]( + view: Callable[P, R], *, mode: AppMode | list[AppMode] | None = None, -) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: - def decorator(view_func: Callable[..., Any]) -> Callable[..., Any]: +) -> Callable[P, R]: ... + + +@overload +def get_app_model_with_trial[**P, R]( + view: None = None, + *, + mode: AppMode | list[AppMode] | None = None, +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + + +def get_app_model_with_trial[**P, R]( + view: Callable[P, R] | None = None, + *, + mode: AppMode | list[AppMode] | None = None, +) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: + def decorator(view_func: Callable[P, R]) -> Callable[P, R]: @wraps(view_func) - def decorated_view(*args: Any, **kwargs: Any): + def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R: if not kwargs.get("app_id"): raise ValueError("missing app_id in path parameters") diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py index d635dcb5301..93feec00195 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_draft_variable.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Callable from typing import Any, NoReturn from flask import Response, request @@ -55,7 +56,7 @@ class WorkflowDraftVariablePatchPayload(BaseModel): register_schema_models(console_ns, WorkflowDraftVariablePatchPayload) -def _api_prerequisite(f): +def _api_prerequisite[**P, R](f: Callable[P, R]) -> Callable[P, R | Response]: """Common prerequisites for all draft workflow variable APIs. It ensures the following conditions are satisfied: @@ -70,7 +71,7 @@ def _api_prerequisite(f): @login_required @account_initialization_required @get_rag_pipeline - def wrapper(*args, **kwargs): + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | Response: if not isinstance(current_user, Account) or not current_user.has_edit_permission: raise Forbidden() return f(*args, **kwargs) diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index 2dd916bb312..b9389ccc479 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -1,9 +1,10 @@ +import inspect import logging import time from collections.abc import Callable from enum import StrEnum, auto from functools import wraps -from typing import Any, cast, overload +from typing import cast, overload from flask import current_app, request from flask_login import user_logged_in @@ -230,94 +231,73 @@ def cloud_edition_billing_rate_limit_check[**P, R]( return interceptor -def validate_dataset_token( - view: Callable[..., Any] | None = None, -) -> Callable[..., Any] | Callable[[Callable[..., Any]], Callable[..., Any]]: - def decorator(view_func: Callable[..., Any]) -> Callable[..., Any]: - @wraps(view_func) - def decorated(*args: Any, **kwargs: Any) -> Any: - api_token = validate_and_get_api_token("dataset") +def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]: + positional_parameters = [ + parameter + for parameter in inspect.signature(view).parameters.values() + if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + expects_bound_instance = bool(positional_parameters and positional_parameters[0].name in {"self", "cls"}) - # get url path dataset_id from positional args or kwargs - # Flask passes URL path parameters as positional arguments - dataset_id = None + @wraps(view) + def decorated(*args: object, **kwargs: object) -> R: + api_token = validate_and_get_api_token("dataset") - # First try to get from kwargs (explicit parameter) - dataset_id = kwargs.get("dataset_id") + # Flask may pass URL path parameters positionally, so inspect both kwargs and args. + dataset_id = kwargs.get("dataset_id") - # If not in kwargs, try to extract from positional args - if not dataset_id and args: - # For class methods: args[0] is self, args[1] is dataset_id (if exists) - # Check if first arg is likely a class instance (has __dict__ or __class__) - if len(args) > 1 and hasattr(args[0], "__dict__"): - # This is a class method, dataset_id should be in args[1] - potential_id = args[1] - # Validate it's a string-like UUID, not another object - try: - # Try to convert to string and check if it's a valid UUID format - str_id = str(potential_id) - # Basic check: UUIDs are 36 chars with hyphens - if len(str_id) == 36 and str_id.count("-") == 4: - dataset_id = str_id - except Exception: - logger.exception("Failed to parse dataset_id from class method args") - elif len(args) > 0: - # Not a class method, check if args[0] looks like a UUID - potential_id = args[0] - try: - str_id = str(potential_id) - if len(str_id) == 36 and str_id.count("-") == 4: - dataset_id = str_id - except Exception: - logger.exception("Failed to parse dataset_id from positional args") + if not dataset_id and args: + potential_id = args[0] + try: + str_id = str(potential_id) + if len(str_id) == 36 and str_id.count("-") == 4: + dataset_id = str_id + except Exception: + logger.exception("Failed to parse dataset_id from positional args") - # Validate dataset if dataset_id is provided - if dataset_id: - dataset_id = str(dataset_id) - dataset = db.session.scalar( - select(Dataset) - .where( - Dataset.id == dataset_id, - Dataset.tenant_id == api_token.tenant_id, - ) - .limit(1) + if dataset_id: + dataset_id = str(dataset_id) + dataset = db.session.scalar( + select(Dataset) + .where( + Dataset.id == dataset_id, + Dataset.tenant_id == api_token.tenant_id, ) - if not dataset: - raise NotFound("Dataset not found.") - if not dataset.enable_api: - raise Forbidden("Dataset api access is not enabled.") - tenant_account_join = db.session.execute( - select(Tenant, TenantAccountJoin) - .where(Tenant.id == api_token.tenant_id) - .where(TenantAccountJoin.tenant_id == Tenant.id) - .where(TenantAccountJoin.role.in_(["owner"])) - .where(Tenant.status == TenantStatus.NORMAL) - ).one_or_none() # TODO: only owner information is required, so only one is returned. - if tenant_account_join: - tenant, ta = tenant_account_join - account = db.session.get(Account, ta.account_id) - # Login admin - if account: - account.current_tenant = tenant - current_app.login_manager._update_request_context_with_user(account) # type: ignore - user_logged_in.send(current_app._get_current_object(), user=current_user) # type: ignore - else: - raise Unauthorized("Tenant owner account does not exist.") + .limit(1) + ) + if not dataset: + raise NotFound("Dataset not found.") + if not dataset.enable_api: + raise Forbidden("Dataset api access is not enabled.") + + tenant_account_join = db.session.execute( + select(Tenant, TenantAccountJoin) + .where(Tenant.id == api_token.tenant_id) + .where(TenantAccountJoin.tenant_id == Tenant.id) + .where(TenantAccountJoin.role.in_(["owner"])) + .where(Tenant.status == TenantStatus.NORMAL) + ).one_or_none() # TODO: only owner information is required, so only one is returned. + if tenant_account_join: + tenant, ta = tenant_account_join + account = db.session.get(Account, ta.account_id) + # Login admin + if account: + account.current_tenant = tenant + current_app.login_manager._update_request_context_with_user(account) # type: ignore + user_logged_in.send(current_app._get_current_object(), user=current_user) # type: ignore else: - raise Unauthorized("Tenant does not exist.") - if args and isinstance(args[0], Resource): - return view_func(args[0], api_token.tenant_id, *args[1:], **kwargs) + raise Unauthorized("Tenant owner account does not exist.") + else: + raise Unauthorized("Tenant does not exist.") - return view_func(api_token.tenant_id, *args, **kwargs) + if expects_bound_instance: + if not args: + raise TypeError("validate_dataset_token expected a bound resource instance.") + return view(args[0], api_token.tenant_id, *args[1:], **kwargs) - return decorated + return view(api_token.tenant_id, *args, **kwargs) - if view: - return decorator(view) - - # if view is None, it means that the decorator is used without parentheses - # use the decorator as a function for method_decorators - return decorator + return decorated def validate_and_get_api_token(scope: str | None = None): diff --git a/api/dify_app.py b/api/dify_app.py index d6deb8e0074..bbe3f33787a 100644 --- a/api/dify_app.py +++ b/api/dify_app.py @@ -1,5 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from flask import Flask +if TYPE_CHECKING: + from extensions.ext_login import DifyLoginManager + class DifyApp(Flask): - pass + """Flask application type with Dify-specific extension attributes.""" + + login_manager: DifyLoginManager diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index 02e50a90fcc..bc59eaca635 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -1,7 +1,8 @@ import json +from typing import cast import flask_login -from flask import Response, request +from flask import Request, Response, request from flask_login import user_loaded_from_request, user_logged_in from sqlalchemy import select from werkzeug.exceptions import NotFound, Unauthorized @@ -16,13 +17,35 @@ from models import Account, Tenant, TenantAccountJoin from models.model import AppMCPServer, EndUser from services.account_service import AccountService -login_manager = flask_login.LoginManager() +type LoginUser = Account | EndUser + + +class DifyLoginManager(flask_login.LoginManager): + """Project-specific Flask-Login manager with a stable unauthorized contract. + + Dify registers `unauthorized_handler` below to always return a JSON `Response`. + Overriding this method lets callers rely on that narrower return type instead of + Flask-Login's broader callback contract. + """ + + def unauthorized(self) -> Response: + """Return the registered unauthorized handler result as a Flask `Response`.""" + return cast(Response, super().unauthorized()) + + def load_user_from_request_context(self) -> None: + """Populate Flask-Login's request-local user cache for the current request.""" + self._load_user() + + +login_manager = DifyLoginManager() # Flask-Login configuration @login_manager.request_loader -def load_user_from_request(request_from_flask_login): +def load_user_from_request(request_from_flask_login: Request) -> LoginUser | None: """Load user based on the request.""" + del request_from_flask_login + # Skip authentication for documentation endpoints if dify_config.SWAGGER_UI_ENABLED and request.path.endswith((dify_config.SWAGGER_UI_PATH, "/swagger.json")): return None @@ -100,10 +123,12 @@ def load_user_from_request(request_from_flask_login): raise NotFound("End user not found.") return end_user + return None + @user_logged_in.connect @user_loaded_from_request.connect -def on_user_logged_in(_sender, user): +def on_user_logged_in(_sender: object, user: LoginUser) -> None: """Called when a user logged in. Note: AccountService.load_logged_in_account will populate user.current_tenant_id @@ -114,8 +139,10 @@ def on_user_logged_in(_sender, user): @login_manager.unauthorized_handler -def unauthorized_handler(): +def unauthorized_handler() -> Response: """Handle unauthorized requests.""" + # Keep this as a concrete `Response`; `DifyLoginManager.unauthorized()` narrows + # Flask-Login's callback contract based on this override. return Response( json.dumps({"code": "unauthorized", "message": "Unauthorized."}), status=401, @@ -123,5 +150,5 @@ def unauthorized_handler(): ) -def init_app(app: DifyApp): +def init_app(app: DifyApp) -> None: login_manager.init_app(app) diff --git a/api/libs/login.py b/api/libs/login.py index 68a2050747d..067597cb3c2 100644 --- a/api/libs/login.py +++ b/api/libs/login.py @@ -2,19 +2,19 @@ from __future__ import annotations from collections.abc import Callable from functools import wraps -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast -from flask import current_app, g, has_request_context, request +from flask import Response, current_app, g, has_request_context, request from flask_login.config import EXEMPT_METHODS from werkzeug.local import LocalProxy from configs import dify_config +from dify_app import DifyApp +from extensions.ext_login import DifyLoginManager from libs.token import check_csrf_token from models import Account if TYPE_CHECKING: - from flask.typing import ResponseReturnValue - from models.model import EndUser @@ -29,7 +29,13 @@ def _resolve_current_user() -> EndUser | Account | None: return get_current_object() if callable(get_current_object) else user_proxy # type: ignore -def current_account_with_tenant(): +def _get_login_manager() -> DifyLoginManager: + """Return the project login manager with Dify's narrowed unauthorized contract.""" + app = cast(DifyApp, current_app) + return app.login_manager + + +def current_account_with_tenant() -> tuple[Account, str]: """ Resolve the underlying account for the current user proxy and ensure tenant context exists. Allows tests to supply plain Account mocks without the LocalProxy helper. @@ -42,7 +48,7 @@ def current_account_with_tenant(): return user, user.current_tenant_id -def login_required[**P, R](func: Callable[P, R]) -> Callable[P, R | ResponseReturnValue]: +def login_required[**P, R](func: Callable[P, R]) -> Callable[P, R | Response]: """ If you decorate a view with this, it will ensure that the current user is logged in and authenticated before calling the actual view. (If they are @@ -77,13 +83,16 @@ def login_required[**P, R](func: Callable[P, R]) -> Callable[P, R | ResponseRetu """ @wraps(func) - def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R | ResponseReturnValue: + def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R | Response: if request.method in EXEMPT_METHODS or dify_config.LOGIN_DISABLED: return current_app.ensure_sync(func)(*args, **kwargs) user = _resolve_current_user() if user is None or not user.is_authenticated: - return current_app.login_manager.unauthorized() # type: ignore + # `DifyLoginManager` guarantees that the registered unauthorized handler + # is surfaced here as a concrete Flask `Response`. + unauthorized_response: Response = _get_login_manager().unauthorized() + return unauthorized_response g._login_user = user # we put csrf validation here for less conflicts # TODO: maybe find a better place for it. @@ -96,7 +105,7 @@ def login_required[**P, R](func: Callable[P, R]) -> Callable[P, R | ResponseRetu def _get_user() -> EndUser | Account | None: if has_request_context(): if "_login_user" not in g: - current_app.login_manager._load_user() # type: ignore + _get_login_manager().load_user_from_request_context() return g._login_user diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index 9afc1c41661..7f9fe9cbf95 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -20,7 +20,7 @@ def app(): app = Flask(__name__) app.config["TESTING"] = True app.config["RESTX_MASK_HEADER"] = "X-Fields" - app.login_manager = SimpleNamespace(_load_user=lambda: None) + app.login_manager = SimpleNamespace(load_user_from_request_context=lambda: None) return app diff --git a/api/tests/unit_tests/controllers/console/test_workspace_members.py b/api/tests/unit_tests/controllers/console/test_workspace_members.py index 368892b9228..239fec84308 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_members.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_members.py @@ -12,7 +12,7 @@ from models.account import Account, TenantAccountRole def app(): flask_app = Flask(__name__) flask_app.config["TESTING"] = True - flask_app.login_manager = SimpleNamespace(_load_user=lambda: None) + flask_app.login_manager = SimpleNamespace(load_user_from_request_context=lambda: None) return flask_app diff --git a/api/tests/unit_tests/extensions/test_ext_login.py b/api/tests/unit_tests/extensions/test_ext_login.py new file mode 100644 index 00000000000..64abc194276 --- /dev/null +++ b/api/tests/unit_tests/extensions/test_ext_login.py @@ -0,0 +1,17 @@ +import json + +from flask import Response + +from extensions.ext_login import unauthorized_handler + + +def test_unauthorized_handler_returns_json_response() -> None: + response = unauthorized_handler() + + assert isinstance(response, Response) + assert response.status_code == 401 + assert response.content_type == "application/json" + assert json.loads(response.get_data(as_text=True)) == { + "code": "unauthorized", + "message": "Unauthorized.", + } diff --git a/api/tests/unit_tests/libs/test_login.py b/api/tests/unit_tests/libs/test_login.py index 0c9e73299b8..2bf22128448 100644 --- a/api/tests/unit_tests/libs/test_login.py +++ b/api/tests/unit_tests/libs/test_login.py @@ -2,11 +2,12 @@ from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from flask import Flask, g -from flask_login import LoginManager, UserMixin +from flask import Flask, Response, g +from flask_login import UserMixin from pytest_mock import MockerFixture import libs.login as login_module +from extensions.ext_login import DifyLoginManager from libs.login import current_user from models.account import Account @@ -39,9 +40,12 @@ def login_app(mocker: MockerFixture) -> Flask: app = Flask(__name__) app.config["TESTING"] = True - login_manager = LoginManager() + login_manager = DifyLoginManager() login_manager.init_app(app) - login_manager.unauthorized = mocker.Mock(name="unauthorized", return_value="Unauthorized") + login_manager.unauthorized = mocker.Mock( + name="unauthorized", + return_value=Response("Unauthorized", status=401, content_type="application/json"), + ) @login_manager.user_loader def load_user(_user_id: str): @@ -109,18 +113,43 @@ class TestLoginRequired: resolved_user: MockUser | None, description: str, ): - """Test that missing or unauthenticated users are redirected.""" + """Test that missing or unauthenticated users return the manager response.""" resolve_user = resolve_current_user(resolved_user) with login_app.test_request_context(): result = protected_view() - assert result == "Unauthorized", description + assert result is login_app.login_manager.unauthorized.return_value, description + assert isinstance(result, Response) + assert result.status_code == 401 resolve_user.assert_called_once_with() login_app.login_manager.unauthorized.assert_called_once_with() csrf_check.assert_not_called() + def test_unauthorized_access_propagates_response_object( + self, + login_app: Flask, + protected_view, + csrf_check: MagicMock, + resolve_current_user, + mocker: MockerFixture, + ) -> None: + """Test that unauthorized responses are propagated as Flask Response objects.""" + resolve_user = resolve_current_user(None) + response = Response("Unauthorized", status=401, content_type="application/json") + mocker.patch.object( + login_module, "_get_login_manager", return_value=SimpleNamespace(unauthorized=lambda: response) + ) + + with login_app.test_request_context(): + result = protected_view() + + assert result is response + assert isinstance(result, Response) + resolve_user.assert_called_once_with() + csrf_check.assert_not_called() + @pytest.mark.parametrize( ("method", "login_disabled"), [ @@ -168,10 +197,14 @@ class TestGetUser: """Test that _get_user loads user if not already in g.""" mock_user = MockUser("test_user") - def _load_user() -> None: + def load_user_from_request_context() -> None: g._login_user = mock_user - load_user = mocker.patch.object(login_app.login_manager, "_load_user", side_effect=_load_user) + load_user = mocker.patch.object( + login_app.login_manager, + "load_user_from_request_context", + side_effect=load_user_from_request_context, + ) with login_app.test_request_context(): user = login_module._get_user() From a3386da5d61ed22b6770687db2791584a8affdf0 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Thu, 2 Apr 2026 18:48:46 +0900 Subject: [PATCH 2/2] ci: Update pyrefly version to 0.59.1 (#34452) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/uv.lock | 40 ++++++++++++---------------------------- 2 files changed, 13 insertions(+), 29 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 863b61cad13..cbd9af151b1 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -171,7 +171,7 @@ dev = [ "sseclient-py>=1.8.0", "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", - "pyrefly>=0.57.1", + "pyrefly>=0.59.1", ] ############################################################ diff --git a/api/uv.lock b/api/uv.lock index 9381fabb403..d171483d374 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -53,23 +53,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/7e/cb94129302d78c46662b47f9897d642fd0b33bdfef4b73b20c6ced35aa4c/aiohttp-3.13.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ea0c64d1bcbf201b285c2246c51a0c035ba3bbd306640007bc5844a3b4658c1", size = 760027, upload-time = "2026-03-28T17:15:33.022Z" }, - { url = "https://files.pythonhosted.org/packages/5e/cd/2db3c9397c3bd24216b203dd739945b04f8b87bb036c640da7ddb63c75ef/aiohttp-3.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f742e1fa45c0ed522b00ede565e18f97e4cf8d1883a712ac42d0339dfb0cce7", size = 508325, upload-time = "2026-03-28T17:15:34.714Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/d28b2722ec13107f2e37a86b8a169897308bab6a3b9e071ecead9d67bd9b/aiohttp-3.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dcfb50ee25b3b7a1222a9123be1f9f89e56e67636b561441f0b304e25aaef8f", size = 502402, upload-time = "2026-03-28T17:15:36.409Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d6/acd47b5f17c4430e555590990a4746efbcb2079909bb865516892bf85f37/aiohttp-3.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3262386c4ff370849863ea93b9ea60fd59c6cf56bf8f93beac625cf4d677c04d", size = 1771224, upload-time = "2026-03-28T17:15:38.223Z" }, - { url = "https://files.pythonhosted.org/packages/98/af/af6e20113ba6a48fd1cd9e5832c4851e7613ef50c7619acdaee6ec5f1aff/aiohttp-3.13.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:473bb5aa4218dd254e9ae4834f20e31f5a0083064ac0136a01a62ddbae2eaa42", size = 1731530, upload-time = "2026-03-28T17:15:39.988Z" }, - { url = "https://files.pythonhosted.org/packages/81/16/78a2f5d9c124ad05d5ce59a9af94214b6466c3491a25fb70760e98e9f762/aiohttp-3.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56423766399b4c77b965f6aaab6c9546617b8994a956821cc507d00b91d978c", size = 1827925, upload-time = "2026-03-28T17:15:41.944Z" }, - { url = "https://files.pythonhosted.org/packages/2a/1f/79acf0974ced805e0e70027389fccbb7d728e6f30fcac725fb1071e63075/aiohttp-3.13.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8af249343fafd5ad90366a16d230fc265cf1149f26075dc9fe93cfd7c7173942", size = 1923579, upload-time = "2026-03-28T17:15:44.071Z" }, - { url = "https://files.pythonhosted.org/packages/af/53/29f9e2054ea6900413f3b4c3eb9d8331f60678ec855f13ba8714c47fd48d/aiohttp-3.13.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc0a5cf4f10ef5a2c94fdde488734b582a3a7a000b131263e27c9295bd682d9", size = 1767655, upload-time = "2026-03-28T17:15:45.911Z" }, - { url = "https://files.pythonhosted.org/packages/f3/57/462fe1d3da08109ba4aa8590e7aed57c059af2a7e80ec21f4bac5cfe1094/aiohttp-3.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5c7ff1028e3c9fc5123a865ce17df1cb6424d180c503b8517afbe89aa566e6be", size = 1630439, upload-time = "2026-03-28T17:15:48.11Z" }, - { url = "https://files.pythonhosted.org/packages/d7/4b/4813344aacdb8127263e3eec343d24e973421143826364fa9fc847f6283f/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ba5cf98b5dcb9bddd857da6713a503fa6d341043258ca823f0f5ab7ab4a94ee8", size = 1745557, upload-time = "2026-03-28T17:15:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/d4/01/1ef1adae1454341ec50a789f03cfafe4c4ac9c003f6a64515ecd32fe4210/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d85965d3ba21ee4999e83e992fecb86c4614d6920e40705501c0a1f80a583c12", size = 1741796, upload-time = "2026-03-28T17:15:52.351Z" }, - { url = "https://files.pythonhosted.org/packages/22/04/8cdd99af988d2aa6922714d957d21383c559835cbd43fbf5a47ddf2e0f05/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:49f0b18a9b05d79f6f37ddd567695943fcefb834ef480f17a4211987302b2dc7", size = 1805312, upload-time = "2026-03-28T17:15:54.407Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/b48d5577338d4b25bbdbae35c75dbfd0493cb8886dc586fbfb2e90862239/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7f78cb080c86fbf765920e5f1ef35af3f24ec4314d6675d0a21eaf41f6f2679c", size = 1621751, upload-time = "2026-03-28T17:15:56.564Z" }, - { url = "https://files.pythonhosted.org/packages/bc/89/4eecad8c1858e6d0893c05929e22343e0ebe3aec29a8a399c65c3cc38311/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:67a3ec705534a614b68bbf1c70efa777a21c3da3895d1c44510a41f5a7ae0453", size = 1826073, upload-time = "2026-03-28T17:15:58.489Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5c/9dc8293ed31b46c39c9c513ac7ca152b3c3d38e0ea111a530ad12001b827/aiohttp-3.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6630ec917e85c5356b2295744c8a97d40f007f96a1c76bf1928dc2e27465393", size = 1760083, upload-time = "2026-03-28T17:16:00.677Z" }, - { url = "https://files.pythonhosted.org/packages/1e/19/8bbf6a4994205d96831f97b7d21a0feed120136e6267b5b22d229c6dc4dc/aiohttp-3.13.4-cp311-cp311-win32.whl", hash = "sha256:54049021bc626f53a5394c29e8c444f726ee5a14b6e89e0ad118315b1f90f5e3", size = 439690, upload-time = "2026-03-28T17:16:02.902Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f5/ac409ecd1007528d15c3e8c3a57d34f334c70d76cfb7128a28cffdebd4c1/aiohttp-3.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:c033f2bc964156030772d31cbf7e5defea181238ce1f87b9455b786de7d30145", size = 463824, upload-time = "2026-03-28T17:16:05.058Z" }, { url = "https://files.pythonhosted.org/packages/1e/bd/ede278648914cabbabfdf95e436679b5d4156e417896a9b9f4587169e376/aiohttp-3.13.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62d4471ce86b108b19c3364db4b91180d13fe3510144872d6bad5401957360", size = 752158, upload-time = "2026-03-28T17:16:06.901Z" }, { url = "https://files.pythonhosted.org/packages/90/de/581c053253c07b480b03785196ca5335e3c606a37dc73e95f6527f1591fe/aiohttp-3.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c0fd8f41b54b58636402eb493afd512c23580456f022c1ba2db0f810c959ed0d", size = 501037, upload-time = "2026-03-28T17:16:08.82Z" }, { url = "https://files.pythonhosted.org/packages/fa/f9/a5ede193c08f13cc42c0a5b50d1e246ecee9115e4cf6e900d8dbd8fd6acb/aiohttp-3.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4baa48ce49efd82d6b1a0be12d6a36b35e5594d1dd42f8bfba96ea9f8678b88c", size = 501556, upload-time = "2026-03-28T17:16:10.63Z" }, @@ -1586,7 +1569,7 @@ dev = [ { name = "lxml-stubs", specifier = "~=0.5.1" }, { name = "mypy", specifier = "~=1.19.1" }, { name = "pandas-stubs", specifier = "~=3.0.0" }, - { name = "pyrefly", specifier = ">=0.57.1" }, + { name = "pyrefly", specifier = ">=0.59.1" }, { name = "pytest", specifier = "~=9.0.2" }, { name = "pytest-benchmark", specifier = "~=5.2.3" }, { name = "pytest-cov", specifier = "~=7.1.0" }, @@ -4839,18 +4822,19 @@ wheels = [ [[package]] name = "pyrefly" -version = "0.57.1" +version = "0.59.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c1/c17211e5bbd2b90a24447484713da7cc2cee4e9455e57b87016ffc69d426/pyrefly-0.57.1.tar.gz", hash = "sha256:b05f6f5ee3a6a5d502ca19d84cb9ab62d67f05083819964a48c1510f2993efc6", size = 5310800, upload-time = "2026-03-18T18:42:35.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/ce/7882c2af92b2ff6505fcd3430eff8048ece6c6254cc90bdc76ecee12dfab/pyrefly-0.59.1.tar.gz", hash = "sha256:bf1675b0c38d45df2c8f8618cbdfa261a1b92430d9d31eba16e0282b551e210f", size = 5475432, upload-time = "2026-04-01T22:04:04.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/58/8af37856c8d45b365ece635a6728a14b0356b08d1ff1ac601d7120def1e0/pyrefly-0.57.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:91974bfbe951eebf5a7bc959c1f3921f0371c789cad84761511d695e9ab2265f", size = 12681847, upload-time = "2026-03-18T18:42:10.963Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d7/fae6dd9d0355fc5b8df7793f1423b7433ca8e10b698ea934c35f0e4e6522/pyrefly-0.57.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:808087298537c70f5e7cdccb5bbaad482e7e056e947c0adf00fb612cbace9fdc", size = 12219634, upload-time = "2026-03-18T18:42:13.469Z" }, - { url = "https://files.pythonhosted.org/packages/29/8f/9511ae460f0690e837b9ba0f7e5e192079e16ff9a9ba8a272450e81f11f8/pyrefly-0.57.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b01f454fa5539e070c0cba17ddec46b3d2107d571d519bd8eca8f3142ba02a6", size = 34947757, upload-time = "2026-03-18T18:42:17.152Z" }, - { url = "https://files.pythonhosted.org/packages/07/43/f053bf9c65218f70e6a49561e9942c7233f8c3e4da8d42e5fe2aae50b3d2/pyrefly-0.57.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02ad59ea722191f51635f23e37574662116b82ca9d814529f7cb5528f041f381", size = 37621018, upload-time = "2026-03-18T18:42:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/0e/76/9cea46de01665bbc125e4f215340c9365c8d56cda6198ff238a563ea8e75/pyrefly-0.57.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54bc0afe56776145e37733ff763e7e9679ee8a76c467b617dc3f227d4124a9e2", size = 40203649, upload-time = "2026-03-18T18:42:24.519Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8b/2fb4a96d75e2a57df698a43e2970e441ba2704e3906cdc0386a055daa05a/pyrefly-0.57.1-py3-none-win32.whl", hash = "sha256:468e5839144b25bb0dce839bfc5fd879c9f38e68ebf5de561f30bed9ae19d8ca", size = 11732953, upload-time = "2026-03-18T18:42:27.379Z" }, - { url = "https://files.pythonhosted.org/packages/13/5a/4a197910fe2e9b102b15ae5e7687c45b7b5981275a11a564b41e185dd907/pyrefly-0.57.1-py3-none-win_amd64.whl", hash = "sha256:46db9c97093673c4fb7fab96d610e74d140661d54688a92d8e75ad885a56c141", size = 12537319, upload-time = "2026-03-18T18:42:30.196Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c6/bc442874be1d9b63da1f9debb4f04b7d0c590a8dc4091921f3c288207242/pyrefly-0.57.1-py3-none-win_arm64.whl", hash = "sha256:feb1bbe3b0d8d5a70121dcdf1476e6a99cc056a26a49379a156f040729244dcb", size = 12013455, upload-time = "2026-03-18T18:42:32.928Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/04a0e05b08fc855b6fe38c3df549925fc3c2c6e750506870de7335d3e1f7/pyrefly-0.59.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:390db3cd14aa7e0268e847b60cd9ee18b04273eddfa38cf341ed3bb43f3fef2a", size = 12868133, upload-time = "2026-04-01T22:03:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/c7/78/fa7be227c3e3fcacee501c1562278dd026186ffd1b5b5beb51d3941a3aed/pyrefly-0.59.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d246d417b6187c1650d7f855f61c68fbfd6d6155dc846d4e4d273a3e6b5175cb", size = 12379325, upload-time = "2026-04-01T22:03:42.046Z" }, + { url = "https://files.pythonhosted.org/packages/bb/13/6828ce1c98171b5f8388f33c4b0b9ea2ab8c49abe0ef8d793c31e30a05cb/pyrefly-0.59.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:575ac67b04412dc651a7143d27e38a40fbdd3c831c714d5520d0e9d4c8631ab4", size = 35826408, upload-time = "2026-04-01T22:03:45.067Z" }, + { url = "https://files.pythonhosted.org/packages/23/56/79ed8ece9a7ecad0113c394a06a084107db3ad8f1fefe19e7ded43c51245/pyrefly-0.59.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:062e6262ce1064d59dcad81ac0499bb7a3ad501e9bc8a677a50dc630ff0bf862", size = 38532699, upload-time = "2026-04-01T22:03:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/18/7d/ecc025e0f0e3f295b497f523cc19cefaa39e57abede8fc353d29445d174b/pyrefly-0.59.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43ef4247f9e6f734feb93e1f2b75335b943629956e509f545cc9cdcccd76dd20", size = 36743570, upload-time = "2026-04-01T22:03:51.362Z" }, + { url = "https://files.pythonhosted.org/packages/2f/03/b1ce882ebcb87c673165c00451fbe4df17bf96ccfde18c75880dc87c5f5e/pyrefly-0.59.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a2d01723b84d042f4fa6ec871ffd52d0a7e83b0ea791c2e0bb0ff750abce56", size = 41236246, upload-time = "2026-04-01T22:03:54.361Z" }, + { url = "https://files.pythonhosted.org/packages/17/af/5e9c7afd510e7dd64a2204be0ed39e804089cbc4338675a28615c7176acb/pyrefly-0.59.1-py3-none-win32.whl", hash = "sha256:4ea70c780848f8376411e787643ae5d2d09da8a829362332b7b26d15ebcbaf56", size = 11884747, upload-time = "2026-04-01T22:03:56.776Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c1/7db1077627453fd1068f0761f059a9512645c00c4c20acfb9f0c24ac02ec/pyrefly-0.59.1-py3-none-win_amd64.whl", hash = "sha256:67e6a08cfd129a0d2788d5e40a627f9860e0fe91a876238d93d5c63ff4af68ae", size = 12720608, upload-time = "2026-04-01T22:03:59.252Z" }, + { url = "https://files.pythonhosted.org/packages/07/16/4bb6e5fce5a9cf0992932d9435d964c33e507aaaf96fdfbb1be493078a4a/pyrefly-0.59.1-py3-none-win_arm64.whl", hash = "sha256:01179cb215cf079e8223a064f61a074f7079aa97ea705cbbc68af3d6713afd15", size = 12223158, upload-time = "2026-04-01T22:04:01.869Z" }, ] [[package]]