diff --git a/.flake8 b/.flake8
deleted file mode 100644
index ec13119..0000000
--- a/.flake8
+++ /dev/null
@@ -1,13 +0,0 @@
-[flake8]
- max-line-length = 100
- per-file-ignores =
- django_saml2_auth/tests/test_saml.py: E501, F821
- django_saml2_auth/saml.py: E231
- exclude =
- django_saml2_auth.egg-info,
- dist,
- build,
- env,
- venv,
- .env,
- .venv,
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..3f97bd4
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,2 @@
+# Format code with ruff
+169ea6d286d639d1670acbe6c07609b0dffa7f62
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index c477373..24810c5 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -15,35 +15,38 @@ jobs:
strategy:
matrix:
versions:
- - { "djangoVersion": "3.2.23", "pythonVersion": "3.9" }
- - { "djangoVersion": "3.2.23", "pythonVersion": "3.10" }
- - { "djangoVersion": "4.2.7", "pythonVersion": "3.9" }
- - { "djangoVersion": "4.2.7", "pythonVersion": "3.10" }
- - { "djangoVersion": "4.2.7", "pythonVersion": "3.11" }
- - { "djangoVersion": "4.2.7", "pythonVersion": "3.12" }
+ - { "djangoVersion": "3.2.25", "pythonVersion": "3.10" }
+ - { "djangoVersion": "4.2.11", "pythonVersion": "3.10" }
+ - { "djangoVersion": "4.2.11", "pythonVersion": "3.11" }
+ - { "djangoVersion": "4.2.11", "pythonVersion": "3.12" }
+ - { "djangoVersion": "5.0.4", "pythonVersion": "3.10" }
+ - { "djangoVersion": "5.0.4", "pythonVersion": "3.11" }
+ - { "djangoVersion": "5.0.4", "pythonVersion": "3.12" }
steps:
- name: Checkout ๐๏ธ
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
- name: Set up Python ๐
- uses: actions/setup-python@v3
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.versions.pythonVersion }}
- name: Install xmlsec1 ๐ฆ
run: sudo apt-get install xmlsec1
- name: Install dependencies ๐ฆ
- run: python -m pip install -r requirements_test.txt && python -m pip install -e .
+ run: |
+ python -m pip install poetry
+ poetry install --with dev
- name: Install Django ${{ matrix.versions.djangoVersion }} ๐ฆ
- run: python -m pip install Django==${{ matrix.versions.djangoVersion }}
+ run: pip install Django==${{ matrix.versions.djangoVersion }}
- name: Check types, syntax and duckstrings ๐ฆ
run: |
- mypy .
- flake8 .
- interrogate --quiet --fail-under=95 .
+ poetry run mypy --explicit-package-bases .
+ poetry run ruff check .
+ poetry run interrogate --ignore-init-module --quiet --fail-under=95 .
- name: Test Django ${{ matrix.versions.djangoVersion }} with coverage ๐งช
- run: coverage run --source=django_saml2_auth -m pytest . && coverage lcov -o coverage.lcov
+ run: poetry run coverage run --source=django_saml2_auth -m pytest . && poetry run coverage lcov -o coverage.lcov
- name: Submit coverage report to Coveralls ๐
if: ${{ success() }}
- uses: coverallsapp/github-action@1.1.3
+ uses: coverallsapp/github-action@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
path-to-lcov: ./coverage.lcov
@@ -54,17 +57,17 @@ jobs:
- name: Generate CycloneDX SBOM artifacts ๐
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
run: |
- cyclonedx-bom -r --format json -i requirements.txt -o cyclonedx-django-saml2-auth-${{ github.ref_name }}.json
- cyclonedx-bom -r --format json -i requirements_test.txt -o cyclonedx-django-saml2-auth-test-${{ github.ref_name }}.json
+ poetry run cyclonedx-bom -r --format json -i requirements.txt -o cyclonedx-django-saml2-auth-${{ github.ref_name }}.json
+ poetry run cyclonedx-bom -r --format json -i requirements_test.txt -o cyclonedx-django-saml2-auth-test-${{ github.ref_name }}.json
- name: Upload CycloneDX SBOM artifact for requirements.txt ๐พ
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: cyclonedx-django-saml2-auth-${{ github.ref_name }}.json
path: cyclonedx-django-saml2-auth-${{ github.ref_name }}.json
- name: Upload CycloneDX SBOM artifact for requirements_test.txt ๐พ
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
- uses: actions/upload-artifact@v3
+ uses: actions/upload-artifact@v4
with:
name: cyclonedx-django-saml2-auth-test-${{ github.ref_name }}.json
path: cyclonedx-django-saml2-auth-test-${{ github.ref_name }}.json
diff --git a/README.md b/README.md
index 4eacf42..c480334 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,11 @@ For IdP-initiated SSO, the user will be created if it doesn't exist. Still, for
- Original Author: Fang Li ([@fangli](https://github.com/fangli))
- Maintainer: Mostafa Moradian ([@mostafa](https://github.com/mostafa))
- Version support matrix:
- | **Python** | **Django** | **django-saml2-auth** | **End of Support
(django-saml2-auth)** | **End of extended support
(Django)** |
- | ----------------------------- | ---------- | --------------------- | ------------------------------------------ | ---------------------------------------- |
- | 3.9.x, 3.10.x | 3.2.x | >=3.4.0 | | April 2024 |
- | 3.9.x, 3.10.x, 3.11.x, 3.12.x | 4.2.x | >=3.4.0 | | April 2026 |
+ | **Python** | **Django** | **django-saml2-auth** | **End of extended support
(Django)** |
+ | ---------------------- | ---------- | --------------------- | ---------------------------------------- |
+ | 3.10.x | 3.2.x | >=3.4.0 | April 2024 |
+ | 3.10.x, 3.11.x, 3.12.x | 4.2.x | >=3.4.0 | April 2026 |
+ | 3.10.x, 3.11.x, 3.12.x | 5.0.x | >3.12.0 | April 2026 |
- Release logs are available [here](https://github.com/grafana/django-saml2-auth/releases).
diff --git a/django_saml2_auth/exceptions.py b/django_saml2_auth/exceptions.py
index c7b5e26..ee17b21 100644
--- a/django_saml2_auth/exceptions.py
+++ b/django_saml2_auth/exceptions.py
@@ -1,6 +1,5 @@
"""Custom exception class for handling extra arguments."""
-
from typing import Any, Dict, Optional
diff --git a/django_saml2_auth/saml.py b/django_saml2_auth/saml.py
index a40c0d0..ea4c4d0 100644
--- a/django_saml2_auth/saml.py
+++ b/django_saml2_auth/saml.py
@@ -1,5 +1,5 @@
-"""Utility functions for various SAML client functions.
-"""
+"""Utility functions for various SAML client functions."""
+
import base64
from typing import Any, Callable, Dict, Mapping, Optional, Union
@@ -7,18 +7,20 @@
from django.conf import settings
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.urls import NoReverseMatch
-from django_saml2_auth.errors import (ERROR_CREATING_SAML_CONFIG_OR_CLIENT,
- INVALID_METADATA_URL,
- NO_ISSUER_IN_SAML_RESPONSE,
- NO_METADATA_URL_ASSOCIATED,
- NO_METADATA_URL_OR_FILE,
- NO_NAME_ID_IN_SAML_RESPONSE,
- NO_SAML_CLIENT,
- NO_SAML_RESPONSE_FROM_CLIENT,
- NO_SAML_RESPONSE_FROM_IDP,
- NO_TOKEN_SPECIFIED,
- NO_USER_IDENTITY_IN_SAML_RESPONSE,
- NO_USERNAME_OR_EMAIL_SPECIFIED)
+from django_saml2_auth.errors import (
+ ERROR_CREATING_SAML_CONFIG_OR_CLIENT,
+ INVALID_METADATA_URL,
+ NO_ISSUER_IN_SAML_RESPONSE,
+ NO_METADATA_URL_ASSOCIATED,
+ NO_METADATA_URL_OR_FILE,
+ NO_NAME_ID_IN_SAML_RESPONSE,
+ NO_SAML_CLIENT,
+ NO_SAML_RESPONSE_FROM_CLIENT,
+ NO_SAML_RESPONSE_FROM_IDP,
+ NO_TOKEN_SPECIFIED,
+ NO_USER_IDENTITY_IN_SAML_RESPONSE,
+ NO_USERNAME_OR_EMAIL_SPECIFIED,
+)
from django_saml2_auth.exceptions import SAMLAuthError
from django_saml2_auth.utils import get_reverse, run_hook
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT, entity
@@ -109,16 +111,19 @@ def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]:
if metadata_urls:
# Filter invalid metadata URLs
filtered_metadata_urls = list(
- filter(lambda md: validate_metadata_url(md["url"]), metadata_urls))
+ filter(lambda md: validate_metadata_url(md["url"]), metadata_urls)
+ )
return {"remote": filtered_metadata_urls}
else:
- raise SAMLAuthError("No metadata URL associated with the given user identifier.",
- extra={
- "exc_type": ValueError,
- "error_code": NO_METADATA_URL_ASSOCIATED,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "No metadata URL associated with the given user identifier.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_METADATA_URL_ASSOCIATED,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
metadata_local_file_path = dictor(saml2_auth_settings, "METADATA_LOCAL_FILE_PATH")
if metadata_local_file_path:
@@ -128,18 +133,23 @@ def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]:
if validate_metadata_url(single_metadata_url):
return {"remote": [{"url": single_metadata_url}]}
else:
- raise SAMLAuthError("Invalid metadata URL.", extra={
- "exc_type": ValueError,
- "error_code": INVALID_METADATA_URL,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Invalid metadata URL.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": INVALID_METADATA_URL,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
-def get_saml_client(domain: str,
- acs: Callable[..., HttpResponse],
- user_id: Optional[str] = None,
- saml_response: Optional[str] = None) -> Optional[Saml2Client]:
+def get_saml_client(
+ domain: str,
+ acs: Callable[..., HttpResponse],
+ user_id: Optional[str] = None,
+ saml_response: Optional[str] = None,
+) -> Optional[Saml2Client]:
"""Create a new Saml2Config object with the given config and return an initialized Saml2Client
using the config object. The settings are read from django settings key: SAML2_AUTH.
@@ -161,22 +171,26 @@ def get_saml_client(domain: str,
# get_reverse raises an exception if the view is not found, so we can safely ignore type errors
acs_url = domain + get_reverse([acs, "acs", "django_saml2_auth:acs"]) # type: ignore
- get_user_id_from_saml_response = dictor(settings.SAML2_AUTH,
- "TRIGGER.GET_USER_ID_FROM_SAML_RESPONSE")
+ get_user_id_from_saml_response = dictor(
+ settings.SAML2_AUTH, "TRIGGER.GET_USER_ID_FROM_SAML_RESPONSE"
+ )
if get_user_id_from_saml_response and saml_response:
user_id = run_hook(get_user_id_from_saml_response, saml_response, user_id) # type: ignore
metadata = get_metadata(user_id)
- if (metadata and (
- ("local" in metadata and not metadata["local"]) or
- ("remote" in metadata and not metadata["remote"])
- )):
- raise SAMLAuthError("Metadata URL/file is missing.", extra={
- "exc_type": NoReverseMatch,
- "error_code": NO_METADATA_URL_OR_FILE,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ if metadata and (
+ ("local" in metadata and not metadata["local"])
+ or ("remote" in metadata and not metadata["remote"])
+ ):
+ raise SAMLAuthError(
+ "Metadata URL/file is missing.",
+ extra={
+ "exc_type": NoReverseMatch,
+ "error_code": NO_METADATA_URL_OR_FILE,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
saml2_auth_settings = settings.SAML2_AUTH
@@ -189,18 +203,22 @@ def get_saml_client(domain: str,
"endpoints": {
"assertion_consumer_service": [
(acs_url, BINDING_HTTP_REDIRECT),
- (acs_url, BINDING_HTTP_POST)
+ (acs_url, BINDING_HTTP_POST),
],
},
"allow_unsolicited": True,
"authn_requests_signed": dictor(
- saml2_auth_settings, "AUTHN_REQUESTS_SIGNED", default=True),
+ saml2_auth_settings, "AUTHN_REQUESTS_SIGNED", default=True
+ ),
"logout_requests_signed": dictor(
- saml2_auth_settings, "LOGOUT_REQUESTS_SIGNED", default=True),
+ saml2_auth_settings, "LOGOUT_REQUESTS_SIGNED", default=True
+ ),
"want_assertions_signed": dictor(
- saml2_auth_settings, "WANT_ASSERTIONS_SIGNED", default=True),
+ saml2_auth_settings, "WANT_ASSERTIONS_SIGNED", default=True
+ ),
"want_response_signed": dictor(
- saml2_auth_settings, "WANT_RESPONSE_SIGNED", default=True),
+ saml2_auth_settings, "WANT_RESPONSE_SIGNED", default=True
+ ),
},
},
}
@@ -215,7 +233,7 @@ def get_saml_client(domain: str,
accepted_time_diff = saml2_auth_settings.get("ACCEPTED_TIME_DIFF")
if accepted_time_diff:
- saml_settings['accepted_time_diff'] = accepted_time_diff
+ saml_settings["accepted_time_diff"] = accepted_time_diff
# Enable logging with a custom logger. See below for more details:
# https://pysaml2.readthedocs.io/en/latest/howto/config.html?highlight=debug#logging
@@ -225,11 +243,11 @@ def get_saml_client(domain: str,
key_file = saml2_auth_settings.get("KEY_FILE")
if key_file:
- saml_settings['key_file'] = key_file
+ saml_settings["key_file"] = key_file
cert_file = saml2_auth_settings.get("CERT_FILE")
if cert_file:
- saml_settings['cert_file'] = cert_file
+ saml_settings["cert_file"] = cert_file
encryption_keypairs = saml2_auth_settings.get("ENCRYPTION_KEYPAIRS")
if encryption_keypairs:
@@ -248,19 +266,21 @@ def get_saml_client(domain: str,
saml_client = Saml2Client(config=sp_config)
return saml_client
except Exception as exc:
- raise SAMLAuthError(str(exc), extra={
- "exc": exc,
- "exc_type": type(exc),
- "error_code": ERROR_CREATING_SAML_CONFIG_OR_CLIENT,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ str(exc),
+ extra={
+ "exc": exc,
+ "exc_type": type(exc),
+ "error_code": ERROR_CREATING_SAML_CONFIG_OR_CLIENT,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
def decode_saml_response(
- request: HttpRequest,
- acs: Callable[..., HttpResponse]) -> Union[
- HttpResponseRedirect, Optional[AuthnResponse], None]:
+ request: HttpRequest, acs: Callable[..., HttpResponse]
+) -> Union[HttpResponseRedirect, Optional[AuthnResponse], None]:
"""Given a request, the authentication response inside the SAML response body is parsed,
decoded and returned. If there are any issues parsing the request, the identity or the issuer,
an exception is raised.
@@ -282,58 +302,76 @@ def decode_saml_response(
"""
response = request.POST.get("SAMLResponse") or None
if not response:
- raise SAMLAuthError("There was no response from SAML client.", extra={
- "exc_type": ValueError,
- "error_code": NO_SAML_RESPONSE_FROM_CLIENT,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "There was no response from SAML client.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_SAML_RESPONSE_FROM_CLIENT,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
try:
- saml_response = base64.b64decode(response).decode('UTF-8')
+ saml_response = base64.b64decode(response).decode("UTF-8")
except Exception:
saml_response = None
saml_client = get_saml_client(get_assertion_url(request), acs, saml_response=saml_response)
if not saml_client:
- raise SAMLAuthError("There was an error creating the SAML client.", extra={
- "exc_type": ValueError,
- "error_code": NO_SAML_CLIENT,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "There was an error creating the SAML client.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_SAML_CLIENT,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
authn_response = saml_client.parse_authn_request_response(response, entity.BINDING_HTTP_POST)
if not authn_response:
- raise SAMLAuthError("There was no response from SAML identity provider.", extra={
- "exc_type": ValueError,
- "error_code": NO_SAML_RESPONSE_FROM_IDP,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "There was no response from SAML identity provider.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_SAML_RESPONSE_FROM_IDP,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
if not authn_response.name_id:
- raise SAMLAuthError("No name_id in SAML response.", extra={
- "exc_type": ValueError,
- "error_code": NO_NAME_ID_IN_SAML_RESPONSE,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "No name_id in SAML response.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_NAME_ID_IN_SAML_RESPONSE,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
if not authn_response.issuer():
- raise SAMLAuthError("No issuer/entity_id in SAML response.", extra={
- "exc_type": ValueError,
- "error_code": NO_ISSUER_IN_SAML_RESPONSE,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "No issuer/entity_id in SAML response.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_ISSUER_IN_SAML_RESPONSE,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
if not authn_response.get_identity():
- raise SAMLAuthError("No user identity in SAML response.", extra={
- "exc_type": ValueError,
- "error_code": NO_USER_IDENTITY_IN_SAML_RESPONSE,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "No user identity in SAML response.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_USER_IDENTITY_IN_SAML_RESPONSE,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
return authn_response
@@ -354,14 +392,14 @@ def extract_user_identity(user_identity: Dict[str, Any]) -> Dict[str, Optional[A
"""
saml2_auth_settings = settings.SAML2_AUTH
- email_field = dictor(
- saml2_auth_settings, "ATTRIBUTES_MAP.email", default="user.email")
- username_field = dictor(
- saml2_auth_settings, "ATTRIBUTES_MAP.username", default="user.username")
+ email_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.email", default="user.email")
+ username_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.username", default="user.username")
firstname_field = dictor(
- saml2_auth_settings, "ATTRIBUTES_MAP.first_name", default="user.first_name")
+ saml2_auth_settings, "ATTRIBUTES_MAP.first_name", default="user.first_name"
+ )
lastname_field = dictor(
- saml2_auth_settings, "ATTRIBUTES_MAP.last_name", default="user.last_name")
+ saml2_auth_settings, "ATTRIBUTES_MAP.last_name", default="user.last_name"
+ )
user = {}
user["email"] = dictor(user_identity, f"{email_field}/0", pathsep="/") # Path includes "."
@@ -383,19 +421,25 @@ def extract_user_identity(user_identity: Dict[str, Any]) -> Dict[str, Optional[A
user["user_identity"] = user_identity
if not user["email"] and not user["username"]:
- raise SAMLAuthError("No username or email provided.", extra={
- "exc_type": ValueError,
- "error_code": NO_USERNAME_OR_EMAIL_SPECIFIED,
- "reason": "Username or email must be configured on the SAML app before logging in.",
- "status_code": 422
- })
+ raise SAMLAuthError(
+ "No username or email provided.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_USERNAME_OR_EMAIL_SPECIFIED,
+ "reason": "Username or email must be configured on the SAML app before logging in.",
+ "status_code": 422,
+ },
+ )
if token_required and not user.get("token"):
- raise SAMLAuthError("No token specified.", extra={
- "exc_type": ValueError,
- "error_code": NO_TOKEN_SPECIFIED,
- "reason": "Token must be configured on the SAML app before logging in.",
- "status_code": 422
- })
+ raise SAMLAuthError(
+ "No token specified.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": NO_TOKEN_SPECIFIED,
+ "reason": "Token must be configured on the SAML app before logging in.",
+ "status_code": 422,
+ },
+ )
return user
diff --git a/django_saml2_auth/tests/settings.py b/django_saml2_auth/tests/settings.py
index f6e2d06..79b6ef7 100644
--- a/django_saml2_auth/tests/settings.py
+++ b/django_saml2_auth/tests/settings.py
@@ -79,19 +79,18 @@
"USER_GROUPS": [],
"ACTIVE_STATUS": True,
"STAFF_STATUS": False,
- "SUPERUSER_STATUS": False
+ "SUPERUSER_STATUS": False,
},
"ATTRIBUTES_MAP": {
"email": "user.email",
"username": "user.username",
"first_name": "user.first_name",
"last_name": "user.last_name",
- "token": "token"
+ "token": "token",
},
"TRIGGER": {
"BEFORE_LOGIN": "django_saml2_auth.tests.test_user.saml_user_setup",
- "GET_METADATA_AUTO_CONF_URLS":
- "django_saml2_auth.tests.test_saml.get_metadata_auto_conf_urls"
+ "GET_METADATA_AUTO_CONF_URLS": "django_saml2_auth.tests.test_saml.get_metadata_auto_conf_urls",
},
"ASSERTION_URL": "https://api.example.com",
"ENTITY_ID": "https://api.example.com/sso/acs/",
@@ -104,8 +103,10 @@
"LOGIN_CASE_SENSITIVE": False,
"WANT_ASSERTIONS_SIGNED": True,
"WANT_RESPONSE_SIGNED": True,
- "ALLOWED_REDIRECT_HOSTS": ["https://app.example.com",
- "https://api.example.com",
- "https://example.com"],
- "TOKEN_REQUIRED": True
+ "ALLOWED_REDIRECT_HOSTS": [
+ "https://app.example.com",
+ "https://api.example.com",
+ "https://example.com",
+ ],
+ "TOKEN_REQUIRED": True,
}
diff --git a/django_saml2_auth/tests/test_saml.py b/django_saml2_auth/tests/test_saml.py
index 3411346..f1145f9 100644
--- a/django_saml2_auth/tests/test_saml.py
+++ b/django_saml2_auth/tests/test_saml.py
@@ -12,10 +12,15 @@
from django.test.client import RequestFactory
from django.urls import NoReverseMatch
from django_saml2_auth.exceptions import SAMLAuthError
-from django_saml2_auth.saml import (decode_saml_response,
- extract_user_identity, get_assertion_url,
- get_default_next_url, get_metadata,
- get_saml_client, validate_metadata_url)
+from django_saml2_auth.saml import (
+ decode_saml_response,
+ extract_user_identity,
+ get_assertion_url,
+ get_default_next_url,
+ get_metadata,
+ get_saml_client,
+ validate_metadata_url,
+)
from django_saml2_auth.views import acs
from pytest_django.fixtures import SettingsWrapper
from saml2.client import Saml2Client
@@ -23,7 +28,9 @@
from django_saml2_auth import user
-GET_METADATA_AUTO_CONF_URLS = "django_saml2_auth.tests.test_saml.get_metadata_auto_conf_urls"
+GET_METADATA_AUTO_CONF_URLS = (
+ "django_saml2_auth.tests.test_saml.get_metadata_auto_conf_urls"
+)
METADATA_URL1 = "https://testserver1.com/saml/sso/metadata"
METADATA_URL2 = "https://testserver2.com/saml/sso/metadata"
# Ref: https://en.wikipedia.org/wiki/SAML_metadata#Entity_metadata
@@ -85,7 +92,9 @@
"""
-def get_metadata_auto_conf_urls(user_id: Optional[str] = None) -> List[Optional[Mapping[str, str]]]:
+def get_metadata_auto_conf_urls(
+ user_id: Optional[str] = None,
+) -> List[Optional[Mapping[str, str]]]:
"""Fixture for returning metadata autoconf URL(s) based on the user_id.
Args:
@@ -112,20 +121,22 @@ def get_user_identity() -> Mapping[str, List[str]]:
"user.email": ["test@example.com"],
"user.first_name": ["John"],
"user.last_name": ["Doe"],
- "token": ["TOKEN"]
+ "token": ["TOKEN"],
}
def mock_parse_authn_request_response(
- self: Saml2Client, response: AuthnResponse, binding: str
-) -> "MockAuthnResponse": # type: ignore
+ self: Saml2Client, response: AuthnResponse, binding: str
+) -> "MockAuthnResponse": # type: ignore # noqa: F821
"""Mock function to return an mocked instance of AuthnResponse.
Returns:
MockAuthnResponse: A mocked instance of AuthnResponse
"""
+
class MockAuthnRequest:
"""Mock class for AuthnRequest."""
+
name_id = "Username"
@staticmethod
@@ -142,8 +153,7 @@ def get_identity():
def test_get_assertion_url_success():
- """Test get_assertion_url function to verify if it correctly returns the default assertion URL.
- """
+ """Test get_assertion_url function to verify if it correctly returns the default assertion URL."""
assertion_url = get_assertion_url(HttpRequest())
assert assertion_url == "https://api.example.com"
@@ -241,7 +251,9 @@ def test_get_metadata_success_with_multiple_metadata_urls(settings: SettingsWrap
Args:
settings (SettingsWrapper): Fixture for django settings
"""
- settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS
+ settings.SAML2_AUTH["TRIGGER"][
+ "GET_METADATA_AUTO_CONF_URLS"
+ ] = GET_METADATA_AUTO_CONF_URLS
responses.add(responses.GET, METADATA_URL1, body=METADATA1)
responses.add(responses.GET, METADATA_URL2, body=METADATA2)
@@ -256,7 +268,9 @@ def test_get_metadata_success_with_user_id(settings: SettingsWrapper):
Args:
settings (SettingsWrapper): Fixture for django settings
"""
- settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS
+ settings.SAML2_AUTH["TRIGGER"][
+ "GET_METADATA_AUTO_CONF_URLS"
+ ] = GET_METADATA_AUTO_CONF_URLS
responses.add(responses.GET, METADATA_URL1, body=METADATA1)
result = get_metadata("test@example.com")
@@ -269,11 +283,16 @@ def test_get_metadata_failure_with_nonexistent_user_id(settings: SettingsWrapper
Args:
settings (SettingsWrapper): Fixture for django settings
"""
- settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS
+ settings.SAML2_AUTH["TRIGGER"][
+ "GET_METADATA_AUTO_CONF_URLS"
+ ] = GET_METADATA_AUTO_CONF_URLS
with pytest.raises(SAMLAuthError) as exc_info:
get_metadata("nonexistent_user@example.com")
- assert str(exc_info.value) == "No metadata URL associated with the given user identifier."
+ assert (
+ str(exc_info.value)
+ == "No metadata URL associated with the given user identifier."
+ )
def test_get_metadata_success_with_local_file(settings: SettingsWrapper):
@@ -296,7 +315,9 @@ def test_get_saml_client_success(settings: SettingsWrapper):
Args:
settings (SettingsWrapper): Fixture for django settings
"""
- settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "django_saml2_auth/tests/metadata.xml"
+ settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = (
+ "django_saml2_auth/tests/metadata.xml"
+ )
result = get_saml_client("example.com", acs)
assert isinstance(result, Saml2Client)
@@ -309,7 +330,9 @@ def test_get_saml_client_success_with_user_id(settings: SettingsWrapper):
Args:
settings (SettingsWrapper): Fixture for django settings
"""
- settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS
+ settings.SAML2_AUTH["TRIGGER"][
+ "GET_METADATA_AUTO_CONF_URLS"
+ ] = GET_METADATA_AUTO_CONF_URLS
responses.add(responses.GET, METADATA_URL1, body=METADATA1)
result = get_saml_client("example.com", acs, "test@example.com")
@@ -323,7 +346,9 @@ def test_get_saml_client_failure_with_missing_metadata_url(settings: SettingsWra
Args:
settings (SettingsWrapper): Fixture for django settings
"""
- settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS
+ settings.SAML2_AUTH["TRIGGER"][
+ "GET_METADATA_AUTO_CONF_URLS"
+ ] = GET_METADATA_AUTO_CONF_URLS
with pytest.raises(SAMLAuthError) as exc_info:
get_saml_client("example.com", acs, "test@example.com")
@@ -344,7 +369,10 @@ def test_get_saml_client_failure_with_invalid_file(settings: SettingsWrapper):
with pytest.raises(SAMLAuthError) as exc_info:
get_saml_client("example.com", acs)
- assert str(exc_info.value) == "[Errno 2] No such file or directory: '/invalid/metadata.xml'"
+ assert (
+ str(exc_info.value)
+ == "[Errno 2] No such file or directory: '/invalid/metadata.xml'"
+ )
assert exc_info.value.extra is not None
assert isinstance(exc_info.value.extra["exc"], FileNotFoundError)
@@ -390,7 +418,9 @@ def test_get_saml_client_success_with_key_and_cert_files(
settings (SettingsWrapper): Fixture for django settings
"""
- settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = "django_saml2_auth/tests/metadata.xml"
+ settings.SAML2_AUTH["METADATA_LOCAL_FILE_PATH"] = (
+ "django_saml2_auth/tests/metadata.xml"
+ )
for key, value in supplied_config_values.items():
settings.SAML2_AUTH[key] = value
@@ -406,7 +436,9 @@ def test_get_saml_client_success_with_key_and_cert_files(
@responses.activate
def test_decode_saml_response_success(
- settings: SettingsWrapper, monkeypatch: "MonkeyPatch"): # type: ignore
+ settings: SettingsWrapper,
+ monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821
+):
"""Test decode_saml_response function to verify if it correctly decodes the SAML response.
Args:
@@ -415,12 +447,16 @@ def test_decode_saml_response_success(
"""
responses.add(responses.GET, METADATA_URL1, body=METADATA1)
settings.SAML2_AUTH["ASSERTION_URL"] = "https://api.example.com"
- settings.SAML2_AUTH["TRIGGER"]["GET_METADATA_AUTO_CONF_URLS"] = GET_METADATA_AUTO_CONF_URLS
-
- post_request = RequestFactory().post(METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"})
- monkeypatch.setattr(Saml2Client,
- "parse_authn_request_response",
- mock_parse_authn_request_response)
+ settings.SAML2_AUTH["TRIGGER"][
+ "GET_METADATA_AUTO_CONF_URLS"
+ ] = GET_METADATA_AUTO_CONF_URLS
+
+ post_request = RequestFactory().post(
+ METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}
+ )
+ monkeypatch.setattr(
+ Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response
+ )
result = decode_saml_response(post_request, acs)
assert len(result.get_identity()) > 0 # type: ignore
@@ -449,9 +485,11 @@ def test_extract_user_identity_token_not_required(settings: SettingsWrapper):
@pytest.mark.django_db
@responses.activate
-def test_acs_view_when_next_url_is_none(settings: SettingsWrapper, monkeypatch: "MonkeyPatch"): # type: ignore
- """Test Acs view when login_next_url is None in the session
- """
+def test_acs_view_when_next_url_is_none(
+ settings: SettingsWrapper,
+ monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821
+):
+ """Test Acs view when login_next_url is None in the session"""
responses.add(responses.GET, METADATA_URL1, body=METADATA1)
settings.SAML2_AUTH = {
"ASSERTION_URL": "https://api.example.com",
@@ -460,25 +498,29 @@ def test_acs_view_when_next_url_is_none(settings: SettingsWrapper, monkeypatch:
"TRIGGER": {
"BEFORE_LOGIN": None,
"AFTER_LOGIN": None,
- "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS
- }
+ "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS,
+ },
}
- post_request = RequestFactory().post(METADATA_URL1,
- {"SAMLResponse": "SAML RESPONSE"})
+ post_request = RequestFactory().post(
+ METADATA_URL1, {"SAMLResponse": "SAML RESPONSE"}
+ )
- monkeypatch.setattr(Saml2Client,
- "parse_authn_request_response",
- mock_parse_authn_request_response)
+ monkeypatch.setattr(
+ Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response
+ )
- created, mock_user = user.get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe"
- })
+ created, mock_user = user.get_or_create_user(
+ {"username": "test@example.com", "first_name": "John", "last_name": "Doe"}
+ )
- monkeypatch.setattr(user,
- "get_or_create_user",
- (created, mock_user,))
+ monkeypatch.setattr(
+ user,
+ "get_or_create_user",
+ (
+ created,
+ mock_user,
+ ),
+ )
middleware = SessionMiddleware(MagicMock())
middleware.process_request(post_request)
@@ -486,14 +528,16 @@ def test_acs_view_when_next_url_is_none(settings: SettingsWrapper, monkeypatch:
post_request.session.save()
result = acs(post_request)
- assert result['Location'] == "default_next_url"
+ assert result["Location"] == "default_next_url"
@pytest.mark.django_db
@responses.activate
-def test_acs_view_when_redirection_state_is_passed_in_relay_state(settings: SettingsWrapper, monkeypatch: "MonkeyPatch"): # type: ignore
- """Test Acs view when login_next_url is None and redirection state in POST request
- """
+def test_acs_view_when_redirection_state_is_passed_in_relay_state(
+ settings: SettingsWrapper,
+ monkeypatch: "MonkeyPatch", # type: ignore # noqa: F821
+):
+ """Test Acs view when login_next_url is None and redirection state in POST request"""
responses.add(responses.GET, METADATA_URL1, body=METADATA1)
settings.SAML2_AUTH = {
"ASSERTION_URL": "https://api.example.com",
@@ -502,26 +546,29 @@ def test_acs_view_when_redirection_state_is_passed_in_relay_state(settings: Sett
"TRIGGER": {
"BEFORE_LOGIN": None,
"AFTER_LOGIN": None,
- "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS
- }
+ "GET_METADATA_AUTO_CONF_URLS": GET_METADATA_AUTO_CONF_URLS,
+ },
}
- post_request = RequestFactory().post(METADATA_URL1,
- {"SAMLResponse": "SAML RESPONSE",
- "RelayState": '/admin/logs'})
+ post_request = RequestFactory().post(
+ METADATA_URL1, {"SAMLResponse": "SAML RESPONSE", "RelayState": "/admin/logs"}
+ )
- monkeypatch.setattr(Saml2Client,
- "parse_authn_request_response",
- mock_parse_authn_request_response)
+ monkeypatch.setattr(
+ Saml2Client, "parse_authn_request_response", mock_parse_authn_request_response
+ )
- created, mock_user = user.get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe"
- })
+ created, mock_user = user.get_or_create_user(
+ {"username": "test@example.com", "first_name": "John", "last_name": "Doe"}
+ )
- monkeypatch.setattr(user,
- "get_or_create_user",
- (created, mock_user,))
+ monkeypatch.setattr(
+ user,
+ "get_or_create_user",
+ (
+ created,
+ mock_user,
+ ),
+ )
middleware = SessionMiddleware(MagicMock())
middleware.process_request(post_request)
@@ -529,4 +576,4 @@ def test_acs_view_when_redirection_state_is_passed_in_relay_state(settings: Sett
post_request.session.save()
result = acs(post_request)
- assert result['Location'] == "/admin/logs"
+ assert result["Location"] == "/admin/logs"
diff --git a/django_saml2_auth/tests/test_user.py b/django_saml2_auth/tests/test_user.py
index 475904a..114e0ce 100644
--- a/django_saml2_auth/tests/test_user.py
+++ b/django_saml2_auth/tests/test_user.py
@@ -8,9 +8,14 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, User
from django_saml2_auth.exceptions import SAMLAuthError
-from django_saml2_auth.user import (create_custom_or_default_jwt, create_new_user,
- decode_custom_or_default_jwt, get_or_create_user,
- get_user, get_user_id)
+from django_saml2_auth.user import (
+ create_custom_or_default_jwt,
+ create_new_user,
+ decode_custom_or_default_jwt,
+ get_or_create_user,
+ get_user,
+ get_user_id,
+)
from jwt.exceptions import PyJWTError
from pytest_django.fixtures import SettingsWrapper
@@ -105,7 +110,7 @@ def trigger_get_user(user: Dict) -> User:
user (Union[str, Dict[str, str]]): User information
"""
user_model = get_user_model()
- return user_model.objects.get(email=user['username'])
+ return user_model.objects.get(email=user["username"])
@pytest.mark.django_db
@@ -148,10 +153,7 @@ def test_create_new_user_with_dict_success(settings: SettingsWrapper):
# Create a group for the users to join
Group.objects.create(name="users")
- params = {
- "first_name": "test_John",
- "last_name": "test_Doe"
- }
+ params = {"first_name": "test_John", "last_name": "test_Doe"}
user = create_new_user("user_test@example.com", **params)
# It can also be email depending on USERNAME_FIELD setting
assert user.username == "user_test@example.com"
@@ -229,23 +231,23 @@ def test_get_or_create_user_success(settings: SettingsWrapper):
"ATTRIBUTES_MAP": {
"groups": "groups",
},
- "GROUPS_MAP": {
- "consumers": "users"
- }
+ "GROUPS_MAP": {"consumers": "users"},
}
Group.objects.create(name="users")
- created, user = get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe",
- "user_identity": {
- "user.username": "test@example.com",
- "user.first_name": "John",
- "user.last_name": "Doe",
- "groups": ["consumers"]
+ created, user = get_or_create_user(
+ {
+ "username": "test@example.com",
+ "first_name": "John",
+ "last_name": "Doe",
+ "user_identity": {
+ "user.username": "test@example.com",
+ "user.first_name": "John",
+ "user.last_name": "Doe",
+ "groups": ["consumers"],
+ },
}
- })
+ )
assert created
assert user.username == "test@example.com"
assert user.is_active is True
@@ -268,14 +270,13 @@ def test_get_or_create_user_trigger_error(settings: SettingsWrapper):
}
with pytest.raises(SAMLAuthError) as exc_info:
- get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe"
- })
+ get_or_create_user(
+ {"username": "test@example.com", "first_name": "John", "last_name": "Doe"}
+ )
assert str(exc_info.value) == (
- "module 'django_saml2_auth.tests.test_user' has no attribute 'nonexistent_trigger'")
+ "module 'django_saml2_auth.tests.test_user' has no attribute 'nonexistent_trigger'"
+ )
assert exc_info.value.extra is not None
assert isinstance(exc_info.value.extra["exc"], AttributeError)
@@ -294,34 +295,27 @@ def test_get_user_trigger_error(settings: SettingsWrapper):
}
}
with pytest.raises(SAMLAuthError) as exc_info:
- get_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe"
- })
+ get_user({"username": "test@example.com", "first_name": "John", "last_name": "Doe"})
assert str(exc_info.value) == (
- "module 'django_saml2_auth.tests.test_user' has no attribute 'nonexistent_trigger'")
+ "module 'django_saml2_auth.tests.test_user' has no attribute 'nonexistent_trigger'"
+ )
assert exc_info.value.extra is not None
assert isinstance(exc_info.value.extra["exc"], AttributeError)
@pytest.mark.django_db
def test_get_user_trigger(settings: SettingsWrapper):
-
settings.SAML2_AUTH = {
"TRIGGER": {
"GET_USER": "django_saml2_auth.tests.test_user.trigger_get_user",
}
}
user_model = get_user_model()
- user_model.objects.create(
- username="test_example_com", email="test@example.com")
- created, user = get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe"
- })
+ user_model.objects.create(username="test_example_com", email="test@example.com")
+ created, user = get_or_create_user(
+ {"username": "test@example.com", "first_name": "John", "last_name": "Doe"}
+ )
assert created is False
assert user.username == "test_example_com"
@@ -340,11 +334,9 @@ def test_get_or_create_user_trigger_change_first_name(settings: SettingsWrapper)
}
}
- created, user = get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe"
- })
+ created, user = get_or_create_user(
+ {"username": "test@example.com", "first_name": "John", "last_name": "Doe"}
+ )
assert created
assert user.username == "test@example.com"
@@ -366,16 +358,15 @@ def test_get_or_create_user_should_not_create_user(settings: SettingsWrapper):
}
with pytest.raises(SAMLAuthError) as exc_info:
- get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe"
- })
+ get_or_create_user(
+ {"username": "test@example.com", "first_name": "John", "last_name": "Doe"}
+ )
assert str(exc_info.value) == "Cannot create user."
assert exc_info.value.extra is not None
assert exc_info.value.extra["reason"] == (
- "Due to current config, a new user should not be created.")
+ "Due to current config, a new user should not be created."
+ )
@pytest.mark.django_db
@@ -393,17 +384,19 @@ def test_get_or_create_user_should_not_create_group(settings: SettingsWrapper):
}
Group.objects.create(name="users")
- created, user = get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe",
- "user_identity": {
- "user.username": "test@example.com",
- "user.first_name": "John",
- "user.last_name": "Doe",
- "groups": ["users", "consumers"]
+ created, user = get_or_create_user(
+ {
+ "username": "test@example.com",
+ "first_name": "John",
+ "last_name": "Doe",
+ "user_identity": {
+ "user.username": "test@example.com",
+ "user.first_name": "John",
+ "user.last_name": "Doe",
+ "groups": ["users", "consumers"],
+ },
}
- })
+ )
assert created
assert user.username == "test@example.com"
assert user.is_active is True
@@ -429,17 +422,19 @@ def test_get_or_create_user_should_create_group(settings: SettingsWrapper):
}
Group.objects.create(name="users")
- created, user = get_or_create_user({
- "username": "test@example.com",
- "first_name": "John",
- "last_name": "Doe",
- "user_identity": {
- "user.username": "test@example.com",
- "user.first_name": "John",
- "user.last_name": "Doe",
- "groups": ["users", "consumers"]
+ created, user = get_or_create_user(
+ {
+ "username": "test@example.com",
+ "first_name": "John",
+ "last_name": "Doe",
+ "user_identity": {
+ "user.username": "test@example.com",
+ "user.first_name": "John",
+ "user.last_name": "Doe",
+ "groups": ["users", "consumers"],
+ },
}
- })
+ )
assert created
assert user.username == "test@example.com"
assert user.is_active is True
@@ -508,23 +503,26 @@ def test_get_user_success():
assert user_1 == user_2
-@pytest.mark.parametrize("saml2_settings", [
- {
- "JWT_ALGORITHM": "HS256",
- "JWT_SECRET": "secret"
- },
- {
- "JWT_ALGORITHM": "RS256",
- "JWT_PRIVATE_KEY": private_key,
- "JWT_PRIVATE_KEY_PASSPHRASE": passphrase,
- "JWT_PUBLIC_KEY": public_key},
- {
- "JWT_ALGORITHM": "RS256",
- "JWT_PRIVATE_KEY": unencrypted_private_key,
- "JWT_PUBLIC_KEY": public_key},
-])
+@pytest.mark.parametrize(
+ "saml2_settings",
+ [
+ {"JWT_ALGORITHM": "HS256", "JWT_SECRET": "secret"},
+ {
+ "JWT_ALGORITHM": "RS256",
+ "JWT_PRIVATE_KEY": private_key,
+ "JWT_PRIVATE_KEY_PASSPHRASE": passphrase,
+ "JWT_PUBLIC_KEY": public_key,
+ },
+ {
+ "JWT_ALGORITHM": "RS256",
+ "JWT_PRIVATE_KEY": unencrypted_private_key,
+ "JWT_PUBLIC_KEY": public_key,
+ },
+ ],
+)
def test_create_and_decode_jwt_token_success(
- settings: SettingsWrapper, saml2_settings: Dict[str, Any]):
+ settings: SettingsWrapper, saml2_settings: Dict[str, Any]
+):
"""Test create_jwt_token and decode_jwt_token functions by verifying if the newly created
JWT token using is valid.
@@ -539,32 +537,40 @@ def test_create_and_decode_jwt_token_success(
assert user_id == "test@example.com"
-@pytest.mark.parametrize('saml2_settings,error_msg', [
- ({
- "JWT_ALGORITHM": None
- }, "Cannot encode/decode JWT token. Specify an algorithm."),
- ({
- "JWT_ALGORITHM": "HS256",
- "JWT_SECRET": None
- }, "Cannot encode/decode JWT token. Specify a secret."),
- ({
- "JWT_ALGORITHM": "HS256",
- "JWT_SECRET": "",
- }, "Cannot encode/decode JWT token. Specify a secret."),
- ({
- "JWT_ALGORITHM": "HS256",
- "JWT_PRIVATE_KEY": "-- PRIVATE KEY --"
- }, "Cannot encode/decode JWT token. Specify a secret."),
- ({
- "JWT_ALGORITHM": "RS256",
- }, "Cannot encode/decode JWT token. Specify a private key."),
- ({
- "JWT_ALGORITHM": "RS256",
- "JWT_SECRET": "A_SECRET_PHRASE"
- }, "Cannot encode/decode JWT token. Specify a private key."),
-])
+@pytest.mark.parametrize(
+ "saml2_settings,error_msg",
+ [
+ ({"JWT_ALGORITHM": None}, "Cannot encode/decode JWT token. Specify an algorithm."),
+ (
+ {"JWT_ALGORITHM": "HS256", "JWT_SECRET": None},
+ "Cannot encode/decode JWT token. Specify a secret.",
+ ),
+ (
+ {
+ "JWT_ALGORITHM": "HS256",
+ "JWT_SECRET": "",
+ },
+ "Cannot encode/decode JWT token. Specify a secret.",
+ ),
+ (
+ {"JWT_ALGORITHM": "HS256", "JWT_PRIVATE_KEY": "-- PRIVATE KEY --"},
+ "Cannot encode/decode JWT token. Specify a secret.",
+ ),
+ (
+ {
+ "JWT_ALGORITHM": "RS256",
+ },
+ "Cannot encode/decode JWT token. Specify a private key.",
+ ),
+ (
+ {"JWT_ALGORITHM": "RS256", "JWT_SECRET": "A_SECRET_PHRASE"},
+ "Cannot encode/decode JWT token. Specify a private key.",
+ ),
+ ],
+)
def test_create_jwt_token_with_incorrect_jwt_settings(
- settings: SettingsWrapper, saml2_settings: Dict[str, str], error_msg: str):
+ settings: SettingsWrapper, saml2_settings: Dict[str, str], error_msg: str
+):
"""Test create_jwt_token function by trying to create a JWT token with incorrect settings.
Args:
@@ -580,37 +586,44 @@ def test_create_jwt_token_with_incorrect_jwt_settings(
assert str(exc_info.value) == error_msg
-@pytest.mark.parametrize('saml2_settings,error_msg', [
- ({
- "JWT_ALGORITHM": None
- }, "Cannot encode/decode JWT token. Specify an algorithm."),
- ({
- "JWT_ALGORITHM": "HS256",
- "JWT_SECRET": None
- }, "Cannot encode/decode JWT token. Specify a secret."),
- ({
- "JWT_ALGORITHM": "HS256",
- "JWT_SECRET": "",
- }, "Cannot encode/decode JWT token. Specify a secret."),
- ({
- "JWT_ALGORITHM": "HS256",
- "JWT_PRIVATE_KEY": "-- PRIVATE KEY --"
- }, "Cannot encode/decode JWT token. Specify a secret."),
- ({
- "JWT_ALGORITHM": "HS256",
- "JWT_SECRET": "secret",
- "JWT_EXP": -60
- }, "Cannot decode JWT token."),
- ({
- "JWT_ALGORITHM": "RS256",
- }, "Cannot encode/decode JWT token. Specify a public key."),
- ({
- "JWT_ALGORITHM": "RS256",
- "JWT_SECRET": "A_SECRET_PHRASE"
- }, "Cannot encode/decode JWT token. Specify a public key.",),
-])
+@pytest.mark.parametrize(
+ "saml2_settings,error_msg",
+ [
+ ({"JWT_ALGORITHM": None}, "Cannot encode/decode JWT token. Specify an algorithm."),
+ (
+ {"JWT_ALGORITHM": "HS256", "JWT_SECRET": None},
+ "Cannot encode/decode JWT token. Specify a secret.",
+ ),
+ (
+ {
+ "JWT_ALGORITHM": "HS256",
+ "JWT_SECRET": "",
+ },
+ "Cannot encode/decode JWT token. Specify a secret.",
+ ),
+ (
+ {"JWT_ALGORITHM": "HS256", "JWT_PRIVATE_KEY": "-- PRIVATE KEY --"},
+ "Cannot encode/decode JWT token. Specify a secret.",
+ ),
+ (
+ {"JWT_ALGORITHM": "HS256", "JWT_SECRET": "secret", "JWT_EXP": -60},
+ "Cannot decode JWT token.",
+ ),
+ (
+ {
+ "JWT_ALGORITHM": "RS256",
+ },
+ "Cannot encode/decode JWT token. Specify a public key.",
+ ),
+ (
+ {"JWT_ALGORITHM": "RS256", "JWT_SECRET": "A_SECRET_PHRASE"},
+ "Cannot encode/decode JWT token. Specify a public key.",
+ ),
+ ],
+)
def test_decode_jwt_token_with_incorrect_jwt_settings(
- settings: SettingsWrapper, saml2_settings: Dict[str, str], error_msg: str):
+ settings: SettingsWrapper, saml2_settings: Dict[str, str], error_msg: str
+):
"""Test decode_jwt_token function by trying to create a JWT token with incorrect settings.
Args:
diff --git a/django_saml2_auth/tests/test_utils.py b/django_saml2_auth/tests/test_utils.py
index 08a803a..e975564 100644
--- a/django_saml2_auth/tests/test_utils.py
+++ b/django_saml2_auth/tests/test_utils.py
@@ -19,7 +19,7 @@ def divide(a: int, b: int = 1) -> int:
Returns:
int: Quotient
"""
- return int(a/b)
+ return int(a / b)
def hello(_: HttpRequest) -> HttpResponse:
@@ -43,13 +43,16 @@ def goodbye(_: HttpRequest) -> None:
Raises:
SAMLAuthError: Goodbye, world!
"""
- raise SAMLAuthError("Goodbye, world!", extra={
- "exc": RuntimeError("World not found!"),
- "exc_type": RuntimeError,
- "error_code": 0,
- "reason": "Internal world error!",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Goodbye, world!",
+ extra={
+ "exc": RuntimeError("World not found!"),
+ "exc_type": RuntimeError,
+ "error_code": 0,
+ "reason": "Internal world error!",
+ "status_code": 500,
+ },
+ )
def test_run_hook_success():
@@ -82,7 +85,8 @@ def test_run_hook_import_error():
run_hook("django_saml2_auth.tests.test_utils.nonexistent_divide", 2, b=2)
assert str(exc_info.value) == (
- "module 'django_saml2_auth.tests.test_utils' has no attribute 'nonexistent_divide'")
+ "module 'django_saml2_auth.tests.test_utils' has no attribute 'nonexistent_divide'"
+ )
assert isinstance(exc_info.value.extra["exc"], AttributeError)
assert exc_info.value.extra["exc_type"] == AttributeError
@@ -132,8 +136,8 @@ def test_exception_handler_handle_exception():
def test_jwt_well_formed():
"""Test if passed RelayState is a well formed JWT"""
- token = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MjQyIiwibmFtZSI6Ikplc3NpY2EgVGVtcG9yYWwiLCJuaWNrbmFtZSI6Ikplc3MifQ.EDkUUxaM439gWLsQ8a8mJWIvQtgZe0et3O3z4Fd_J8o' # noqa
+ token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MjQyIiwibmFtZSI6Ikplc3NpY2EgVGVtcG9yYWwiLCJuaWNrbmFtZSI6Ikplc3MifQ.EDkUUxaM439gWLsQ8a8mJWIvQtgZe0et3O3z4Fd_J8o" # noqa
res = is_jwt_well_formed(token) # True
assert res is True
- res = is_jwt_well_formed('/') # False
+ res = is_jwt_well_formed("/") # False
assert res is False
diff --git a/django_saml2_auth/user.py b/django_saml2_auth/user.py
index b35d66c..09f2c51 100644
--- a/django_saml2_auth/user.py
+++ b/django_saml2_auth/user.py
@@ -1,5 +1,4 @@
-"""Utility functions for getting or creating user accounts
-"""
+"""Utility functions for getting or creating user accounts"""
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple, Union
@@ -10,23 +9,27 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, User
-from django_saml2_auth.errors import (CANNOT_DECODE_JWT_TOKEN,
- CREATE_USER_ERROR, GROUP_JOIN_ERROR,
- INVALID_JWT_ALGORITHM, NO_JWT_ALGORITHM,
- NO_JWT_PRIVATE_KEY, NO_JWT_PUBLIC_KEY,
- NO_JWT_SECRET, NO_USER_ID,
- SHOULD_NOT_CREATE_USER)
+from django_saml2_auth.errors import (
+ CANNOT_DECODE_JWT_TOKEN,
+ CREATE_USER_ERROR,
+ GROUP_JOIN_ERROR,
+ INVALID_JWT_ALGORITHM,
+ NO_JWT_ALGORITHM,
+ NO_JWT_PRIVATE_KEY,
+ NO_JWT_PUBLIC_KEY,
+ NO_JWT_SECRET,
+ NO_USER_ID,
+ SHOULD_NOT_CREATE_USER,
+)
from django_saml2_auth.exceptions import SAMLAuthError
from django_saml2_auth.utils import run_hook
-from jwt.algorithms import (get_default_algorithms, has_crypto,
- requires_cryptography)
+from jwt.algorithms import get_default_algorithms, has_crypto, requires_cryptography
from jwt.exceptions import PyJWTError
-def create_new_user(email: str,
- first_name: Optional[str] = None,
- last_name: Optional[str] = None,
- **kwargs) -> User:
+def create_new_user(
+ email: str, first_name: Optional[str] = None, last_name: Optional[str] = None, **kwargs
+) -> User:
"""Create a new user with the given information
Args:
@@ -53,8 +56,8 @@ def create_new_user(email: str,
user_groups = dictor(saml2_auth_settings, "NEW_USER_PROFILE.USER_GROUPS", default=[])
if first_name and last_name:
- kwargs['first_name'] = first_name
- kwargs['last_name'] = last_name
+ kwargs["first_name"] = first_name
+ kwargs["last_name"] = last_name
try:
user = user_model.objects.create_user(email, **kwargs)
@@ -63,26 +66,32 @@ def create_new_user(email: str,
user.is_superuser = is_superuser
user.save()
except Exception as exc:
- raise SAMLAuthError("There was an error creating the new user.", extra={
- "exc": exc,
- "exc_type": type(exc),
- "error_code": CREATE_USER_ERROR,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "There was an error creating the new user.",
+ extra={
+ "exc": exc,
+ "exc_type": type(exc),
+ "error_code": CREATE_USER_ERROR,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
try:
groups = [Group.objects.get(name=group) for group in user_groups]
if groups:
user.groups.set(groups)
except Exception as exc:
- raise SAMLAuthError("There was an error joining the user to the group.", extra={
- "exc": exc,
- "exc_type": type(exc),
- "error_code": GROUP_JOIN_ERROR,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "There was an error joining the user to the group.",
+ extra={
+ "exc": exc,
+ "exc_type": type(exc),
+ "error_code": GROUP_JOIN_ERROR,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
user.save()
user.refresh_from_db()
@@ -114,11 +123,14 @@ def get_or_create_user(user: Dict[str, Any]) -> Tuple[bool, User]:
if should_create_new_user:
user_id = get_user_id(user)
if not user_id:
- raise SAMLAuthError("Cannot create user. Missing user_id.", extra={
- "error_code": SHOULD_NOT_CREATE_USER,
- "reason": "Cannot create user. Missing user_id.",
- "status_code": 400
- })
+ raise SAMLAuthError(
+ "Cannot create user. Missing user_id.",
+ extra={
+ "error_code": SHOULD_NOT_CREATE_USER,
+ "reason": "Cannot create user. Missing user_id.",
+ "status_code": 400,
+ },
+ )
target_user = create_new_user(user_id, user["first_name"], user["last_name"])
create_user_trigger = dictor(saml2_auth_settings, "TRIGGER.CREATE_USER")
@@ -128,12 +140,15 @@ def get_or_create_user(user: Dict[str, Any]) -> Tuple[bool, User]:
target_user.refresh_from_db()
created = True
else:
- raise SAMLAuthError("Cannot create user.", extra={
- "exc_type": Exception,
- "error_code": SHOULD_NOT_CREATE_USER,
- "reason": "Due to current config, a new user should not be created.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Cannot create user.",
+ extra={
+ "exc_type": Exception,
+ "error_code": SHOULD_NOT_CREATE_USER,
+ "reason": "Due to current config, a new user should not be created.",
+ "status_code": 500,
+ },
+ )
# Optionally update this user's group assignments by updating group memberships from SAML groups
# to Django equivalents
@@ -210,7 +225,8 @@ def get_user(user: Union[str, Dict[str, str]]) -> User:
id_field = (
user_model.USERNAME_FIELD
if login_case_sensitive
- else f"{user_model.USERNAME_FIELD}__iexact")
+ else f"{user_model.USERNAME_FIELD}__iexact"
+ )
return user_model.objects.get(**{id_field: user_id})
@@ -225,20 +241,26 @@ def validate_jwt_algorithm(jwt_algorithm: str) -> None:
SAMLAuthError: Cannot encode/decode JWT token. Specify a valid algorithm.
"""
if not jwt_algorithm:
- raise SAMLAuthError("Cannot encode/decode JWT token. Specify an algorithm.", extra={
- "exc_type": Exception,
- "error_code": NO_JWT_ALGORITHM,
- "reason": "Cannot create JWT token for login.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Cannot encode/decode JWT token. Specify an algorithm.",
+ extra={
+ "exc_type": Exception,
+ "error_code": NO_JWT_ALGORITHM,
+ "reason": "Cannot create JWT token for login.",
+ "status_code": 500,
+ },
+ )
if jwt_algorithm not in list(get_default_algorithms()):
- raise SAMLAuthError("Cannot encode/decode JWT token. Specify a valid algorithm.", extra={
- "exc_type": Exception,
- "error_code": INVALID_JWT_ALGORITHM,
- "reason": "Cannot encode/decode JWT token for login.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Cannot encode/decode JWT token. Specify a valid algorithm.",
+ extra={
+ "exc_type": Exception,
+ "error_code": INVALID_JWT_ALGORITHM,
+ "reason": "Cannot encode/decode JWT token for login.",
+ "status_code": 500,
+ },
+ )
def validate_secret(jwt_algorithm: str, jwt_secret: str) -> None:
@@ -252,12 +274,15 @@ def validate_secret(jwt_algorithm: str, jwt_secret: str) -> None:
SAMLAuthError: Cannot encode/decode JWT token. Specify a secret.
"""
if jwt_algorithm not in requires_cryptography and not jwt_secret:
- raise SAMLAuthError("Cannot encode/decode JWT token. Specify a secret.", extra={
- "exc_type": Exception,
- "error_code": NO_JWT_SECRET,
- "reason": "Cannot encode/decode JWT token for login.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Cannot encode/decode JWT token. Specify a secret.",
+ extra={
+ "exc_type": Exception,
+ "error_code": NO_JWT_SECRET,
+ "reason": "Cannot encode/decode JWT token for login.",
+ "status_code": 500,
+ },
+ )
def validate_private_key(jwt_algorithm: str, jwt_private_key: str) -> None:
@@ -271,12 +296,15 @@ def validate_private_key(jwt_algorithm: str, jwt_private_key: str) -> None:
SAMLAuthError: Cannot encode/decode JWT token. Specify a private key.
"""
if (jwt_algorithm in requires_cryptography and has_crypto) and not jwt_private_key:
- raise SAMLAuthError("Cannot encode/decode JWT token. Specify a private key.", extra={
- "exc_type": Exception,
- "error_code": NO_JWT_PRIVATE_KEY,
- "reason": "Cannot encode/decode JWT token for login.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Cannot encode/decode JWT token. Specify a private key.",
+ extra={
+ "exc_type": Exception,
+ "error_code": NO_JWT_PRIVATE_KEY,
+ "reason": "Cannot encode/decode JWT token for login.",
+ "status_code": 500,
+ },
+ )
def validate_public_key(jwt_algorithm: str, jwt_public_key: str) -> None:
@@ -290,12 +318,15 @@ def validate_public_key(jwt_algorithm: str, jwt_public_key: str) -> None:
SAMLAuthError: Cannot encode/decode JWT token. Specify a public key.
"""
if (jwt_algorithm in requires_cryptography and has_crypto) and not jwt_public_key:
- raise SAMLAuthError("Cannot encode/decode JWT token. Specify a public key.", extra={
- "exc_type": Exception,
- "error_code": NO_JWT_PUBLIC_KEY,
- "reason": "Cannot encode/decode JWT token for login.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Cannot encode/decode JWT token. Specify a public key.",
+ extra={
+ "exc_type": Exception,
+ "error_code": NO_JWT_PUBLIC_KEY,
+ "reason": "Cannot encode/decode JWT token for login.",
+ "status_code": 500,
+ },
+ )
def create_jwt_token(user_id: str) -> Optional[str]:
@@ -324,8 +355,7 @@ def create_jwt_token(user_id: str) -> Optional[str]:
payload = {
user_model.USERNAME_FIELD: user_id,
- "exp": (datetime.now(tz=timezone.utc) +
- timedelta(seconds=jwt_expiration)).timestamp()
+ "exp": (datetime.now(tz=timezone.utc) + timedelta(seconds=jwt_expiration)).timestamp(),
}
# If a passphrase is specified, we need to use a PEM-encoded private key
@@ -338,13 +368,14 @@ def create_jwt_token(user_id: str) -> Optional[str]:
# load_pem_private_key requires data and password to be in bytes
jwt_private_key = serialization.load_pem_private_key(
- data=jwt_private_key,
- password=jwt_private_key_passphrase
+ data=jwt_private_key, password=jwt_private_key_passphrase
)
- secret = jwt_secret if (
- jwt_secret and
- jwt_algorithm not in requires_cryptography) else jwt_private_key
+ secret = (
+ jwt_secret
+ if (jwt_secret and jwt_algorithm not in requires_cryptography)
+ else jwt_private_key
+ )
return jwt.encode(payload, secret, algorithm=jwt_algorithm)
@@ -378,9 +409,7 @@ def create_custom_or_default_jwt(user: Union[str, User]):
# If user is user_id, get user instance
if user_id:
user_model = get_user_model()
- _user = {
- user_model.USERNAME_FIELD: user_id
- }
+ _user = {user_model.USERNAME_FIELD: user_id}
target_user = get_user(_user)
jwt_token = run_hook(custom_create_jwt_trigger, target_user) # type: ignore
else:
@@ -389,12 +418,15 @@ def create_custom_or_default_jwt(user: Union[str, User]):
user_id = getattr(user, user_model.USERNAME_FIELD)
# Create a new JWT token with PyJWT
if not user_id:
- raise SAMLAuthError("Cannot create JWT token. Specify a user.", extra={
- "exc_type": Exception,
- "error_code": NO_USER_ID,
- "reason": "Cannot create JWT token for login.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Cannot create JWT token. Specify a user.",
+ extra={
+ "exc_type": Exception,
+ "error_code": NO_USER_ID,
+ "reason": "Cannot create JWT token for login.",
+ "status_code": 500,
+ },
+ )
jwt_token = create_jwt_token(user_id)
return jwt_token
@@ -423,22 +455,27 @@ def decode_jwt_token(jwt_token: str) -> Optional[str]:
jwt_public_key = dictor(saml2_auth_settings, "JWT_PUBLIC_KEY")
validate_public_key(jwt_algorithm, jwt_public_key)
- secret = jwt_secret if (
- jwt_secret and
- jwt_algorithm not in requires_cryptography) else jwt_public_key
+ secret = (
+ jwt_secret
+ if (jwt_secret and jwt_algorithm not in requires_cryptography)
+ else jwt_public_key
+ )
try:
data = jwt.decode(jwt_token, secret, algorithms=jwt_algorithm)
user_model = get_user_model()
return data[user_model.USERNAME_FIELD]
except PyJWTError as exc:
- raise SAMLAuthError("Cannot decode JWT token.", extra={
- "exc": exc,
- "exc_type": type(exc),
- "error_code": CANNOT_DECODE_JWT_TOKEN,
- "reason": "Cannot decode JWT token.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "Cannot decode JWT token.",
+ extra={
+ "exc": exc,
+ "exc_type": type(exc),
+ "error_code": CANNOT_DECODE_JWT_TOKEN,
+ "reason": "Cannot decode JWT token.",
+ "status_code": 500,
+ },
+ )
def decode_custom_or_default_jwt(jwt_token: str) -> Optional[str]:
diff --git a/django_saml2_auth/utils.py b/django_saml2_auth/utils.py
index 2633d75..f167eaa 100644
--- a/django_saml2_auth/utils.py
+++ b/django_saml2_auth/utils.py
@@ -6,8 +6,7 @@
from functools import wraps
from importlib import import_module
import logging
-from typing import (Any, Callable, Dict, Iterable, Mapping, Optional, Tuple,
- Union)
+from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple, Union
from dictor import dictor # type: ignore
from django.conf import settings
@@ -15,15 +14,19 @@
from django.shortcuts import render
from django.urls import NoReverseMatch, reverse
from django.utils.module_loading import import_string
-from django_saml2_auth.errors import (EMPTY_FUNCTION_PATH, GENERAL_EXCEPTION,
- IMPORT_ERROR, NO_REVERSE_MATCH,
- PATH_ERROR)
+from django_saml2_auth.errors import (
+ EMPTY_FUNCTION_PATH,
+ GENERAL_EXCEPTION,
+ IMPORT_ERROR,
+ NO_REVERSE_MATCH,
+ PATH_ERROR,
+)
from django_saml2_auth.exceptions import SAMLAuthError
-def run_hook(function_path: str,
- *args: Optional[Tuple[Any]],
- **kwargs: Optional[Mapping[str, Any]]) -> Optional[Any]:
+def run_hook(
+ function_path: str, *args: Optional[Tuple[Any]], **kwargs: Optional[Mapping[str, Any]]
+) -> Optional[Any]:
"""Runs a hook function with given args and kwargs. For example, given
"models.User.create_new_user", the "create_new_user" function is imported from
the "models.User" module and run with args and kwargs. Functions can be
@@ -44,22 +47,28 @@ def run_hook(function_path: str,
of any exceptions, errors in arguments and related issues.
"""
if not function_path:
- raise SAMLAuthError("function_path isn't specified", extra={
- "exc_type": ValueError,
- "error_code": EMPTY_FUNCTION_PATH,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "function_path isn't specified",
+ extra={
+ "exc_type": ValueError,
+ "error_code": EMPTY_FUNCTION_PATH,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
path = function_path.split(".")
if len(path) < 2:
# Nothing to import
- raise SAMLAuthError("There's nothing to import. Check your hook's import path!", extra={
- "exc_type": ValueError,
- "error_code": PATH_ERROR,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "There's nothing to import. Check your hook's import path!",
+ extra={
+ "exc_type": ValueError,
+ "error_code": PATH_ERROR,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
module_path = ".".join(path[:-1])
result = None
@@ -69,34 +78,43 @@ def run_hook(function_path: str,
try:
cls = import_string(module_path)
except ImportError as exc:
- raise SAMLAuthError(str(exc), extra={
- "exc": exc,
- "exc_type": type(exc),
- "error_code": IMPORT_ERROR,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ str(exc),
+ extra={
+ "exc": exc,
+ "exc_type": type(exc),
+ "error_code": IMPORT_ERROR,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
try:
result = getattr(cls, path[-1])(*args, **kwargs)
except SAMLAuthError as exc:
# Re-raise the exception
raise exc
except AttributeError as exc:
- raise SAMLAuthError(str(exc), extra={
- "exc": exc,
- "exc_type": type(exc),
- "error_code": IMPORT_ERROR,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ str(exc),
+ extra={
+ "exc": exc,
+ "exc_type": type(exc),
+ "error_code": IMPORT_ERROR,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
except Exception as exc:
- raise SAMLAuthError(str(exc), extra={
- "exc": exc,
- "exc_type": type(exc),
- "error_code": GENERAL_EXCEPTION,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ str(exc),
+ extra={
+ "exc": exc,
+ "exc_type": type(exc),
+ "error_code": GENERAL_EXCEPTION,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
return result
@@ -121,17 +139,20 @@ def get_reverse(objects: Union[Any, Iterable[Any]]) -> Optional[str]:
return reverse(obj)
except NoReverseMatch:
pass
- raise SAMLAuthError(f"We got a URL reverse issue: {str(objects)}", extra={
- "exc_type": NoReverseMatch,
- "error_code": NO_REVERSE_MATCH,
- "reason": "There was an error processing your request.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ f"We got a URL reverse issue: {str(objects)}",
+ extra={
+ "exc_type": NoReverseMatch,
+ "error_code": NO_REVERSE_MATCH,
+ "reason": "There was an error processing your request.",
+ "status_code": 500,
+ },
+ )
def exception_handler(
- function: Callable[..., Union[HttpResponse, HttpResponseRedirect]]) -> \
- Callable[..., Union[HttpResponse, HttpResponseRedirect]]:
+ function: Callable[..., Union[HttpResponse, HttpResponseRedirect]],
+) -> Callable[..., Union[HttpResponse, HttpResponseRedirect]]:
"""This decorator can be used by view function to handle exceptions
Args:
@@ -142,6 +163,7 @@ def exception_handler(
Callable[..., Union[HttpResponse, HttpResponseRedirect]]:
Decorated view function with exception handling
"""
+
def handle_exception(exc: Exception, request: HttpRequest) -> HttpResponse:
"""Render page with exception details
@@ -166,12 +188,9 @@ def handle_exception(exc: Exception, request: HttpRequest) -> HttpResponse:
else:
status = 500
- return render(request,
- "django_saml2_auth/error.html",
- context=context,
- status=status)
+ return render(request, "django_saml2_auth/error.html", context=context, status=status)
- @ wraps(function)
+ @wraps(function)
def wrapper(request: HttpRequest) -> HttpResponse:
"""Decorated function is wrapped and called here
@@ -187,6 +206,7 @@ def wrapper(request: HttpRequest) -> HttpResponse:
except (SAMLAuthError, Exception) as exc:
result = handle_exception(exc, request)
return result
+
return wrapper
@@ -201,14 +221,14 @@ def is_jwt_well_formed(jwt: str):
"""
if isinstance(jwt, str):
# JWT should contain three segments, separated by two period ('.') characters.
- jwt_segments = jwt.split('.')
+ jwt_segments = jwt.split(".")
if len(jwt_segments) == 3:
jose_header = jwt_segments[0]
# base64-encoded string length should be a multiple of 4
if len(jose_header) % 4 == 0:
try:
- jh_decoded = base64.b64decode(jose_header).decode('utf-8')
- if jh_decoded and jh_decoded.find('JWT') > -1:
+ jh_decoded = base64.b64decode(jose_header).decode("utf-8")
+ if jh_decoded and jh_decoded.find("JWT") > -1:
return True
except Exception:
return False
diff --git a/django_saml2_auth/views.py b/django_saml2_auth/views.py
index cc49180..d0cf2b3 100644
--- a/django_saml2_auth/views.py
+++ b/django_saml2_auth/views.py
@@ -16,24 +16,38 @@
from django.template import TemplateDoesNotExist
try:
- from django.utils.http import \
- url_has_allowed_host_and_scheme as is_safe_url
+ from django.utils.http import url_has_allowed_host_and_scheme as is_safe_url
except ImportError:
from django.utils.http import is_safe_url
from django.views.decorators.csrf import csrf_exempt
-from django_saml2_auth.errors import (INACTIVE_USER, INVALID_NEXT_URL,
- INVALID_REQUEST_METHOD, INVALID_TOKEN,
- USER_MISMATCH)
+from django_saml2_auth.errors import (
+ INACTIVE_USER,
+ INVALID_NEXT_URL,
+ INVALID_REQUEST_METHOD,
+ INVALID_TOKEN,
+ USER_MISMATCH,
+)
from django_saml2_auth.exceptions import SAMLAuthError
-from django_saml2_auth.saml import (decode_saml_response,
- extract_user_identity, get_assertion_url,
- get_default_next_url, get_saml_client)
-from django_saml2_auth.user import (create_custom_or_default_jwt,
- decode_custom_or_default_jwt,
- get_or_create_user, get_user_id)
-from django_saml2_auth.utils import (exception_handler, get_reverse,
- is_jwt_well_formed, run_hook)
+from django_saml2_auth.saml import (
+ decode_saml_response,
+ extract_user_identity,
+ get_assertion_url,
+ get_default_next_url,
+ get_saml_client,
+)
+from django_saml2_auth.user import (
+ create_custom_or_default_jwt,
+ decode_custom_or_default_jwt,
+ get_or_create_user,
+ get_user_id,
+)
+from django_saml2_auth.utils import (
+ exception_handler,
+ get_reverse,
+ is_jwt_well_formed,
+ run_hook,
+)
@login_required
@@ -50,9 +64,11 @@ def welcome(request: HttpRequest) -> Union[HttpResponse, HttpResponseRedirect]:
return render(request, "django_saml2_auth/welcome.html", {"user": request.user})
except TemplateDoesNotExist:
default_next_url = get_default_next_url()
- return (HttpResponseRedirect(default_next_url)
- if default_next_url
- else HttpResponseRedirect("/"))
+ return (
+ HttpResponseRedirect(default_next_url)
+ if default_next_url
+ else HttpResponseRedirect("/")
+ )
def denied(request: HttpRequest) -> HttpResponse:
@@ -115,12 +131,15 @@ def acs(request: HttpRequest):
# This prevents users from entering an email on the SP, but use a different email on IdP
if get_user_id(user) != redirected_user_id:
- raise SAMLAuthError("The user identifier doesn't match.", extra={
- "exc_type": ValueError,
- "error_code": USER_MISMATCH,
- "reason": "User identifier mismatch.",
- "status_code": 403
- })
+ raise SAMLAuthError(
+ "The user identifier doesn't match.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": USER_MISMATCH,
+ "reason": "User identifier mismatch.",
+ "status_code": 403,
+ },
+ )
is_new_user, target_user = get_or_create_user(user)
@@ -134,7 +153,9 @@ def acs(request: HttpRequest):
if use_jwt and target_user.is_active:
# Create a new JWT token for IdP-initiated login (acs)
jwt_token = create_custom_or_default_jwt(target_user)
- custom_token_query_trigger = dictor(saml2_auth_settings, "TRIGGER.CUSTOM_TOKEN_QUERY")
+ custom_token_query_trigger = dictor(
+ saml2_auth_settings, "TRIGGER.CUSTOM_TOKEN_QUERY"
+ )
if custom_token_query_trigger:
query = run_hook(custom_token_query_trigger, jwt_token)
else:
@@ -147,7 +168,10 @@ def acs(request: HttpRequest):
if target_user.is_active:
# Try to load from the `AUTHENTICATION_BACKENDS` setting in settings.py
- if hasattr(settings, "AUTHENTICATION_BACKENDS") and settings.AUTHENTICATION_BACKENDS:
+ if (
+ hasattr(settings, "AUTHENTICATION_BACKENDS")
+ and settings.AUTHENTICATION_BACKENDS
+ ):
model_backend = settings.AUTHENTICATION_BACKENDS[0]
else:
model_backend = "django.contrib.auth.backends.ModelBackend"
@@ -158,12 +182,15 @@ def acs(request: HttpRequest):
if after_login_trigger:
run_hook(after_login_trigger, request.session, user) # type: ignore
else:
- raise SAMLAuthError("The target user is inactive.", extra={
- "exc_type": Exception,
- "error_code": INACTIVE_USER,
- "reason": "User is inactive.",
- "status_code": 500
- })
+ raise SAMLAuthError(
+ "The target user is inactive.",
+ extra={
+ "exc_type": Exception,
+ "error_code": INACTIVE_USER,
+ "reason": "User is inactive.",
+ "status_code": 500,
+ },
+ )
def redirect(redirect_url: Optional[str] = None) -> HttpResponseRedirect:
"""Redirect to the redirect_url or the root page.
@@ -181,7 +208,9 @@ def redirect(redirect_url: Optional[str] = None) -> HttpResponseRedirect:
if is_new_user:
try:
- return render(request, "django_saml2_auth/welcome.html", {"user": request.user})
+ return render(
+ request, "django_saml2_auth/welcome.html", {"user": request.user}
+ )
except TemplateDoesNotExist:
return redirect(next_url)
else:
@@ -204,30 +233,37 @@ def sp_initiated_login(request: HttpRequest) -> HttpResponseRedirect:
if token:
user_id = decode_custom_or_default_jwt(token)
if not user_id:
- raise SAMLAuthError("The token is invalid.", extra={
- "exc_type": ValueError,
- "error_code": INVALID_TOKEN,
- "reason": "The token is invalid.",
- "status_code": 403
- })
+ raise SAMLAuthError(
+ "The token is invalid.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": INVALID_TOKEN,
+ "reason": "The token is invalid.",
+ "status_code": 403,
+ },
+ )
saml_client = get_saml_client(get_assertion_url(request), acs, user_id)
jwt_token = create_custom_or_default_jwt(user_id)
_, info = saml_client.prepare_for_authenticate( # type: ignore
- sign=False, relay_state=jwt_token)
+ sign=False, relay_state=jwt_token
+ )
redirect_url = dict(info["headers"]).get("Location", "")
if not redirect_url:
return HttpResponseRedirect(
- get_reverse([denied, "denied", "django_saml2_auth:denied"])) # type: ignore
+ get_reverse([denied, "denied", "django_saml2_auth:denied"]) # type: ignore
+ )
return HttpResponseRedirect(redirect_url)
else:
- raise SAMLAuthError("Request method is not supported.", extra={
- "exc_type": Exception,
- "error_code": INVALID_REQUEST_METHOD,
- "reason": "Request method is not supported.",
- "status_code": 404
- })
- return HttpResponseRedirect(
- get_reverse([denied, "denied", "django_saml2_auth:denied"])) # type: ignore
+ raise SAMLAuthError(
+ "Request method is not supported.",
+ extra={
+ "exc_type": Exception,
+ "error_code": INVALID_REQUEST_METHOD,
+ "reason": "Request method is not supported.",
+ "status_code": 404,
+ },
+ )
+ return HttpResponseRedirect(get_reverse([denied, "denied", "django_saml2_auth:denied"])) # type: ignore
@exception_handler
@@ -248,16 +284,21 @@ def signin(request: HttpRequest) -> HttpResponseRedirect:
next_url = request.GET.get("next") or get_default_next_url()
if not next_url:
- raise SAMLAuthError("The next URL is invalid.", extra={
- "exc_type": ValueError,
- "error_code": INVALID_NEXT_URL,
- "reason": "The next URL is invalid.",
- "status_code": 403
- })
+ raise SAMLAuthError(
+ "The next URL is invalid.",
+ extra={
+ "exc_type": ValueError,
+ "error_code": INVALID_NEXT_URL,
+ "reason": "The next URL is invalid.",
+ "status_code": 403,
+ },
+ )
try:
if "next=" in unquote(next_url):
- parsed_next_url = urlparse.parse_qs(urlparse.urlparse(unquote(next_url)).query)
+ parsed_next_url = urlparse.parse_qs(
+ urlparse.urlparse(unquote(next_url)).query
+ )
next_url = dictor(parsed_next_url, "next.0")
except Exception:
next_url = request.GET.get("next") or get_default_next_url()
@@ -267,8 +308,7 @@ def signin(request: HttpRequest) -> HttpResponseRedirect:
url_ok = is_safe_url(next_url, allowed_hosts)
if not url_ok:
- return HttpResponseRedirect(
- get_reverse([denied, "denied", "django_saml2_auth:denied"])) # type: ignore
+ return HttpResponseRedirect(get_reverse([denied, "denied", "django_saml2_auth:denied"])) # type: ignore
request.session["login_next_url"] = next_url
diff --git a/mypy.ini b/mypy.ini
deleted file mode 100644
index a0f4bfe..0000000
--- a/mypy.ini
+++ /dev/null
@@ -1,6 +0,0 @@
-[mypy]
-plugins =
- mypy_django_plugin.main
-
-[mypy.plugins.django-stubs]
-django_settings_module = "django_saml2_auth.tests.settings"
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..e0f8662
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1156 @@
+# This file is automatically @generated by Poetry 1.8.1 and should not be changed by hand.
+
+[[package]]
+name = "asgiref"
+version = "3.8.1"
+description = "ASGI specs, helper code, and adapters"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"},
+ {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""}
+
+[package.extras]
+tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
+
+[[package]]
+name = "attrs"
+version = "23.2.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"},
+ {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"},
+]
+
+[package.extras]
+cov = ["attrs[tests]", "coverage[toml] (>=5.3)"]
+dev = ["attrs[tests]", "pre-commit"]
+docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"]
+tests = ["attrs[tests-no-zope]", "zope-interface"]
+tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"]
+tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"]
+
+[[package]]
+name = "certifi"
+version = "2024.2.2"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"},
+ {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"},
+]
+
+[[package]]
+name = "cffi"
+version = "1.16.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
+ {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
+ {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
+ {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
+ {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
+ {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
+ {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
+ {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
+ {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
+ {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
+ {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
+ {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
+ {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
+ {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
+ {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
+]
+
+[package.dependencies]
+pycparser = "*"
+
+[[package]]
+name = "charset-normalizer"
+version = "3.3.2"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
+ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
+ {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coverage"
+version = "7.5.0"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "coverage-7.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:432949a32c3e3f820af808db1833d6d1631664d53dd3ce487aa25d574e18ad1c"},
+ {file = "coverage-7.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bd7065249703cbeb6d4ce679c734bef0ee69baa7bff9724361ada04a15b7e3b"},
+ {file = "coverage-7.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbfe6389c5522b99768a93d89aca52ef92310a96b99782973b9d11e80511f932"},
+ {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39793731182c4be939b4be0cdecde074b833f6171313cf53481f869937129ed3"},
+ {file = "coverage-7.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85a5dbe1ba1bf38d6c63b6d2c42132d45cbee6d9f0c51b52c59aa4afba057517"},
+ {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:357754dcdfd811462a725e7501a9b4556388e8ecf66e79df6f4b988fa3d0b39a"},
+ {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a81eb64feded34f40c8986869a2f764f0fe2db58c0530d3a4afbcde50f314880"},
+ {file = "coverage-7.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:51431d0abbed3a868e967f8257c5faf283d41ec882f58413cf295a389bb22e58"},
+ {file = "coverage-7.5.0-cp310-cp310-win32.whl", hash = "sha256:f609ebcb0242d84b7adeee2b06c11a2ddaec5464d21888b2c8255f5fd6a98ae4"},
+ {file = "coverage-7.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:6782cd6216fab5a83216cc39f13ebe30adfac2fa72688c5a4d8d180cd52e8f6a"},
+ {file = "coverage-7.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e768d870801f68c74c2b669fc909839660180c366501d4cc4b87efd6b0eee375"},
+ {file = "coverage-7.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:84921b10aeb2dd453247fd10de22907984eaf80901b578a5cf0bb1e279a587cb"},
+ {file = "coverage-7.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:710c62b6e35a9a766b99b15cdc56d5aeda0914edae8bb467e9c355f75d14ee95"},
+ {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c379cdd3efc0658e652a14112d51a7668f6bfca7445c5a10dee7eabecabba19d"},
+ {file = "coverage-7.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea9d3ca80bcf17edb2c08a4704259dadac196fe5e9274067e7a20511fad1743"},
+ {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:41327143c5b1d715f5f98a397608f90ab9ebba606ae4e6f3389c2145410c52b1"},
+ {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:565b2e82d0968c977e0b0f7cbf25fd06d78d4856289abc79694c8edcce6eb2de"},
+ {file = "coverage-7.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cf3539007202ebfe03923128fedfdd245db5860a36810136ad95a564a2fdffff"},
+ {file = "coverage-7.5.0-cp311-cp311-win32.whl", hash = "sha256:bf0b4b8d9caa8d64df838e0f8dcf68fb570c5733b726d1494b87f3da85db3a2d"},
+ {file = "coverage-7.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c6384cc90e37cfb60435bbbe0488444e54b98700f727f16f64d8bfda0b84656"},
+ {file = "coverage-7.5.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fed7a72d54bd52f4aeb6c6e951f363903bd7d70bc1cad64dd1f087980d309ab9"},
+ {file = "coverage-7.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cbe6581fcff7c8e262eb574244f81f5faaea539e712a058e6707a9d272fe5b64"},
+ {file = "coverage-7.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad97ec0da94b378e593ef532b980c15e377df9b9608c7c6da3506953182398af"},
+ {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd4bacd62aa2f1a1627352fe68885d6ee694bdaebb16038b6e680f2924a9b2cc"},
+ {file = "coverage-7.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adf032b6c105881f9d77fa17d9eebe0ad1f9bfb2ad25777811f97c5362aa07f2"},
+ {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4ba01d9ba112b55bfa4b24808ec431197bb34f09f66f7cb4fd0258ff9d3711b1"},
+ {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f0bfe42523893c188e9616d853c47685e1c575fe25f737adf473d0405dcfa7eb"},
+ {file = "coverage-7.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a9a7ef30a1b02547c1b23fa9a5564f03c9982fc71eb2ecb7f98c96d7a0db5cf2"},
+ {file = "coverage-7.5.0-cp312-cp312-win32.whl", hash = "sha256:3c2b77f295edb9fcdb6a250f83e6481c679335ca7e6e4a955e4290350f2d22a4"},
+ {file = "coverage-7.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:427e1e627b0963ac02d7c8730ca6d935df10280d230508c0ba059505e9233475"},
+ {file = "coverage-7.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9dd88fce54abbdbf4c42fb1fea0e498973d07816f24c0e27a1ecaf91883ce69e"},
+ {file = "coverage-7.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a898c11dca8f8c97b467138004a30133974aacd572818c383596f8d5b2eb04a9"},
+ {file = "coverage-7.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07dfdd492d645eea1bd70fb1d6febdcf47db178b0d99161d8e4eed18e7f62fe7"},
+ {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3d117890b6eee85887b1eed41eefe2e598ad6e40523d9f94c4c4b213258e4a4"},
+ {file = "coverage-7.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6afd2e84e7da40fe23ca588379f815fb6dbbb1b757c883935ed11647205111cb"},
+ {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a9960dd1891b2ddf13a7fe45339cd59ecee3abb6b8326d8b932d0c5da208104f"},
+ {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ced268e82af993d7801a9db2dbc1d2322e786c5dc76295d8e89473d46c6b84d4"},
+ {file = "coverage-7.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7c211f25777746d468d76f11719e64acb40eed410d81c26cefac641975beb88"},
+ {file = "coverage-7.5.0-cp38-cp38-win32.whl", hash = "sha256:262fffc1f6c1a26125d5d573e1ec379285a3723363f3bd9c83923c9593a2ac25"},
+ {file = "coverage-7.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:eed462b4541c540d63ab57b3fc69e7d8c84d5957668854ee4e408b50e92ce26a"},
+ {file = "coverage-7.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d0194d654e360b3e6cc9b774e83235bae6b9b2cac3be09040880bb0e8a88f4a1"},
+ {file = "coverage-7.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:33c020d3322662e74bc507fb11488773a96894aa82a622c35a5a28673c0c26f5"},
+ {file = "coverage-7.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbdf2cae14a06827bec50bd58e49249452d211d9caddd8bd80e35b53cb04631"},
+ {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3235d7c781232e525b0761730e052388a01548bd7f67d0067a253887c6e8df46"},
+ {file = "coverage-7.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2de4e546f0ec4b2787d625e0b16b78e99c3e21bc1722b4977c0dddf11ca84e"},
+ {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4d0e206259b73af35c4ec1319fd04003776e11e859936658cb6ceffdeba0f5be"},
+ {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:2055c4fb9a6ff624253d432aa471a37202cd8f458c033d6d989be4499aed037b"},
+ {file = "coverage-7.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:075299460948cd12722a970c7eae43d25d37989da682997687b34ae6b87c0ef0"},
+ {file = "coverage-7.5.0-cp39-cp39-win32.whl", hash = "sha256:280132aada3bc2f0fac939a5771db4fbb84f245cb35b94fae4994d4c1f80dae7"},
+ {file = "coverage-7.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:c58536f6892559e030e6924896a44098bc1290663ea12532c78cef71d0df8493"},
+ {file = "coverage-7.5.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:2b57780b51084d5223eee7b59f0d4911c31c16ee5aa12737c7a02455829ff067"},
+ {file = "coverage-7.5.0.tar.gz", hash = "sha256:cf62d17310f34084c59c01e027259076479128d11e4661bb6c9acb38c5e19bb8"},
+]
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "cryptography"
+version = "42.0.5"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"},
+ {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"},
+ {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"},
+ {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"},
+ {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"},
+ {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"},
+ {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"},
+ {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"},
+ {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"},
+ {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"},
+ {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"},
+ {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"},
+ {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"},
+ {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"},
+ {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"},
+ {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"},
+ {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"},
+ {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"},
+ {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"},
+ {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"},
+ {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"},
+ {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"},
+ {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"},
+ {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"},
+ {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"},
+ {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"},
+ {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"},
+ {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"},
+ {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"},
+ {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"},
+ {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"},
+ {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"},
+]
+
+[package.dependencies]
+cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""}
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
+docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"]
+nox = ["nox"]
+pep8test = ["check-sdist", "click", "mypy", "ruff"]
+sdist = ["build"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test-randomorder = ["pytest-randomly"]
+
+[[package]]
+name = "cyclonedx-bom"
+version = "3.11.0"
+description = "CycloneDX Software Bill of Materials (SBOM) generation utility"
+optional = false
+python-versions = ">=3.6,<4.0"
+files = [
+ {file = "cyclonedx_bom-3.11.0-py3-none-any.whl", hash = "sha256:d7f611967a71a5a5f4d1c7a53fe1d5f86e4c7b04b41b67d99efed185cce56816"},
+ {file = "cyclonedx_bom-3.11.0.tar.gz", hash = "sha256:cec3b5a5b4d7703bb807732566cc777c07749efffaf2d1b4724dcbb61d3c0fd1"},
+]
+
+[package.dependencies]
+cyclonedx-python-lib = ">=2.0.0,<4.0.0"
+packageurl-python = ">=0.9"
+pip-requirements-parser = ">=32.0.0,<33.0.0"
+setuptools = ">=47.0.0"
+toml = ">=0.10.0,<0.11.0"
+
+[[package]]
+name = "cyclonedx-python-lib"
+version = "3.1.5"
+description = "A library for producing CycloneDX SBOM (Software Bill of Materials) files."
+optional = false
+python-versions = ">=3.6,<4.0"
+files = [
+ {file = "cyclonedx_python_lib-3.1.5-py3-none-any.whl", hash = "sha256:8981ca462fba91469c268d684a03f72c89c7a807674d884f83a28d8c2822a9b6"},
+ {file = "cyclonedx_python_lib-3.1.5.tar.gz", hash = "sha256:1ccd482024a30b95c4fffb3fe567a9df97b705f34c1075f8abde8537867600c3"},
+]
+
+[package.dependencies]
+packageurl-python = ">=0.9"
+setuptools = ">=47.0.0"
+sortedcontainers = ">=2.4.0,<3.0.0"
+toml = ">=0.10.0,<0.11.0"
+
+[[package]]
+name = "defusedxml"
+version = "0.7.1"
+description = "XML bomb protection for Python stdlib modules"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
+ {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
+]
+
+[[package]]
+name = "dictor"
+version = "0.1.11"
+description = "an elegant dictionary and JSON handler"
+optional = false
+python-versions = "*"
+files = [
+ {file = "dictor-0.1.11.tar.gz", hash = "sha256:4a8b1c2226f992d4cef4bad7435ffb5c647f982c8de9345d7cd1dcca25d1636d"},
+]
+
+[[package]]
+name = "django"
+version = "5.0.4"
+description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
+optional = false
+python-versions = ">=3.10"
+files = [
+ {file = "Django-5.0.4-py3-none-any.whl", hash = "sha256:916423499d75d62da7aa038d19aef23d23498d8df229775eb0a6309ee1013775"},
+ {file = "Django-5.0.4.tar.gz", hash = "sha256:4bd01a8c830bb77a8a3b0e7d8b25b887e536ad17a81ba2dce5476135c73312bd"},
+]
+
+[package.dependencies]
+asgiref = ">=3.7.0,<4"
+sqlparse = ">=0.3.1"
+tzdata = {version = "*", markers = "sys_platform == \"win32\""}
+
+[package.extras]
+argon2 = ["argon2-cffi (>=19.1.0)"]
+bcrypt = ["bcrypt"]
+
+[[package]]
+name = "django-stubs"
+version = "4.2.0"
+description = "Mypy stubs for Django"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "django-stubs-4.2.0.tar.gz", hash = "sha256:93baff824f0a056e71036b423b942a74f07b909e45e3fa38185b910f597c5c08"},
+ {file = "django_stubs-4.2.0-py3-none-any.whl", hash = "sha256:d2c671989efb3f7b0fa91e461909ad5a5a52155fe7fe6d1f2058cb88e3afb123"},
+]
+
+[package.dependencies]
+django = "*"
+django-stubs-ext = ">=4.2.0"
+mypy = ">=0.980"
+tomli = {version = "*", markers = "python_version < \"3.11\""}
+types-pytz = "*"
+types-PyYAML = "*"
+typing-extensions = "*"
+
+[package.extras]
+compatible-mypy = ["mypy (>=1.2.0,<1.3)"]
+
+[[package]]
+name = "django-stubs-ext"
+version = "4.2.7"
+description = "Monkey-patching and extensions for django-stubs"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "django-stubs-ext-4.2.7.tar.gz", hash = "sha256:519342ac0849cda1559746c9a563f03ff99f636b0ebe7c14b75e816a00dfddc3"},
+ {file = "django_stubs_ext-4.2.7-py3-none-any.whl", hash = "sha256:45a5d102417a412e3606e3c358adb4744988a92b7b58ccf3fd64bddd5d04d14c"},
+]
+
+[package.dependencies]
+django = "*"
+typing-extensions = "*"
+
+[[package]]
+name = "elementpath"
+version = "4.4.0"
+description = "XPath 1.0/2.0/3.0/3.1 parsers and selectors for ElementTree and lxml"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "elementpath-4.4.0-py3-none-any.whl", hash = "sha256:cda092281afe508ece1bf65373905b30196c9426f3730cfea46059e103a131bd"},
+ {file = "elementpath-4.4.0.tar.gz", hash = "sha256:dfc4b8ca3d87966dcb0df40b5b6d04a98f053683271930fad9e7fa000924dfb2"},
+]
+
+[package.extras]
+dev = ["Sphinx", "coverage", "flake8", "lxml", "lxml-stubs", "memory-profiler", "memray", "mypy", "tox", "xmlschema (>=2.0.0)"]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.1"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
+ {file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "idna"
+version = "3.7"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
+ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "interrogate"
+version = "1.5.0"
+description = "Interrogate a codebase for docstring coverage."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "interrogate-1.5.0-py3-none-any.whl", hash = "sha256:a4ccc5cbd727c74acc98dee6f5e79ef264c0bcfa66b68d4e123069b2af89091a"},
+ {file = "interrogate-1.5.0.tar.gz", hash = "sha256:b6f325f0aa84ac3ac6779d8708264d366102226c5af7d69058cecffcff7a6d6c"},
+]
+
+[package.dependencies]
+attrs = "*"
+click = ">=7.1"
+colorama = "*"
+py = "*"
+tabulate = "*"
+toml = "*"
+
+[package.extras]
+dev = ["cairosvg", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "sphinx", "sphinx-autobuild", "wheel"]
+docs = ["sphinx", "sphinx-autobuild"]
+png = ["cairosvg"]
+tests = ["pytest", "pytest-cov", "pytest-mock"]
+
+[[package]]
+name = "mypy"
+version = "1.4.1"
+description = "Optional static typing for Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "mypy-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:566e72b0cd6598503e48ea610e0052d1b8168e60a46e0bfd34b3acf2d57f96a8"},
+ {file = "mypy-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca637024ca67ab24a7fd6f65d280572c3794665eaf5edcc7e90a866544076878"},
+ {file = "mypy-1.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dde1d180cd84f0624c5dcaaa89c89775550a675aff96b5848de78fb11adabcd"},
+ {file = "mypy-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8c4d8e89aa7de683e2056a581ce63c46a0c41e31bd2b6d34144e2c80f5ea53dc"},
+ {file = "mypy-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:bfdca17c36ae01a21274a3c387a63aa1aafe72bff976522886869ef131b937f1"},
+ {file = "mypy-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7549fbf655e5825d787bbc9ecf6028731973f78088fbca3a1f4145c39ef09462"},
+ {file = "mypy-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98324ec3ecf12296e6422939e54763faedbfcc502ea4a4c38502082711867258"},
+ {file = "mypy-1.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141dedfdbfe8a04142881ff30ce6e6653c9685b354876b12e4fe6c78598b45e2"},
+ {file = "mypy-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8207b7105829eca6f3d774f64a904190bb2231de91b8b186d21ffd98005f14a7"},
+ {file = "mypy-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:16f0db5b641ba159eff72cff08edc3875f2b62b2fa2bc24f68c1e7a4e8232d01"},
+ {file = "mypy-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:470c969bb3f9a9efcedbadcd19a74ffb34a25f8e6b0e02dae7c0e71f8372f97b"},
+ {file = "mypy-1.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5952d2d18b79f7dc25e62e014fe5a23eb1a3d2bc66318df8988a01b1a037c5b"},
+ {file = "mypy-1.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:190b6bab0302cec4e9e6767d3eb66085aef2a1cc98fe04936d8a42ed2ba77bb7"},
+ {file = "mypy-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9d40652cc4fe33871ad3338581dca3297ff5f2213d0df345bcfbde5162abf0c9"},
+ {file = "mypy-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:01fd2e9f85622d981fd9063bfaef1aed6e336eaacca00892cd2d82801ab7c042"},
+ {file = "mypy-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2460a58faeea905aeb1b9b36f5065f2dc9a9c6e4c992a6499a2360c6c74ceca3"},
+ {file = "mypy-1.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2746d69a8196698146a3dbe29104f9eb6a2a4d8a27878d92169a6c0b74435b6"},
+ {file = "mypy-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae704dcfaa180ff7c4cfbad23e74321a2b774f92ca77fd94ce1049175a21c97f"},
+ {file = "mypy-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:43d24f6437925ce50139a310a64b2ab048cb2d3694c84c71c3f2a1626d8101dc"},
+ {file = "mypy-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c482e1246726616088532b5e964e39765b6d1520791348e6c9dc3af25b233828"},
+ {file = "mypy-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43b592511672017f5b1a483527fd2684347fdffc041c9ef53428c8dc530f79a3"},
+ {file = "mypy-1.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34a9239d5b3502c17f07fd7c0b2ae6b7dd7d7f6af35fbb5072c6208e76295816"},
+ {file = "mypy-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5703097c4936bbb9e9bce41478c8d08edd2865e177dc4c52be759f81ee4dd26c"},
+ {file = "mypy-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e02d700ec8d9b1859790c0475df4e4092c7bf3272a4fd2c9f33d87fac4427b8f"},
+ {file = "mypy-1.4.1-py3-none-any.whl", hash = "sha256:45d32cec14e7b97af848bddd97d85ea4f0db4d5a149ed9676caa4eb2f7402bb4"},
+ {file = "mypy-1.4.1.tar.gz", hash = "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b"},
+]
+
+[package.dependencies]
+mypy-extensions = ">=1.0.0"
+tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+typing-extensions = ">=4.1.0"
+
+[package.extras]
+dmypy = ["psutil (>=4.0)"]
+install-types = ["pip"]
+python2 = ["typed-ast (>=1.4.0,<2)"]
+reports = ["lxml"]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "packageurl-python"
+version = "0.15.0"
+description = "A purl aka. Package URL parser and builder"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "packageurl-python-0.15.0.tar.gz", hash = "sha256:f219b2ce6348185a27bd6a72e6fdc9f984e6c9fa157effa7cb93e341c49cdcc2"},
+ {file = "packageurl_python-0.15.0-py3-none-any.whl", hash = "sha256:cdc6bd42dc30c4fc7f8f0ccb721fc31f8c33985dbffccb6e6be4c72874de48ca"},
+]
+
+[package.extras]
+build = ["setuptools", "wheel"]
+lint = ["black", "isort", "mypy"]
+sqlalchemy = ["sqlalchemy (>=2.0.0)"]
+test = ["pytest"]
+
+[[package]]
+name = "packaging"
+version = "24.0"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
+ {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
+]
+
+[[package]]
+name = "pip-requirements-parser"
+version = "32.0.1"
+description = "pip requirements parser - a mostly correct pip requirements parsing library because it uses pip's own code."
+optional = false
+python-versions = ">=3.6.0"
+files = [
+ {file = "pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3"},
+ {file = "pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526"},
+]
+
+[package.dependencies]
+packaging = "*"
+pyparsing = "*"
+
+[package.extras]
+docs = ["Sphinx (>=3.3.1)", "doc8 (>=0.8.1)", "sphinx-rtd-theme (>=0.5.0)"]
+testing = ["aboutcode-toolkit (>=6.0.0)", "black", "pytest (>=6,!=7.0.0)", "pytest-xdist (>=2)"]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
+ {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "py"
+version = "1.11.0"
+description = "library with cross-python path, ini-parsing, io, code, log facilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+files = [
+ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
+ {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
+]
+
+[[package]]
+name = "pycparser"
+version = "2.22"
+description = "C parser in Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
+ {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
+]
+
+[[package]]
+name = "pyjwt"
+version = "2.8.0"
+description = "JSON Web Token implementation in Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "PyJWT-2.8.0-py3-none-any.whl", hash = "sha256:59127c392cc44c2da5bb3192169a91f429924e17aff6534d70fdc02ab3e04320"},
+ {file = "PyJWT-2.8.0.tar.gz", hash = "sha256:57e28d156e3d5c10088e0c68abb90bfac3df82b40a71bd0daa20c65ccd5c23de"},
+]
+
+[package.extras]
+crypto = ["cryptography (>=3.4.0)"]
+dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
+docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
+tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
+
+[[package]]
+name = "pyopenssl"
+version = "24.1.0"
+description = "Python wrapper module around the OpenSSL library"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pyOpenSSL-24.1.0-py3-none-any.whl", hash = "sha256:17ed5be5936449c5418d1cd269a1a9e9081bc54c17aed272b45856a3d3dc86ad"},
+ {file = "pyOpenSSL-24.1.0.tar.gz", hash = "sha256:cabed4bfaa5df9f1a16c0ef64a0cb65318b5cd077a7eda7d6970131ca2f41a6f"},
+]
+
+[package.dependencies]
+cryptography = ">=41.0.5,<43"
+
+[package.extras]
+docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"]
+test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"]
+
+[[package]]
+name = "pyparsing"
+version = "3.1.2"
+description = "pyparsing module - Classes and methods to define and execute parsing grammars"
+optional = false
+python-versions = ">=3.6.8"
+files = [
+ {file = "pyparsing-3.1.2-py3-none-any.whl", hash = "sha256:f9db75911801ed778fe61bb643079ff86601aca99fcae6345aa67292038fb742"},
+ {file = "pyparsing-3.1.2.tar.gz", hash = "sha256:a1bac0ce561155ecc3ed78ca94d3c9378656ad4c94c1270de543f621420f94ad"},
+]
+
+[package.extras]
+diagrams = ["jinja2", "railroad-diagrams"]
+
+[[package]]
+name = "pysaml2"
+version = "7.4.2"
+description = "Python implementation of SAML Version 2 Standard"
+optional = false
+python-versions = ">=3.9,<4.0"
+files = [
+ {file = "pysaml2-7.4.2-py3-none-any.whl", hash = "sha256:6616abe0526915cabef6af3a81570bd4c339bedd8db3ab12dcd4fa0612896837"},
+ {file = "pysaml2-7.4.2.tar.gz", hash = "sha256:2bc5147b3b2f902a9131bf08240c068becea29994aafb7654a63d7270ac5b63b"},
+]
+
+[package.dependencies]
+cryptography = ">=3.1"
+defusedxml = "*"
+pyopenssl = "*"
+python-dateutil = "*"
+pytz = "*"
+requests = ">=2,<3"
+xmlschema = ">=1.2.1"
+
+[package.extras]
+s2repoze = ["paste", "repoze.who", "zope.interface"]
+
+[[package]]
+name = "pytest"
+version = "8.1.1"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"},
+ {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.4,<2.0"
+tomli = {version = ">=1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-django"
+version = "4.5.2"
+description = "A Django plugin for pytest."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "pytest-django-4.5.2.tar.gz", hash = "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2"},
+ {file = "pytest_django-4.5.2-py3-none-any.whl", hash = "sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e"},
+]
+
+[package.dependencies]
+pytest = ">=5.4.0"
+
+[package.extras]
+docs = ["sphinx", "sphinx-rtd-theme"]
+testing = ["Django", "django-configurations (>=2.0)"]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "pytz"
+version = "2024.1"
+description = "World timezone definitions, modern and historical"
+optional = false
+python-versions = "*"
+files = [
+ {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"},
+ {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.1"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+ {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+]
+
+[[package]]
+name = "requests"
+version = "2.31.0"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
+ {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "responses"
+version = "0.25.0"
+description = "A utility library for mocking out the `requests` Python library."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "responses-0.25.0-py3-none-any.whl", hash = "sha256:2f0b9c2b6437db4b528619a77e5d565e4ec2a9532162ac1a131a83529db7be1a"},
+ {file = "responses-0.25.0.tar.gz", hash = "sha256:01ae6a02b4f34e39bffceb0fc6786b67a25eae919c6368d05eabc8d9576c2a66"},
+]
+
+[package.dependencies]
+pyyaml = "*"
+requests = ">=2.30.0,<3.0"
+urllib3 = ">=1.25.10,<3.0"
+
+[package.extras]
+tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asyncio", "pytest-cov", "pytest-httpserver", "tomli", "tomli-w", "types-PyYAML", "types-requests"]
+
+[[package]]
+name = "ruff"
+version = "0.4.1"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "ruff-0.4.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2d9ef6231e3fbdc0b8c72404a1a0c46fd0dcea84efca83beb4681c318ea6a953"},
+ {file = "ruff-0.4.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9485f54a7189e6f7433e0058cf8581bee45c31a25cd69009d2a040d1bd4bfaef"},
+ {file = "ruff-0.4.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2921ac03ce1383e360e8a95442ffb0d757a6a7ddd9a5be68561a671e0e5807e"},
+ {file = "ruff-0.4.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eec8d185fe193ad053eda3a6be23069e0c8ba8c5d20bc5ace6e3b9e37d246d3f"},
+ {file = "ruff-0.4.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa27d9d72a94574d250f42b7640b3bd2edc4c58ac8ac2778a8c82374bb27984"},
+ {file = "ruff-0.4.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f1ee41580bff1a651339eb3337c20c12f4037f6110a36ae4a2d864c52e5ef954"},
+ {file = "ruff-0.4.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0926cefb57fc5fced629603fbd1a23d458b25418681d96823992ba975f050c2b"},
+ {file = "ruff-0.4.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c6e37f2e3cd74496a74af9a4fa67b547ab3ca137688c484749189bf3a686ceb"},
+ {file = "ruff-0.4.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efd703a5975ac1998c2cc5e9494e13b28f31e66c616b0a76e206de2562e0843c"},
+ {file = "ruff-0.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b92f03b4aa9fa23e1799b40f15f8b95cdc418782a567d6c43def65e1bbb7f1cf"},
+ {file = "ruff-0.4.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1c859f294f8633889e7d77de228b203eb0e9a03071b72b5989d89a0cf98ee262"},
+ {file = "ruff-0.4.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b34510141e393519a47f2d7b8216fec747ea1f2c81e85f076e9f2910588d4b64"},
+ {file = "ruff-0.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6e68d248ed688b9d69fd4d18737edcbb79c98b251bba5a2b031ce2470224bdf9"},
+ {file = "ruff-0.4.1-py3-none-win32.whl", hash = "sha256:b90506f3d6d1f41f43f9b7b5ff845aeefabed6d2494307bc7b178360a8805252"},
+ {file = "ruff-0.4.1-py3-none-win_amd64.whl", hash = "sha256:c7d391e5936af5c9e252743d767c564670dc3889aff460d35c518ee76e4b26d7"},
+ {file = "ruff-0.4.1-py3-none-win_arm64.whl", hash = "sha256:a1eaf03d87e6a7cd5e661d36d8c6e874693cb9bc3049d110bc9a97b350680c43"},
+ {file = "ruff-0.4.1.tar.gz", hash = "sha256:d592116cdbb65f8b1b7e2a2b48297eb865f6bdc20641879aa9d7b9c11d86db79"},
+]
+
+[[package]]
+name = "setuptools"
+version = "67.8.0"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "setuptools-67.8.0-py3-none-any.whl", hash = "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f"},
+ {file = "setuptools-67.8.0.tar.gz", hash = "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+optional = false
+python-versions = "*"
+files = [
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
+]
+
+[[package]]
+name = "sqlparse"
+version = "0.5.0"
+description = "A non-validating SQL parser."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"},
+ {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"},
+]
+
+[package.extras]
+dev = ["build", "hatch"]
+doc = ["sphinx"]
+
+[[package]]
+name = "tabulate"
+version = "0.9.0"
+description = "Pretty-print tabular data"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f"},
+ {file = "tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c"},
+]
+
+[package.extras]
+widechars = ["wcwidth"]
+
+[[package]]
+name = "toml"
+version = "0.10.2"
+description = "Python Library for Tom's Obvious, Minimal Language"
+optional = false
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
+]
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[[package]]
+name = "types-pkg-resources"
+version = "0.1.3"
+description = "Typing stubs for pkg_resources"
+optional = false
+python-versions = "*"
+files = [
+ {file = "types-pkg_resources-0.1.3.tar.gz", hash = "sha256:834a9b8d3dbea343562fd99d5d3359a726f6bf9d3733bccd2b4f3096fbab9dae"},
+ {file = "types_pkg_resources-0.1.3-py2.py3-none-any.whl", hash = "sha256:0cb9972cee992249f93fff1a491bf2dc3ce674e5a1926e27d4f0866f7d9b6d9c"},
+]
+
+[[package]]
+name = "types-pysaml2"
+version = "1.0.1"
+description = "Type Stubs for pysaml2"
+optional = false
+python-versions = ">=3.7, <4"
+files = [
+ {file = "types-pysaml2-1.0.1.tar.gz", hash = "sha256:5b3191724179c8ea56f6c3ddf71b0ef1789d915fbe5840845c624b02370250cc"},
+ {file = "types_pysaml2-1.0.1-py3-none-any.whl", hash = "sha256:92996a9e746bf676d35d9ceea8c4321aa27074978d5e5198d75b97937dea49be"},
+]
+
+[package.extras]
+dev = ["mypy (==0.991)", "pipenv-setup (==3.2.0)", "pysaml2 (==7.2.1)", "twine (==4.0.2)", "types-requests (==2.28.11.7)", "types-six (==1.16.21.4)"]
+
+[[package]]
+name = "types-pytz"
+version = "2024.1.0.20240417"
+description = "Typing stubs for pytz"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "types-pytz-2024.1.0.20240417.tar.gz", hash = "sha256:6810c8a1f68f21fdf0f4f374a432487c77645a0ac0b31de4bf4690cf21ad3981"},
+ {file = "types_pytz-2024.1.0.20240417-py3-none-any.whl", hash = "sha256:8335d443310e2db7b74e007414e74c4f53b67452c0cb0d228ca359ccfba59659"},
+]
+
+[[package]]
+name = "types-pyyaml"
+version = "6.0.12.20240311"
+description = "Typing stubs for PyYAML"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "types-PyYAML-6.0.12.20240311.tar.gz", hash = "sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342"},
+ {file = "types_PyYAML-6.0.12.20240311-py3-none-any.whl", hash = "sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6"},
+]
+
+[[package]]
+name = "types-setuptools"
+version = "68.0.0.3"
+description = "Typing stubs for setuptools"
+optional = false
+python-versions = "*"
+files = [
+ {file = "types-setuptools-68.0.0.3.tar.gz", hash = "sha256:d57ae6076100b5704b3cc869fdefc671e1baf4c2cd6643f84265dfc0b955bf05"},
+ {file = "types_setuptools-68.0.0.3-py3-none-any.whl", hash = "sha256:fec09e5c18264c5c09351c00be01a34456fb7a88e457abe97401325f84ad9d36"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.11.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
+ {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
+]
+
+[[package]]
+name = "tzdata"
+version = "2024.1"
+description = "Provider of IANA time zone data"
+optional = false
+python-versions = ">=2"
+files = [
+ {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"},
+ {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.2.1"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"},
+ {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "xmlschema"
+version = "3.3.0"
+description = "An XML Schema validator and decoder"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "xmlschema-3.3.0-py3-none-any.whl", hash = "sha256:29fd761c705a90db771bca4252ee7967f036c2fa1012514dd782008c1354e665"},
+ {file = "xmlschema-3.3.0.tar.gz", hash = "sha256:3f603cd89f78ba94e467f6d9d9800fb42ee5f0bd2788202c794f4c1cdce41ab8"},
+]
+
+[package.dependencies]
+elementpath = ">=4.4.0,<5.0.0"
+
+[package.extras]
+codegen = ["elementpath (>=4.4.0,<5.0.0)", "jinja2"]
+dev = ["Sphinx", "coverage", "elementpath (>=4.4.0,<5.0.0)", "flake8", "jinja2", "lxml", "lxml-stubs", "memory-profiler", "mypy", "sphinx-rtd-theme", "tox"]
+docs = ["Sphinx", "elementpath (>=4.4.0,<5.0.0)", "jinja2", "sphinx-rtd-theme"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.10"
+content-hash = "7ee7ac6eb4d725322a2b8d6b8a3ee2690003b132a519278b17a0abd0c8975809"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..85289a0
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,62 @@
+[tool.poetry]
+name = "django-saml2-auth"
+version = "0.1.0"
+description = "Django SAML2 Authentication Made Easy."
+authors = ["Mostafa Moradian "]
+license = "Apache 2.0"
+readme = "README.md"
+
+[tool.poetry.dependencies]
+python = "^3.10"
+dictor = "0.1.11"
+PyJWT = "2.8.0"
+pysaml2 = "7.4.2"
+setuptools = "67.8.0"
+
+[tool.poetry.group.dev.dependencies]
+coverage = "7.5.0"
+cyclonedx-bom = "3.11.0"
+django-stubs = "4.2.0"
+interrogate = "1.5.0"
+mypy = "1.4.1"
+pytest = "8.1.1"
+pytest-django = "4.5.2"
+responses = "0.25.0"
+ruff = "^0.4.1"
+types-pkg-resources = "0.1.3"
+types-pysaml2 = "1.0.1"
+types-setuptools = "68.0.0.3"
+
+[tool.ruff]
+exclude = [
+ "dist",
+ "build",
+ "env",
+ "venv",
+ ".env",
+ ".venv",
+ ".tox",
+ ".git",
+ ".mypy_cache",
+ ".pytest_cache",
+ "__pycache__",
+ ".ruff",
+]
+line-length = 100
+
+[tool.pytest.ini_options]
+DJANGO_SETTINGS_MODULE = "django_saml2_auth.tests.settings"
+pythonpath = "."
+filterwarnings = "ignore::DeprecationWarning"
+addopts = ["--import-mode=importlib"]
+testpaths = ["django_saml2_auth/tests"]
+
+[tool.mypy]
+plugins = ["mypy_django_plugin.main"]
+
+[tool.django-stubs]
+django_settings_module = "django_saml2_auth.tests.settings"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index 85678a3..0000000
--- a/pytest.ini
+++ /dev/null
@@ -1,2 +0,0 @@
-[pytest]
-DJANGO_SETTINGS_MODULE = django_saml2_auth.tests.settings
\ No newline at end of file
diff --git a/requirements_test.txt b/requirements_test.txt
deleted file mode 100644
index 7a09f95..0000000
--- a/requirements_test.txt
+++ /dev/null
@@ -1,12 +0,0 @@
-pytest==7.4.0
-pytest-django==4.5.2
-responses==0.23.3
-mypy==1.4.1
-flake8==5.0.4
-django-stubs==4.2.0
-types-pysaml2==1.0.1
-types-setuptools==68.0.0.3
-types-pkg-resources==0.1.3
-interrogate==1.5.0
-coverage==7.2.7
-cyclonedx-bom==3.11.0
diff --git a/setup.py b/setup.py
index d051a1b..e374a8f 100644
--- a/setup.py
+++ b/setup.py
@@ -4,7 +4,7 @@
"""
from codecs import open
-from setuptools import (setup, find_packages)
+from setuptools import setup, find_packages
from os import path
here = path.abspath(path.dirname(__file__))
@@ -18,53 +18,31 @@
setup(
name="grafana_django_saml2_auth",
-
version="3.12.0",
-
description="Django SAML2 Authentication Made Easy.",
long_description=long_description,
long_description_content_type="text/markdown",
-
url="https://github.com/grafana/django-saml2-auth",
-
author="Fang Li",
author_email="surivlee+djsaml2auth@gmail.com",
-
maintainer="Mostafa Moradian",
maintainer_email="mostafa@grafana.com",
-
license="Apache 2.0",
-
classifiers=[
"Development Status :: 5 - Production/Stable",
-
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries :: Python Modules",
-
"License :: OSI Approved :: Apache Software License",
-
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.2",
-
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
-
- keywords=[
- "django",
- "saml",
- "saml2"
- "sso",
- "authentication",
- "okta",
- "standard"
- ],
-
+ keywords=["django", "saml", "saml2" "sso", "authentication", "okta", "standard"],
packages=find_packages(),
-
install_requires=requirements,
include_package_data=True,
)