From c34db390228dfc27aba1e5bb0d4d62f2c30127c1 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Fri, 24 Oct 2025 14:56:25 -0400 Subject: [PATCH 1/2] Consolidate/reorganize the login/logout views --- .../mitol/apigateway/settings/__init__.py | 11 +-- src/apigateway/mitol/apigateway/urls.py | 10 +- src/apigateway/mitol/apigateway/utils.py | 9 ++ src/apigateway/mitol/apigateway/views.py | 92 +++++++------------ .../mitol/authentication/settings/auth.py | 3 + .../mitol/authentication/urls/auth.py | 9 ++ .../mitol/authentication/utils.py | 88 ++++++++++++++++++ .../mitol/authentication/views/auth.py | 69 ++++++++++++++ 8 files changed, 217 insertions(+), 74 deletions(-) create mode 100644 src/apigateway/mitol/apigateway/utils.py create mode 100644 src/authentication/mitol/authentication/settings/auth.py create mode 100644 src/authentication/mitol/authentication/urls/auth.py create mode 100644 src/authentication/mitol/authentication/utils.py create mode 100644 src/authentication/mitol/authentication/views/auth.py diff --git a/src/apigateway/mitol/apigateway/settings/__init__.py b/src/apigateway/mitol/apigateway/settings/__init__.py index 1736b756..7fa34c92 100644 --- a/src/apigateway/mitol/apigateway/settings/__init__.py +++ b/src/apigateway/mitol/apigateway/settings/__init__.py @@ -57,12 +57,7 @@ # Set to the URL that APISIX uses for logout. MITOL_APIGATEWAY_LOGOUT_URL = "/logout" +MITOL_APIGATEWAY_HEADER_NAME = "HTTP_X_USERINFO" -# Set to the default URL the user should be sent to when logging out. -# If there's no redirect URL specified otherwise, the user gets sent here. -MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST = "/app" - -# Set to the list of hosts the app is allowed to redirect to. -MITOL_APIGATEWAY_ALLOWED_REDIRECT_HOSTS = [ - "localhost", -] +MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_TTL = 60 +MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_NAME = "logout-next" diff --git a/src/apigateway/mitol/apigateway/urls.py b/src/apigateway/mitol/apigateway/urls.py index c060cfb9..9cdf8512 100644 --- a/src/apigateway/mitol/apigateway/urls.py +++ b/src/apigateway/mitol/apigateway/urls.py @@ -1,13 +1,9 @@ -"""URL routes for the apigateway app. Mostly for testing.""" +"""URL routes for the apigateway app.""" -from django.urls import path +from django.urls import re_path from mitol.apigateway.views import ApiGatewayLogoutView urlpatterns = [ - path( - "applogout/", - ApiGatewayLogoutView.as_view(), - name="logout", - ), + re_path(r"^logout", ApiGatewayLogoutView.as_view(), name="logout"), ] diff --git a/src/apigateway/mitol/apigateway/utils.py b/src/apigateway/mitol/apigateway/utils.py new file mode 100644 index 00000000..dc2f4cfc --- /dev/null +++ b/src/apigateway/mitol/apigateway/utils.py @@ -0,0 +1,9 @@ +"""API gateway utils""" + +from django.conf import settings +from django.http.request import HttpRequest + + +def has_gateway_auth(request: HttpRequest) -> bool: + """Return True if the request has auth information from the API gateway""" + return request.META.get(settings.MITOL_APIGATEWAY_HEADER_NAME) diff --git a/src/apigateway/mitol/apigateway/views.py b/src/apigateway/mitol/apigateway/views.py index 270914d5..b70b64e2 100644 --- a/src/apigateway/mitol/apigateway/views.py +++ b/src/apigateway/mitol/apigateway/views.py @@ -1,51 +1,13 @@ """Custom logout view for the API Gateway.""" -import logging - from django.conf import settings -from django.contrib.auth import logout -from django.shortcuts import redirect -from django.utils.http import url_has_allowed_host_and_scheme -from django.views import View +from django.http.request import HttpRequest -log = logging.getLogger(__name__) +from mitol.apigateway.utils import has_gateway_auth +from mitol.authentication.views.auth import AuthRedirectView -def get_redirect_url(request): - """ - Get the redirect URL from the request. - - Args: - request: Django request object - - Returns: - str: Redirect URL - """ - log.debug("views.get_redirect_url: Request GET is: %s", request.GET.get("next")) - log.debug( - "views.get_redirect_url: Request cookie is: %s", request.COOKIES.get("next") - ) - - next_url = request.GET.get("next") or request.COOKIES.get("next") - log.debug("views.get_redirect_url: Redirect URL (before valid check): %s", next_url) - - if request.COOKIES.get("next"): - # Clear the cookie after using it - log.debug("views.get_redirect_url: Popping the next cookie") - - request.COOKIES.pop("next", None) - - return ( - next_url - if next_url - and url_has_allowed_host_and_scheme( - next_url, allowed_hosts=settings.MITOL_APIGATEWAY_ALLOWED_REDIRECT_HOSTS - ) - else settings.MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST - ) - - -class ApiGatewayLogoutView(View): +class ApiGatewayLogoutView(AuthRedirectView): """ Log the user out. @@ -61,27 +23,39 @@ class ApiGatewayLogoutView(View): Keycloak will throw an error.) """ + next_url_cookie_names = [settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_NAME] + + def get_redirect_url(self, request: HttpRequest) -> tuple[str, bool]: + """Get the redirect url""" + next_url, prune_cookies = super().get_redirect_url(request) + + if has_gateway_auth(request): + # Still logged in via Apisix/Keycloak, so log out there + # and use cookies to preserve the next url + return settings.MITOL_APIGATEWAY_LOGOUT_URL, False + + return next_url, prune_cookies + def get( self, request, - *args, # noqa: ARG002 - **kwargs, # noqa: ARG002 + *args, + **kwargs, ): """ - GET endpoint reached after logging a user out from Keycloak + GET endpoint reached to logout the user """ - user = getattr(request, "user", None) - user_redirect_url = get_redirect_url(request) - log.debug( - "views.ApiGatewayLogoutView.get: User redirect URL: %s", user_redirect_url - ) - if user and user.is_authenticated: - logout(request) + response = super().get(request, *args, **kwargs) + + if has_gateway_auth(request): + # we can only preserve the next url via cookies because APISIX + # won't accept a post_logout_redirect_url + next_url, _ = super().get_redirect_url(request) + + response.set_cookie( + settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_NAME, + value=next_url, + max_age=settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_TTL, + ) - if request.META.get(settings.MITOL_APIGATEWAY_USERINFO_HEADER_NAME): - # Still logged in via Apisix/Keycloak, so log out there as well - log.debug("views.ApiGatewayLogoutView.get: Send to APISIX logout URL") - return redirect(settings.MITOL_APIGATEWAY_LOGOUT_URL) - else: - log.debug("views.ApiGatewayLogoutView.get: Send to %s", user_redirect_url) - return redirect(user_redirect_url) + return response diff --git a/src/authentication/mitol/authentication/settings/auth.py b/src/authentication/mitol/authentication/settings/auth.py new file mode 100644 index 00000000..34096064 --- /dev/null +++ b/src/authentication/mitol/authentication/settings/auth.py @@ -0,0 +1,3 @@ +# Set to the default URL the user should be sent to when logging out. +# If there's no redirect URL specified otherwise, the user gets sent here. +MITOL_DEFAULT_POST_LOGOUT_DEST = "/app" diff --git a/src/authentication/mitol/authentication/urls/auth.py b/src/authentication/mitol/authentication/urls/auth.py new file mode 100644 index 00000000..11f1cd73 --- /dev/null +++ b/src/authentication/mitol/authentication/urls/auth.py @@ -0,0 +1,9 @@ +"""URL configurations for authentication""" + +from django.urls import re_path +from mitol.authentication.views import LoginRedirectView, LogoutRedirectView + +urlpatterns = [ + re_path(r"^logout", LogoutRedirectView.as_view(), name="logout"), + re_path(r"^login", LoginRedirectView.as_view(), name="login"), +] diff --git a/src/authentication/mitol/authentication/utils.py b/src/authentication/mitol/authentication/utils.py new file mode 100644 index 00000000..b827456c --- /dev/null +++ b/src/authentication/mitol/authentication/utils.py @@ -0,0 +1,88 @@ +import logging + +from django.conf import settings +from django.http.request import HttpRequest +from django.utils.http import url_has_allowed_host_and_scheme + +log = logging.getLogger() + + +def get_redirect_url( + request: HttpRequest, + *, + param_names: list[str] | None = None, + cookie_names: list[str] | None = None, +) -> str: + """ + Get the redirect URL from the request. + + Args: + request: Django request object + param_names: Names of the GET parameters to look for the redirect URL; + first match will be used. + cookie_names: Names of the cookies to look for the redirect URL; + first match will be used. + + Returns: + str: Redirect URL + """ + param_next_url = get_redirect_url_from_params(request, param_names) + cookie_next_url = get_redirect_url_from_cookies(request, cookie_names) + + log.debug("views.get_redirect_url: Request param is: %s", param_next_url) + log.debug("views.get_redirect_url: Request cookie is: %s", cookie_next_url) + + next_url = param_next_url or cookie_next_url + + log.debug("mitol.authentication.utils.get_redirect_url: next_url='%s'", next_url) + + return next_url or settings.MITOL_DEFAULT_POST_LOGOUT_URL or "/" + + +def get_redirect_url_from_cookies( + request: HttpRequest, cookie_names: list[str] +) -> str | None: + """ + Get the redirect URL from the request cookies. + + Args: + request: Django request object + cookie_names: Names of the cookies to look for the redirect URL; + first match will be used. + + Returns: + str: Redirect URL + """ + return _get_redirect_url(request.COOKIES, cookie_names) + + +def get_redirect_url_from_params( + request: HttpRequest, param_names: list[str] +) -> str | None: + """ + Get the redirect URL from the request params. + + Args: + request: Django request object + param_names: Names of the GET parameter to look for the redirect URL; + first match will be used. + + Returns: + str: Redirect URL + """ + + return _get_redirect_url(request.GET, param_names) + + +def _get_redirect_url(record: dict[str, str], keys: list[str]) -> str | None: + """ + Get a valid redirect + """ + for key in keys: + next_url = record.get(key) + if next_url and url_has_allowed_host_and_scheme( + next_url, allowed_hosts=settings.ALLOWED_REDIRECT_HOSTS + ): + return next_url + + return None diff --git a/src/authentication/mitol/authentication/views/auth.py b/src/authentication/mitol/authentication/views/auth.py new file mode 100644 index 00000000..40c73b73 --- /dev/null +++ b/src/authentication/mitol/authentication/views/auth.py @@ -0,0 +1,69 @@ +"""Authentication views""" + +import logging + +from django.contrib.auth import logout +from django.http.request import HttpRequest +from django.shortcuts import redirect +from django.views import View +from mitol.authentication.utils import get_redirect_url + +log = logging.getLogger(__name__) + + +class AuthRedirectView(View): + """Base class for auth views that need to do a redirect based on params/cookies""" + + next_url_param_names = ["next"] + next_url_cookie_names = [] + + def get_redirect_url(self, request: HttpRequest) -> tuple[str, bool]: + """Get the redirect url based on params or cookies""" + return get_redirect_url( + request, + param_names=self.next_url_param_names, + cookie_names=self.next_url_cookie_names, + ), True + + def prune_next_url_cookies(self, request: HttpRequest): + """Prune the next url cookies""" + for cookie_name in self.next_url_cookie_names: + request.COOKIES.pop(cookie_name, None) + + def get( + self, + request, + *_args, + **_kwargs, + ): + """ + GET endpoint reached after logging a user out from Keycloak + """ + redirect_url, prune_cookies = self.get_redirect_url(request) + + if prune_cookies: + self.prune_next_url_cookies(request) + + return redirect(redirect_url) + + +class LogoutRedirectView(AuthRedirectView): + """ + Log out the user from django and redirect + """ + + def get( + self, + request, + *args, + **kwargs, + ): + """ + GET endpoint reached to logout the user + """ + user = getattr(request, "user", None) + + if user and user.is_authenticated: + logout(request) + + return super().get(request, *args, **kwargs) From 53d7d0abe1a12517d2f48af45e976c4b26f6d324 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Mon, 10 Nov 2025 17:21:36 -0500 Subject: [PATCH 2/2] Fix code and tests --- pyproject.toml | 1 + src/apigateway/README.md | 2 +- src/apigateway/mitol/apigateway/views.py | 2 +- .../mitol/authentication/settings/auth.py | 8 ++- .../mitol/authentication/utils.py | 2 +- testapp/main/settings/shared.py | 1 + testapp/main/settings/test.py | 2 +- tests/apigateway/test_views.py | 28 ++++----- uv.lock | 57 ++++++++++++++++++- 9 files changed, 80 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fc8adcef..d2e96610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dev-dependencies = [ "pytest-django==4.11.1", "pytest-lazy-fixtures>=1", "pytest-mock==3.15.1", + "pytest-xdist[psutil]>=3.8.0", "responses==0.25.8", "ruff", "scriv[toml]", diff --git a/src/apigateway/README.md b/src/apigateway/README.md index 68aecb88..24dee786 100644 --- a/src/apigateway/README.md +++ b/src/apigateway/README.md @@ -82,7 +82,7 @@ Your application configuration will need some settings added to it. Reasonable d These settings are needed for your environment: - `MITOL_APIGATEWAY_LOGOUT_URL` - the URL that APISIX uses for logout. This needs to be set in your APISIX configuration; the corresponding setting is `logout_path`. Defaults to `/logout`. -- `MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST` - the URL that the logout view should send users when they log out by default. (You can programmatically set a destination but you should also have a default.) Defaults to `/app`. +- `MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_URL` - the URL that the logout view should send users when they log out by default. (You can programmatically set a destination but you should also have a default.) Defaults to `/app`. These settings are likely to need adjustment for your environment: diff --git a/src/apigateway/mitol/apigateway/views.py b/src/apigateway/mitol/apigateway/views.py index b70b64e2..cb806728 100644 --- a/src/apigateway/mitol/apigateway/views.py +++ b/src/apigateway/mitol/apigateway/views.py @@ -55,7 +55,7 @@ def get( response.set_cookie( settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_NAME, value=next_url, - max_age=settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_TTL, + max_age=settings.MITOL_APIGATEWAY_LOGOUT_NEXT_URL_COOKIE_TTL, ) return response diff --git a/src/authentication/mitol/authentication/settings/auth.py b/src/authentication/mitol/authentication/settings/auth.py index 34096064..c82c58bb 100644 --- a/src/authentication/mitol/authentication/settings/auth.py +++ b/src/authentication/mitol/authentication/settings/auth.py @@ -1,3 +1,9 @@ +from mitol.common.envs import get_list_literal + # Set to the default URL the user should be sent to when logging out. # If there's no redirect URL specified otherwise, the user gets sent here. -MITOL_DEFAULT_POST_LOGOUT_DEST = "/app" +MITOL_DEFAULT_POST_LOGOUT_URL = "/app" + +MITOL_ALLOWED_REDIRECT_HOSTS = get_list_literal( + name="ALLOWED_REDIRECT_HOSTS", description="Allowed redirect hostnames", default=[] +) diff --git a/src/authentication/mitol/authentication/utils.py b/src/authentication/mitol/authentication/utils.py index b827456c..84d54267 100644 --- a/src/authentication/mitol/authentication/utils.py +++ b/src/authentication/mitol/authentication/utils.py @@ -81,7 +81,7 @@ def _get_redirect_url(record: dict[str, str], keys: list[str]) -> str | None: for key in keys: next_url = record.get(key) if next_url and url_has_allowed_host_and_scheme( - next_url, allowed_hosts=settings.ALLOWED_REDIRECT_HOSTS + next_url, allowed_hosts=settings.MITOL_ALLOWED_REDIRECT_HOSTS ): return next_url diff --git a/testapp/main/settings/shared.py b/testapp/main/settings/shared.py index 739bb257..b500e296 100644 --- a/testapp/main/settings/shared.py +++ b/testapp/main/settings/shared.py @@ -21,6 +21,7 @@ "mitol.common.settings.base", "mitol.common.settings.webpack", "mitol.mail.settings.email", + "mitol.authentication.settings.auth", "mitol.authentication.settings.touchstone", "mitol.authentication.settings.djoser_settings", "mitol.payment_gateway.settings.cybersource", diff --git a/testapp/main/settings/test.py b/testapp/main/settings/test.py index f12f13e6..88b66ea9 100644 --- a/testapp/main/settings/test.py +++ b/testapp/main/settings/test.py @@ -28,5 +28,5 @@ FEATURES = {} +MITOL_DEFAULT_POST_LOGOUT_URL = "/app-after-logout" MITOL_APIGATEWAY_LOGOUT_URL = "/logout" -MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST = "/app-after-logout" diff --git a/tests/apigateway/test_views.py b/tests/apigateway/test_views.py index bba7f60a..6a9be977 100644 --- a/tests/apigateway/test_views.py +++ b/tests/apigateway/test_views.py @@ -4,11 +4,10 @@ from base64 import b64encode import pytest -from django.conf import settings +from django.urls import reverse from mitol.common.factories import UserFactory pytestmark = pytest.mark.django_db -INTERNAL_LOGOUT_URL_PATH = "/applogout" @pytest.fixture @@ -19,7 +18,7 @@ def user(): @pytest.fixture(autouse=True) -def _apigateway_reqs(): +def _apigateway_reqs(settings): """ Make sure our backend and middleware are in place. @@ -48,7 +47,7 @@ def _apigateway_reqs(): @pytest.mark.parametrize("has_apisix_header", [True, False]) @pytest.mark.parametrize("next_url", ["/search", None]) -def test_logout(next_url, client, user, has_apisix_header): +def test_logout(settings, next_url, client, user, has_apisix_header): """User should be properly redirected and logged out""" header_str = b64encode( json.dumps( @@ -61,7 +60,7 @@ def test_logout(next_url, client, user, has_apisix_header): ) client.force_login(user) response = client.get( - f"{INTERNAL_LOGOUT_URL_PATH}/?next={next_url or ''}", + f"{reverse('logout')}/?next={next_url or ''}", follow=False, HTTP_X_USERINFO=header_str if has_apisix_header else None, ) @@ -70,13 +69,11 @@ def test_logout(next_url, client, user, has_apisix_header): if next_url: assert response.cookies.get("next") assert response.cookies["next"].value == ( - next_url - if next_url - else settings.MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST + next_url if next_url else settings.MITOL_DEFAULT_POST_LOGOUT_URL ) else: assert response.url == ( - next_url if next_url else settings.MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST + next_url if next_url else settings.MITOL_DEFAULT_POST_LOGOUT_URL ) @@ -84,15 +81,14 @@ def test_logout(next_url, client, user, has_apisix_header): @pytest.mark.parametrize("has_next", [False]) @pytest.mark.parametrize("next_host_is_invalid", [True, False]) def test_next_logout( # noqa: PLR0913 - mocker, client, user, is_authenticated, has_next, next_host_is_invalid + settings, mocker, client, user, is_authenticated, has_next, next_host_is_invalid ): """Test logout redirect cache assignment""" next_url = "https://ocw.mit.edu" mock_request = mocker.MagicMock( GET={"next": next_url if has_next else None}, ) - original_allowed_hosts = settings.MITOL_APIGATEWAY_ALLOWED_REDIRECT_HOSTS - settings.MITOL_APIGATEWAY_ALLOWED_REDIRECT_HOSTS = [ + settings.MITOL_ALLOWED_REDIRECT_HOSTS = [ "testserver", "invalid.com" if next_host_is_invalid else "ocw.mit.edu", ] @@ -112,7 +108,7 @@ def test_next_logout( # noqa: PLR0913 } url_params = f"?next={next_url}" if has_next else "" resp = client.get( - f"{INTERNAL_LOGOUT_URL_PATH}/{url_params}", + f"{reverse('logout')}/{url_params}", request=mock_request, follow=False, HTTP_X_USERINFO=b64encode( @@ -131,10 +127,8 @@ def test_next_logout( # noqa: PLR0913 assert resp.url == settings.MITOL_APIGATEWAY_LOGOUT_URL elif next_host_is_invalid: # If host isn't in the allow list, this should always go to the default. - assert resp.url.endswith(settings.MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST) + assert resp.url.endswith(settings.MITOL_DEFAULT_POST_LOGOUT_URL) else: assert resp.url.endswith( - next_url if has_next else settings.MITOL_APIGATEWAY_DEFAULT_POST_LOGOUT_DEST + next_url if has_next else settings.MITOL_DEFAULT_POST_LOGOUT_URL ) - - settings.MITOL_APIGATEWAY_ALLOWED_REDIRECT_HOSTS = original_allowed_hosts diff --git a/uv.lock b/uv.lock index 3bf52202..86e545a3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13'", @@ -858,6 +858,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, ] +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + [[package]] name = "executing" version = "2.2.0" @@ -1928,6 +1937,7 @@ dev = [ { name = "pytest-lazy-fixtures" }, { name = "pytest-mock" }, { name = "pytest-responses" }, + { name = "pytest-xdist", extra = ["psutil"] }, { name = "responses" }, { name = "ruff" }, { name = "scriv", extra = ["toml"] }, @@ -1986,6 +1996,7 @@ dev = [ { name = "pytest-lazy-fixtures", specifier = ">=1" }, { name = "pytest-mock", specifier = "==3.15.1" }, { name = "pytest-responses", specifier = ">=0.5.1" }, + { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.8.0" }, { name = "responses", specifier = "==0.25.8" }, { name = "ruff" }, { name = "scriv", extras = ["toml"] }, @@ -2125,6 +2136,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/a1/93c2acf4ade3c5b557d02d500b06798f4ed2c176fa03e3c34973ca92df7f/protobuf-6.30.2-py3-none-any.whl", hash = "sha256:ae86b030e69a98e08c77beab574cbcb9fff6d031d57209f574a5aea1445f4b51", size = 167062, upload-time = "2025-03-26T19:12:55.892Z" }, ] +[[package]] +name = "psutil" +version = "7.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/ec/7b8e6b9b1d22708138630ef34c53ab2b61032c04f16adfdbb96791c8c70c/psutil-7.1.2.tar.gz", hash = "sha256:aa225cdde1335ff9684708ee8c72650f6598d5ed2114b9a7c5802030b1785018", size = 487424, upload-time = "2025-10-25T10:46:34.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/d9/b56cc9f883140ac10021a8c9b0f4e16eed1ba675c22513cdcbce3ba64014/psutil-7.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0cc5c6889b9871f231ed5455a9a02149e388fffcb30b607fb7a8896a6d95f22e", size = 238575, upload-time = "2025-10-25T10:46:38.728Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/28d22de383888deb252c818622196e709da98816e296ef95afda33f1c0a2/psutil-7.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8e9e77a977208d84aa363a4a12e0f72189d58bbf4e46b49aae29a2c6e93ef206", size = 239297, upload-time = "2025-10-25T10:46:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/220039e2f28cc129626e54d63892ab05c0d56a29818bfe7268dcb5008932/psutil-7.1.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d9623a5e4164d2220ecceb071f4b333b3c78866141e8887c072129185f41278", size = 280420, upload-time = "2025-10-25T10:46:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/286f0e1c167445b2ef4a6cbdfc8c59fdb45a5a493788950cf8467201dc73/psutil-7.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:364b1c10fe4ed59c89ec49e5f1a70da353b27986fa8233b4b999df4742a5ee2f", size = 283049, upload-time = "2025-10-25T10:46:47.095Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cc/7eb93260794a42e39b976f3a4dde89725800b9f573b014fac142002a5c98/psutil-7.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f101ef84de7e05d41310e3ccbdd65a6dd1d9eed85e8aaf0758405d022308e204", size = 248713, upload-time = "2025-10-25T10:46:49.573Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/0681a92b53366e01f0a099f5237d0c8a2f79d322ac589cccde5e30c8a4e2/psutil-7.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:20c00824048a95de67f00afedc7b08b282aa08638585b0206a9fb51f28f1a165", size = 244644, upload-time = "2025-10-25T10:46:51.924Z" }, + { url = "https://files.pythonhosted.org/packages/56/9e/f1c5c746b4ed5320952acd3002d3962fe36f30524c00ea79fdf954cc6779/psutil-7.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e09cfe92aa8e22b1ec5e2d394820cf86c5dff6367ac3242366485dfa874d43bc", size = 238640, upload-time = "2025-10-25T10:46:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/32/ee/fd26216a735395cc25c3899634e34aeb41fb1f3dbb44acc67d9e594be562/psutil-7.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa6342cf859c48b19df3e4aa170e4cfb64aadc50b11e06bb569c6c777b089c9e", size = 239303, upload-time = "2025-10-25T10:46:56.932Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cd/7d96eaec4ef7742b845a9ce2759a2769ecce4ab7a99133da24abacbc9e41/psutil-7.1.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:625977443498ee7d6c1e63e93bacca893fd759a66c5f635d05e05811d23fb5ee", size = 281717, upload-time = "2025-10-25T10:46:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1a/7f0b84bdb067d35fe7fade5fff888408688caf989806ce2d6dae08c72dd5/psutil-7.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a24bcd7b7f2918d934af0fb91859f621b873d6aa81267575e3655cd387572a7", size = 284575, upload-time = "2025-10-25T10:47:00.944Z" }, + { url = "https://files.pythonhosted.org/packages/de/05/7820ef8f7b275268917e0c750eada5834581206d9024ca88edce93c4b762/psutil-7.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:329f05610da6380982e6078b9d0881d9ab1e9a7eb7c02d833bfb7340aa634e31", size = 249491, upload-time = "2025-10-25T10:47:03.174Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/58de399c7cb58489f08498459ff096cd76b3f1ddc4f224ec2c5ef729c7d0/psutil-7.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7b04c29e3c0c888e83ed4762b70f31e65c42673ea956cefa8ced0e31e185f582", size = 244880, upload-time = "2025-10-25T10:47:05.228Z" }, + { url = "https://files.pythonhosted.org/packages/ae/89/b9f8d47ddbc52d7301fc868e8224e5f44ed3c7f55e6d0f54ecaf5dd9ff5e/psutil-7.1.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c9ba5c19f2d46203ee8c152c7b01df6eec87d883cfd8ee1af2ef2727f6b0f814", size = 237244, upload-time = "2025-10-25T10:47:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7a/8628c2f6b240680a67d73d8742bb9ff39b1820a693740e43096d5dcb01e5/psutil-7.1.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:2a486030d2fe81bec023f703d3d155f4823a10a47c36784c84f1cc7f8d39bedb", size = 238101, upload-time = "2025-10-25T10:47:09.523Z" }, + { url = "https://files.pythonhosted.org/packages/30/28/5e27f4d5a0e347f8e3cc16cd7d35533dbce086c95807f1f0e9cd77e26c10/psutil-7.1.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3efd8fc791492e7808a51cb2b94889db7578bfaea22df931424f874468e389e3", size = 258675, upload-time = "2025-10-25T10:47:11.082Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5c/79cf60c9acf36d087f0db0f82066fca4a780e97e5b3a2e4c38209c03d170/psutil-7.1.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2aeb9b64f481b8eabfc633bd39e0016d4d8bbcd590d984af764d80bf0851b8a", size = 260203, upload-time = "2025-10-25T10:47:13.226Z" }, + { url = "https://files.pythonhosted.org/packages/f7/03/0a464404c51685dcb9329fdd660b1721e076ccd7b3d97dee066bcc9ffb15/psutil-7.1.2-cp37-abi3-win_amd64.whl", hash = "sha256:8e17852114c4e7996fe9da4745c2bdef001ebbf2f260dec406290e66628bdb91", size = 246714, upload-time = "2025-10-25T10:47:15.093Z" }, + { url = "https://files.pythonhosted.org/packages/6a/32/97ca2090f2f1b45b01b6aa7ae161cfe50671de097311975ca6eea3e7aabc/psutil-7.1.2-cp37-abi3-win_arm64.whl", hash = "sha256:3e988455e61c240cc879cb62a008c2699231bf3e3d061d7fce4234463fd2abb4", size = 243742, upload-time = "2025-10-25T10:47:17.302Z" }, +] + [[package]] name = "psycopg2-binary" version = "2.9.10" @@ -2437,6 +2474,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/0a/81b8cc3cf4b6605d97ed37217af9e2f82c97ebe130f60cf85fe82edfe0e1/pytest_responses-0.5.1-py2.py3-none-any.whl", hash = "sha256:4172e565b94ac1ea3b10aba6e40855ad60cd7f141476b2d8a47e4b5f250be734", size = 6693, upload-time = "2022-10-11T17:15:40.889Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[package.optional-dependencies] +psutil = [ + { name = "psutil" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"