diff --git a/authentik/flows/signals.py b/authentik/flows/signals.py index 4ceda51520..703da5606b 100644 --- a/authentik/flows/signals.py +++ b/authentik/flows/signals.py @@ -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): diff --git a/authentik/root/test_plugin.py b/authentik/root/test_plugin.py index caab7803a8..64057f6aac 100644 --- a/authentik/root/test_plugin.py +++ b/authentik/root/test_plugin.py @@ -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)})", ] diff --git a/authentik/root/test_runner.py b/authentik/root/test_runner.py index b624bd663c..7f24e2dbdf 100644 --- a/authentik/root/test_runner.py +++ b/authentik/root/test_runner.py @@ -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""" diff --git a/authentik/tasks/test.py b/authentik/tasks/test.py index 168b44ad75..def9fd4e82 100644 --- a/authentik/tasks/test.py +++ b/authentik/tasks/test.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 46169e60fb..d5a9e2a941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/tests/docker.py b/tests/docker.py new file mode 100644 index 0000000000..8a5cdf6ce7 --- /dev/null +++ b/tests/docker.py @@ -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() diff --git a/tests/e2e/test_provider_oauth2_grafana.py b/tests/e2e/test_provider_oauth2_grafana.py index 569a47b63f..bd540b7350 100644 --- a/tests/e2e/test_provider_oauth2_grafana.py +++ b/tests/e2e/test_provider_oauth2_grafana.py @@ -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", diff --git a/tests/e2e/test_provider_saml.py b/tests/e2e/test_provider_saml.py index 2f5a2829c8..f4d6bacb25 100644 --- a/tests/e2e/test_provider_saml.py +++ b/tests/e2e/test_provider_saml.py @@ -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", diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index 44c895b0d6..12e4147495 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -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) diff --git a/tests/integration/test_outpost_docker.py b/tests/integration/test_outpost_docker.py index 41c0882380..3398a464ab 100644 --- a/tests/integration/test_outpost_docker.py +++ b/tests/integration/test_outpost_docker.py @@ -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) diff --git a/tests/integration/test_outpost_kubernetes.py b/tests/integration/test_outpost_kubernetes.py index 01566c4f06..e77e9daf91 100644 --- a/tests/integration/test_outpost_kubernetes.py +++ b/tests/integration/test_outpost_kubernetes.py @@ -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) diff --git a/tests/integration/test_proxy_docker.py b/tests/integration/test_proxy_docker.py index bebe376e97..14abfa2ae3 100644 --- a/tests/integration/test_proxy_docker.py +++ b/tests/integration/test_proxy_docker.py @@ -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) diff --git a/tests/integration/test_proxy_kubernetes.py b/tests/integration/test_proxy_kubernetes.py index 262e57ad45..da994bd228 100644 --- a/tests/integration/test_proxy_kubernetes.py +++ b/tests/integration/test_proxy_kubernetes.py @@ -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( diff --git a/uv.lock b/uv.lock index 445c18db8d..d6f7978cd4 100644 --- a/uv.lock +++ b/uv.lock @@ -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"