From 852b047c1ef9cde4e8aa4eb25717e5b9ce0a1fe1 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Tue, 28 Oct 2025 20:44:52 +0100 Subject: [PATCH 01/15] fix(django): handle request.user in async middleware context Fixes SynchronousOnlyOperation when accessing request.user in ASGI deployments. Django's request.user is a lazy object that triggers DB access when touched. In async context, this raises SynchronousOnlyOperation. The middleware now uses request.auser() in async paths to avoid blocking calls. Changes: - Add aextract_tags() and aextract_request_user() methods - Update __acall__() to use async versions - Add test verifying user extraction works in async context - Follow Django's naming convention for async methods (auser, asave, etc.) The issue was introduced in v6.7.5 (PR #328) but only became apparent after v6.7.10 (PR #348) made ASGI functional enough for users to discover it. Fixes #355 --- posthog/integrations/django.py | 99 +++++++++++++++++++- posthog/test/integrations/test_middleware.py | 44 +++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/posthog/integrations/django.py b/posthog/integrations/django.py index 99987c8a..8d9d17e1 100644 --- a/posthog/integrations/django.py +++ b/posthog/integrations/django.py @@ -184,6 +184,101 @@ def extract_request_user(self, request): return user_id, email + async def aextract_tags(self, request): + # type: (HttpRequest) -> Dict[str, Any] + """ + Async version of extract_tags for use in async request handling. + + Uses await request.auser() instead of request.user to avoid + SynchronousOnlyOperation in async context. + + Follows Django's naming convention for async methods (auser, asave, etc.). + """ + tags = {} + + (user_id, user_email) = await self.aextract_request_user(request) + + # Extract session ID from X-POSTHOG-SESSION-ID header + session_id = request.headers.get("X-POSTHOG-SESSION-ID") + if session_id: + contexts.set_context_session(session_id) + + # Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id + distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id + if distinct_id: + contexts.identify_context(distinct_id) + + # Extract user email + if user_email: + tags["email"] = user_email + + # Extract current URL + absolute_url = request.build_absolute_uri() + if absolute_url: + tags["$current_url"] = absolute_url + + # Extract request method + if request.method: + tags["$request_method"] = request.method + + # Extract request path + if request.path: + tags["$request_path"] = request.path + + # Extract IP address + ip_address = request.headers.get("X-Forwarded-For") + if ip_address: + tags["$ip_address"] = ip_address + + # Extract user agent + user_agent = request.headers.get("User-Agent") + if user_agent: + tags["$user_agent"] = user_agent + + # Apply extra tags if configured + if self.extra_tags: + extra = self.extra_tags(request) + if extra: + tags.update(extra) + + # Apply tag mapping if configured + if self.tag_map: + tags = self.tag_map(tags) + + return tags + + async def aextract_request_user(self, request): + """ + Async version of extract_request_user for use in async request handling. + + Uses await request.auser() instead of request.user to avoid + SynchronousOnlyOperation in async context. + + Follows Django's naming convention for async methods (auser, asave, etc.). + """ + user_id = None + email = None + + # In async context, use auser() instead of user attribute + if hasattr(request, "auser"): + user = await request.auser() + else: + # Fallback for non-Django or test requests + user = getattr(request, "user", None) + + if user and getattr(user, "is_authenticated", False): + try: + user_id = str(user.pk) + except Exception: + pass + + try: + email = str(user.email) + except Exception: + pass + + return user_id, email + def __call__(self, request): # type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]] """ @@ -211,12 +306,14 @@ async def __acall__(self, request): Asynchronous entry point for async request handling. This method is called when the middleware chain is async. + Uses aextract_tags() which calls request.auser() to avoid + SynchronousOnlyOperation when accessing user in async context. """ if self.request_filter and not self.request_filter(request): return await self.get_response(request) with contexts.new_context(self.capture_exceptions, client=self.client): - for k, v in self.extract_tags(request).items(): + for k, v in (await self.aextract_tags(request)).items(): contexts.tag(k, v) return await self.get_response(request) diff --git a/posthog/test/integrations/test_middleware.py b/posthog/test/integrations/test_middleware.py index 0e214382..107b0cbc 100644 --- a/posthog/test/integrations/test_middleware.py +++ b/posthog/test/integrations/test_middleware.py @@ -499,6 +499,50 @@ async def raise_exception(request): asyncio.run(run_test()) + def test_async_middleware_with_authenticated_user(self): + """ + Test that async middleware correctly extracts user info in async context. + + Django's request.user is a SimpleLazyObject that defers DB access. + In async context, accessing it directly raises SynchronousOnlyOperation. + The middleware should use request.auser() instead. + + This tests the fix for issue #355. + """ + + async def run_test(): + mock_response = Mock() + mock_user = Mock() + mock_user.is_authenticated = True + mock_user.pk = 123 + mock_user.email = "test@example.com" + + async def async_get_response(request): + # Verify user info was extracted and set as distinct_id + distinct_id = get_context_distinct_id() + self.assertEqual(distinct_id, "123") + return mock_response + + middleware = PosthogContextMiddleware(async_get_response) + middleware.client = Mock() + + request = MockRequest( + headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET" + ) + + # Mock auser() to return authenticated user + async def mock_auser(): + return mock_user + + request.auser = mock_auser + + with new_context(): + result = middleware(request) + response = await result + self.assertEqual(response, mock_response) + + asyncio.run(run_test()) + class TestPosthogContextMiddlewareHybrid(unittest.TestCase): """Test hybrid middleware behavior with mixed sync/async chains""" From 15963d002c7f3200801a5fdbf53fb30666408f27 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Tue, 28 Oct 2025 20:54:43 +0100 Subject: [PATCH 02/15] test(django): add comprehensive async middleware tests Add 5 additional tests covering edge cases for async middleware: - Unauthenticated users - Requests without user attribute (no auth middleware) - extra_tags callbacks in async context - tag_map callbacks in async context - Full header extraction with authenticated user Ensures async middleware works correctly in all scenarios users might encounter. 24 middleware tests now pass (up from 19). --- posthog/test/integrations/test_middleware.py | 179 +++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/posthog/test/integrations/test_middleware.py b/posthog/test/integrations/test_middleware.py index 107b0cbc..403c1b67 100644 --- a/posthog/test/integrations/test_middleware.py +++ b/posthog/test/integrations/test_middleware.py @@ -543,6 +543,185 @@ async def mock_auser(): asyncio.run(run_test()) + def test_async_middleware_with_unauthenticated_user(self): + """ + Test that async middleware handles unauthenticated users correctly. + """ + + async def run_test(): + mock_response = Mock() + mock_user = Mock() + mock_user.is_authenticated = False # Not authenticated + + async def async_get_response(request): + # Verify no distinct_id was set (no user) + distinct_id = get_context_distinct_id() + self.assertIsNone(distinct_id) + return mock_response + + middleware = PosthogContextMiddleware(async_get_response) + middleware.client = Mock() + + request = MockRequest( + headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET" + ) + + async def mock_auser(): + return mock_user + + request.auser = mock_auser + + with new_context(): + result = middleware(request) + response = await result + self.assertEqual(response, mock_response) + + asyncio.run(run_test()) + + def test_async_middleware_without_user_attribute(self): + """ + Test that async middleware handles requests without user attribute (no auth middleware). + """ + + async def run_test(): + mock_response = Mock() + + async def async_get_response(request): + return mock_response + + middleware = PosthogContextMiddleware(async_get_response) + middleware.client = Mock() + + # Request without auser method (no auth middleware) + request = MockRequest( + headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET" + ) + + with new_context(): + result = middleware(request) + response = await result + self.assertEqual(response, mock_response) + + asyncio.run(run_test()) + + def test_async_middleware_with_extra_tags(self): + """ + Test that async middleware works with extra_tags callback. + """ + + async def run_test(): + mock_response = Mock() + + def extra_tags_callback(request): + # Simple sync callback - should work + return {"custom_tag": "custom_value"} + + async def async_get_response(request): + return mock_response + + middleware = PosthogContextMiddleware(async_get_response) + middleware.extra_tags = extra_tags_callback + middleware.client = Mock() + + request = MockRequest( + headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET" + ) + + # Mock auser for no user + async def mock_auser(): + return None + + request.auser = mock_auser + + with new_context(): + result = middleware(request) + response = await result + self.assertEqual(response, mock_response) + + asyncio.run(run_test()) + + def test_async_middleware_with_tag_map(self): + """ + Test that async middleware works with tag_map callback. + """ + + async def run_test(): + mock_response = Mock() + + def tag_map_callback(tags): + # Simple sync callback - should work + tags["mapped"] = "yes" + return tags + + async def async_get_response(request): + return mock_response + + middleware = PosthogContextMiddleware(async_get_response) + middleware.tag_map = tag_map_callback + middleware.client = Mock() + + request = MockRequest( + headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET" + ) + + # Mock auser for no user + async def mock_auser(): + return None + + request.auser = mock_auser + + with new_context(): + result = middleware(request) + response = await result + self.assertEqual(response, mock_response) + + asyncio.run(run_test()) + + def test_async_middleware_user_extraction_with_all_headers(self): + """ + Test async middleware extracts all request info correctly. + """ + + async def run_test(): + mock_response = Mock() + mock_user = Mock() + mock_user.is_authenticated = True + mock_user.pk = 456 + mock_user.email = "async@test.com" + + async def async_get_response(request): + # Verify all context was set correctly + distinct_id = get_context_distinct_id() + session_id = get_context_session_id() + self.assertEqual(distinct_id, "456") + self.assertEqual(session_id, "async-sess-123") + return mock_response + + middleware = PosthogContextMiddleware(async_get_response) + middleware.client = Mock() + + request = MockRequest( + headers={ + "X-POSTHOG-SESSION-ID": "async-sess-123", + "X-Forwarded-For": "192.168.1.1", + "User-Agent": "TestAgent/1.0", + }, + method="POST", + path="/api/test", + ) + + async def mock_auser(): + return mock_user + + request.auser = mock_auser + + with new_context(): + result = middleware(request) + response = await result + self.assertEqual(response, mock_response) + + asyncio.run(run_test()) + class TestPosthogContextMiddlewareHybrid(unittest.TestCase): """Test hybrid middleware behavior with mixed sync/async chains""" From 8e5444f3049b09ce07cf1d109c882134b8ab879d Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 08:27:08 +0100 Subject: [PATCH 03/15] refactor(django): DRY improvements to async middleware Extract common logic into helper methods to eliminate duplication: - Add _build_tags() helper used by both extract_tags and aextract_tags Eliminates ~50 lines of duplicated tag extraction logic - Add _resolve_user_details() helper for user info extraction Centralizes user ID/email extraction logic - Use defensive getattr() instead of try/except More readable and explicit about what can be None - Handle callable is_authenticated for legacy Django Supports Django versions where is_authenticated was a method Benefits: - Single source of truth for tag extraction logic - Easier maintenance (change once, applies to sync and async) - More robust user detail extraction - All 24 tests still pass Inspired by code review feedback while maintaining Django conventions (aextract_* naming pattern). --- posthog/integrations/django.py | 141 +++++++++++++-------------------- 1 file changed, 56 insertions(+), 85 deletions(-) diff --git a/posthog/integrations/django.py b/posthog/integrations/django.py index 8d9d17e1..55579f57 100644 --- a/posthog/integrations/django.py +++ b/posthog/integrations/django.py @@ -112,9 +112,18 @@ def __init__(self, get_response): def extract_tags(self, request): # type: (HttpRequest) -> Dict[str, Any] - tags = {} + """Extract tags from request in sync context.""" + user_id, user_email = self.extract_request_user(request) + return self._build_tags(request, user_id, user_email) + + def _build_tags(self, request, user_id, user_email): + # type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any] + """ + Build tags dict from request and user info. - (user_id, user_email) = self.extract_request_user(request) + Centralized tag extraction logic used by both sync and async paths. + """ + tags = {} # Extract session ID from X-POSTHOG-SESSION-ID header session_id = request.headers.get("X-POSTHOG-SESSION-ID") @@ -166,23 +175,10 @@ def extract_tags(self, request): return tags def extract_request_user(self, request): - user_id = None - email = None - + # type: (HttpRequest) -> tuple[Optional[str], Optional[str]] + """Extract user ID and email from request in sync context.""" user = getattr(request, "user", None) - - if user and getattr(user, "is_authenticated", False): - try: - user_id = str(user.pk) - except Exception: - pass - - try: - email = str(user.email) - except Exception: - pass - - return user_id, email + return self._resolve_user_details(user) async def aextract_tags(self, request): # type: (HttpRequest) -> Dict[str, Any] @@ -194,60 +190,11 @@ async def aextract_tags(self, request): Follows Django's naming convention for async methods (auser, asave, etc.). """ - tags = {} - - (user_id, user_email) = await self.aextract_request_user(request) - - # Extract session ID from X-POSTHOG-SESSION-ID header - session_id = request.headers.get("X-POSTHOG-SESSION-ID") - if session_id: - contexts.set_context_session(session_id) - - # Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id - distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id - if distinct_id: - contexts.identify_context(distinct_id) - - # Extract user email - if user_email: - tags["email"] = user_email - - # Extract current URL - absolute_url = request.build_absolute_uri() - if absolute_url: - tags["$current_url"] = absolute_url - - # Extract request method - if request.method: - tags["$request_method"] = request.method - - # Extract request path - if request.path: - tags["$request_path"] = request.path - - # Extract IP address - ip_address = request.headers.get("X-Forwarded-For") - if ip_address: - tags["$ip_address"] = ip_address - - # Extract user agent - user_agent = request.headers.get("User-Agent") - if user_agent: - tags["$user_agent"] = user_agent - - # Apply extra tags if configured - if self.extra_tags: - extra = self.extra_tags(request) - if extra: - tags.update(extra) - - # Apply tag mapping if configured - if self.tag_map: - tags = self.tag_map(tags) - - return tags + user_id, user_email = await self.aextract_request_user(request) + return self._build_tags(request, user_id, user_email) async def aextract_request_user(self, request): + # type: (HttpRequest) -> tuple[Optional[str], Optional[str]] """ Async version of extract_request_user for use in async request handling. @@ -256,26 +203,50 @@ async def aextract_request_user(self, request): Follows Django's naming convention for async methods (auser, asave, etc.). """ + auser = getattr(request, "auser", None) + if callable(auser): + try: + user = await auser() + return self._resolve_user_details(user) + except Exception: + # If auser() fails, return empty - don't break the request + # Real errors (permissions, broken auth) will be logged by Django + return None, None + + # Fallback for test requests without auser + return None, None + + def _resolve_user_details(self, user): + # type: (Any) -> tuple[Optional[str], Optional[str]] + """ + Extract user ID and email from a user object. + + Handles both authenticated and unauthenticated users, as well as + legacy Django where is_authenticated was a method. + """ user_id = None email = None - # In async context, use auser() instead of user attribute - if hasattr(request, "auser"): - user = await request.auser() - else: - # Fallback for non-Django or test requests - user = getattr(request, "user", None) + if user is None: + return user_id, email - if user and getattr(user, "is_authenticated", False): - try: - user_id = str(user.pk) - except Exception: - pass + # Handle is_authenticated (property in modern Django, method in legacy) + is_authenticated = getattr(user, "is_authenticated", False) + if callable(is_authenticated): + is_authenticated = is_authenticated() - try: - email = str(user.email) - except Exception: - pass + if not is_authenticated: + return user_id, email + + # Extract user primary key + user_pk = getattr(user, "pk", None) + if user_pk is not None: + user_id = str(user_pk) + + # Extract user email + user_email = getattr(user, "email", None) + if user_email: + email = str(user_email) return user_id, email From 4f24ecf91e1aa136896af46915f6a4557a5626ec Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:20:33 +0100 Subject: [PATCH 04/15] test(django): add Django 5 test project for ASGI middleware validation Add standalone Django 5.2.7 test project to validate middleware fixes work correctly with modern Django's async features: - Test views for async user access (uses request.auser()) - Test views for exception capture (both sync and async) - ASGI configuration with uvicorn - Separate virtualenv to avoid conflicts with main project dependencies Verified fixes work correctly: - Async user access: middleware uses await request.auser() without SynchronousOnlyOperation - Exception capture: process_exception called for both sync and async views --- test_project_django5/manage.py | 22 + test_project_django5/pyproject.toml | 12 + test_project_django5/testdjango/__init__.py | 0 test_project_django5/testdjango/asgi.py | 16 + test_project_django5/testdjango/settings.py | 130 +++++ test_project_django5/testdjango/urls.py | 27 + test_project_django5/testdjango/views.py | 43 ++ test_project_django5/testdjango/wsgi.py | 16 + test_project_django5/uv.lock | 561 ++++++++++++++++++++ 9 files changed, 827 insertions(+) create mode 100755 test_project_django5/manage.py create mode 100644 test_project_django5/pyproject.toml create mode 100644 test_project_django5/testdjango/__init__.py create mode 100644 test_project_django5/testdjango/asgi.py create mode 100644 test_project_django5/testdjango/settings.py create mode 100644 test_project_django5/testdjango/urls.py create mode 100644 test_project_django5/testdjango/views.py create mode 100644 test_project_django5/testdjango/wsgi.py create mode 100644 test_project_django5/uv.lock diff --git a/test_project_django5/manage.py b/test_project_django5/manage.py new file mode 100755 index 00000000..3795858a --- /dev/null +++ b/test_project_django5/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testdjango.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/test_project_django5/pyproject.toml b/test_project_django5/pyproject.toml new file mode 100644 index 00000000..e2ddd269 --- /dev/null +++ b/test_project_django5/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "test-django5" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "django>=5.0", + "uvicorn[standard]", + "posthog", +] + +[tool.uv.sources] +posthog = { path = "..", editable = true } diff --git a/test_project_django5/testdjango/__init__.py b/test_project_django5/testdjango/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_project_django5/testdjango/asgi.py b/test_project_django5/testdjango/asgi.py new file mode 100644 index 00000000..48037cf3 --- /dev/null +++ b/test_project_django5/testdjango/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for testdjango project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testdjango.settings') + +application = get_asgi_application() diff --git a/test_project_django5/testdjango/settings.py b/test_project_django5/testdjango/settings.py new file mode 100644 index 00000000..6c3e0422 --- /dev/null +++ b/test_project_django5/testdjango/settings.py @@ -0,0 +1,130 @@ +""" +Django settings for testdjango project. + +Generated by 'django-admin startproject' using Django 5.2.7. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-q5(&wfw@_lb)noyowbfl$2ls8c82hl__0f9s5(mohlh2)aas#3' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'posthog.integrations.django.PosthogContextMiddleware', # Test PostHog middleware +] + +ROOT_URLCONF = 'testdjango.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'testdjango.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/5.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +# PostHog settings for testing +POSTHOG_API_KEY = 'test-key' +POSTHOG_HOST = 'https://app.posthog.com' +POSTHOG_MW_CAPTURE_EXCEPTIONS = True + diff --git a/test_project_django5/testdjango/urls.py b/test_project_django5/testdjango/urls.py new file mode 100644 index 00000000..1eb84dd6 --- /dev/null +++ b/test_project_django5/testdjango/urls.py @@ -0,0 +1,27 @@ +""" +URL configuration for testdjango project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path +from testdjango import views + +urlpatterns = [ + path('admin/', admin.site.urls), + path('test/async-user', views.test_async_user), + path('test/sync-user', views.test_sync_user), + path('test/async-exception', views.test_async_exception), + path('test/sync-exception', views.test_sync_exception), +] diff --git a/test_project_django5/testdjango/views.py b/test_project_django5/testdjango/views.py new file mode 100644 index 00000000..393d08fc --- /dev/null +++ b/test_project_django5/testdjango/views.py @@ -0,0 +1,43 @@ +""" +Test views for validating PostHog middleware with Django 5 ASGI. +""" +from django.http import JsonResponse + + +async def test_async_user(request): + """ + Async view that tests middleware with request.user access. + + The middleware will access request.user (SimpleLazyObject) via auser() + in async context. Without the fix, this causes SynchronousOnlyOperation. + """ + # The middleware has already accessed request.user via auser() + # If we got here, the fix works! + user = await request.auser() + + return JsonResponse({ + "status": "success", + "message": "Django 5 async middleware test passed!", + "django_version": "5.x", + "user_authenticated": user.is_authenticated if user else False, + "note": "Middleware used await request.auser() successfully" + }) + + +def test_sync_user(request): + """Sync view for comparison.""" + return JsonResponse({ + "status": "success", + "message": "Sync view works", + "user_authenticated": request.user.is_authenticated if hasattr(request, 'user') else False + }) + + +async def test_async_exception(request): + """Async view that raises an exception for testing exception capture.""" + raise ValueError("Test exception from Django 5 async view") + + +def test_sync_exception(request): + """Sync view that raises an exception for testing exception capture.""" + raise ValueError("Test exception from Django 5 sync view") diff --git a/test_project_django5/testdjango/wsgi.py b/test_project_django5/testdjango/wsgi.py new file mode 100644 index 00000000..510b0cdb --- /dev/null +++ b/test_project_django5/testdjango/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for testdjango project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testdjango.settings') + +application = get_wsgi_application() diff --git a/test_project_django5/uv.lock b/test_project_django5/uv.lock new file mode 100644 index 00000000..0eb95570 --- /dev/null +++ b/test_project_django5/uv.lock @@ -0,0 +1,561 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "asgiref" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "django" +version = "5.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "posthog" +source = { editable = "../" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'test'" }, + { name = "backoff", specifier = ">=1.10.0" }, + { name = "coverage", marker = "extra == 'test'" }, + { name = "distro", specifier = ">=1.5.0" }, + { name = "django", marker = "extra == 'test'" }, + { name = "django-stubs", marker = "extra == 'dev'" }, + { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, + { name = "google-genai", marker = "extra == 'test'" }, + { name = "langchain", marker = "extra == 'langchain'", specifier = ">=0.2.0" }, + { name = "langchain-anthropic", marker = "extra == 'test'", specifier = ">=0.3.15" }, + { name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.3.25" }, + { name = "langchain-core", marker = "extra == 'test'", specifier = ">=0.3.65" }, + { name = "langchain-openai", marker = "extra == 'test'", specifier = ">=0.3.22" }, + { name = "langgraph", marker = "extra == 'test'", specifier = ">=0.4.8" }, + { name = "lxml", marker = "extra == 'dev'" }, + { name = "mock", marker = "extra == 'test'", specifier = ">=2.0.0" }, + { name = "mypy", marker = "extra == 'dev'" }, + { name = "mypy-baseline", marker = "extra == 'dev'" }, + { name = "openai", marker = "extra == 'test'" }, + { name = "packaging", marker = "extra == 'dev'" }, + { name = "parameterized", marker = "extra == 'test'", specifier = ">=0.8.1" }, + { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pydantic", marker = "extra == 'dev'" }, + { name = "pydantic", marker = "extra == 'test'" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "pytest-timeout", marker = "extra == 'test'" }, + { name = "python-dateutil", specifier = ">=2.2" }, + { name = "requests", specifier = ">=2.7,<3.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "setuptools", marker = "extra == 'dev'" }, + { name = "six", specifier = ">=1.5" }, + { name = "tomli", marker = "extra == 'dev'" }, + { name = "tomli-w", marker = "extra == 'dev'" }, + { name = "twine", marker = "extra == 'dev'" }, + { name = "types-mock", marker = "extra == 'dev'" }, + { name = "types-python-dateutil", marker = "extra == 'dev'" }, + { name = "types-requests", marker = "extra == 'dev'" }, + { name = "types-setuptools", marker = "extra == 'dev'" }, + { name = "types-six", marker = "extra == 'dev'" }, + { name = "typing-extensions", specifier = ">=4.2.0" }, + { name = "wheel", marker = "extra == 'dev'" }, +] +provides-extras = ["langchain", "dev", "test"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, +] + +[[package]] +name = "test-django5" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "posthog" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=5.0" }, + { name = "posthog", editable = "../" }, + { name = "uvicorn", extras = ["standard"] }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] From 2521f4ae24208bf79cc207001c370088c9a8e603 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:33:05 +0100 Subject: [PATCH 05/15] test(django): add middleware tests for Django 5 ASGI validation Add comprehensive test suite for PostHog Django middleware in async context: - Test async user access (unauthenticated) - Test async authenticated user access (triggers SynchronousOnlyOperation in v6.7.11) - Test sync user access - Test async exception capture - Test sync exception capture Tests run directly against ASGI application using httpx AsyncClient without needing a server. Uses pytest-asyncio for async test support. The authenticated user test demonstrates the bug fixed in this PR: v6.7.11 raises SynchronousOnlyOperation when accessing request.user in async middleware with authenticated users. The fix uses await request.auser() instead. Add test dependencies: pytest, pytest-asyncio, httpx --- test_project_django5/.gitignore | 4 + test_project_django5/pyproject.toml | 3 + test_project_django5/test_middleware.py | 192 ++++++++++++++++++++++++ test_project_django5/uv.lock | 99 ++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 test_project_django5/.gitignore create mode 100644 test_project_django5/test_middleware.py diff --git a/test_project_django5/.gitignore b/test_project_django5/.gitignore new file mode 100644 index 00000000..f02357a8 --- /dev/null +++ b/test_project_django5/.gitignore @@ -0,0 +1,4 @@ +db.sqlite3 +*.pyc +__pycache__/ +.pytest_cache/ diff --git a/test_project_django5/pyproject.toml b/test_project_django5/pyproject.toml index e2ddd269..1047a25f 100644 --- a/test_project_django5/pyproject.toml +++ b/test_project_django5/pyproject.toml @@ -6,6 +6,9 @@ dependencies = [ "django>=5.0", "uvicorn[standard]", "posthog", + "pytest>=8.0", + "pytest-asyncio>=0.23", + "httpx>=0.27", ] [tool.uv.sources] diff --git a/test_project_django5/test_middleware.py b/test_project_django5/test_middleware.py new file mode 100644 index 00000000..cfe3ed89 --- /dev/null +++ b/test_project_django5/test_middleware.py @@ -0,0 +1,192 @@ +""" +Tests for PostHog Django middleware in async context. + +These tests verify that the middleware correctly handles: +1. Async user access (request.auser() in Django 5) +2. Exception capture in both sync and async views +3. No SynchronousOnlyOperation errors in async context + +Tests run directly against the ASGI application without needing a server. +""" +import os +import django + +# Setup Django before importing anything else +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") +django.setup() + +import pytest +from httpx import AsyncClient, ASGITransport +from django.core.asgi import get_asgi_application + + +@pytest.mark.asyncio +async def test_async_user_access(): + """ + Test that middleware can access request.user in async context. + + In Django 5, this requires using await request.auser() instead of request.user + to avoid SynchronousOnlyOperation error. + + Without authentication, request.user is AnonymousUser which doesn't + trigger the lazy loading bug. This test verifies the middleware works + in the common case. + """ + app = get_asgi_application() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + response = await ac.get("/test/async-user") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert "django_version" in data + print(f"✓ Async user access test passed: {data['message']}") + + +@pytest.mark.asyncio +async def test_async_authenticated_user_access(): + """ + Test that middleware can access an authenticated user in async context. + + This is the critical test that triggers the SynchronousOnlyOperation bug + in v6.7.11. When AuthenticationMiddleware sets request.user to a + SimpleLazyObject wrapping a database query, accessing user.pk or user.email + in async context causes the error. + + In v6.7.11, extract_request_user() does getattr(user, "is_authenticated", False) + which triggers the lazy object evaluation synchronously. + + The fix uses await request.auser() instead to avoid this. + """ + from django.contrib.auth import get_user_model + from django.test import Client + from asgiref.sync import sync_to_async + from django.test import override_settings + + # Create a test user (must use sync_to_async since we're in async test) + User = get_user_model() + + @sync_to_async + def create_or_get_user(): + user, created = User.objects.get_or_create( + username='testuser', + defaults={ + 'email': 'test@example.com', + } + ) + if created: + user.set_password('testpass123') + user.save() + return user + + user = await create_or_get_user() + + # Create a session with authenticated user (sync operation) + @sync_to_async + def create_session(): + client = Client() + client.force_login(user) + return client.cookies.get('sessionid') + + session_cookie = await create_session() + + if not session_cookie: + print("⚠ Warning: Could not create authenticated session, skipping auth test") + return + + # Make request with session cookie - this should trigger the bug in v6.7.11 + # Disable exception capture to see the SynchronousOnlyOperation clearly + with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False): + app = get_asgi_application() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + response = await ac.get( + "/test/async-user", + cookies={"sessionid": session_cookie.value} + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert data["user_authenticated"] == True + print(f"✓ Async authenticated user access test passed: {data['message']}") + + +@pytest.mark.asyncio +async def test_sync_user_access(): + """ + Test that middleware works with sync views. + + This should always work regardless of middleware version. + """ + app = get_asgi_application() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + response = await ac.get("/test/sync-user") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + print(f"✓ Sync user access test passed: {data['message']}") + + +@pytest.mark.asyncio +async def test_async_exception_capture(): + """ + Test that middleware captures exceptions from async views. + + The middleware should capture the exception and send it to PostHog. + """ + app = get_asgi_application() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + response = await ac.get("/test/async-exception") + + # Django returns 500 for unhandled exceptions + assert response.status_code == 500 + print("✓ Async exception capture test passed (exception raised as expected)") + + +@pytest.mark.asyncio +async def test_sync_exception_capture(): + """ + Test that middleware captures exceptions from sync views. + + The middleware should capture the exception and send it to PostHog. + """ + app = get_asgi_application() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + response = await ac.get("/test/sync-exception") + + # Django returns 500 for unhandled exceptions + assert response.status_code == 500 + print("✓ Sync exception capture test passed (exception raised as expected)") + + +if __name__ == "__main__": + """Run tests directly with asyncio for quick testing.""" + import asyncio + + async def run_all_tests(): + print("\nRunning PostHog Django middleware tests...\n") + + try: + await test_async_user_access() + except Exception as e: + print(f"✗ Async user access test failed: {e}") + + try: + await test_sync_user_access() + except Exception as e: + print(f"✗ Sync user access test failed: {e}") + + try: + await test_async_exception_capture() + except Exception as e: + print(f"✗ Async exception capture test failed: {e}") + + try: + await test_sync_exception_capture() + except Exception as e: + print(f"✗ Sync exception capture test failed: {e}") + + print("\nAll tests completed!\n") + + asyncio.run(run_all_tests()) diff --git a/test_project_django5/uv.lock b/test_project_django5/uv.lock index 0eb95570..3631e90d 100644 --- a/test_project_django5/uv.lock +++ b/test_project_django5/uv.lock @@ -153,6 +153,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -182,6 +195,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -191,6 +219,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "posthog" source = { editable = "../" } @@ -250,6 +305,44 @@ requires-dist = [ ] provides-extras = ["langchain", "dev", "test"] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -365,14 +458,20 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "django" }, + { name = "httpx" }, { name = "posthog" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "uvicorn", extra = ["standard"] }, ] [package.metadata] requires-dist = [ { name = "django", specifier = ">=5.0" }, + { name = "httpx", specifier = ">=0.27" }, { name = "posthog", editable = "../" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, { name = "uvicorn", extras = ["standard"] }, ] From 1798df0e0ac43905fa7c4cd7b5ff86beae67bdb2 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:34:01 +0100 Subject: [PATCH 06/15] docs(django): clarify exception test limitations Exception tests verify 500 responses but not actual PostHog capture. Exception capture requires process_exception() method from PR #350. Without process_exception(), Django converts view exceptions to responses before they propagate through the middleware context manager, so they're not captured to PostHog even though 500 is still returned. --- test_project_django5/test_middleware.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/test_project_django5/test_middleware.py b/test_project_django5/test_middleware.py index cfe3ed89..b66848e2 100644 --- a/test_project_django5/test_middleware.py +++ b/test_project_django5/test_middleware.py @@ -133,7 +133,16 @@ async def test_async_exception_capture(): """ Test that middleware captures exceptions from async views. - The middleware should capture the exception and send it to PostHog. + IMPORTANT: This test verifies that exceptions raise 500 errors, but it doesn't + verify that PostHog actually captures them. The exception capture fix (PR #350) + adds a process_exception() method that Django calls to capture view exceptions. + + Without process_exception(), Django converts view exceptions to responses + before they propagate through the middleware context manager, so they're not + captured to PostHog even though they still return 500 to the client. + + To properly test exception capture, you'd need to mock posthog.capture_exception + and verify it's called. That's tested in PR #350. """ app = get_asgi_application() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: @@ -141,15 +150,17 @@ async def test_async_exception_capture(): # Django returns 500 for unhandled exceptions assert response.status_code == 500 - print("✓ Async exception capture test passed (exception raised as expected)") + print("✓ Async exception raises 500 (capture requires process_exception from PR #350)") @pytest.mark.asyncio async def test_sync_exception_capture(): """ - Test that middleware captures exceptions from sync views. + Test that middleware handles exceptions from sync views. - The middleware should capture the exception and send it to PostHog. + IMPORTANT: Same as test_async_exception_capture - this verifies 500 response + but not actual PostHog capture. Exception capture requires process_exception() + method from PR #350. """ app = get_asgi_application() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: @@ -157,7 +168,7 @@ async def test_sync_exception_capture(): # Django returns 500 for unhandled exceptions assert response.status_code == 500 - print("✓ Sync exception capture test passed (exception raised as expected)") + print("✓ Sync exception raises 500 (capture requires process_exception from PR #350)") if __name__ == "__main__": From b956cc9ecf53ae7935632b1df10ebc3d200204b4 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:41:18 +0100 Subject: [PATCH 07/15] test(django): add tests proving exception capture fix works Add test_exception_capture.py that demonstrates: - Without process_exception() (v6.7.11), view exceptions are NOT captured to PostHog - With process_exception() (PR #350), exceptions ARE captured Tests use mocking to verify posthog.capture_exception is called. Successfully validated both async and sync view exception capture. This branch is stacked on PR #350 to test both fixes together: - PR #350: Exception capture via process_exception() - PR #358: Async user access via request.auser() All 7 tests pass, demonstrating both bug fixes work correctly. --- .../test_exception_capture.py | 130 ++++++++++++++++++ test_project_django5/test_middleware.py | 25 ++-- 2 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 test_project_django5/test_exception_capture.py diff --git a/test_project_django5/test_exception_capture.py b/test_project_django5/test_exception_capture.py new file mode 100644 index 00000000..4271cf45 --- /dev/null +++ b/test_project_django5/test_exception_capture.py @@ -0,0 +1,130 @@ +""" +Test that demonstrates the exception capture bug and fix. + +This test uses a real PostHog client with a test consumer to verify that +exceptions are actually captured to PostHog, not just that 500 responses are returned. + +Bug: Without process_exception(), view exceptions are NOT captured to PostHog. +Fix: PR #350 adds process_exception() which Django calls to capture exceptions. +""" +import os +import django + +# Setup Django before importing anything else +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") +django.setup() + +import pytest +from httpx import AsyncClient, ASGITransport +from django.core.asgi import get_asgi_application +from posthog import Client + + +@pytest.mark.asyncio +async def test_async_exception_is_captured(): + """ + Test that async view exceptions are captured to PostHog. + + With process_exception() (PR #350), exceptions are captured. + Without it, exceptions are NOT captured even though 500 is returned. + """ + from unittest.mock import patch + + # Track captured exceptions + captured = [] + + def mock_capture(exception, **kwargs): + """Mock capture_exception to record calls.""" + captured.append({ + 'exception': exception, + 'type': type(exception).__name__, + 'message': str(exception) + }) + + with patch('posthog.capture_exception', side_effect=mock_capture): + app = get_asgi_application() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + response = await ac.get("/test/async-exception") + + # Django returns 500 + assert response.status_code == 500 + + # CRITICAL: Verify PostHog captured the exception + assert len(captured) > 0, f"Exception was NOT captured to PostHog!" + + # Verify it's the right exception + exception_data = captured[0] + assert exception_data['type'] == 'ValueError' + assert 'Test exception from Django 5 async view' in exception_data['message'] + + print(f"✓ Async exception captured: {len(captured)} exception event(s)") + print(f" Exception type: {exception_data['type']}") + print(f" Exception message: {exception_data['message']}") + + +@pytest.mark.asyncio +async def test_sync_exception_is_captured(): + """ + Test that sync view exceptions are captured to PostHog. + + With process_exception() (PR #350), exceptions are captured. + Without it, exceptions are NOT captured even though 500 is returned. + """ + from unittest.mock import patch + + # Track captured exceptions + captured = [] + + def mock_capture(exception, **kwargs): + """Mock capture_exception to record calls.""" + captured.append({ + 'exception': exception, + 'type': type(exception).__name__, + 'message': str(exception) + }) + + with patch('posthog.capture_exception', side_effect=mock_capture): + app = get_asgi_application() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + response = await ac.get("/test/sync-exception") + + # Django returns 500 + assert response.status_code == 500 + + # CRITICAL: Verify PostHog captured the exception + assert len(captured) > 0, f"Exception was NOT captured to PostHog!" + + # Verify it's the right exception + exception_data = captured[0] + assert exception_data['type'] == 'ValueError' + assert 'Test exception from Django 5 sync view' in exception_data['message'] + + print(f"✓ Sync exception captured: {len(captured)} exception event(s)") + print(f" Exception type: {exception_data['type']}") + print(f" Exception message: {exception_data['message']}") + + +if __name__ == "__main__": + """Run tests directly.""" + import asyncio + + async def run_tests(): + print("\nTesting exception capture with process_exception() fix...\n") + + try: + await test_async_exception_is_captured() + except AssertionError as e: + print(f"✗ Async exception capture failed: {e}") + except Exception as e: + print(f"✗ Async test error: {e}") + + try: + await test_sync_exception_is_captured() + except AssertionError as e: + print(f"✗ Sync exception capture failed: {e}") + except Exception as e: + print(f"✗ Sync test error: {e}") + + print("\nDone!\n") + + asyncio.run(run_tests()) diff --git a/test_project_django5/test_middleware.py b/test_project_django5/test_middleware.py index b66848e2..02812bfc 100644 --- a/test_project_django5/test_middleware.py +++ b/test_project_django5/test_middleware.py @@ -131,18 +131,14 @@ async def test_sync_user_access(): @pytest.mark.asyncio async def test_async_exception_capture(): """ - Test that middleware captures exceptions from async views. + Test that middleware handles exceptions from async views. - IMPORTANT: This test verifies that exceptions raise 500 errors, but it doesn't - verify that PostHog actually captures them. The exception capture fix (PR #350) - adds a process_exception() method that Django calls to capture view exceptions. + This branch is stacked on PR #350 which adds process_exception() to capture + view exceptions. Django calls process_exception() for view exceptions, allowing + the middleware to capture them to PostHog before Django converts them to 500 responses. - Without process_exception(), Django converts view exceptions to responses - before they propagate through the middleware context manager, so they're not - captured to PostHog even though they still return 500 to the client. - - To properly test exception capture, you'd need to mock posthog.capture_exception - and verify it's called. That's tested in PR #350. + This test verifies the exception causes a 500 response. To verify actual PostHog + capture, you'd need to mock posthog.capture_exception (tested in PR #350). """ app = get_asgi_application() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: @@ -150,7 +146,7 @@ async def test_async_exception_capture(): # Django returns 500 for unhandled exceptions assert response.status_code == 500 - print("✓ Async exception raises 500 (capture requires process_exception from PR #350)") + print("✓ Async exception raises 500 (captured via process_exception from PR #350)") @pytest.mark.asyncio @@ -158,9 +154,8 @@ async def test_sync_exception_capture(): """ Test that middleware handles exceptions from sync views. - IMPORTANT: Same as test_async_exception_capture - this verifies 500 response - but not actual PostHog capture. Exception capture requires process_exception() - method from PR #350. + This branch is stacked on PR #350 which adds process_exception() to capture + view exceptions. This test verifies the exception causes a 500 response. """ app = get_asgi_application() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: @@ -168,7 +163,7 @@ async def test_sync_exception_capture(): # Django returns 500 for unhandled exceptions assert response.status_code == 500 - print("✓ Sync exception raises 500 (capture requires process_exception from PR #350)") + print("✓ Sync exception raises 500 (captured via process_exception from PR #350)") if __name__ == "__main__": From f2ab54a8afd57b89fffdcc03caf93e6a20485c82 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:43:03 +0100 Subject: [PATCH 08/15] docs(django): remove PR references from test comments Remove references to pending PRs and stacked branches from test comments to make the code ready to merge. Comments now only reference the implemented functionality and earlier versions (v6.7.11) for context. --- .../test_exception_capture.py | 19 ++++++++++--------- test_project_django5/test_middleware.py | 18 ++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/test_project_django5/test_exception_capture.py b/test_project_django5/test_exception_capture.py index 4271cf45..db854c89 100644 --- a/test_project_django5/test_exception_capture.py +++ b/test_project_django5/test_exception_capture.py @@ -1,11 +1,12 @@ """ -Test that demonstrates the exception capture bug and fix. +Test that verifies exception capture functionality. -This test uses a real PostHog client with a test consumer to verify that -exceptions are actually captured to PostHog, not just that 500 responses are returned. +These tests verify that exceptions are actually captured to PostHog, not just that +500 responses are returned. -Bug: Without process_exception(), view exceptions are NOT captured to PostHog. -Fix: PR #350 adds process_exception() which Django calls to capture exceptions. +Without process_exception(), view exceptions are NOT captured to PostHog (v6.7.11 and earlier). +With process_exception(), Django calls this method to capture exceptions before +converting them to 500 responses. """ import os import django @@ -25,8 +26,8 @@ async def test_async_exception_is_captured(): """ Test that async view exceptions are captured to PostHog. - With process_exception() (PR #350), exceptions are captured. - Without it, exceptions are NOT captured even though 500 is returned. + The middleware's process_exception() method ensures exceptions are captured. + Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned. """ from unittest.mock import patch @@ -67,8 +68,8 @@ async def test_sync_exception_is_captured(): """ Test that sync view exceptions are captured to PostHog. - With process_exception() (PR #350), exceptions are captured. - Without it, exceptions are NOT captured even though 500 is returned. + The middleware's process_exception() method ensures exceptions are captured. + Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned. """ from unittest.mock import patch diff --git a/test_project_django5/test_middleware.py b/test_project_django5/test_middleware.py index 02812bfc..d6ea508a 100644 --- a/test_project_django5/test_middleware.py +++ b/test_project_django5/test_middleware.py @@ -133,12 +133,10 @@ async def test_async_exception_capture(): """ Test that middleware handles exceptions from async views. - This branch is stacked on PR #350 which adds process_exception() to capture - view exceptions. Django calls process_exception() for view exceptions, allowing - the middleware to capture them to PostHog before Django converts them to 500 responses. - - This test verifies the exception causes a 500 response. To verify actual PostHog - capture, you'd need to mock posthog.capture_exception (tested in PR #350). + The middleware's process_exception() method captures view exceptions to PostHog + before Django converts them to 500 responses. This test verifies the exception + causes a 500 response. See test_exception_capture.py for tests that verify + actual exception capture to PostHog. """ app = get_asgi_application() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: @@ -146,7 +144,7 @@ async def test_async_exception_capture(): # Django returns 500 for unhandled exceptions assert response.status_code == 500 - print("✓ Async exception raises 500 (captured via process_exception from PR #350)") + print("✓ Async exception raises 500 (captured via process_exception)") @pytest.mark.asyncio @@ -154,8 +152,8 @@ async def test_sync_exception_capture(): """ Test that middleware handles exceptions from sync views. - This branch is stacked on PR #350 which adds process_exception() to capture - view exceptions. This test verifies the exception causes a 500 response. + The middleware's process_exception() method captures view exceptions to PostHog. + This test verifies the exception causes a 500 response. """ app = get_asgi_application() async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: @@ -163,7 +161,7 @@ async def test_sync_exception_capture(): # Django returns 500 for unhandled exceptions assert response.status_code == 500 - print("✓ Sync exception raises 500 (captured via process_exception from PR #350)") + print("✓ Sync exception raises 500 (captured via process_exception)") if __name__ == "__main__": From 2e61869672cdbb028c53ffc28c7a424f33f6539e Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:48:25 +0100 Subject: [PATCH 09/15] test(django5): improve test quality and cleanup Apply pytest best practices to Django 5 test suite: - Add session-scoped asgi_app fixture to share app instance across tests - Replace print() statements with assertions for cleaner test output - Use pytest.skip() instead of print+return for skipped tests - Add @pytest.mark.django_db(transaction=True) for authenticated user test - Remove if __name__ == "__main__" blocks in favor of pytest runner - Add pytest-django dependency to properly recognize django_db marker - Fix mock patch target to 'posthog.capture_exception' (module level) All 7 tests pass with proper pytest output. --- test_project_django5/pyproject.toml | 1 + .../test_exception_capture.py | 53 ++++---------- test_project_django5/test_middleware.py | 72 +++++-------------- test_project_django5/uv.lock | 14 ++++ 4 files changed, 45 insertions(+), 95 deletions(-) diff --git a/test_project_django5/pyproject.toml b/test_project_django5/pyproject.toml index 1047a25f..1205adac 100644 --- a/test_project_django5/pyproject.toml +++ b/test_project_django5/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "posthog", "pytest>=8.0", "pytest-asyncio>=0.23", + "pytest-django>=4.0", "httpx>=0.27", ] diff --git a/test_project_django5/test_exception_capture.py b/test_project_django5/test_exception_capture.py index db854c89..882509da 100644 --- a/test_project_django5/test_exception_capture.py +++ b/test_project_django5/test_exception_capture.py @@ -18,11 +18,16 @@ import pytest from httpx import AsyncClient, ASGITransport from django.core.asgi import get_asgi_application -from posthog import Client + + +@pytest.fixture(scope="session") +def asgi_app(): + """Shared ASGI application for all tests.""" + return get_asgi_application() @pytest.mark.asyncio -async def test_async_exception_is_captured(): +async def test_async_exception_is_captured(asgi_app): """ Test that async view exceptions are captured to PostHog. @@ -42,9 +47,9 @@ def mock_capture(exception, **kwargs): 'message': str(exception) }) + # Patch at the posthog module level where middleware imports from with patch('posthog.capture_exception', side_effect=mock_capture): - app = get_asgi_application() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: response = await ac.get("/test/async-exception") # Django returns 500 @@ -58,13 +63,9 @@ def mock_capture(exception, **kwargs): assert exception_data['type'] == 'ValueError' assert 'Test exception from Django 5 async view' in exception_data['message'] - print(f"✓ Async exception captured: {len(captured)} exception event(s)") - print(f" Exception type: {exception_data['type']}") - print(f" Exception message: {exception_data['message']}") - @pytest.mark.asyncio -async def test_sync_exception_is_captured(): +async def test_sync_exception_is_captured(asgi_app): """ Test that sync view exceptions are captured to PostHog. @@ -84,9 +85,9 @@ def mock_capture(exception, **kwargs): 'message': str(exception) }) + # Patch at the posthog module level where middleware imports from with patch('posthog.capture_exception', side_effect=mock_capture): - app = get_asgi_application() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: response = await ac.get("/test/sync-exception") # Django returns 500 @@ -99,33 +100,3 @@ def mock_capture(exception, **kwargs): exception_data = captured[0] assert exception_data['type'] == 'ValueError' assert 'Test exception from Django 5 sync view' in exception_data['message'] - - print(f"✓ Sync exception captured: {len(captured)} exception event(s)") - print(f" Exception type: {exception_data['type']}") - print(f" Exception message: {exception_data['message']}") - - -if __name__ == "__main__": - """Run tests directly.""" - import asyncio - - async def run_tests(): - print("\nTesting exception capture with process_exception() fix...\n") - - try: - await test_async_exception_is_captured() - except AssertionError as e: - print(f"✗ Async exception capture failed: {e}") - except Exception as e: - print(f"✗ Async test error: {e}") - - try: - await test_sync_exception_is_captured() - except AssertionError as e: - print(f"✗ Sync exception capture failed: {e}") - except Exception as e: - print(f"✗ Sync test error: {e}") - - print("\nDone!\n") - - asyncio.run(run_tests()) diff --git a/test_project_django5/test_middleware.py b/test_project_django5/test_middleware.py index d6ea508a..1ce13e5f 100644 --- a/test_project_django5/test_middleware.py +++ b/test_project_django5/test_middleware.py @@ -20,8 +20,14 @@ from django.core.asgi import get_asgi_application +@pytest.fixture(scope="session") +def asgi_app(): + """Shared ASGI application for all tests.""" + return get_asgi_application() + + @pytest.mark.asyncio -async def test_async_user_access(): +async def test_async_user_access(asgi_app): """ Test that middleware can access request.user in async context. @@ -32,19 +38,18 @@ async def test_async_user_access(): trigger the lazy loading bug. This test verifies the middleware works in the common case. """ - app = get_asgi_application() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: response = await ac.get("/test/async-user") assert response.status_code == 200 data = response.json() assert data["status"] == "success" assert "django_version" in data - print(f"✓ Async user access test passed: {data['message']}") +@pytest.mark.django_db(transaction=True) @pytest.mark.asyncio -async def test_async_authenticated_user_access(): +async def test_async_authenticated_user_access(asgi_app): """ Test that middleware can access an authenticated user in async context. @@ -91,14 +96,12 @@ def create_session(): session_cookie = await create_session() if not session_cookie: - print("⚠ Warning: Could not create authenticated session, skipping auth test") - return + pytest.skip("Could not create authenticated session") # Make request with session cookie - this should trigger the bug in v6.7.11 # Disable exception capture to see the SynchronousOnlyOperation clearly with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False): - app = get_asgi_application() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: response = await ac.get( "/test/async-user", cookies={"sessionid": session_cookie.value} @@ -108,28 +111,25 @@ def create_session(): data = response.json() assert data["status"] == "success" assert data["user_authenticated"] == True - print(f"✓ Async authenticated user access test passed: {data['message']}") @pytest.mark.asyncio -async def test_sync_user_access(): +async def test_sync_user_access(asgi_app): """ Test that middleware works with sync views. This should always work regardless of middleware version. """ - app = get_asgi_application() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: response = await ac.get("/test/sync-user") assert response.status_code == 200 data = response.json() assert data["status"] == "success" - print(f"✓ Sync user access test passed: {data['message']}") @pytest.mark.asyncio -async def test_async_exception_capture(): +async def test_async_exception_capture(asgi_app): """ Test that middleware handles exceptions from async views. @@ -138,59 +138,23 @@ async def test_async_exception_capture(): causes a 500 response. See test_exception_capture.py for tests that verify actual exception capture to PostHog. """ - app = get_asgi_application() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: response = await ac.get("/test/async-exception") # Django returns 500 for unhandled exceptions assert response.status_code == 500 - print("✓ Async exception raises 500 (captured via process_exception)") @pytest.mark.asyncio -async def test_sync_exception_capture(): +async def test_sync_exception_capture(asgi_app): """ Test that middleware handles exceptions from sync views. The middleware's process_exception() method captures view exceptions to PostHog. This test verifies the exception causes a 500 response. """ - app = get_asgi_application() - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as ac: + async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: response = await ac.get("/test/sync-exception") # Django returns 500 for unhandled exceptions assert response.status_code == 500 - print("✓ Sync exception raises 500 (captured via process_exception)") - - -if __name__ == "__main__": - """Run tests directly with asyncio for quick testing.""" - import asyncio - - async def run_all_tests(): - print("\nRunning PostHog Django middleware tests...\n") - - try: - await test_async_user_access() - except Exception as e: - print(f"✗ Async user access test failed: {e}") - - try: - await test_sync_user_access() - except Exception as e: - print(f"✗ Sync user access test failed: {e}") - - try: - await test_async_exception_capture() - except Exception as e: - print(f"✗ Async exception capture test failed: {e}") - - try: - await test_sync_exception_capture() - except Exception as e: - print(f"✗ Sync exception capture test failed: {e}") - - print("\nAll tests completed!\n") - - asyncio.run(run_all_tests()) diff --git a/test_project_django5/uv.lock b/test_project_django5/uv.lock index 3631e90d..3f156985 100644 --- a/test_project_django5/uv.lock +++ b/test_project_django5/uv.lock @@ -343,6 +343,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] +[[package]] +name = "pytest-django" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } +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 = "python-dateutil" version = "2.9.0.post0" @@ -462,6 +474,7 @@ dependencies = [ { name = "posthog" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-django" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -472,6 +485,7 @@ requires-dist = [ { name = "posthog", editable = "../" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-django", specifier = ">=4.0" }, { name = "uvicorn", extras = ["standard"] }, ] From 5d749fabb31efd7c983629be2e91d0f81bfdd787 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:49:13 +0100 Subject: [PATCH 10/15] fix(django5): set cookies on AsyncClient instance to avoid deprecation warning Move cookie parameter from per-request to AsyncClient constructor. This aligns with httpx's preferred API and eliminates the deprecation warning. --- test_project_django5/test_middleware.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test_project_django5/test_middleware.py b/test_project_django5/test_middleware.py index 1ce13e5f..efc5a532 100644 --- a/test_project_django5/test_middleware.py +++ b/test_project_django5/test_middleware.py @@ -101,11 +101,12 @@ def create_session(): # Make request with session cookie - this should trigger the bug in v6.7.11 # Disable exception capture to see the SynchronousOnlyOperation clearly with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False): - async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: - response = await ac.get( - "/test/async-user", - cookies={"sessionid": session_cookie.value} - ) + async with AsyncClient( + transport=ASGITransport(app=asgi_app), + base_url="http://testserver", + cookies={"sessionid": session_cookie.value} + ) as ac: + response = await ac.get("/test/async-user") assert response.status_code == 200 data = response.json() From c1db63829929a6cf1449eb2c12d4c3140ea0341e Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:50:16 +0100 Subject: [PATCH 11/15] chore(django5): pin dependencies to prevent drift Use compatible release operator (~=) to pin major.minor versions while allowing patch updates: - django~=5.2.7 - uvicorn[standard]~=0.38.0 - pytest~=8.4.2 - pytest-asyncio~=1.2.0 - pytest-django~=4.11.1 - httpx~=0.28.1 Prevents accidental behavior changes from minor version bumps while still getting security patches. --- test_project_django5/pyproject.toml | 12 ++++++------ test_project_django5/uv.lock | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/test_project_django5/pyproject.toml b/test_project_django5/pyproject.toml index 1205adac..17425eb0 100644 --- a/test_project_django5/pyproject.toml +++ b/test_project_django5/pyproject.toml @@ -3,13 +3,13 @@ name = "test-django5" version = "0.1.0" requires-python = ">=3.12" dependencies = [ - "django>=5.0", - "uvicorn[standard]", + "django~=5.2.7", + "uvicorn[standard]~=0.38.0", "posthog", - "pytest>=8.0", - "pytest-asyncio>=0.23", - "pytest-django>=4.0", - "httpx>=0.27", + "pytest~=8.4.2", + "pytest-asyncio~=1.2.0", + "pytest-django~=4.11.1", + "httpx~=0.28.1", ] [tool.uv.sources] diff --git a/test_project_django5/uv.lock b/test_project_django5/uv.lock index 3f156985..45cbb261 100644 --- a/test_project_django5/uv.lock +++ b/test_project_django5/uv.lock @@ -480,13 +480,13 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "django", specifier = ">=5.0" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "django", specifier = "~=5.2.7" }, + { name = "httpx", specifier = "~=0.28.1" }, { name = "posthog", editable = "../" }, - { name = "pytest", specifier = ">=8.0" }, - { name = "pytest-asyncio", specifier = ">=0.23" }, - { name = "pytest-django", specifier = ">=4.0" }, - { name = "uvicorn", extras = ["standard"] }, + { name = "pytest", specifier = "~=8.4.2" }, + { name = "pytest-asyncio", specifier = "~=1.2.0" }, + { name = "pytest-django", specifier = "~=4.11.1" }, + { name = "uvicorn", extras = ["standard"], specifier = "~=0.38.0" }, ] [[package]] From f0c3a97978345d4205150ccace7aefa1c05a4b12 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:52:04 +0100 Subject: [PATCH 12/15] ci: add Django 5 integration test job Add separate CI job for Django 5 integration tests that verify: - Async user access with request.auser() - Exception capture via process_exception() - Real ASGI application behavior with httpx AsyncClient Runs on Python 3.12 with pinned Django 5.2.7 dependencies in isolated test_project_django5 environment. Complements existing unit tests that run across Python 3.9-3.13. --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a181988..791798ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,3 +68,34 @@ jobs: - name: Run posthog tests run: | pytest --verbose --timeout=30 + + django5-integration: + name: Django 5 integration tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 + with: + fetch-depth: 1 + + - name: Set up Python 3.12 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 + with: + python-version: 3.12 + + - name: Install uv + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1 + with: + enable-cache: true + pyproject-file: 'test_project_django5/pyproject.toml' + + - name: Install Django 5 test project dependencies + shell: bash + working-directory: test_project_django5 + run: | + UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync + + - name: Run Django 5 middleware integration tests + working-directory: test_project_django5 + run: | + uv run pytest test_middleware.py test_exception_capture.py --verbose From f8170a549ccb1f1c68255f645d83162ff778aefb Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 09:55:00 +0100 Subject: [PATCH 13/15] chore: add async user access fix to unreleased changelog --- CHANGELOG.md | 4 +++- posthog/version.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0b08d9..4d1e21ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ -# Unreleased +# 6.7.12 - 2025-10-29 +- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355) - fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329) +- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior # 6.7.11 - 2025-10-28 diff --git a/posthog/version.py b/posthog/version.py index 1fe91ee0..b4d60d14 100644 --- a/posthog/version.py +++ b/posthog/version.py @@ -1,4 +1,4 @@ -VERSION = "6.7.11" +VERSION = "6.7.12" if __name__ == "__main__": print(VERSION, end="") # noqa: T201 From 00ce411c451207c94aa609903278f656d02fc576 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Wed, 29 Oct 2025 10:05:50 +0100 Subject: [PATCH 14/15] fix: address CI failures and code review comments - Add permissions: contents: read to CI workflow (security best practice) - Exclude test_project_django5 from main pytest run to avoid name collision - Fix ruff E712: use truth check instead of == True - Add noqa: E402 for unavoidable Django setup imports - Configure testpaths to only run posthog/test in main CI --- .github/workflows/ci.yml | 3 + posthog/client.py | 3 +- posthog/test/ai/anthropic/test_anthropic.py | 1 - pyproject.toml | 2 + test_project_django5/manage.py | 5 +- test_project_django5/pyproject.toml | 3 + .../test_exception_capture.py | 55 +++++++------ test_project_django5/test_middleware.py | 37 +++++---- test_project_django5/testdjango/asgi.py | 2 +- test_project_django5/testdjango/settings.py | 79 +++++++++---------- test_project_django5/testdjango/urls.py | 11 +-- test_project_django5/testdjango/views.py | 31 +++++--- test_project_django5/testdjango/wsgi.py | 2 +- 13 files changed, 133 insertions(+), 101 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 791798ad..7bc9a0c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,9 @@ name: CI on: - pull_request +permissions: + contents: read + jobs: code-quality: name: Code quality checks diff --git a/posthog/client.py b/posthog/client.py index 620064f6..6658f88c 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -3,7 +3,7 @@ import os import sys from datetime import datetime, timedelta -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Dict, Optional, Union from typing_extensions import Unpack from uuid import uuid4 @@ -60,7 +60,6 @@ SizeLimitedDict, clean, guess_timezone, - remove_trailing_slash, system_context, ) from posthog.version import VERSION diff --git a/posthog/test/ai/anthropic/test_anthropic.py b/posthog/test/ai/anthropic/test_anthropic.py index 5f65a99e..640d7770 100644 --- a/posthog/test/ai/anthropic/test_anthropic.py +++ b/posthog/test/ai/anthropic/test_anthropic.py @@ -1,4 +1,3 @@ -import os from unittest.mock import patch import pytest diff --git a/pyproject.toml b/pyproject.toml index 7732c0e8..5d00f5f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,3 +96,5 @@ version = { attr = "posthog.version.VERSION" } [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +testpaths = ["posthog/test"] +norecursedirs = ["test_project_django5"] diff --git a/test_project_django5/manage.py b/test_project_django5/manage.py index 3795858a..193b27e2 100755 --- a/test_project_django5/manage.py +++ b/test_project_django5/manage.py @@ -1,12 +1,13 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testdjango.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +19,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/test_project_django5/pyproject.toml b/test_project_django5/pyproject.toml index 17425eb0..d2f03ddf 100644 --- a/test_project_django5/pyproject.toml +++ b/test_project_django5/pyproject.toml @@ -12,5 +12,8 @@ dependencies = [ "httpx~=0.28.1", ] +[tool.uv] +required-version = ">=0.5" + [tool.uv.sources] posthog = { path = "..", editable = true } diff --git a/test_project_django5/test_exception_capture.py b/test_project_django5/test_exception_capture.py index 882509da..4a495e3e 100644 --- a/test_project_django5/test_exception_capture.py +++ b/test_project_django5/test_exception_capture.py @@ -8,6 +8,7 @@ With process_exception(), Django calls this method to capture exceptions before converting them to 500 responses. """ + import os import django @@ -15,9 +16,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") django.setup() -import pytest -from httpx import AsyncClient, ASGITransport -from django.core.asgi import get_asgi_application +import pytest # noqa: E402 +from httpx import AsyncClient, ASGITransport # noqa: E402 +from django.core.asgi import get_asgi_application # noqa: E402 @pytest.fixture(scope="session") @@ -41,27 +42,31 @@ async def test_async_exception_is_captured(asgi_app): def mock_capture(exception, **kwargs): """Mock capture_exception to record calls.""" - captured.append({ - 'exception': exception, - 'type': type(exception).__name__, - 'message': str(exception) - }) + captured.append( + { + "exception": exception, + "type": type(exception).__name__, + "message": str(exception), + } + ) # Patch at the posthog module level where middleware imports from - with patch('posthog.capture_exception', side_effect=mock_capture): - async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: + with patch("posthog.capture_exception", side_effect=mock_capture): + async with AsyncClient( + transport=ASGITransport(app=asgi_app), base_url="http://testserver" + ) as ac: response = await ac.get("/test/async-exception") # Django returns 500 assert response.status_code == 500 # CRITICAL: Verify PostHog captured the exception - assert len(captured) > 0, f"Exception was NOT captured to PostHog!" + assert len(captured) > 0, "Exception was NOT captured to PostHog!" # Verify it's the right exception exception_data = captured[0] - assert exception_data['type'] == 'ValueError' - assert 'Test exception from Django 5 async view' in exception_data['message'] + assert exception_data["type"] == "ValueError" + assert "Test exception from Django 5 async view" in exception_data["message"] @pytest.mark.asyncio @@ -79,24 +84,28 @@ async def test_sync_exception_is_captured(asgi_app): def mock_capture(exception, **kwargs): """Mock capture_exception to record calls.""" - captured.append({ - 'exception': exception, - 'type': type(exception).__name__, - 'message': str(exception) - }) + captured.append( + { + "exception": exception, + "type": type(exception).__name__, + "message": str(exception), + } + ) # Patch at the posthog module level where middleware imports from - with patch('posthog.capture_exception', side_effect=mock_capture): - async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: + with patch("posthog.capture_exception", side_effect=mock_capture): + async with AsyncClient( + transport=ASGITransport(app=asgi_app), base_url="http://testserver" + ) as ac: response = await ac.get("/test/sync-exception") # Django returns 500 assert response.status_code == 500 # CRITICAL: Verify PostHog captured the exception - assert len(captured) > 0, f"Exception was NOT captured to PostHog!" + assert len(captured) > 0, "Exception was NOT captured to PostHog!" # Verify it's the right exception exception_data = captured[0] - assert exception_data['type'] == 'ValueError' - assert 'Test exception from Django 5 sync view' in exception_data['message'] + assert exception_data["type"] == "ValueError" + assert "Test exception from Django 5 sync view" in exception_data["message"] diff --git a/test_project_django5/test_middleware.py b/test_project_django5/test_middleware.py index efc5a532..c9fd8cb3 100644 --- a/test_project_django5/test_middleware.py +++ b/test_project_django5/test_middleware.py @@ -8,6 +8,7 @@ Tests run directly against the ASGI application without needing a server. """ + import os import django @@ -15,9 +16,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") django.setup() -import pytest -from httpx import AsyncClient, ASGITransport -from django.core.asgi import get_asgi_application +import pytest # noqa: E402 +from httpx import AsyncClient, ASGITransport # noqa: E402 +from django.core.asgi import get_asgi_application # noqa: E402 @pytest.fixture(scope="session") @@ -38,7 +39,9 @@ async def test_async_user_access(asgi_app): trigger the lazy loading bug. This test verifies the middleware works in the common case. """ - async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: + async with AsyncClient( + transport=ASGITransport(app=asgi_app), base_url="http://testserver" + ) as ac: response = await ac.get("/test/async-user") assert response.status_code == 200 @@ -74,13 +77,13 @@ async def test_async_authenticated_user_access(asgi_app): @sync_to_async def create_or_get_user(): user, created = User.objects.get_or_create( - username='testuser', + username="testuser", defaults={ - 'email': 'test@example.com', - } + "email": "test@example.com", + }, ) if created: - user.set_password('testpass123') + user.set_password("testpass123") user.save() return user @@ -91,7 +94,7 @@ def create_or_get_user(): def create_session(): client = Client() client.force_login(user) - return client.cookies.get('sessionid') + return client.cookies.get("sessionid") session_cookie = await create_session() @@ -104,14 +107,14 @@ def create_session(): async with AsyncClient( transport=ASGITransport(app=asgi_app), base_url="http://testserver", - cookies={"sessionid": session_cookie.value} + cookies={"sessionid": session_cookie.value}, ) as ac: response = await ac.get("/test/async-user") assert response.status_code == 200 data = response.json() assert data["status"] == "success" - assert data["user_authenticated"] == True + assert data["user_authenticated"] @pytest.mark.asyncio @@ -121,7 +124,9 @@ async def test_sync_user_access(asgi_app): This should always work regardless of middleware version. """ - async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: + async with AsyncClient( + transport=ASGITransport(app=asgi_app), base_url="http://testserver" + ) as ac: response = await ac.get("/test/sync-user") assert response.status_code == 200 @@ -139,7 +144,9 @@ async def test_async_exception_capture(asgi_app): causes a 500 response. See test_exception_capture.py for tests that verify actual exception capture to PostHog. """ - async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: + async with AsyncClient( + transport=ASGITransport(app=asgi_app), base_url="http://testserver" + ) as ac: response = await ac.get("/test/async-exception") # Django returns 500 for unhandled exceptions @@ -154,7 +161,9 @@ async def test_sync_exception_capture(asgi_app): The middleware's process_exception() method captures view exceptions to PostHog. This test verifies the exception causes a 500 response. """ - async with AsyncClient(transport=ASGITransport(app=asgi_app), base_url="http://testserver") as ac: + async with AsyncClient( + transport=ASGITransport(app=asgi_app), base_url="http://testserver" + ) as ac: response = await ac.get("/test/sync-exception") # Django returns 500 for unhandled exceptions diff --git a/test_project_django5/testdjango/asgi.py b/test_project_django5/testdjango/asgi.py index 48037cf3..655c4107 100644 --- a/test_project_django5/testdjango/asgi.py +++ b/test_project_django5/testdjango/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testdjango.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") application = get_asgi_application() diff --git a/test_project_django5/testdjango/settings.py b/test_project_django5/testdjango/settings.py index 6c3e0422..48ac37d7 100644 --- a/test_project_django5/testdjango/settings.py +++ b/test_project_django5/testdjango/settings.py @@ -20,63 +20,63 @@ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-q5(&wfw@_lb)noyowbfl$2ls8c82hl__0f9s5(mohlh2)aas#3' +SECRET_KEY = "django-insecure-q5(&wfw@_lb)noyowbfl$2ls8c82hl__0f9s5(mohlh2)aas#3" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ['*'] +ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'posthog.integrations.django.PosthogContextMiddleware', # Test PostHog middleware + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "posthog.integrations.django.PosthogContextMiddleware", # Test PostHog middleware ] -ROOT_URLCONF = 'testdjango.urls' +ROOT_URLCONF = "testdjango.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'testdjango.wsgi.application' +WSGI_APPLICATION = "testdjango.wsgi.application" # Database # https://docs.djangoproject.com/en/5.2/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -86,16 +86,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -103,9 +103,9 @@ # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -115,16 +115,15 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/5.2/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = "static/" # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # PostHog settings for testing -POSTHOG_API_KEY = 'test-key' -POSTHOG_HOST = 'https://app.posthog.com' +POSTHOG_API_KEY = "test-key" +POSTHOG_HOST = "https://app.posthog.com" POSTHOG_MW_CAPTURE_EXCEPTIONS = True - diff --git a/test_project_django5/testdjango/urls.py b/test_project_django5/testdjango/urls.py index 1eb84dd6..b0dd8921 100644 --- a/test_project_django5/testdjango/urls.py +++ b/test_project_django5/testdjango/urls.py @@ -14,14 +14,15 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path from testdjango import views urlpatterns = [ - path('admin/', admin.site.urls), - path('test/async-user', views.test_async_user), - path('test/sync-user', views.test_sync_user), - path('test/async-exception', views.test_async_exception), - path('test/sync-exception', views.test_sync_exception), + path("admin/", admin.site.urls), + path("test/async-user", views.test_async_user), + path("test/sync-user", views.test_sync_user), + path("test/async-exception", views.test_async_exception), + path("test/sync-exception", views.test_sync_exception), ] diff --git a/test_project_django5/testdjango/views.py b/test_project_django5/testdjango/views.py index 393d08fc..248b1864 100644 --- a/test_project_django5/testdjango/views.py +++ b/test_project_django5/testdjango/views.py @@ -1,6 +1,7 @@ """ Test views for validating PostHog middleware with Django 5 ASGI. """ + from django.http import JsonResponse @@ -15,22 +16,28 @@ async def test_async_user(request): # If we got here, the fix works! user = await request.auser() - return JsonResponse({ - "status": "success", - "message": "Django 5 async middleware test passed!", - "django_version": "5.x", - "user_authenticated": user.is_authenticated if user else False, - "note": "Middleware used await request.auser() successfully" - }) + return JsonResponse( + { + "status": "success", + "message": "Django 5 async middleware test passed!", + "django_version": "5.x", + "user_authenticated": user.is_authenticated if user else False, + "note": "Middleware used await request.auser() successfully", + } + ) def test_sync_user(request): """Sync view for comparison.""" - return JsonResponse({ - "status": "success", - "message": "Sync view works", - "user_authenticated": request.user.is_authenticated if hasattr(request, 'user') else False - }) + return JsonResponse( + { + "status": "success", + "message": "Sync view works", + "user_authenticated": request.user.is_authenticated + if hasattr(request, "user") + else False, + } + ) async def test_async_exception(request): diff --git a/test_project_django5/testdjango/wsgi.py b/test_project_django5/testdjango/wsgi.py index 510b0cdb..edf1d8b0 100644 --- a/test_project_django5/testdjango/wsgi.py +++ b/test_project_django5/testdjango/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testdjango.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings") application = get_wsgi_application() From 0c688ba8ecfba08c8e89fae73633dd95d316e948 Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Fri, 31 Oct 2025 13:33:53 +0100 Subject: [PATCH 15/15] chore: update changelog date to 2025-10-31 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d1e21ec..cb25d4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 6.7.12 - 2025-10-29 +# 6.7.12 - 2025-10-31 - fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355) - fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)