tests: improve e2e/integration test reliability (#19540)

* add flakefinder

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* show local IP in test header

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* attempt to join worker on test finish

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add timeout

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add flush

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* stop -> close

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix rare test issue of this failing

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* check correctly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* un-serialize rollback?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* explicitly join before db teardown

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* skip flaky tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* new broker

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* classmethod

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* separate docker helpers

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only timeout functions

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* type and format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* show detected IP too

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2026-01-20 02:15:35 +01:00
committed by GitHub
parent ed17c53c70
commit 083b61ca7f
14 changed files with 234 additions and 120 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ def invalidate_flow_cache(sender, instance, **_):
if isinstance(instance, Flow):
total = delete_cache_prefix(f"{cache_key(instance)}*")
LOGGER.debug("Invalidating Flow cache", flow=instance, len=total)
if isinstance(instance, FlowStageBinding):
if isinstance(instance, FlowStageBinding) and instance.target_id:
total = delete_cache_prefix(f"{cache_key(instance.target)}*")
LOGGER.debug("Invalidating Flow cache from FlowStageBinding", binding=instance, len=total)
if isinstance(instance, Stage):
+2
View File
@@ -6,6 +6,7 @@ import pytest
from cryptography.hazmat.backends.openssl.backend import backend
from authentik import authentik_full_version
from tests.e2e.utils import get_local_ip
IS_CI = "CI" in environ
@@ -24,6 +25,7 @@ def pytest_report_header(*_, **__):
return [
f"authentik version: {authentik_full_version()}",
f"OpenSSL version: {OPENSSL_VERSION}, FIPS: {backend._fips_enabled}",
f"Local IP: {get_local_ip()} (Detected as {get_local_ip(override=False)})",
]
+5
View File
@@ -56,6 +56,10 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
if kwargs.get("no_capture", False):
self.args.append("--capture=no")
if kwargs.get("count", None):
self.args.append("--flake-finder")
self.args.append(f"--flake-runs={kwargs['count']}")
self._setup_test_environment()
def _setup_test_environment(self):
@@ -113,6 +117,7 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
action="store_true",
help="Disable any capturing of stdout/stderr during tests.",
)
parser.add_argument("--count", type=int, help="Re-run selected tests n times")
def _validate_test_label(self, label: str) -> bool:
"""Validate test label format"""
+4 -3
View File
@@ -1,9 +1,10 @@
from queue import PriorityQueue
import dramatiq
from django.utils.module_loading import import_string
from django_dramatiq_postgres.conf import Conf
from dramatiq import set_broker
from dramatiq.broker import Broker, MessageProxy, get_broker
from dramatiq.middleware.middleware import Middleware
from dramatiq.middleware.retries import Retries
from dramatiq.results.middleware import Results
from dramatiq.worker import Worker, _ConsumerThread, _WorkerThread
@@ -65,7 +66,7 @@ def use_test_broker():
actor.broker.declare_actor(actor)
for middleware_class, middleware_kwargs in Conf().middlewares:
middleware: dramatiq.middleware.middleware.Middleware = import_string(middleware_class)(
middleware: Middleware = import_string(middleware_class)(
**middleware_kwargs,
)
if isinstance(middleware, MetricsMiddleware):
@@ -79,4 +80,4 @@ def use_test_broker():
)
broker.add_middleware(middleware)
dramatiq.set_broker(broker)
set_broker(broker)
+5
View File
@@ -97,6 +97,7 @@ dev = [
"mypy==1.19.1",
"pdoc==16.0.0",
"pytest-django==4.11.1",
"pytest-flakefinder==1.1.0",
"pytest-github-actions-annotate-failures==0.3.0",
"pytest-randomly==4.0.1",
"pytest-timeout==2.4.0",
@@ -105,7 +106,11 @@ dev = [
"ruff==0.14.13",
"selenium==4.39.0",
"types-channels==4.3.0.20250822",
"types-docker==7.1.0.20260109",
"types-jwcrypto==1.5.0.20251102",
"types-ldap3==2.9.13.20251121",
"types-requests==2.32.4.20260107",
"types-zxcvbn==4.5.0.20250809",
]
[tool.uv]
+114
View File
@@ -0,0 +1,114 @@
"""authentik e2e testing utilities"""
from os import environ
from time import sleep
from typing import Any
from unittest.case import TestCase
from docker import DockerClient, from_env
from docker.errors import DockerException
from docker.models.containers import Container
from docker.models.networks import Network
from authentik.lib.generators import generate_id
from authentik.root.test_runner import get_docker_tag
IS_CI = "CI" in environ
class DockerTestCase(TestCase):
"""Mixin for dealing with containers"""
max_healthcheck_attempts = 45
__client: DockerClient
__network: Network
__label_id = generate_id()
def setUp(self) -> None:
self.__client = from_env()
self.__network = self.docker_client.networks.create(name=f"authentik-test-{generate_id()}")
@property
def docker_client(self) -> DockerClient:
return self.__client
@property
def docker_network(self) -> Network:
return self.__network
@property
def docker_labels(self) -> dict[str, str]:
return {"io.goauthentik.test": self.__label_id}
def wait_for_container(self, container: Container) -> Container:
"""Check that container is health"""
attempt = 0
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
sleep(1)
attempt += 1
if attempt >= self.max_healthcheck_attempts:
self.output_container_logs(container)
raise self.failureException("Container failed to start")
def get_container_image(self, base: str) -> str:
"""Try to pull docker image based on git branch, fallback to main if not found."""
image = f"{base}:gh-main"
try:
branch_image = f"{base}:{get_docker_tag()}"
self.docker_client.images.pull(branch_image)
return branch_image
except DockerException:
self.docker_client.images.pull(image)
return image
def run_container(self, **specs: Any) -> Container:
if "network_mode" not in specs:
specs["network"] = self.__network.name
specs["labels"] = self.docker_labels
specs["detach"] = True
if hasattr(self, "live_server_url"):
specs.setdefault("environment", {})
specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url
container: Container = self.docker_client.containers.run(**specs)
container.reload()
state = container.attrs.get("State", {})
if "Health" not in state:
return container
self.wait_for_container(container)
return container
def output_container_logs(self, container: Container | None = None) -> None:
"""Output the container logs to our STDOUT"""
if not container:
return
if IS_CI:
image = container.image
if image:
tags = image.tags[0] if len(image.tags) > 0 else str(image)
print(f"::group::Container logs - {tags}")
for log in container.logs().decode().split("\n"):
print(log)
if IS_CI:
print("::endgroup::")
def tearDown(self) -> None:
containers: list[Container] = self.docker_client.containers.list(
filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())}
)
for container in containers:
self.output_container_logs(container)
try:
container.kill()
except DockerException:
pass
try:
container.remove(force=True)
except DockerException:
pass
self.__network.remove()
@@ -1,6 +1,7 @@
"""test OAuth2 OpenID Provider flow"""
from time import sleep
from unittest import skip
from docker.types import Healthcheck
from selenium.webdriver.common.by import By
@@ -415,6 +416,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"Permission denied",
)
@skip("Flaky test")
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
+2
View File
@@ -2,6 +2,7 @@
from json import dumps
from time import sleep
from unittest import skip
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
@@ -582,6 +583,7 @@ class TestProviderSAML(SeleniumTestCase):
f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
)
@skip("Flaky test")
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
+16 -104
View File
@@ -10,7 +10,6 @@ from sys import stderr
from tempfile import gettempdir
from time import sleep
from typing import Any
from unittest.case import TestCase
from urllib.parse import urlencode
from django.apps import apps
@@ -19,10 +18,8 @@ from django.db import connection
from django.db.migrations.loader import MigrationLoader
from django.test.testcases import TransactionTestCase
from django.urls import reverse
from docker import DockerClient, from_env
from docker.errors import DockerException
from docker.models.containers import Container
from docker.models.networks import Network
from dramatiq import get_broker
from requests import RequestException
from selenium import webdriver
from selenium.common.exceptions import (
@@ -45,9 +42,9 @@ from structlog.stdlib import get_logger
from authentik.core.api.users import UserSerializer
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.lib.utils.http import get_http_session
from authentik.root.test_runner import get_docker_tag
from authentik.tasks.test import use_test_broker
from tests.docker import DockerTestCase
IS_CI = "CI" in environ
RETRIES = int(environ.get("RETRIES", "3")) if IS_CI else 1
@@ -56,114 +53,18 @@ SHADOW_ROOT_RETRIES = 5
JSONType = dict[str, Any] | list[Any] | str | int | float | bool | None
def get_local_ip() -> str:
def get_local_ip(override=True) -> str:
"""Get the local machine's IP"""
if local_ip := getenv("LOCAL_IP"):
if (local_ip := getenv("LOCAL_IP")) and override:
return local_ip
hostname = socket.gethostname()
ip_addr = socket.gethostbyname(hostname)
return ip_addr
class DockerTestCase(TestCase):
"""Mixin for dealing with containers"""
max_healthcheck_attempts = 45
__client: DockerClient
__network: Network
__label_id = generate_id()
def setUp(self) -> None:
self.__client = from_env()
self.__network = self.docker_client.networks.create(name=f"authentik-test-{generate_id()}")
@property
def docker_client(self) -> DockerClient:
return self.__client
@property
def docker_network(self) -> Network:
return self.__network
@property
def docker_labels(self) -> dict:
return {"io.goauthentik.test": self.__label_id}
def wait_for_container(self, container: Container):
"""Check that container is health"""
attempt = 0
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
sleep(1)
attempt += 1
if attempt >= self.max_healthcheck_attempts:
self.output_container_logs(container)
raise self.failureException("Container failed to start")
def get_container_image(self, base: str) -> str:
"""Try to pull docker image based on git branch, fallback to main if not found."""
image = f"{base}:gh-main"
try:
branch_image = f"{base}:{get_docker_tag()}"
self.docker_client.images.pull(branch_image)
return branch_image
except DockerException:
self.docker_client.images.pull(image)
return image
def run_container(self, **specs: dict[str, Any]) -> Container:
if "network_mode" not in specs:
specs["network"] = self.__network.name
specs["labels"] = self.docker_labels
specs["detach"] = True
if hasattr(self, "live_server_url"):
specs.setdefault("environment", {})
specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url
container = self.docker_client.containers.run(**specs)
container.reload()
state = container.attrs.get("State", {})
if "Health" not in state:
return container
self.wait_for_container(container)
return container
def output_container_logs(self, container: Container | None = None):
"""Output the container logs to our STDOUT"""
if IS_CI:
image = container.image
tags = image.tags[0] if len(image.tags) > 0 else str(image)
print(f"::group::Container logs - {tags}")
for log in container.logs().decode().split("\n"):
print(log)
if IS_CI:
print("::endgroup::")
def tearDown(self):
containers: list[Container] = self.docker_client.containers.list(
filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())}
)
for container in containers:
self.output_container_logs(container)
try:
container.kill()
except DockerException:
pass
try:
container.remove(force=True)
except DockerException:
pass
self.__network.remove()
class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
serialized_rollback = True
host = get_local_ip()
wait_timeout: int
user: User
@@ -232,6 +133,17 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
def driver_container(self) -> Container:
return self.docker_client.containers.list(filters={"label": "io.goauthentik.tests"})[0]
@classmethod
def _pre_setup(cls):
use_test_broker()
return super()._pre_setup()
def _post_teardown(self):
broker = get_broker()
broker.flush_all()
broker.close()
return super()._post_teardown()
def tearDown(self):
if IS_CI:
print("::endgroup::", file=stderr)
+4 -3
View File
@@ -20,7 +20,8 @@ from authentik.outposts.models import (
)
from authentik.outposts.tasks import outpost_connection_discovery
from authentik.providers.proxy.models import ProxyProvider
from tests.e2e.utils import DockerTestCase, get_docker_tag
from authentik.root.test_runner import get_docker_tag
from tests.docker import DockerTestCase
class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
@@ -88,7 +89,7 @@ class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
except PermissionError:
pass
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
@CONFIG.patch("outposts.container_image_base", "ghcr.io/goauthentik/dev-proxy:gh-main")
def test_docker_controller(self):
"""test that deployment requires update"""
@@ -96,7 +97,7 @@ class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
controller.up()
controller.down()
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
def test_docker_static(self):
"""test that deployment requires update"""
controller = DockerController(self.outpost, self.service_connection)
+4 -4
View File
@@ -53,7 +53,7 @@ class OutpostKubernetesTests(TestCase):
self.outpost.providers.add(self.provider)
self.outpost.save()
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
def test_deployment_reconciler(self):
"""test that deployment requires update"""
controller = ProxyKubernetesController(self.outpost, self.service_connection)
@@ -92,7 +92,7 @@ class OutpostKubernetesTests(TestCase):
deployment_reconciler.delete(deployment_reconciler.get_reference_object())
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
def test_service_reconciler(self):
"""test that service requires update"""
controller = ProxyKubernetesController(self.outpost, self.service_connection)
@@ -121,7 +121,7 @@ class OutpostKubernetesTests(TestCase):
service_reconciler.delete(service_reconciler.get_reference_object())
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
def test_controller_rename(self):
"""test that objects get deleted and re-created with new names"""
controller = ProxyKubernetesController(self.outpost, self.service_connection)
@@ -134,7 +134,7 @@ class OutpostKubernetesTests(TestCase):
apps.read_namespaced_deployment("test", self.outpost.config.kubernetes_namespace)
controller.down()
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
def test_controller_full_update(self):
"""Test an update that triggers all objects"""
controller = ProxyKubernetesController(self.outpost, self.service_connection)
+4 -3
View File
@@ -20,7 +20,8 @@ from authentik.outposts.models import (
from authentik.outposts.tasks import outpost_connection_discovery
from authentik.providers.proxy.controllers.docker import DockerController
from authentik.providers.proxy.models import ProxyProvider
from tests.e2e.utils import DockerTestCase, get_docker_tag
from authentik.root.test_runner import get_docker_tag
from tests.docker import DockerTestCase
class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
@@ -88,7 +89,7 @@ class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
except PermissionError:
pass
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
@CONFIG.patch("outposts.container_image_base", "ghcr.io/goauthentik/dev-proxy:gh-main")
def test_docker_controller(self):
"""test that deployment requires update"""
@@ -96,7 +97,7 @@ class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
controller.up()
controller.down()
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
def test_docker_static(self):
"""test that deployment requires update"""
controller = DockerController(self.outpost, self.service_connection)
+2 -2
View File
@@ -26,7 +26,7 @@ class TestProxyKubernetes(TestCase):
outpost_connection_discovery.send()
self.controller = None
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
def test_kubernetes_controller_static(self):
"""Test Kubernetes Controller"""
provider: ProxyProvider = ProxyProvider.objects.create(
@@ -48,7 +48,7 @@ class TestProxyKubernetes(TestCase):
manifest = self.controller.get_static_deployment()
self.assertEqual(len(list(yaml.load_all(manifest, Loader=yaml.SafeLoader))), 4)
@pytest.mark.timeout(120)
@pytest.mark.timeout(120, func_only=True)
def test_kubernetes_controller_ingress(self):
"""Test Kubernetes Controller's Ingress"""
provider: ProxyProvider = ProxyProvider.objects.create(
Generated
+69
View File
@@ -308,6 +308,7 @@ dev = [
{ name = "pdoc" },
{ name = "pytest" },
{ name = "pytest-django" },
{ name = "pytest-flakefinder" },
{ name = "pytest-github-actions-annotate-failures" },
{ name = "pytest-randomly" },
{ name = "pytest-timeout" },
@@ -315,7 +316,11 @@ dev = [
{ name = "ruff" },
{ name = "selenium" },
{ name = "types-channels" },
{ name = "types-docker" },
{ name = "types-jwcrypto" },
{ name = "types-ldap3" },
{ name = "types-requests" },
{ name = "types-zxcvbn" },
]
[package.metadata]
@@ -413,6 +418,7 @@ dev = [
{ name = "pdoc", specifier = "==16.0.0" },
{ name = "pytest", specifier = "==9.0.2" },
{ name = "pytest-django", specifier = "==4.11.1" },
{ name = "pytest-flakefinder", specifier = "==1.1.0" },
{ name = "pytest-github-actions-annotate-failures", specifier = "==0.3.0" },
{ name = "pytest-randomly", specifier = "==4.0.1" },
{ name = "pytest-timeout", specifier = "==2.4.0" },
@@ -420,7 +426,11 @@ dev = [
{ name = "ruff", specifier = "==0.14.13" },
{ name = "selenium", specifier = "==4.39.0" },
{ name = "types-channels", specifier = "==4.3.0.20250822" },
{ name = "types-docker", specifier = "==7.1.0.20260109" },
{ name = "types-jwcrypto", specifier = "==1.5.0.20251102" },
{ name = "types-ldap3", specifier = "==2.9.13.20251121" },
{ name = "types-requests", specifier = "==2.32.4.20260107" },
{ name = "types-zxcvbn", specifier = "==4.5.0.20250809" },
]
[[package]]
@@ -3029,6 +3039,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
]
[[package]]
name = "pytest-flakefinder"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/53/69c56a93ea057895b5761c5318455804873a6cd9d796d7c55d41c2358125/pytest-flakefinder-1.1.0.tar.gz", hash = "sha256:e2412a1920bdb8e7908783b20b3d57e9dad590cc39a93e8596ffdd493b403e0e", size = 6795, upload-time = "2022-10-26T18:27:54.243Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" },
]
[[package]]
name = "pytest-github-actions-annotate-failures"
version = "0.3.0"
@@ -3601,6 +3623,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/52/4e3094e43d460feacb9051ec4c3498f8272f69d92b772647211478b25079/types_channels-4.3.0.20250822-py3-none-any.whl", hash = "sha256:d3fc0a1467c8cc901686826408c8a673822e07aa79cbe1a6d21946e7e55d9ddf", size = 21125, upload-time = "2025-08-22T03:04:25.539Z" },
]
[[package]]
name = "types-docker"
version = "7.1.0.20260109"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-paramiko" },
{ name = "types-requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/54/08/ffef2a8e29e9e22c724f9c1b22563c0938c3ab3fa728ff5b966465e12b93/types_docker-7.1.0.20260109.tar.gz", hash = "sha256:b36ef355ec9ba8bf29bcc4e32cc61dd9138ce4d8352c599c8fbc65f1a3e87b57", size = 32551, upload-time = "2026-01-09T03:21:49.238Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/0d/cdf37dcd0cd4c942a1634daf3ae3a99833791c7a316bff4d4ce04a30652e/types_docker-7.1.0.20260109-py3-none-any.whl", hash = "sha256:001a5a377d3fb287b7279cf4265b8ba3857e7d4203a16ab03e6e512f68f2f3d4", size = 47216, upload-time = "2026-01-09T03:21:48.059Z" },
]
[[package]]
name = "types-jwcrypto"
version = "1.5.0.20251102"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/a7/103a4b02c6fb8718994252d5840b11d770f090d4100aa460194cc009bc62/types_jwcrypto-1.5.0.20251102.tar.gz", hash = "sha256:c3b93a85d130a1c16999d2a3c435e5bd6a9b394754239190c5fe49cedcc0a98f", size = 11637, upload-time = "2025-11-02T03:07:38.388Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/15/e305c0ae6aaca68842c73fee5080eadd51a702a1562f05d9653d6e9b5a94/types_jwcrypto-1.5.0.20251102-py3-none-any.whl", hash = "sha256:506c93a09c6a988fc5a56bfe92f0cf80b31a0acee98bd6e807277bb0c6f8c1d0", size = 12978, upload-time = "2025-11-02T03:07:37.398Z" },
]
[[package]]
name = "types-ldap3"
version = "2.9.13.20251121"
@@ -3613,6 +3661,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/28/05989820ae4694b15acf9fe26c73901fda117d99720954e969b5ec498399/types_ldap3-2.9.13.20251121-py3-none-any.whl", hash = "sha256:20356bf413cb178898f5b171463b44b82044b8b69f9331e09950009cfef05e48", size = 56808, upload-time = "2025-11-21T03:03:41.926Z" },
]
[[package]]
name = "types-paramiko"
version = "4.0.0.20250822"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/b8/c6ff3b10c2f7b9897650af746f0dc6c5cddf054db857bc79d621f53c7d22/types_paramiko-4.0.0.20250822.tar.gz", hash = "sha256:1b56b0cbd3eec3d2fd123c9eb2704e612b777e15a17705a804279ea6525e0c53", size = 28730, upload-time = "2025-08-22T03:03:43.262Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/a1/b3774ed924a66ee2c041224d89c36f0c21f4f6cf75036d6ee7698bf8a4b9/types_paramiko-4.0.0.20250822-py3-none-any.whl", hash = "sha256:55bdb14db75ca89039725ec64ae3fa26b8d57b6991cfb476212fa8f83a59753c", size = 38833, upload-time = "2025-08-22T03:03:42.072Z" },
]
[[package]]
name = "types-pyasn1"
version = "0.6.0.20250914"
@@ -3643,6 +3703,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" },
]
[[package]]
name = "types-zxcvbn"
version = "4.5.0.20250809"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/27/70ca6f3f295c48f87495e283061e60d53a814853bd213f24125cf4192cad/types_zxcvbn-4.5.0.20250809.tar.gz", hash = "sha256:da19c7c416ad26ecb934110260375e687f37f1ed897522214d97ca2e9ccb2de5", size = 9389, upload-time = "2025-08-09T03:15:01.058Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/e0/0af4e865c28da104fa6ec45a2a336521fe34cbe606ef2ab6984b14d96e2c/types_zxcvbn-4.5.0.20250809-py3-none-any.whl", hash = "sha256:2cd151a5b35a976ae22017b5caed8f99d5e1be455cf9f9497cd86419073bc9cb", size = 10821, upload-time = "2025-08-09T03:15:00.299Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"