From 68014a2bbd0c375d77b9fc87bcdf8ec5cdbae61a Mon Sep 17 00:00:00 2001 From: dennisjoseph2025-dotcom Date: Wed, 24 Jun 2026 14:57:30 +0530 Subject: [PATCH 1/4] feat: add Django core service under services/core/ --- services/core/.gitignore | 14 + services/core/.python-version | 1 + services/core/README.md | 0 services/core/apps/__init__.py | 0 .../core/apps/admin_dashboard/__init__.py | 0 services/core/apps/admin_dashboard/apps.py | 6 + .../apps/admin_dashboard/tests/__init__.py | 1 + .../admin_dashboard/tests/test_projects.py | 66 + .../apps/admin_dashboard/tests/test_stats.py | 35 + .../apps/admin_dashboard/tests/test_users.py | 83 + services/core/apps/admin_dashboard/urls.py | 23 + .../apps/admin_dashboard/views/__init__.py | 14 + .../apps/admin_dashboard/views/projects.py | 89 + .../core/apps/admin_dashboard/views/stats.py | 57 + .../core/apps/admin_dashboard/views/users.py | 112 + services/core/apps/ai/__init__.py | 0 services/core/apps/ai/apps.py | 6 + services/core/apps/ai/migrations/__init__.py | 0 services/core/apps/ai/tests/__init__.py | 1 + services/core/apps/ai/tests/test_views.py | 72 + services/core/apps/ai/urls.py | 7 + services/core/apps/ai/views/__init__.py | 5 + services/core/apps/ai/views/status.py | 52 + services/core/apps/comments/__init__.py | 0 services/core/apps/comments/apps.py | 6 + .../apps/comments/migrations/0001_initial.py | 37 + .../core/apps/comments/migrations/__init__.py | 0 services/core/apps/comments/models.py | 29 + .../apps/comments/serializers/__init__.py | 49 + services/core/apps/comments/tests/__init__.py | 1 + .../core/apps/comments/tests/test_models.py | 40 + .../apps/comments/tests/test_serializers.py | 47 + .../apps/comments/tests/test_throttles.py | 25 + .../core/apps/comments/tests/test_views.py | 115 + services/core/apps/comments/throttles.py | 10 + services/core/apps/comments/urls.py | 9 + services/core/apps/comments/views/__init__.py | 1 + services/core/apps/comments/views/comments.py | 82 + services/core/apps/common/__init__.py | 0 services/core/apps/common/apps.py | 6 + services/core/apps/common/config.py | 3 + services/core/apps/common/github.py | 115 + services/core/apps/common/health.py | 4 + services/core/apps/common/pagination.py | 17 + services/core/apps/common/tests/__init__.py | 0 .../core/apps/common/tests/test_config.py | 6 + .../core/apps/common/tests/test_github.py | 95 + .../core/apps/common/tests/test_pagination.py | 35 + services/core/apps/conftest.py | 112 + services/core/apps/exports/__init__.py | 0 services/core/apps/exports/apps.py | 6 + services/core/apps/exports/generators.py | 51 + .../core/apps/exports/migrations/__init__.py | 0 services/core/apps/exports/tests/__init__.py | 1 + .../apps/exports/tests/test_generators.py | 46 + .../core/apps/exports/tests/test_views.py | 70 + services/core/apps/exports/urls.py | 8 + services/core/apps/exports/views/__init__.py | 7 + services/core/apps/exports/views/folder.py | 54 + services/core/apps/exports/views/markdown.py | 19 + services/core/apps/feedback/__init__.py | 0 services/core/apps/feedback/admin.py | 33 + services/core/apps/feedback/apps.py | 6 + services/core/apps/feedback/email_utils.py | 66 + .../apps/feedback/migrations/0001_initial.py | 39 + .../0002_feedback_admin_response.py | 18 + ...remove_feedback_admin_response_and_more.py | 40 + .../0004_remove_feedback_is_public.py | 17 + .../core/apps/feedback/migrations/__init__.py | 0 services/core/apps/feedback/models.py | 48 + .../apps/feedback/serializers/__init__.py | 30 + services/core/apps/feedback/tasks.py | 27 + services/core/apps/feedback/tests/__init__.py | 1 + .../apps/feedback/tests/test_email_utils.py | 37 + .../core/apps/feedback/tests/test_models.py | 46 + .../apps/feedback/tests/test_serializers.py | 43 + .../core/apps/feedback/tests/test_tasks.py | 31 + .../core/apps/feedback/tests/test_views.py | 102 + services/core/apps/feedback/urls.py | 17 + services/core/apps/feedback/views/__init__.py | 10 + services/core/apps/feedback/views/feedback.py | 100 + services/core/apps/feedback/views/replies.py | 29 + .../core/apps/github_integration/__init__.py | 0 services/core/apps/github_integration/apps.py | 6 + .../core/apps/github_integration/fetcher.py | 98 + .../github_integration/migrations/__init__.py | 0 .../serializers/__init__.py | 10 + .../core/apps/github_integration/tasks.py | 100 + .../apps/github_integration/tests/__init__.py | 1 + .../tests/test_serializers.py | 26 + .../github_integration/tests/test_views.py | 119 + services/core/apps/github_integration/urls.py | 19 + .../apps/github_integration/views/__init__.py | 12 + .../apps/github_integration/views/public.py | 110 + .../apps/github_integration/views/user.py | 103 + services/core/apps/internal/__init__.py | 0 services/core/apps/internal/tests/__init__.py | 1 + .../core/apps/internal/tests/test_views.py | 137 + services/core/apps/internal/urls.py | 10 + services/core/apps/internal/views/__init__.py | 8 + services/core/apps/internal/views/receive.py | 152 ++ services/core/apps/notifications/__init__.py | 0 services/core/apps/notifications/apps.py | 6 + .../notifications/migrations/0001_initial.py | 36 + .../apps/notifications/migrations/__init__.py | 0 services/core/apps/notifications/models.py | 27 + .../notifications/serializers/__init__.py | 14 + services/core/apps/notifications/tasks.py | 17 + .../core/apps/notifications/tests/__init__.py | 1 + .../apps/notifications/tests/test_models.py | 34 + .../notifications/tests/test_serializers.py | 29 + .../apps/notifications/tests/test_tasks.py | 35 + .../apps/notifications/tests/test_utils.py | 99 + .../apps/notifications/tests/test_views.py | 100 + services/core/apps/notifications/urls.py | 19 + services/core/apps/notifications/utils.py | 66 + .../core/apps/notifications/views/__init__.py | 8 + .../apps/notifications/views/notifications.py | 56 + services/core/apps/parser/__init__.py | 0 services/core/apps/parser/apps.py | 6 + services/core/apps/parser/ast_parser.py | 165 ++ .../core/apps/parser/migrations/__init__.py | 0 services/core/apps/parser/tasks.py | 115 + services/core/apps/parser/tests/__init__.py | 1 + .../core/apps/parser/tests/test_ast_parser.py | 100 + services/core/apps/parser/tests/test_tasks.py | 20 + .../core/apps/parser/tests/test_validators.py | 55 + services/core/apps/parser/tests/test_views.py | 113 + services/core/apps/parser/urls.py | 8 + services/core/apps/parser/validators.py | 45 + services/core/apps/parser/views/__init__.py | 7 + services/core/apps/parser/views/file.py | 46 + services/core/apps/parser/views/folder.py | 78 + services/core/apps/projects/__init__.py | 0 services/core/apps/projects/admin.py | 2 + services/core/apps/projects/apps.py | 6 + .../apps/projects/migrations/0001_initial.py | 63 + ...roject_project_info_project_readme_docs.py | 23 + .../migrations/0003_project_api_docs.py | 18 + .../migrations/0004_project_custom_details.py | 18 + .../migrations/0005_remove_file_fields.py | 21 + ...work_info_project_is_published_and_more.py | 57 + .../core/apps/projects/migrations/__init__.py | 0 .../core/apps/projects/models/__init__.py | 4 + services/core/apps/projects/models/file.py | 26 + services/core/apps/projects/models/project.py | 68 + .../apps/projects/serializers/__init__.py | 10 + .../core/apps/projects/serializers/project.py | 46 + .../core/apps/projects/serializers/public.py | 37 + services/core/apps/projects/tests/__init__.py | 0 .../core/apps/projects/tests/test_models.py | 58 + .../apps/projects/tests/test_serializers.py | 62 + .../core/apps/projects/tests/test_views.py | 125 + services/core/apps/projects/throttles.py | 17 + services/core/apps/projects/urls.py | 20 + services/core/apps/projects/views/__init__.py | 10 + services/core/apps/projects/views/project.py | 137 + services/core/apps/projects/views/public.py | 58 + services/core/apps/universal/__init__.py | 0 services/core/apps/universal/apps.py | 6 + services/core/apps/universal/prompts.py | 196 ++ services/core/apps/universal/tasks.py | 304 +++ .../core/apps/universal/tests/__init__.py | 1 + .../core/apps/universal/tests/test_prompts.py | 47 + .../core/apps/universal/tests/test_tasks.py | 68 + .../core/apps/universal/tests/test_views.py | 71 + services/core/apps/universal/urls.py | 8 + .../core/apps/universal/views/__init__.py | 7 + services/core/apps/universal/views/status.py | 27 + services/core/apps/universal/views/upload.py | 124 + services/core/apps/users/__init__.py | 0 services/core/apps/users/admin.py | 54 + services/core/apps/users/apps.py | 6 + services/core/apps/users/email_utils.py | 60 + services/core/apps/users/github.py | 46 + .../management/commands/create_test_user.py | 26 + .../apps/users/migrations/0001_initial.py | 41 + .../apps/users/migrations/0002_user_name.py | 20 + .../migrations/0003_passwordresettoken.py | 30 + .../core/apps/users/migrations/__init__.py | 0 services/core/apps/users/models/__init__.py | 4 + .../core/apps/users/models/password_reset.py | 22 + services/core/apps/users/models/user.py | 62 + .../core/apps/users/serializers/__init__.py | 14 + services/core/apps/users/serializers/auth.py | 40 + .../apps/users/serializers/password_reset.py | 11 + .../core/apps/users/serializers/profile.py | 56 + services/core/apps/users/tasks.py | 27 + services/core/apps/users/tests/__init__.py | 0 services/core/apps/users/tests/test_models.py | 54 + .../core/apps/users/tests/test_serializers.py | 77 + services/core/apps/users/tests/test_views.py | 157 ++ services/core/apps/users/urls.py | 27 + services/core/apps/users/views/__init__.py | 17 + services/core/apps/users/views/admin.py | 51 + services/core/apps/users/views/auth.py | 136 + .../core/apps/users/views/password_reset.py | 47 + services/core/apps/users/views/profile.py | 31 + services/core/config/__init__.py | 3 + services/core/config/asgi.py | 7 + services/core/config/celery.py | 9 + services/core/config/settings/__init__.py | 0 services/core/config/settings/base.py | 210 ++ services/core/config/settings/development.py | 39 + services/core/config/settings/production.py | 97 + services/core/config/storage_backends.py | 5 + services/core/config/urls.py | 29 + services/core/config/wsgi.py | 7 + services/core/deploy/Procfile | 2 + services/core/docker/.dockerignore | 13 + services/core/docker/Dockerfile | 43 + services/core/docker/entrypoint.sh | 4 + services/core/env/.env.example | 30 + services/core/manage.py | 14 + services/core/pyproject.toml | 75 + services/core/requirements/base.txt | 14 + services/core/requirements/development.txt | 2 + services/core/requirements/production.txt | 2 + services/core/seed/seed_admin.py | 24 + .../templates/emails/account_blocked.html | 25 + .../templates/emails/account_deleted.html | 31 + .../emails/feedback_confirmation.html | 30 + .../emails/feedback_confirmation.txt | 13 + .../core/templates/emails/feedback_reply.html | 30 + .../core/templates/emails/feedback_reply.txt | 14 + .../emails/notification_comment.html | 33 + .../templates/emails/notification_reply.html | 38 + .../core/templates/emails/password_reset.html | 26 + .../core/templates/emails/password_reset.txt | 12 + services/core/templates/emails/welcome.html | 24 + services/core/templates/emails/welcome.txt | 10 + services/core/uv.lock | 2296 +++++++++++++++++ 232 files changed, 10780 insertions(+) create mode 100644 services/core/.gitignore create mode 100644 services/core/.python-version create mode 100644 services/core/README.md create mode 100644 services/core/apps/__init__.py create mode 100644 services/core/apps/admin_dashboard/__init__.py create mode 100644 services/core/apps/admin_dashboard/apps.py create mode 100644 services/core/apps/admin_dashboard/tests/__init__.py create mode 100644 services/core/apps/admin_dashboard/tests/test_projects.py create mode 100644 services/core/apps/admin_dashboard/tests/test_stats.py create mode 100644 services/core/apps/admin_dashboard/tests/test_users.py create mode 100644 services/core/apps/admin_dashboard/urls.py create mode 100644 services/core/apps/admin_dashboard/views/__init__.py create mode 100644 services/core/apps/admin_dashboard/views/projects.py create mode 100644 services/core/apps/admin_dashboard/views/stats.py create mode 100644 services/core/apps/admin_dashboard/views/users.py create mode 100644 services/core/apps/ai/__init__.py create mode 100644 services/core/apps/ai/apps.py create mode 100644 services/core/apps/ai/migrations/__init__.py create mode 100644 services/core/apps/ai/tests/__init__.py create mode 100644 services/core/apps/ai/tests/test_views.py create mode 100644 services/core/apps/ai/urls.py create mode 100644 services/core/apps/ai/views/__init__.py create mode 100644 services/core/apps/ai/views/status.py create mode 100644 services/core/apps/comments/__init__.py create mode 100644 services/core/apps/comments/apps.py create mode 100644 services/core/apps/comments/migrations/0001_initial.py create mode 100644 services/core/apps/comments/migrations/__init__.py create mode 100644 services/core/apps/comments/models.py create mode 100644 services/core/apps/comments/serializers/__init__.py create mode 100644 services/core/apps/comments/tests/__init__.py create mode 100644 services/core/apps/comments/tests/test_models.py create mode 100644 services/core/apps/comments/tests/test_serializers.py create mode 100644 services/core/apps/comments/tests/test_throttles.py create mode 100644 services/core/apps/comments/tests/test_views.py create mode 100644 services/core/apps/comments/throttles.py create mode 100644 services/core/apps/comments/urls.py create mode 100644 services/core/apps/comments/views/__init__.py create mode 100644 services/core/apps/comments/views/comments.py create mode 100644 services/core/apps/common/__init__.py create mode 100644 services/core/apps/common/apps.py create mode 100644 services/core/apps/common/config.py create mode 100644 services/core/apps/common/github.py create mode 100644 services/core/apps/common/health.py create mode 100644 services/core/apps/common/pagination.py create mode 100644 services/core/apps/common/tests/__init__.py create mode 100644 services/core/apps/common/tests/test_config.py create mode 100644 services/core/apps/common/tests/test_github.py create mode 100644 services/core/apps/common/tests/test_pagination.py create mode 100644 services/core/apps/conftest.py create mode 100644 services/core/apps/exports/__init__.py create mode 100644 services/core/apps/exports/apps.py create mode 100644 services/core/apps/exports/generators.py create mode 100644 services/core/apps/exports/migrations/__init__.py create mode 100644 services/core/apps/exports/tests/__init__.py create mode 100644 services/core/apps/exports/tests/test_generators.py create mode 100644 services/core/apps/exports/tests/test_views.py create mode 100644 services/core/apps/exports/urls.py create mode 100644 services/core/apps/exports/views/__init__.py create mode 100644 services/core/apps/exports/views/folder.py create mode 100644 services/core/apps/exports/views/markdown.py create mode 100644 services/core/apps/feedback/__init__.py create mode 100644 services/core/apps/feedback/admin.py create mode 100644 services/core/apps/feedback/apps.py create mode 100644 services/core/apps/feedback/email_utils.py create mode 100644 services/core/apps/feedback/migrations/0001_initial.py create mode 100644 services/core/apps/feedback/migrations/0002_feedback_admin_response.py create mode 100644 services/core/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py create mode 100644 services/core/apps/feedback/migrations/0004_remove_feedback_is_public.py create mode 100644 services/core/apps/feedback/migrations/__init__.py create mode 100644 services/core/apps/feedback/models.py create mode 100644 services/core/apps/feedback/serializers/__init__.py create mode 100644 services/core/apps/feedback/tasks.py create mode 100644 services/core/apps/feedback/tests/__init__.py create mode 100644 services/core/apps/feedback/tests/test_email_utils.py create mode 100644 services/core/apps/feedback/tests/test_models.py create mode 100644 services/core/apps/feedback/tests/test_serializers.py create mode 100644 services/core/apps/feedback/tests/test_tasks.py create mode 100644 services/core/apps/feedback/tests/test_views.py create mode 100644 services/core/apps/feedback/urls.py create mode 100644 services/core/apps/feedback/views/__init__.py create mode 100644 services/core/apps/feedback/views/feedback.py create mode 100644 services/core/apps/feedback/views/replies.py create mode 100644 services/core/apps/github_integration/__init__.py create mode 100644 services/core/apps/github_integration/apps.py create mode 100644 services/core/apps/github_integration/fetcher.py create mode 100644 services/core/apps/github_integration/migrations/__init__.py create mode 100644 services/core/apps/github_integration/serializers/__init__.py create mode 100644 services/core/apps/github_integration/tasks.py create mode 100644 services/core/apps/github_integration/tests/__init__.py create mode 100644 services/core/apps/github_integration/tests/test_serializers.py create mode 100644 services/core/apps/github_integration/tests/test_views.py create mode 100644 services/core/apps/github_integration/urls.py create mode 100644 services/core/apps/github_integration/views/__init__.py create mode 100644 services/core/apps/github_integration/views/public.py create mode 100644 services/core/apps/github_integration/views/user.py create mode 100644 services/core/apps/internal/__init__.py create mode 100644 services/core/apps/internal/tests/__init__.py create mode 100644 services/core/apps/internal/tests/test_views.py create mode 100644 services/core/apps/internal/urls.py create mode 100644 services/core/apps/internal/views/__init__.py create mode 100644 services/core/apps/internal/views/receive.py create mode 100644 services/core/apps/notifications/__init__.py create mode 100644 services/core/apps/notifications/apps.py create mode 100644 services/core/apps/notifications/migrations/0001_initial.py create mode 100644 services/core/apps/notifications/migrations/__init__.py create mode 100644 services/core/apps/notifications/models.py create mode 100644 services/core/apps/notifications/serializers/__init__.py create mode 100644 services/core/apps/notifications/tasks.py create mode 100644 services/core/apps/notifications/tests/__init__.py create mode 100644 services/core/apps/notifications/tests/test_models.py create mode 100644 services/core/apps/notifications/tests/test_serializers.py create mode 100644 services/core/apps/notifications/tests/test_tasks.py create mode 100644 services/core/apps/notifications/tests/test_utils.py create mode 100644 services/core/apps/notifications/tests/test_views.py create mode 100644 services/core/apps/notifications/urls.py create mode 100644 services/core/apps/notifications/utils.py create mode 100644 services/core/apps/notifications/views/__init__.py create mode 100644 services/core/apps/notifications/views/notifications.py create mode 100644 services/core/apps/parser/__init__.py create mode 100644 services/core/apps/parser/apps.py create mode 100644 services/core/apps/parser/ast_parser.py create mode 100644 services/core/apps/parser/migrations/__init__.py create mode 100644 services/core/apps/parser/tasks.py create mode 100644 services/core/apps/parser/tests/__init__.py create mode 100644 services/core/apps/parser/tests/test_ast_parser.py create mode 100644 services/core/apps/parser/tests/test_tasks.py create mode 100644 services/core/apps/parser/tests/test_validators.py create mode 100644 services/core/apps/parser/tests/test_views.py create mode 100644 services/core/apps/parser/urls.py create mode 100644 services/core/apps/parser/validators.py create mode 100644 services/core/apps/parser/views/__init__.py create mode 100644 services/core/apps/parser/views/file.py create mode 100644 services/core/apps/parser/views/folder.py create mode 100644 services/core/apps/projects/__init__.py create mode 100644 services/core/apps/projects/admin.py create mode 100644 services/core/apps/projects/apps.py create mode 100644 services/core/apps/projects/migrations/0001_initial.py create mode 100644 services/core/apps/projects/migrations/0002_project_project_info_project_readme_docs.py create mode 100644 services/core/apps/projects/migrations/0003_project_api_docs.py create mode 100644 services/core/apps/projects/migrations/0004_project_custom_details.py create mode 100644 services/core/apps/projects/migrations/0005_remove_file_fields.py create mode 100644 services/core/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py create mode 100644 services/core/apps/projects/migrations/__init__.py create mode 100644 services/core/apps/projects/models/__init__.py create mode 100644 services/core/apps/projects/models/file.py create mode 100644 services/core/apps/projects/models/project.py create mode 100644 services/core/apps/projects/serializers/__init__.py create mode 100644 services/core/apps/projects/serializers/project.py create mode 100644 services/core/apps/projects/serializers/public.py create mode 100644 services/core/apps/projects/tests/__init__.py create mode 100644 services/core/apps/projects/tests/test_models.py create mode 100644 services/core/apps/projects/tests/test_serializers.py create mode 100644 services/core/apps/projects/tests/test_views.py create mode 100644 services/core/apps/projects/throttles.py create mode 100644 services/core/apps/projects/urls.py create mode 100644 services/core/apps/projects/views/__init__.py create mode 100644 services/core/apps/projects/views/project.py create mode 100644 services/core/apps/projects/views/public.py create mode 100644 services/core/apps/universal/__init__.py create mode 100644 services/core/apps/universal/apps.py create mode 100644 services/core/apps/universal/prompts.py create mode 100644 services/core/apps/universal/tasks.py create mode 100644 services/core/apps/universal/tests/__init__.py create mode 100644 services/core/apps/universal/tests/test_prompts.py create mode 100644 services/core/apps/universal/tests/test_tasks.py create mode 100644 services/core/apps/universal/tests/test_views.py create mode 100644 services/core/apps/universal/urls.py create mode 100644 services/core/apps/universal/views/__init__.py create mode 100644 services/core/apps/universal/views/status.py create mode 100644 services/core/apps/universal/views/upload.py create mode 100644 services/core/apps/users/__init__.py create mode 100644 services/core/apps/users/admin.py create mode 100644 services/core/apps/users/apps.py create mode 100644 services/core/apps/users/email_utils.py create mode 100644 services/core/apps/users/github.py create mode 100644 services/core/apps/users/management/commands/create_test_user.py create mode 100644 services/core/apps/users/migrations/0001_initial.py create mode 100644 services/core/apps/users/migrations/0002_user_name.py create mode 100644 services/core/apps/users/migrations/0003_passwordresettoken.py create mode 100644 services/core/apps/users/migrations/__init__.py create mode 100644 services/core/apps/users/models/__init__.py create mode 100644 services/core/apps/users/models/password_reset.py create mode 100644 services/core/apps/users/models/user.py create mode 100644 services/core/apps/users/serializers/__init__.py create mode 100644 services/core/apps/users/serializers/auth.py create mode 100644 services/core/apps/users/serializers/password_reset.py create mode 100644 services/core/apps/users/serializers/profile.py create mode 100644 services/core/apps/users/tasks.py create mode 100644 services/core/apps/users/tests/__init__.py create mode 100644 services/core/apps/users/tests/test_models.py create mode 100644 services/core/apps/users/tests/test_serializers.py create mode 100644 services/core/apps/users/tests/test_views.py create mode 100644 services/core/apps/users/urls.py create mode 100644 services/core/apps/users/views/__init__.py create mode 100644 services/core/apps/users/views/admin.py create mode 100644 services/core/apps/users/views/auth.py create mode 100644 services/core/apps/users/views/password_reset.py create mode 100644 services/core/apps/users/views/profile.py create mode 100644 services/core/config/__init__.py create mode 100644 services/core/config/asgi.py create mode 100644 services/core/config/celery.py create mode 100644 services/core/config/settings/__init__.py create mode 100644 services/core/config/settings/base.py create mode 100644 services/core/config/settings/development.py create mode 100644 services/core/config/settings/production.py create mode 100644 services/core/config/storage_backends.py create mode 100644 services/core/config/urls.py create mode 100644 services/core/config/wsgi.py create mode 100644 services/core/deploy/Procfile create mode 100644 services/core/docker/.dockerignore create mode 100644 services/core/docker/Dockerfile create mode 100644 services/core/docker/entrypoint.sh create mode 100644 services/core/env/.env.example create mode 100644 services/core/manage.py create mode 100644 services/core/pyproject.toml create mode 100644 services/core/requirements/base.txt create mode 100644 services/core/requirements/development.txt create mode 100644 services/core/requirements/production.txt create mode 100644 services/core/seed/seed_admin.py create mode 100644 services/core/templates/emails/account_blocked.html create mode 100644 services/core/templates/emails/account_deleted.html create mode 100644 services/core/templates/emails/feedback_confirmation.html create mode 100644 services/core/templates/emails/feedback_confirmation.txt create mode 100644 services/core/templates/emails/feedback_reply.html create mode 100644 services/core/templates/emails/feedback_reply.txt create mode 100644 services/core/templates/emails/notification_comment.html create mode 100644 services/core/templates/emails/notification_reply.html create mode 100644 services/core/templates/emails/password_reset.html create mode 100644 services/core/templates/emails/password_reset.txt create mode 100644 services/core/templates/emails/welcome.html create mode 100644 services/core/templates/emails/welcome.txt create mode 100644 services/core/uv.lock diff --git a/services/core/.gitignore b/services/core/.gitignore new file mode 100644 index 0000000..d23b4fd --- /dev/null +++ b/services/core/.gitignore @@ -0,0 +1,14 @@ +.env +__pycache__/ +*.pyc +*.pyo +db.sqlite3 +staticfiles/ +media/ +.venv/ +venv/ +*.egg-info/ +dist/ +build/ +.DS_Store +tr.md \ No newline at end of file diff --git a/services/core/.python-version b/services/core/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/services/core/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/services/core/README.md b/services/core/README.md new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/__init__.py b/services/core/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/admin_dashboard/__init__.py b/services/core/apps/admin_dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/admin_dashboard/apps.py b/services/core/apps/admin_dashboard/apps.py new file mode 100644 index 0000000..938866a --- /dev/null +++ b/services/core/apps/admin_dashboard/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AdminDashboardConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.admin_dashboard' diff --git a/services/core/apps/admin_dashboard/tests/__init__.py b/services/core/apps/admin_dashboard/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/admin_dashboard/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/admin_dashboard/tests/test_projects.py b/services/core/apps/admin_dashboard/tests/test_projects.py new file mode 100644 index 0000000..c32c1a2 --- /dev/null +++ b/services/core/apps/admin_dashboard/tests/test_projects.py @@ -0,0 +1,66 @@ +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestAdminProjectListView: + def test_non_admin_forbidden(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('admin-project-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_admin_can_list(self, api_client, admin_user, project): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-project-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] >= 1 + assert 'stats' in response.data + assert 'results' in response.data + + def test_search(self, api_client, admin_user, project): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-project-list') + '?search=Test Project' + response = api_client.get(url) + assert response.data['count'] >= 1 + + def test_filter_by_status(self, api_client, admin_user, project): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-project-list') + '?status=done' + response = api_client.get(url) + assert response.data['count'] >= 1 + + +class TestAdminUserProjectsView: + def test_list_user_published_projects(self, api_client, admin_user, user, published_project): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-projects', args=[user.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) >= 1 + + def test_unpublished_not_included(self, api_client, admin_user, user, project): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-projects', args=[user.id]) + response = api_client.get(url) + published = [p for p in response.data if p['is_published']] + assert len(published) == 0 + + +class TestAdminProjectDetailView: + def test_admin_can_view(self, api_client, admin_user, project): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-project-detail', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == str(project.id) + + def test_not_found(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-project-detail', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/services/core/apps/admin_dashboard/tests/test_stats.py b/services/core/apps/admin_dashboard/tests/test_stats.py new file mode 100644 index 0000000..902bf68 --- /dev/null +++ b/services/core/apps/admin_dashboard/tests/test_stats.py @@ -0,0 +1,35 @@ +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestAdminStatsView: + def test_non_admin_forbidden(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('admin-stats') + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_admin_can_access(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-stats') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert 'users' in response.data + assert 'projects' in response.data + assert 'top_users' in response.data + + def test_user_stats(self, api_client, admin_user, user, other_user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-stats') + response = api_client.get(url) + assert response.data['users']['total'] >= 3 + + def test_project_stats(self, api_client, admin_user, project): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-stats') + response = api_client.get(url) + assert response.data['projects']['total'] >= 1 diff --git a/services/core/apps/admin_dashboard/tests/test_users.py b/services/core/apps/admin_dashboard/tests/test_users.py new file mode 100644 index 0000000..542211b --- /dev/null +++ b/services/core/apps/admin_dashboard/tests/test_users.py @@ -0,0 +1,83 @@ +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestAdminUserListView: + def test_non_admin_forbidden(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('admin-user-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_admin_can_list(self, api_client, admin_user, user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) >= 2 + + def test_search(self, api_client, admin_user, user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-list') + '?search=test@example.com' + response = api_client.get(url) + assert len(response.data) >= 1 + assert response.data[0]['email'] == 'test@example.com' + + +class TestAdminUserDetailView: + def test_admin_can_view(self, api_client, admin_user, user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-detail', args=[user.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['email'] == user.email + + def test_not_found(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-detail', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestAdminUserDeleteView: + def test_admin_can_delete(self, api_client, admin_user, user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-delete', args=[user.id]) + response = api_client.post(url, {'reason': 'Inactive'}, format='json') + assert response.status_code == status.HTTP_200_OK + + def test_not_found(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-delete', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.post(url, {'reason': 'Test'}, format='json') + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestAdminUserBlockView: + def test_admin_can_block(self, api_client, admin_user, user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-block', args=[user.id]) + response = api_client.post(url, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['is_active'] is False + user.refresh_from_db() + assert user.is_active is False + + def test_admin_can_unblock(self, api_client, admin_user, user): + user.is_active = False + user.save() + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-block', args=[user.id]) + response = api_client.post(url, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['is_active'] is True + + def test_cannot_block_self(self, api_client, admin_user): + api_client.force_authenticate(user=admin_user) + url = reverse('admin-user-block', args=[admin_user.id]) + response = api_client.post(url, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/services/core/apps/admin_dashboard/urls.py b/services/core/apps/admin_dashboard/urls.py new file mode 100644 index 0000000..4681ee7 --- /dev/null +++ b/services/core/apps/admin_dashboard/urls.py @@ -0,0 +1,23 @@ +from django.urls import path + +from .views import ( + AdminProjectDetailView, + AdminProjectListView, + AdminStatsView, + AdminUserBlockView, + AdminUserDeleteView, + AdminUserDetailView, + AdminUserListView, + AdminUserProjectsView, +) + +urlpatterns = [ + path('stats/', AdminStatsView.as_view(), name='admin-stats'), + path('users/', AdminUserListView.as_view(), name='admin-user-list'), + path('users//', AdminUserDetailView.as_view(), name='admin-user-detail'), + path('users//publish/', AdminUserProjectsView.as_view(), name='admin-user-projects'), + path('users//delete/', AdminUserDeleteView.as_view(), name='admin-user-delete'), + path('users//block/', AdminUserBlockView.as_view(), name='admin-user-block'), + path('projects/', AdminProjectListView.as_view(), name='admin-project-list'), + path('projects//', AdminProjectDetailView.as_view(), name='admin-project-detail'), +] diff --git a/services/core/apps/admin_dashboard/views/__init__.py b/services/core/apps/admin_dashboard/views/__init__.py new file mode 100644 index 0000000..2754b5e --- /dev/null +++ b/services/core/apps/admin_dashboard/views/__init__.py @@ -0,0 +1,14 @@ +from .stats import AdminStatsView +from .users import AdminUserListView, AdminUserDetailView, AdminUserDeleteView, AdminUserBlockView +from .projects import AdminProjectListView, AdminUserProjectsView, AdminProjectDetailView + +__all__ = [ + 'AdminStatsView', + 'AdminUserListView', + 'AdminUserDetailView', + 'AdminUserDeleteView', + 'AdminUserBlockView', + 'AdminProjectListView', + 'AdminUserProjectsView', + 'AdminProjectDetailView', +] diff --git a/services/core/apps/admin_dashboard/views/projects.py b/services/core/apps/admin_dashboard/views/projects.py new file mode 100644 index 0000000..dc72a01 --- /dev/null +++ b/services/core/apps/admin_dashboard/views/projects.py @@ -0,0 +1,89 @@ +from django.db.models import Count, Q +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.projects.models import Project +from apps.projects.serializers import ProjectListSerializer, ProjectSerializer + + +class AdminProjectListView(APIView): + permission_classes = [IsAdminUser] + + def get(self, request): + qs = Project.objects.select_related('user') \ + .annotate(file_count=Count('files')) + + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(name__icontains=search) | + Q(user__email__icontains=search) | + Q(user__name__icontains=search) + ) + + for field in ['status', 'source_type']: + val = request.query_params.get(field) + if val: + qs = qs.filter(**{field: val}) + + ordering = request.query_params.get('ordering', '-created_at') + allowed = ['created_at', 'name', 'status', '-created_at', '-name', '-status'] + if ordering not in allowed: + ordering = '-created_at' + qs = qs.order_by(ordering) + + stats = { + 'total': qs.count(), + 'done': qs.filter(status='done').count(), + 'processing': qs.filter(status='processing').count(), + 'failed': qs.filter(status='failed').count(), + 'pending': qs.filter(status='pending').count(), + 'by_source': list( + qs.values('source_type') + .annotate(count=Count('id')) + .order_by('-count') + ), + } + + page_size = int(request.query_params.get('page_size', 25)) + page = int(request.query_params.get('page', 1)) + start = (page - 1) * page_size + end = start + page_size + total = qs.count() + + page_qs = qs[start:end] + serializer = ProjectListSerializer(page_qs, many=True) + + return Response({ + 'stats': stats, + 'results': serializer.data, + 'count': total, + 'page': page, + 'page_size': page_size, + }) + + +class AdminUserProjectsView(APIView): + permission_classes = [IsAdminUser] + + def get(self, request, pk): + qs = (Project.objects + .filter(user_id=pk, is_published=True) + .select_related('user') + .annotate(file_count=Count('files')) + .order_by('-updated_at')) + serializer = ProjectListSerializer(qs, many=True) + return Response(serializer.data) + + +class AdminProjectDetailView(APIView): + permission_classes = [IsAdminUser] + + def get(self, request, pk): + try: + project = Project.objects.select_related('user').get(pk=pk) + except Project.DoesNotExist: + return Response({'detail': 'Not found.'}, status=404) + serializer = ProjectSerializer(project) + return Response(serializer.data) diff --git a/services/core/apps/admin_dashboard/views/stats.py b/services/core/apps/admin_dashboard/views/stats.py new file mode 100644 index 0000000..0735e4e --- /dev/null +++ b/services/core/apps/admin_dashboard/views/stats.py @@ -0,0 +1,57 @@ +from datetime import timedelta + +from django.db.models import Count, Q +from django.utils import timezone +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.notifications.tasks import send_email_task +from apps.projects.models import Project +from apps.users.models import User + + +class AdminStatsView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + if not request.user.is_staff and not request.user.is_admin: + return Response({'detail': 'Forbidden.'}, status=status.HTTP_403_FORBIDDEN) + + now = timezone.now() + week = now - timedelta(days=7) + month = now - timedelta(days=30) + + stats = { + 'users': { + 'total': User.objects.count(), + 'verified': User.objects.filter(is_verified=True).count(), + 'github_connected': User.objects.exclude(github_token__isnull=True).exclude(github_token='').count(), + 'new_this_week': User.objects.filter(created_at__gte=week).count(), + 'new_this_month': User.objects.filter(created_at__gte=month).count(), + }, + 'projects': { + 'total': Project.objects.count(), + 'done': Project.objects.filter(status='done').count(), + 'processing': Project.objects.filter(status='processing').count(), + 'failed': Project.objects.filter(status='failed').count(), + 'pending': Project.objects.filter(status='pending').count(), + 'new_this_week': Project.objects.filter(created_at__gte=week).count(), + 'new_this_month': Project.objects.filter(created_at__gte=month).count(), + 'by_source': list( + Project.objects.values('source_type') + .annotate(count=Count('id')) + .order_by('-count') + ), + }, + 'top_users': list( + User.objects.annotate( + project_count=Count('projects'), + published_count=Count('projects', filter=Q(projects__is_published=True)), + ) + .order_by('-project_count') + .values('id', 'email', 'name', 'project_count', 'published_count')[:10] + ), + } + return Response(stats) diff --git a/services/core/apps/admin_dashboard/views/users.py b/services/core/apps/admin_dashboard/views/users.py new file mode 100644 index 0000000..d569bc3 --- /dev/null +++ b/services/core/apps/admin_dashboard/views/users.py @@ -0,0 +1,112 @@ +from django.conf import settings +from django.db.models import Count, Q +from django.template.loader import render_to_string +from rest_framework import status +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.notifications.tasks import send_email_task +from apps.users.models import User +from apps.users.serializers import AdminUserSerializer, UserSerializer + + +class AdminUserListView(APIView): + permission_classes = [IsAdminUser] + + def get(self, request): + qs = User.objects.annotate( + project_count=Count('projects'), + published_count=Count('projects', filter=Q(projects__is_published=True)), + ) + + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(email__icontains=search) | + Q(username__icontains=search) | + Q(name__icontains=search) + ) + + for field in ['is_active', 'is_verified', 'role']: + val = request.query_params.get(field) + if val is not None: + qs = qs.filter(**{field: val}) + + ordering = request.query_params.get('ordering', '-created_at') + allowed = ['created_at', 'email', 'username', '-created_at', '-email', '-username'] + if ordering not in allowed: + ordering = '-created_at' + qs = qs.order_by(ordering) + + serializer = AdminUserSerializer(qs, many=True) + return Response(serializer.data) + + +class AdminUserDetailView(APIView): + permission_classes = [IsAdminUser] + + def get(self, request, pk): + try: + user = User.objects.get(pk=pk) + except User.DoesNotExist: + return Response({'detail': 'Not found.'}, status=status.HTTP_404_NOT_FOUND) + serializer = UserSerializer(user) + return Response(serializer.data) + + +class AdminUserDeleteView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request, pk): + try: + target = User.objects.get(pk=pk) + except User.DoesNotExist: + return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND) + + reason = request.data.get('reason', 'No reason provided.') + + if settings.EMAIL_HOST_USER: + subject = 'Your PyDocAI account has been deleted' + send_email_task.delay( + subject=subject, + message=f'Your PyDocAI account has been deleted.\n\nReason: {reason}', + recipient_list=[target.email], + html_message=render_to_string('emails/account_deleted.html', { + 'reason': reason, + }), + ) + + target.projects.all().delete() + target.delete() + return Response({'detail': 'User deleted successfully.'}, status=status.HTTP_200_OK) + + +class AdminUserBlockView(APIView): + permission_classes = [IsAdminUser] + + def post(self, request, pk): + try: + target = User.objects.get(pk=pk) + except User.DoesNotExist: + return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND) + + if target == request.user: + return Response({'detail': 'You cannot block yourself.'}, status=status.HTTP_400_BAD_REQUEST) + + target.is_active = not target.is_active + target.save(update_fields=['is_active']) + + action = 'blocked' if not target.is_active else 'unblocked' + + if settings.EMAIL_HOST_USER: + send_email_task.delay( + subject=f'Your PyDocAI account has been {action}', + message=f'Your PyDocAI account has been {action} by an administrator.', + recipient_list=[target.email], + html_message=render_to_string('emails/account_blocked.html', { + 'action': action, + }), + ) + + return Response({'detail': f'User {action} successfully.', 'is_active': target.is_active}) diff --git a/services/core/apps/ai/__init__.py b/services/core/apps/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/ai/apps.py b/services/core/apps/ai/apps.py new file mode 100644 index 0000000..4fc2e53 --- /dev/null +++ b/services/core/apps/ai/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.ai' diff --git a/services/core/apps/ai/migrations/__init__.py b/services/core/apps/ai/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/ai/tests/__init__.py b/services/core/apps/ai/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/ai/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/ai/tests/test_views.py b/services/core/apps/ai/tests/test_views.py new file mode 100644 index 0000000..e5fe4bc --- /dev/null +++ b/services/core/apps/ai/tests/test_views.py @@ -0,0 +1,72 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestAIStatusView: + def test_unauthenticated(self, api_client): + url = reverse('ai-status') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_no_keys_configured(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('django.conf.settings.GROQ_API_KEY', None): + with patch('django.conf.settings.GROQ_API_KEY_2', None): + url = reverse('ai-status') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['providers']['groq_primary']['configured'] is False + assert response.data['providers']['groq_fallback']['configured'] is False + + def test_primary_key_configured(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('django.conf.settings.GROQ_API_KEY', 'test-key-1'): + with patch('django.conf.settings.GROQ_API_KEY_2', None): + url = reverse('ai-status') + response = api_client.get(url) + assert response.data['providers']['groq_primary']['configured'] is True + assert response.data['providers']['groq_fallback']['configured'] is False + + def test_both_keys_configured(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('django.conf.settings.GROQ_API_KEY', 'test-key-1'): + with patch('django.conf.settings.GROQ_API_KEY_2', 'test-key-2'): + url = reverse('ai-status') + response = api_client.get(url) + assert response.data['providers']['groq_primary']['configured'] is True + assert response.data['providers']['groq_fallback']['configured'] is True + + @patch('groq.Groq') + def test_primary_active(self, mock_groq, api_client, user): + api_client.force_authenticate(user=user) + with patch('django.conf.settings.GROQ_API_KEY', 'test-key-1'): + with patch('django.conf.settings.GROQ_API_KEY_2', None): + url = reverse('ai-status') + response = api_client.get(url) + assert response.data['providers']['groq_primary']['status'] == 'active' + + @patch('groq.Groq') + def test_primary_invalid(self, mock_groq, api_client, user): + mock_groq.side_effect = Exception('Invalid API key') + api_client.force_authenticate(user=user) + with patch('django.conf.settings.GROQ_API_KEY', 'bad-key'): + with patch('django.conf.settings.GROQ_API_KEY_2', None): + url = reverse('ai-status') + response = api_client.get(url) + assert response.data['providers']['groq_primary']['status'] == 'invalid' + + def test_response_structure(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('django.conf.settings.GROQ_API_KEY', None): + with patch('django.conf.settings.GROQ_API_KEY_2', None): + url = reverse('ai-status') + response = api_client.get(url) + assert 'providers' in response.data + assert 'fallback_order' in response.data + assert response.data['fallback_order'] == ['groq_primary', 'groq_fallback'] diff --git a/services/core/apps/ai/urls.py b/services/core/apps/ai/urls.py new file mode 100644 index 0000000..04e7f53 --- /dev/null +++ b/services/core/apps/ai/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import AIStatusView + +urlpatterns = [ + path('status/', AIStatusView.as_view(), name='ai-status'), +] diff --git a/services/core/apps/ai/views/__init__.py b/services/core/apps/ai/views/__init__.py new file mode 100644 index 0000000..ecc5cbe --- /dev/null +++ b/services/core/apps/ai/views/__init__.py @@ -0,0 +1,5 @@ +from .status import AIStatusView + +__all__ = [ + 'AIStatusView', +] diff --git a/services/core/apps/ai/views/status.py b/services/core/apps/ai/views/status.py new file mode 100644 index 0000000..fc01938 --- /dev/null +++ b/services/core/apps/ai/views/status.py @@ -0,0 +1,52 @@ +from django.conf import settings +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + + +class AIStatusView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + groq_key = getattr(settings, 'GROQ_API_KEY', None) + groq_key_2 = getattr(settings, 'GROQ_API_KEY_2', None) + + status_data = { + 'providers': { + 'groq_primary': { + 'configured': bool(groq_key), + 'status': 'unknown', + 'role': 'primary', + 'model': 'llama-3.3-70b-versatile', + }, + 'groq_fallback': { + 'configured': bool(groq_key_2), + 'status': 'unknown', + 'role': 'fallback', + 'model': 'llama-3.3-70b-versatile', + }, + }, + 'fallback_order': ['groq_primary', 'groq_fallback'], + } + + if groq_key: + try: + from groq import Groq + client = Groq(api_key=groq_key) + client.models.list() + status_data['providers']['groq_primary']['status'] = 'active' + except Exception as e: + status_data['providers']['groq_primary']['status'] = 'invalid' + status_data['providers']['groq_primary']['error'] = str(e) + + if groq_key_2: + try: + from groq import Groq + client = Groq(api_key=groq_key_2) + client.models.list() + status_data['providers']['groq_fallback']['status'] = 'active' + except Exception as e: + status_data['providers']['groq_fallback']['status'] = 'invalid' + status_data['providers']['groq_fallback']['error'] = str(e) + + return Response(status_data) diff --git a/services/core/apps/comments/__init__.py b/services/core/apps/comments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/comments/apps.py b/services/core/apps/comments/apps.py new file mode 100644 index 0000000..3b304b2 --- /dev/null +++ b/services/core/apps/comments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.comments' diff --git a/services/core/apps/comments/migrations/0001_initial.py b/services/core/apps/comments/migrations/0001_initial.py new file mode 100644 index 0000000..8d6188a --- /dev/null +++ b/services/core/apps/comments/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.13 on 2026-06-16 13:40 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0006_project_framework_info_project_is_published_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('content', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='comments.comment')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='projects.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'comments', + 'ordering': ['created_at'], + 'indexes': [models.Index(fields=['project', 'created_at'], name='comments_project_7329f9_idx'), models.Index(fields=['user', 'created_at'], name='comments_user_id_88f50c_idx')], + }, + ), + ] diff --git a/services/core/apps/comments/migrations/__init__.py b/services/core/apps/comments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/comments/models.py b/services/core/apps/comments/models.py new file mode 100644 index 0000000..4081229 --- /dev/null +++ b/services/core/apps/comments/models.py @@ -0,0 +1,29 @@ +import uuid + +from django.db import models + +from apps.projects.models import Project +from apps.users.models import User + + +class Comment(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments') + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='comments') + parent = models.ForeignKey( + 'self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies' + ) + content = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'comments' + ordering = ['created_at'] + indexes = [ + models.Index(fields=['project', 'created_at']), + models.Index(fields=['user', 'created_at']), + ] + + def __str__(self): + return f'{self.user.email} on {self.project.name}' diff --git a/services/core/apps/comments/serializers/__init__.py b/services/core/apps/comments/serializers/__init__.py new file mode 100644 index 0000000..1b83636 --- /dev/null +++ b/services/core/apps/comments/serializers/__init__.py @@ -0,0 +1,49 @@ +from rest_framework import serializers + +from ..models import Comment + + +class CommentSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + user_email = serializers.EmailField(source='user.email', read_only=True) + replies = serializers.SerializerMethodField() + parent_content = serializers.SerializerMethodField() + + class Meta: + model = Comment + fields = [ + 'id', 'project', 'user', 'user_name', 'user_email', + 'parent', 'parent_content', 'content', 'replies', 'created_at', 'updated_at', + ] + read_only_fields = ['user', 'project'] + + def get_replies(self, obj): + replies = obj.replies.all()[:3] + return CommentSerializer(replies, many=True).data + + def get_parent_content(self, obj): + if obj.parent: + return obj.parent.content + return None + + def get_fields(self): + fields = super().get_fields() + if self.context.get('depth', 0) >= 3: + fields.pop('replies', None) + return fields + + +class CommentCreateSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + user_email = serializers.EmailField(source='user.email', read_only=True) + parent_content = serializers.SerializerMethodField() + + class Meta: + model = Comment + fields = ['id', 'content', 'parent', 'parent_content', 'user_name', 'user_email', 'created_at', 'updated_at'] + read_only_fields = ['id', 'parent_content', 'user_name', 'user_email', 'created_at', 'updated_at'] + + def get_parent_content(self, obj): + if obj.parent: + return obj.parent.content + return None diff --git a/services/core/apps/comments/tests/__init__.py b/services/core/apps/comments/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/comments/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/comments/tests/test_models.py b/services/core/apps/comments/tests/test_models.py new file mode 100644 index 0000000..b3f591c --- /dev/null +++ b/services/core/apps/comments/tests/test_models.py @@ -0,0 +1,40 @@ +import pytest +from django.db import models as db_models + +from apps.comments.models import Comment + + +class TestCommentModel: + def test_fields(self): + fields = [f.name for f in Comment._meta.get_fields()] + assert 'id' in fields + assert 'project' in fields + assert 'user' in fields + assert 'parent' in fields + assert 'content' in fields + assert 'created_at' in fields + assert 'updated_at' in fields + + def test_str(self, comment): + assert comment.user.email in str(comment) + assert comment.project.name in str(comment) + + def test_ordering(self): + assert Comment._meta.ordering == ['created_at'] + + def test_db_table(self): + assert Comment._meta.db_table == 'comments' + + def test_indexes(self): + field_names = [list(idx.fields) for idx in Comment._meta.indexes] + assert ['project', 'created_at'] in field_names + assert ['user', 'created_at'] in field_names + + def test_parent_relation(self, comment, user, project): + reply = Comment.objects.create(project=project, user=user, parent=comment, content='A reply') + assert reply.parent == comment + assert list(comment.replies.all()) == [reply] + + def test_content_max_length(self): + field = Comment._meta.get_field('content') + assert isinstance(field, db_models.TextField) diff --git a/services/core/apps/comments/tests/test_serializers.py b/services/core/apps/comments/tests/test_serializers.py new file mode 100644 index 0000000..b0ba42c --- /dev/null +++ b/services/core/apps/comments/tests/test_serializers.py @@ -0,0 +1,47 @@ +import pytest + +from apps.comments.serializers import CommentCreateSerializer, CommentSerializer + + +class TestCommentSerializer: + def test_serialize(self, comment): + serializer = CommentSerializer(comment) + assert serializer.data['id'] == str(comment.id) + assert serializer.data['content'] == comment.content + assert serializer.data['user_name'] == comment.user.name + assert serializer.data['user_email'] == comment.user.email + assert 'replies' in serializer.data + assert 'parent_content' in serializer.data + + def test_serialize_with_replies(self, comment, user, project): + reply = comment.replies.create(project=project, user=user, content='Reply') + serializer = CommentSerializer(comment) + assert len(serializer.data['replies']) == 1 + assert serializer.data['replies'][0]['content'] == 'Reply' + + def test_serialize_depth_limit(self, comment): + serializer = CommentSerializer(comment, context={'depth': 3}) + assert 'replies' not in serializer.data + + def test_parent_content(self, comment, user, project): + reply = comment.replies.create(project=project, user=user, content='Reply') + serializer = CommentSerializer(reply) + assert serializer.data['parent_content'] == comment.content + + def test_no_parent_content(self, comment): + serializer = CommentSerializer(comment) + assert serializer.data['parent_content'] is None + + +class TestCommentCreateSerializer: + def test_validate_valid_data(self): + serializer = CommentCreateSerializer(data={'content': 'Nice project!'}) + assert serializer.is_valid() + + def test_validate_empty_content(self): + serializer = CommentCreateSerializer(data={'content': ''}) + assert not serializer.is_valid() + + def test_serialize_created(self, comment): + serializer = CommentCreateSerializer(comment) + assert serializer.data['content'] == comment.content diff --git a/services/core/apps/comments/tests/test_throttles.py b/services/core/apps/comments/tests/test_throttles.py new file mode 100644 index 0000000..37721a8 --- /dev/null +++ b/services/core/apps/comments/tests/test_throttles.py @@ -0,0 +1,25 @@ +from unittest.mock import Mock + +from apps.comments.throttles import CommentRateThrottle + + +class TestCommentRateThrottle: + def test_get_cache_key_authenticated(self): + throttle = CommentRateThrottle() + request = Mock() + request.user.is_authenticated = True + request.user.pk = 'user-123' + view = Mock() + key = throttle.get_cache_key(request, view) + assert key == 'comment_create:user-123' + + def test_get_cache_key_unauthenticated(self): + throttle = CommentRateThrottle() + request = Mock() + request.user.is_authenticated = False + view = Mock() + key = throttle.get_cache_key(request, view) + assert key is None + + def test_scope(self): + assert CommentRateThrottle.scope == 'comment_create' diff --git a/services/core/apps/comments/tests/test_views.py b/services/core/apps/comments/tests/test_views.py new file mode 100644 index 0000000..7025390 --- /dev/null +++ b/services/core/apps/comments/tests/test_views.py @@ -0,0 +1,115 @@ +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestCommentListView: + def test_public_published_project(self, api_client, published_project): + url = reverse('comment_list', args=[published_project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert 'comments' in response.data + assert 'has_next' in response.data + + def test_unpublished_requires_auth(self, api_client, project): + url = reverse('comment_list', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_unpublished_owner_can_view(self, api_client, project, user): + api_client.force_authenticate(user=user) + url = reverse('comment_list', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + + def test_non_owner_cannot_view_unpublished(self, api_client, project, other_user): + api_client.force_authenticate(user=other_user) + url = reverse('comment_list', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_not_found(self, api_client): + url = reverse('comment_list', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_returns_top_level_only(self, api_client, published_project, user): + from apps.comments.models import Comment + top = Comment.objects.create(project=published_project, user=user, content='Top') + Comment.objects.create(project=published_project, user=user, parent=top, content='Reply') + api_client.force_authenticate(user=user) + url = reverse('comment_list', args=[published_project.id]) + response = api_client.get(url) + assert len(response.data['comments']) == 1 + assert response.data['comments'][0]['content'] == 'Top' + + def test_pagination(self, api_client, published_project, user): + from apps.comments.models import Comment + for i in range(5): + Comment.objects.create(project=published_project, user=user, content=f'C{i}') + url = reverse('comment_list', args=[published_project.id]) + response = api_client.get(url + '?limit=2') + assert len(response.data['comments']) == 2 + assert response.data['has_next'] is True + + +class TestCommentCreateView: + def test_unauthenticated(self, api_client, published_project): + url = reverse('comment_create', args=[published_project.id]) + response = api_client.post(url, {'content': 'Great!'}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_create_comment(self, api_client, published_project, user): + api_client.force_authenticate(user=user) + url = reverse('comment_create', args=[published_project.id]) + response = api_client.post(url, {'content': 'Nice project!'}) + assert response.status_code == status.HTTP_201_CREATED + assert response.data['content'] == 'Nice project!' + + def test_create_reply(self, api_client, published_project, user, comment): + api_client.force_authenticate(user=user) + url = reverse('comment_create', args=[published_project.id]) + response = api_client.post(url, {'content': 'A reply', 'parent': str(comment.id)}) + assert response.status_code == status.HTTP_201_CREATED + assert response.data['parent'] == comment.id + + def test_project_not_found(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('comment_create', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.post(url, {'content': 'Test'}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_invalid_data(self, api_client, published_project, user): + api_client.force_authenticate(user=user) + url = reverse('comment_create', args=[published_project.id]) + response = api_client.post(url, {}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestCommentDeleteView: + def test_unauthenticated(self, api_client, comment): + url = reverse('comment_delete', args=[comment.id]) + response = api_client.delete(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_delete_own_comment(self, api_client, comment, user): + api_client.force_authenticate(user=user) + url = reverse('comment_delete', args=[comment.id]) + response = api_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + def test_cannot_delete_others_comment(self, api_client, comment, other_user): + api_client.force_authenticate(user=other_user) + url = reverse('comment_delete', args=[comment.id]) + response = api_client.delete(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_sets_deleted(self, api_client, comment, user): + api_client.force_authenticate(user=user) + url = reverse('comment_delete', args=[comment.id]) + api_client.delete(url) + comment.refresh_from_db() + assert comment.content == '[deleted]' diff --git a/services/core/apps/comments/throttles.py b/services/core/apps/comments/throttles.py new file mode 100644 index 0000000..d14d488 --- /dev/null +++ b/services/core/apps/comments/throttles.py @@ -0,0 +1,10 @@ +from rest_framework.throttling import SimpleRateThrottle + + +class CommentRateThrottle(SimpleRateThrottle): + scope = 'comment_create' + + def get_cache_key(self, request, view): + if request.user.is_authenticated: + return f'comment_create:{request.user.pk}' + return None diff --git a/services/core/apps/comments/urls.py b/services/core/apps/comments/urls.py new file mode 100644 index 0000000..1033f01 --- /dev/null +++ b/services/core/apps/comments/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import CommentCreateView, CommentDeleteView, CommentListView + +urlpatterns = [ + path('/comments/', CommentListView.as_view(), name='comment_list'), + path('/comments/create/', CommentCreateView.as_view(), name='comment_create'), + path('comments//', CommentDeleteView.as_view(), name='comment_delete'), +] diff --git a/services/core/apps/comments/views/__init__.py b/services/core/apps/comments/views/__init__.py new file mode 100644 index 0000000..bb5705f --- /dev/null +++ b/services/core/apps/comments/views/__init__.py @@ -0,0 +1 @@ +from .comments import CommentCreateView, CommentDeleteView, CommentListView diff --git a/services/core/apps/comments/views/comments.py b/services/core/apps/comments/views/comments.py new file mode 100644 index 0000000..29efaa0 --- /dev/null +++ b/services/core/apps/comments/views/comments.py @@ -0,0 +1,82 @@ +from django.db import connection +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.notifications.utils import notify_comment, notify_reply +from apps.projects.models import Project + +from ..models import Comment +from ..serializers import CommentCreateSerializer, CommentSerializer +from ..throttles import CommentRateThrottle + + +class CommentListView(APIView): + permission_classes = [] + + def get(self, request, project_id): + try: + project = Project.objects.get(pk=project_id) + except Project.DoesNotExist: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + if not project.is_published and not request.user.is_authenticated: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + if not project.is_published and request.user.is_authenticated and project.user != request.user: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + offset = int(request.query_params.get('offset', 0)) + limit = min(int(request.query_params.get('limit', 20)), 50) + + top_level = Comment.objects.filter( + project=project, parent__isnull=True + ).select_related('user').order_by('created_at')[offset:offset + limit] + + comments = CommentSerializer(top_level, many=True, context={'depth': 0}).data + has_next = Comment.objects.filter( + project=project, parent__isnull=True + ).count() > offset + limit + + return Response({ + 'comments': comments, + 'has_next': has_next, + 'total': offset + len(comments), + }) + + +class CommentCreateView(APIView): + permission_classes = [permissions.IsAuthenticated] + throttle_classes = [CommentRateThrottle] + + def post(self, request, project_id): + try: + project = Project.objects.get(pk=project_id) + except Project.DoesNotExist: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + serializer = CommentCreateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + comment = serializer.save(user=request.user, project=project) + if comment.parent: + notify_reply(comment) + else: + notify_comment(comment) + + return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED) + + +class CommentDeleteView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def delete(self, request, pk): + try: + comment = Comment.objects.get(pk=pk, user=request.user) + except Comment.DoesNotExist: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + comment.content = '[deleted]' + comment.save(update_fields=['content']) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/core/apps/common/__init__.py b/services/core/apps/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/common/apps.py b/services/core/apps/common/apps.py new file mode 100644 index 0000000..f992f88 --- /dev/null +++ b/services/core/apps/common/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CommonConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.common' diff --git a/services/core/apps/common/config.py b/services/core/apps/common/config.py new file mode 100644 index 0000000..419c44f --- /dev/null +++ b/services/core/apps/common/config.py @@ -0,0 +1,3 @@ +import os + +INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "pydocai-internal-key") diff --git a/services/core/apps/common/github.py b/services/core/apps/common/github.py new file mode 100644 index 0000000..1a394d1 --- /dev/null +++ b/services/core/apps/common/github.py @@ -0,0 +1,115 @@ +import io +import zipfile + +import requests +from django.conf import settings +from github import Github, GithubException + + +def get_github_client(github_token=None): + if github_token: + return Github(github_token, timeout=10, retry=0) + api_token = getattr(settings, 'GITHUB_API_TOKEN', None) + if api_token and api_token.strip(): + return Github(api_token, timeout=10, retry=0) + return Github(timeout=10, retry=0) + + +def _github_api_headers(): + api_token = getattr(settings, 'GITHUB_API_TOKEN', None) + headers = {'Accept': 'application/vnd.github+json'} + if api_token and api_token.strip(): + headers['Authorization'] = f'token {api_token}' + return headers + + +def fetch_public_repo_api(full_name): + headers = _github_api_headers() + resp = requests.get( + f'https://api.github.com/repos/{full_name}', + headers=headers, timeout=10, + ) + if resp.status_code == 404: + raise GithubException(404, {'message': 'Not Found'}) + if resp.status_code == 403: + raise GithubException(403, {'message': 'Rate limit exceeded or forbidden'}) + resp.raise_for_status() + return resp.json() + + +def fetch_public_tree_api(full_name, branch): + headers = _github_api_headers() + resp = requests.get( + f'https://api.github.com/repos/{full_name}/git/trees/{branch}?recursive=1', + headers=headers, timeout=10, + ) + if resp.status_code == 404: + raise GithubException(404, {'message': 'Not Found'}) + if resp.status_code == 403: + raise GithubException(403, {'message': 'Rate limit exceeded or forbidden'}) + resp.raise_for_status() + return resp.json() + + +def download_zipball(url, headers, folder_path, file_filter=None): + """Download a GitHub zipball and extract files. + + file_filter: optional callable(file_name) -> bool to filter files. + By default (None), extracts ALL files except directories. + """ + resp = requests.get(url, headers=headers, timeout=30, stream=True) + resp.raise_for_status() + zip_bytes = resp.content + + files = [] + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + names = zf.namelist() + prefix = '' + if names: + first = names[0] + if '/' in first: + prefix = first.split('/', 1)[0] + '/' + + for name in names: + if name.endswith('/'): + continue + if file_filter and not file_filter(name): + continue + rel_path = name[len(prefix):] if prefix else name + if folder_path and folder_path != '/' and not rel_path.startswith(folder_path.lstrip('/')): + continue + try: + content = zf.read(name).decode('utf-8', errors='ignore').replace('\x00', '') + files.append({'file_path': rel_path, 'content': content}) + except Exception: + files.append({'file_path': rel_path, 'content': '[binary file]'}) + return files + + +def get_repo_tree_items(github_token, full_name, branch=None): + g = Github(github_token) + repo = g.get_repo(full_name) + branch = branch or repo.default_branch + tree = repo.get_git_tree(branch, recursive=True) + items = [] + for item in tree.tree: + items.append({ + 'path': item.path, + 'type': item.type, + 'size': item.size, + }) + return items + + +def get_public_repo_tree_items(full_name, branch=None): + repo_data = fetch_public_repo_api(full_name) + branch = branch or repo_data.get('default_branch') or 'main' + tree_data = fetch_public_tree_api(full_name, branch) + items = [] + for item in tree_data.get('tree', []): + items.append({ + 'path': item['path'], + 'type': item['type'], + 'size': item.get('size', 0), + }) + return items diff --git a/services/core/apps/common/health.py b/services/core/apps/common/health.py new file mode 100644 index 0000000..95be16f --- /dev/null +++ b/services/core/apps/common/health.py @@ -0,0 +1,4 @@ +from django.http import JsonResponse + +def health_check(request): + return JsonResponse({"status": "healthy"}) diff --git a/services/core/apps/common/pagination.py b/services/core/apps/common/pagination.py new file mode 100644 index 0000000..2238e02 --- /dev/null +++ b/services/core/apps/common/pagination.py @@ -0,0 +1,17 @@ +from rest_framework.pagination import PageNumberPagination + + +class NoPagination(PageNumberPagination): + page_size = None + + +class AdminUserPage(PageNumberPagination): + page_size = 50 + page_size_query_param = 'page_size' + max_page_size = 200 + + +class PublicProjectPage(PageNumberPagination): + page_size = 12 + page_size_query_param = 'page_size' + max_page_size = 50 diff --git a/services/core/apps/common/tests/__init__.py b/services/core/apps/common/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/common/tests/test_config.py b/services/core/apps/common/tests/test_config.py new file mode 100644 index 0000000..550672b --- /dev/null +++ b/services/core/apps/common/tests/test_config.py @@ -0,0 +1,6 @@ +from apps.common.config import INTERNAL_API_KEY + + +class TestInternalApiKey: + def test_has_default(self): + assert INTERNAL_API_KEY == 'pydocai-internal-key' diff --git a/services/core/apps/common/tests/test_github.py b/services/core/apps/common/tests/test_github.py new file mode 100644 index 0000000..4897e85 --- /dev/null +++ b/services/core/apps/common/tests/test_github.py @@ -0,0 +1,95 @@ +from unittest.mock import patch + +from github import GithubException + +from apps.common.github import ( + download_zipball, + fetch_public_repo_api, + get_github_client, +) + + +class TestGetGithubClient: + def test_with_token(self): + client = get_github_client('test-token') + assert client is not None + + def test_without_token(self): + with patch('django.conf.settings.GITHUB_API_TOKEN', None): + client = get_github_client() + assert client is not None + + +class TestFetchPublicRepoApi: + def test_success(self): + with patch('apps.common.github.requests.get') as mock: + mock.return_value.status_code = 200 + mock.return_value.json.return_value = {'id': 1, 'name': 'repo'} + result = fetch_public_repo_api('owner/repo') + assert result['id'] == 1 + assert result['name'] == 'repo' + + def test_not_found(self): + with patch('apps.common.github.requests.get') as mock: + mock.return_value.status_code = 404 + mock.return_value.json.return_value = {'message': 'Not Found'} + import pytest + with pytest.raises(GithubException) as exc: + fetch_public_repo_api('owner/repo') + assert exc.value.status == 404 + + def test_forbidden(self): + with patch('apps.common.github.requests.get') as mock: + mock.return_value.status_code = 403 + mock.return_value.json.return_value = {'message': 'Forbidden'} + import pytest + with pytest.raises(GithubException) as exc: + fetch_public_repo_api('owner/repo') + assert exc.value.status == 403 + + +class TestDownloadZipball: + def test_basic_download(self): + with patch('apps.common.github.requests.get') as mock_get: + import io, zipfile + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr('repo-owner-sha/file.py', 'print("hello")') + buf.seek(0) + mock_get.return_value.status_code = 200 + mock_get.return_value.content = buf.read() + + files = download_zipball('http://example.com/zip', {}, '/') + assert len(files) == 1 + assert files[0]['file_path'] == 'file.py' + assert files[0]['content'] == 'print("hello")' + + def test_with_file_filter(self): + with patch('apps.common.github.requests.get') as mock_get: + import io, zipfile + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr('repo/file.py', 'code') + zf.writestr('repo/readme.txt', 'docs') + buf.seek(0) + mock_get.return_value.status_code = 200 + mock_get.return_value.content = buf.read() + + files = download_zipball('http://example.com/zip', {}, '/', file_filter=lambda n: n.endswith('.py')) + assert len(files) == 1 + assert files[0]['file_path'] == 'file.py' + + def test_folder_path_filter(self): + with patch('apps.common.github.requests.get') as mock_get: + import io, zipfile + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr('repo/src/main.py', 'code') + zf.writestr('repo/tests/test_main.py', 'test') + buf.seek(0) + mock_get.return_value.status_code = 200 + mock_get.return_value.content = buf.read() + + files = download_zipball('http://example.com/zip', {}, 'src') + assert len(files) == 1 + assert files[0]['file_path'] == 'src/main.py' diff --git a/services/core/apps/common/tests/test_pagination.py b/services/core/apps/common/tests/test_pagination.py new file mode 100644 index 0000000..58e72c8 --- /dev/null +++ b/services/core/apps/common/tests/test_pagination.py @@ -0,0 +1,35 @@ +from apps.common.pagination import AdminUserPage, NoPagination, PublicProjectPage + + +class TestNoPagination: + def test_page_size_none(self): + paginator = NoPagination() + assert paginator.page_size is None + + +class TestAdminUserPage: + def test_page_size(self): + paginator = AdminUserPage() + assert paginator.page_size == 50 + + def test_page_size_query_param(self): + paginator = AdminUserPage() + assert paginator.page_size_query_param == 'page_size' + + def test_max_page_size(self): + paginator = AdminUserPage() + assert paginator.max_page_size == 200 + + +class TestPublicProjectPage: + def test_page_size(self): + paginator = PublicProjectPage() + assert paginator.page_size == 12 + + def test_page_size_query_param(self): + paginator = PublicProjectPage() + assert paginator.page_size_query_param == 'page_size' + + def test_max_page_size(self): + paginator = PublicProjectPage() + assert paginator.max_page_size == 50 diff --git a/services/core/apps/conftest.py b/services/core/apps/conftest.py new file mode 100644 index 0000000..3112ae5 --- /dev/null +++ b/services/core/apps/conftest.py @@ -0,0 +1,112 @@ +import uuid + +import pytest +from rest_framework.test import APIClient + +from apps.projects.models import Project, ProjectFile +from apps.users.models import User + + +@pytest.fixture +def api_client(): + return APIClient() + + +@pytest.fixture +def user(db): + return User.objects.create_user( + email='test@example.com', + password='testpass123', + name='Test User', + username='testuser', + ) + + +@pytest.fixture +def other_user(db): + return User.objects.create_user( + email='other@example.com', + password='otherpass123', + name='Other User', + username='otheruser', + ) + + +@pytest.fixture +def admin_user(db): + return User.objects.create_user( + email='admin@example.com', + password='adminpass123', + name='Admin User', + username='admin', + role='admin', + is_staff=True, + ) + + +@pytest.fixture +def project(db, user): + return Project.objects.create( + user=user, + name='Test Project', + description='A test project', + source_type=Project.SourceType.FILE, + status=Project.Status.DONE, + ) + + +@pytest.fixture +def published_project(db, user, project): + project.is_published = True + project.public_slug = uuid.uuid4() + project.save(update_fields=['is_published', 'public_slug']) + return project + + +@pytest.fixture +def project_file(db, project): + return ProjectFile.objects.create( + project=project, + file_name='main.py', + file_path='src/main.py', + content='print("hello")', + ) + + +@pytest.fixture +def comment(db, published_project, user): + from apps.comments.models import Comment + return Comment.objects.create( + project=published_project, + user=user, + content='Great project!', + ) + + +@pytest.fixture +def notification(db, user): + from apps.notifications.models import Notification + return Notification.objects.create( + user=user, + message='Someone commented on your project', + ) + + +@pytest.fixture +def notification_with_comment(db, user, comment): + from apps.notifications.models import Notification + return Notification.objects.create( + user=user, + comment=comment, + message='Someone commented on your project', + ) + + +@pytest.fixture +def feedback(db, user): + from apps.feedback.models import Feedback + return Feedback.objects.create( + user=user, + category='bug', + message='Found a bug in the export feature', + ) diff --git a/services/core/apps/exports/__init__.py b/services/core/apps/exports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/exports/apps.py b/services/core/apps/exports/apps.py new file mode 100644 index 0000000..b04cb5d --- /dev/null +++ b/services/core/apps/exports/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ExportsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.exports' diff --git a/services/core/apps/exports/generators.py b/services/core/apps/exports/generators.py new file mode 100644 index 0000000..e8c4901 --- /dev/null +++ b/services/core/apps/exports/generators.py @@ -0,0 +1,51 @@ +from apps.projects.models import Project, ProjectFile + + +def export_project_as_markdown(project_id: str) -> str: + project = Project.objects.get(id=project_id) + + # Check if project-level docs exist (new feature) + if project.readme_docs or project.project_info: + combined = "" + + # Add README if available + if project.readme_docs: + combined += f"# {project.name} — README\n\n" + combined += project.readme_docs + combined += "\n\n---\n\n" + + # Add project summary/documentation if available + if project.project_info and project.project_info.get('summary'): + combined += f"# {project.name} — Project Documentation\n\n" + combined += project.project_info.get('summary') + combined += "\n\n---\n\n" + + # Add project info as JSON if available + if project.project_info and not project.project_info.get('summary'): + combined += f"# {project.name} — Project Information\n\n" + combined += "## Project Details\n\n" + import json + combined += "```json\n" + combined += json.dumps(project.project_info, indent=2) + combined += "\n```\n\n" + + if combined: + return combined + + # Fallback to per-file docs (legacy behavior) + all_files = ProjectFile.objects.filter(project=project) + + combined = f"# {project.name} — Documentation\n\n" + + if not all_files.exists(): + combined += "_No files found for this project._\n" + return combined + + for pf in all_files: + combined += f"---\n\n## {pf.file_path}\n\n" + if pf.generated_docs: + combined += pf.generated_docs + "\n\n" + else: + combined += f"_No documentation generated. parsed_data present: {bool(pf.parsed_data)}_\n\n" + + return combined diff --git a/services/core/apps/exports/migrations/__init__.py b/services/core/apps/exports/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/exports/tests/__init__.py b/services/core/apps/exports/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/exports/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/exports/tests/test_generators.py b/services/core/apps/exports/tests/test_generators.py new file mode 100644 index 0000000..dfea435 --- /dev/null +++ b/services/core/apps/exports/tests/test_generators.py @@ -0,0 +1,46 @@ +import pytest + +from apps.exports.generators import export_project_as_markdown + + +pytestmark = pytest.mark.django_db + + +class TestExportProjectAsMarkdown: + def test_project_with_readme(self, project): + project.readme_docs = '# Project README' + project.save() + result = export_project_as_markdown(str(project.id)) + assert '# Project README' in result + assert project.name in result + + def test_project_with_summary(self, project): + project.project_info = {'summary': 'Project summary'} + project.save() + result = export_project_as_markdown(str(project.id)) + assert 'Project summary' in result + + def test_project_with_info_json(self, project): + project.project_info = {'key': 'value'} + project.save() + result = export_project_as_markdown(str(project.id)) + assert 'key' in result + assert 'value' in result + + def test_fallback_to_per_file_docs(self, project, project_file): + project_file.generated_docs = '## Function docs' + project_file.save() + result = export_project_as_markdown(str(project.id)) + assert project_file.file_path in result + assert 'Function docs' in result + + def test_no_files(self, project): + result = export_project_as_markdown(str(project.id)) + assert '_No files found for this project._' in result + + def test_project_with_parsed_no_docs(self, project, project_file): + project_file.parsed_data = {'functions': ['foo']} + project_file.generated_docs = None + project_file.save() + result = export_project_as_markdown(str(project.id)) + assert 'parsed_data present: True' in result diff --git a/services/core/apps/exports/tests/test_views.py b/services/core/apps/exports/tests/test_views.py new file mode 100644 index 0000000..4ce10bb --- /dev/null +++ b/services/core/apps/exports/tests/test_views.py @@ -0,0 +1,70 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestExportProjectMarkdownView: + def test_export_success(self, api_client, user, project): + api_client.force_authenticate(user=user) + with patch('apps.exports.views.markdown.export_project_as_markdown', return_value='# Docs'): + url = reverse('export-markdown', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.content.decode() == '# Docs' + assert response['Content-Type'] == 'text/markdown' + assert 'attachment' in response['Content-Disposition'] + + def test_export_failure(self, api_client, user, project): + api_client.force_authenticate(user=user) + with patch('apps.exports.views.markdown.export_project_as_markdown', side_effect=Exception('Error')): + url = reverse('export-markdown', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestExportFolderDocsView: + def test_export_all(self, api_client, user, project): + api_client.force_authenticate(user=user) + project.readme_docs = '# README' + project.generated_docs = '# Summary' + project.api_docs = '# API' + project.save() + url = reverse('export-folder', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + content = response.content.decode() + assert '# README' in content + assert '# Project Documentation' in content + assert '# API Documentation' in content + + def test_export_readme_only(self, api_client, user, project): + api_client.force_authenticate(user=user) + project.readme_docs = '# README' + project.save() + url = reverse('export-folder', args=[project.id]) + '?type=readme' + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.content.decode() == '# README' + + def test_export_readme_not_found(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('export-folder', args=[project.id]) + '?type=readme' + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_project_not_found(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('export-folder', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_none_available(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('export-folder', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/services/core/apps/exports/urls.py b/services/core/apps/exports/urls.py new file mode 100644 index 0000000..3982362 --- /dev/null +++ b/services/core/apps/exports/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.exports.views import ExportFolderDocsView, ExportProjectMarkdownView + +urlpatterns = [ + path('/markdown/', ExportProjectMarkdownView.as_view(), name='export-markdown'), + path('/folder/', ExportFolderDocsView.as_view(), name='export-folder'), +] diff --git a/services/core/apps/exports/views/__init__.py b/services/core/apps/exports/views/__init__.py new file mode 100644 index 0000000..56665c0 --- /dev/null +++ b/services/core/apps/exports/views/__init__.py @@ -0,0 +1,7 @@ +from .markdown import ExportProjectMarkdownView +from .folder import ExportFolderDocsView + +__all__ = [ + 'ExportProjectMarkdownView', + 'ExportFolderDocsView', +] diff --git a/services/core/apps/exports/views/folder.py b/services/core/apps/exports/views/folder.py new file mode 100644 index 0000000..b104e56 --- /dev/null +++ b/services/core/apps/exports/views/folder.py @@ -0,0 +1,54 @@ +import uuid + +from django.http import Http404, HttpResponse +from rest_framework.views import APIView + +from apps.projects.models import Project + + +class ExportFolderDocsView(APIView): + + def get(self, request, project_id): + try: + project_id_uuid = uuid.UUID(str(project_id)) + project = Project.objects.get(id=project_id_uuid) + except (Project.DoesNotExist, ValueError): + raise Http404("Project not found") + + doc_type = request.query_params.get('type', 'all') + + if doc_type == 'readme': + if not project.readme_docs: + raise Http404("README not available yet - folder may still be processing") + content = project.readme_docs + filename = "README.md" + elif doc_type == 'summary': + if not project.generated_docs: + raise Http404("Summary not available yet - folder may still be processing") + content = project.generated_docs + filename = "project_summary.md" + elif doc_type == 'api': + if not project.api_docs: + raise Http404("API docs not available yet - folder may still be processing") + content = project.api_docs + filename = "api_docs.md" + else: + if not project.readme_docs and not project.generated_docs and not project.api_docs: + raise Http404("Project documentation not available - please ensure folder upload was completed") + + parts = [] + if project.readme_docs: + parts.append(project.readme_docs) + if project.generated_docs: + parts.append(f"# Project Documentation\n\n{project.generated_docs}") + if project.api_docs: + parts.append(f"# API Documentation\n\n{project.api_docs}") + + content = "\n\n---\n\n".join(parts) + filename = "project_docs.md" + + return HttpResponse( + content, + content_type='text/markdown', + headers={'Content-Disposition': f'attachment; filename="{filename}"'} + ) diff --git a/services/core/apps/exports/views/markdown.py b/services/core/apps/exports/views/markdown.py new file mode 100644 index 0000000..3904837 --- /dev/null +++ b/services/core/apps/exports/views/markdown.py @@ -0,0 +1,19 @@ +from django.http import Http404, HttpResponse +from rest_framework.views import APIView + +from apps.exports.generators import export_project_as_markdown + + +class ExportProjectMarkdownView(APIView): + + def get(self, request, project_id): + try: + markdown = export_project_as_markdown(str(project_id)) + except Exception as e: + raise Http404(f"Export failed: {e}") + + return HttpResponse( + markdown, + content_type='text/markdown', + headers={'Content-Disposition': f'attachment; filename="project_{project_id}_docs.md"'} + ) diff --git a/services/core/apps/feedback/__init__.py b/services/core/apps/feedback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/feedback/admin.py b/services/core/apps/feedback/admin.py new file mode 100644 index 0000000..313036c --- /dev/null +++ b/services/core/apps/feedback/admin.py @@ -0,0 +1,33 @@ +from django.contrib import admin + +from .models import Feedback, FeedbackReply + + +class FeedbackReplyInline(admin.TabularInline): + model = FeedbackReply + extra = 0 + readonly_fields = ['user', 'message', 'created_at'] + + +@admin.register(Feedback) +class FeedbackAdmin(admin.ModelAdmin): + list_display = ['user', 'category', 'project', 'is_resolved', 'created_at'] + list_filter = ['category', 'is_resolved', 'created_at'] + search_fields = ['user__email', 'message'] + ordering = ['-created_at'] + readonly_fields = ['created_at', 'updated_at'] + inlines = [FeedbackReplyInline] + actions = ['mark_resolved'] + + @admin.action(description='Mark selected feedback as resolved') + def mark_resolved(self, request, queryset): + updated = queryset.update(is_resolved=True) + self.message_user(request, f'{updated} feedback item(s) marked as resolved.') + + +@admin.register(FeedbackReply) +class FeedbackReplyAdmin(admin.ModelAdmin): + list_display = ['feedback', 'user', 'created_at'] + list_filter = ['created_at'] + search_fields = ['user__email', 'message'] + readonly_fields = ['feedback', 'user', 'message', 'created_at'] diff --git a/services/core/apps/feedback/apps.py b/services/core/apps/feedback/apps.py new file mode 100644 index 0000000..7d982d2 --- /dev/null +++ b/services/core/apps/feedback/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FeedbackConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.feedback' diff --git a/services/core/apps/feedback/email_utils.py b/services/core/apps/feedback/email_utils.py new file mode 100644 index 0000000..20116ed --- /dev/null +++ b/services/core/apps/feedback/email_utils.py @@ -0,0 +1,66 @@ +import logging + +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string + +logger = logging.getLogger(__name__) + + +def send_feedback_confirmation_email(feedback): + """Send confirmation email after user submits feedback.""" + user = feedback.user + subject = 'We received your feedback — PyDocAI' + message = render_to_string('emails/feedback_confirmation.txt', { + 'user': user, + 'feedback': feedback, + 'frontend_url': settings.FRONTEND_URL, + }) + html_message = render_to_string('emails/feedback_confirmation.html', { + 'user': user, + 'feedback': feedback, + 'frontend_url': settings.FRONTEND_URL, + }) + try: + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + logger.info(f"Feedback confirmation email sent to {user.email}") + except Exception as e: + logger.error(f"Failed to send feedback confirmation to {user.email}: {e}") + + +def send_feedback_reply_email(reply): + """Send notification to feedback owner when someone replies.""" + feedback = reply.feedback + user = feedback.user + subject = 'New reply on your feedback — PyDocAI' + message = render_to_string('emails/feedback_reply.txt', { + 'user': user, + 'feedback': feedback, + 'reply': reply, + 'frontend_url': settings.FRONTEND_URL, + }) + html_message = render_to_string('emails/feedback_reply.html', { + 'user': user, + 'feedback': feedback, + 'reply': reply, + 'frontend_url': settings.FRONTEND_URL, + }) + try: + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + logger.info(f"Feedback reply email sent to {user.email}") + except Exception as e: + logger.error(f"Failed to send feedback reply to {user.email}: {e}") diff --git a/services/core/apps/feedback/migrations/0001_initial.py b/services/core/apps/feedback/migrations/0001_initial.py new file mode 100644 index 0000000..45f80d2 --- /dev/null +++ b/services/core/apps/feedback/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.13 on 2026-05-23 10:20 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('projects', '0005_remove_file_fields'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Feedback', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('rating', models.IntegerField(choices=[(1, '1 Star'), (2, '2 Stars'), (3, '3 Stars'), (4, '4 Stars'), (5, '5 Stars')])), + ('category', models.CharField(choices=[('general', 'General'), ('docs_quality', 'Documentation Quality'), ('ui_ux', 'UI / UX'), ('performance', 'Performance'), ('bug', 'Bug Report'), ('feature', 'Feature Request')], default='general', max_length=20)), + ('message', models.TextField()), + ('is_public', models.BooleanField(default=False)), + ('is_resolved', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='projects.project')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'feedback', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/services/core/apps/feedback/migrations/0002_feedback_admin_response.py b/services/core/apps/feedback/migrations/0002_feedback_admin_response.py new file mode 100644 index 0000000..83b9c50 --- /dev/null +++ b/services/core/apps/feedback/migrations/0002_feedback_admin_response.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-05-25 05:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feedback', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='feedback', + name='admin_response', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/services/core/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py b/services/core/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py new file mode 100644 index 0000000..6afd420 --- /dev/null +++ b/services/core/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.13 on 2026-05-26 06:20 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feedback', '0002_feedback_admin_response'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='feedback', + name='admin_response', + ), + migrations.RemoveField( + model_name='feedback', + name='rating', + ), + migrations.CreateModel( + name='FeedbackReply', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('message', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='feedback.feedback')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedback_replies', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'feedback_replies', + 'ordering': ['created_at'], + }, + ), + ] diff --git a/services/core/apps/feedback/migrations/0004_remove_feedback_is_public.py b/services/core/apps/feedback/migrations/0004_remove_feedback_is_public.py new file mode 100644 index 0000000..6d1d7d9 --- /dev/null +++ b/services/core/apps/feedback/migrations/0004_remove_feedback_is_public.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('feedback', '0003_remove_feedback_admin_response_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='feedback', + name='is_public', + ), + ] diff --git a/services/core/apps/feedback/migrations/__init__.py b/services/core/apps/feedback/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/feedback/models.py b/services/core/apps/feedback/models.py new file mode 100644 index 0000000..473a948 --- /dev/null +++ b/services/core/apps/feedback/models.py @@ -0,0 +1,48 @@ +import uuid + +from django.db import models + +from apps.projects.models import Project +from apps.users.models import User + + +class Feedback(models.Model): + + class Category(models.TextChoices): + GENERAL = 'general', 'General' + DOCS_QUALITY= 'docs_quality','Documentation Quality' + UI_UX = 'ui_ux', 'UI / UX' + PERFORMANCE = 'performance', 'Performance' + BUG = 'bug', 'Bug Report' + FEATURE = 'feature', 'Feature Request' + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feedbacks') + project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='feedbacks') + category = models.CharField(max_length=20, choices=Category.choices, default=Category.GENERAL) + message = models.TextField() + is_resolved = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'feedback' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.user.email} — ({self.category})" + + +class FeedbackReply(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + feedback = models.ForeignKey(Feedback, on_delete=models.CASCADE, related_name='replies') + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feedback_replies') + message = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'feedback_replies' + ordering = ['created_at'] + + def __str__(self): + return f"Reply by {self.user.email} on {self.feedback.id}" diff --git a/services/core/apps/feedback/serializers/__init__.py b/services/core/apps/feedback/serializers/__init__.py new file mode 100644 index 0000000..a6f86de --- /dev/null +++ b/services/core/apps/feedback/serializers/__init__.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from ..models import Feedback, FeedbackReply + + +class FeedbackReplySerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + is_admin = serializers.SerializerMethodField() + + class Meta: + model = FeedbackReply + fields = ['id', 'feedback', 'user', 'user_name', 'is_admin', 'message', 'created_at'] + read_only_fields = ['id', 'feedback', 'user', 'user_name', 'is_admin', 'created_at'] + + def get_is_admin(self, obj): + return obj.user.is_staff or getattr(obj.user, 'is_admin', False) + + +class FeedbackSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + replies = FeedbackReplySerializer(many=True, read_only=True) + + class Meta: + model = Feedback + fields = [ + 'id', 'user', 'user_name', 'project', 'category', + 'message', 'is_resolved', + 'replies', 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'user', 'user_name', 'is_resolved', 'replies', 'created_at', 'updated_at'] diff --git a/services/core/apps/feedback/tasks.py b/services/core/apps/feedback/tasks.py new file mode 100644 index 0000000..0665109 --- /dev/null +++ b/services/core/apps/feedback/tasks.py @@ -0,0 +1,27 @@ +import logging + +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task +def send_feedback_confirmation_task(feedback_id): + from .email_utils import send_feedback_confirmation_email + from .models import Feedback + try: + fb = Feedback.objects.select_related('user').get(id=feedback_id) + send_feedback_confirmation_email(fb) + except Exception as e: + logger.error(f"send_feedback_confirmation_task failed for feedback {feedback_id}: {e}") + + +@shared_task +def send_feedback_reply_task(reply_id): + from .email_utils import send_feedback_reply_email + from .models import FeedbackReply + try: + reply = FeedbackReply.objects.select_related('user', 'feedback__user').get(id=reply_id) + send_feedback_reply_email(reply) + except Exception as e: + logger.error(f"send_feedback_reply_task failed for reply {reply_id}: {e}") diff --git a/services/core/apps/feedback/tests/__init__.py b/services/core/apps/feedback/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/feedback/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/feedback/tests/test_email_utils.py b/services/core/apps/feedback/tests/test_email_utils.py new file mode 100644 index 0000000..866b294 --- /dev/null +++ b/services/core/apps/feedback/tests/test_email_utils.py @@ -0,0 +1,37 @@ +from unittest.mock import patch + +import pytest + + +pytestmark = pytest.mark.django_db + + +class TestSendFeedbackConfirmationEmail: + def test_sends_email(self, feedback): + from apps.feedback.email_utils import send_feedback_confirmation_email + with patch('django.conf.settings.DEFAULT_FROM_EMAIL', 'noreply@test.com'): + with patch('apps.feedback.email_utils.send_mail') as mock: + send_feedback_confirmation_email(feedback) + mock.assert_called_once() + args = mock.call_args[1] + assert 'We received your feedback' in args['subject'] + assert feedback.user.email in args['recipient_list'] + + def test_logs_on_failure(self, feedback, caplog): + import logging + from apps.feedback.email_utils import send_feedback_confirmation_email + with patch('apps.feedback.email_utils.send_mail', side_effect=Exception('SMTP error')): + with caplog.at_level(logging.ERROR): + send_feedback_confirmation_email(feedback) + assert 'Failed to send feedback confirmation' in caplog.text + + +class TestSendFeedbackReplyEmail: + def test_sends_email(self, feedback, user): + from apps.feedback.email_utils import send_feedback_reply_email + reply = feedback.replies.create(user=user, message='Reply') + with patch('django.conf.settings.DEFAULT_FROM_EMAIL', 'noreply@test.com'): + with patch('apps.feedback.email_utils.send_mail') as mock: + send_feedback_reply_email(reply) + mock.assert_called_once() + assert 'New reply on your feedback' in mock.call_args[1]['subject'] diff --git a/services/core/apps/feedback/tests/test_models.py b/services/core/apps/feedback/tests/test_models.py new file mode 100644 index 0000000..ba590b7 --- /dev/null +++ b/services/core/apps/feedback/tests/test_models.py @@ -0,0 +1,46 @@ +import pytest + +from apps.feedback.models import Feedback, FeedbackReply + + +class TestFeedbackModel: + def test_create(self, user): + fb = Feedback.objects.create(user=user, category='bug', message='Found a bug') + assert fb.user == user + assert fb.category == 'bug' + assert fb.message == 'Found a bug' + assert fb.is_resolved is False + + def test_str(self, user): + fb = Feedback.objects.create(user=user, message='Test') + assert user.email in str(fb) + assert fb.category in str(fb) + + def test_ordering(self): + assert Feedback._meta.ordering == ['-created_at'] + + def test_db_table(self): + assert Feedback._meta.db_table == 'feedback' + + def test_categories(self): + assert Feedback.Category.GENERAL == 'general' + assert Feedback.Category.BUG == 'bug' + assert Feedback.Category.FEATURE == 'feature' + + +class TestFeedbackReplyModel: + def test_create(self, user, feedback): + reply = FeedbackReply.objects.create(feedback=feedback, user=user, message='Thanks!') + assert reply.feedback == feedback + assert reply.user == user + assert reply.message == 'Thanks!' + + def test_str(self, user, feedback): + reply = FeedbackReply.objects.create(feedback=feedback, user=user, message='Thanks!') + assert user.email in str(reply) + + def test_ordering(self): + assert FeedbackReply._meta.ordering == ['created_at'] + + def test_db_table(self): + assert FeedbackReply._meta.db_table == 'feedback_replies' diff --git a/services/core/apps/feedback/tests/test_serializers.py b/services/core/apps/feedback/tests/test_serializers.py new file mode 100644 index 0000000..518428e --- /dev/null +++ b/services/core/apps/feedback/tests/test_serializers.py @@ -0,0 +1,43 @@ +import pytest + +from apps.feedback.serializers import FeedbackReplySerializer, FeedbackSerializer + + +class TestFeedbackSerializer: + def test_serialize(self, feedback): + serializer = FeedbackSerializer(feedback) + assert serializer.data['id'] == str(feedback.id) + assert serializer.data['message'] == feedback.message + assert serializer.data['user_name'] == feedback.user.name + assert serializer.data['category'] == feedback.category + assert serializer.data['is_resolved'] is False + + def test_read_only_fields(self, feedback): + serializer = FeedbackSerializer(feedback) + read_only = ['id', 'user', 'user_name', 'is_resolved', 'replies', 'created_at', 'updated_at'] + for field in read_only: + assert field in serializer.data + + def test_replies_included(self, feedback, user): + reply = feedback.replies.create(user=user, message='Reply') + serializer = FeedbackSerializer(feedback) + assert len(serializer.data['replies']) == 1 + assert serializer.data['replies'][0]['message'] == 'Reply' + + +class TestFeedbackReplySerializer: + def test_serialize(self, feedback, user): + reply = feedback.replies.create(user=user, message='A reply') + serializer = FeedbackReplySerializer(reply) + assert serializer.data['message'] == 'A reply' + assert serializer.data['user_name'] == user.name + + def test_is_admin(self, feedback, admin_user): + reply = feedback.replies.create(user=admin_user, message='Admin reply') + serializer = FeedbackReplySerializer(reply) + assert serializer.data['is_admin'] is True + + def test_is_not_admin(self, feedback, user): + reply = feedback.replies.create(user=user, message='User reply') + serializer = FeedbackReplySerializer(reply) + assert serializer.data['is_admin'] is False diff --git a/services/core/apps/feedback/tests/test_tasks.py b/services/core/apps/feedback/tests/test_tasks.py new file mode 100644 index 0000000..b8e131c --- /dev/null +++ b/services/core/apps/feedback/tests/test_tasks.py @@ -0,0 +1,31 @@ +from unittest.mock import patch + +import pytest + + +pytestmark = pytest.mark.django_db + + +class TestSendFeedbackConfirmationTask: + def test_calls_email_utils(self, feedback): + from apps.feedback.tasks import send_feedback_confirmation_task + with patch('apps.feedback.email_utils.send_feedback_confirmation_email') as mock: + send_feedback_confirmation_task(feedback.id) + mock.assert_called_once() + assert mock.call_args[0][0] == feedback + + def test_handles_missing_feedback(self): + from apps.feedback.tasks import send_feedback_confirmation_task + with patch('apps.feedback.tasks.logger.error') as mock_log: + send_feedback_confirmation_task(999) + mock_log.assert_called_once() + + +class TestSendFeedbackReplyTask: + def test_calls_email_utils(self, feedback, user): + from apps.feedback.tasks import send_feedback_reply_task + reply = feedback.replies.create(user=user, message='Reply') + with patch('apps.feedback.email_utils.send_feedback_reply_email') as mock: + send_feedback_reply_task(reply.id) + mock.assert_called_once() + assert mock.call_args[0][0] == reply diff --git a/services/core/apps/feedback/tests/test_views.py b/services/core/apps/feedback/tests/test_views.py new file mode 100644 index 0000000..7179035 --- /dev/null +++ b/services/core/apps/feedback/tests/test_views.py @@ -0,0 +1,102 @@ +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestFeedbackCreateView: + def test_unauthenticated(self, api_client): + url = reverse('feedback-create') + response = api_client.post(url, {'message': 'Great app!', 'category': 'general'}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_create_feedback(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('feedback-create') + response = api_client.post(url, {'message': 'Awesome!', 'category': 'general'}) + assert response.status_code == status.HTTP_201_CREATED + assert response.data['message'] == 'Awesome!' + + def test_invalid_data(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('feedback-create') + response = api_client.post(url, {}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestFeedbackListView: + def test_unauthenticated(self, api_client): + url = reverse('feedback-my') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_returns_own_feedback(self, api_client, user, feedback): + api_client.force_authenticate(user=user) + url = reverse('feedback-my') + response = api_client.get(url) + assert len(response.data) == 1 + assert response.data[0]['message'] == feedback.message + + def test_does_not_return_others_feedback(self, api_client, other_user, feedback): + api_client.force_authenticate(user=other_user) + url = reverse('feedback-my') + response = api_client.get(url) + assert response.data == [] + + def test_filter_by_category(self, api_client, user): + from apps.feedback.models import Feedback + Feedback.objects.create(user=user, category='bug', message='Bug!') + Feedback.objects.create(user=user, category='feature', message='Feature!') + api_client.force_authenticate(user=user) + url = reverse('feedback-my') + '?category=bug' + response = api_client.get(url) + assert len(response.data) == 1 + assert response.data[0]['category'] == 'bug' + + +class TestAdminFeedbackView: + def test_non_admin_gets_empty_list(self, api_client, user, feedback): + api_client.force_authenticate(user=user) + url = reverse('feedback-admin') + response = api_client.get(url) + assert response.data == [] + + def test_admin_views_all(self, api_client, admin_user, feedback): + api_client.force_authenticate(user=admin_user) + url = reverse('feedback-admin') + response = api_client.get(url) + assert len(response.data) >= 1 + + +class TestAdminFeedbackResolveView: + def test_resolve(self, api_client, admin_user, feedback): + api_client.force_authenticate(user=admin_user) + url = reverse('feedback-resolve', args=[feedback.id]) + response = api_client.patch(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['is_resolved'] is True + + def test_non_admin_cannot_resolve(self, api_client, user, feedback): + api_client.force_authenticate(user=user) + url = reverse('feedback-resolve', args=[feedback.id]) + response = api_client.patch(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestFeedbackReplyListCreateView: + def test_get_replies(self, api_client, user, feedback): + feedback.replies.create(user=user, message='Reply 1') + api_client.force_authenticate(user=user) + url = reverse('feedback-replies', args=[feedback.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + + def test_create_reply(self, api_client, user, feedback): + api_client.force_authenticate(user=user) + url = reverse('feedback-replies', args=[feedback.id]) + response = api_client.post(url, {'message': 'Thanks!'}) + assert response.status_code == status.HTTP_201_CREATED + assert response.data['message'] == 'Thanks!' diff --git a/services/core/apps/feedback/urls.py b/services/core/apps/feedback/urls.py new file mode 100644 index 0000000..e318573 --- /dev/null +++ b/services/core/apps/feedback/urls.py @@ -0,0 +1,17 @@ +from django.urls import path + +from .views import ( + AdminFeedbackResolveView, + AdminFeedbackView, + FeedbackCreateView, + FeedbackListView, + FeedbackReplyListCreateView, +) + +urlpatterns = [ + path('', FeedbackCreateView.as_view(), name='feedback-create'), + path('my/', FeedbackListView.as_view(), name='feedback-my'), + path('admin/', AdminFeedbackView.as_view(), name='feedback-admin'), + path('admin//resolve/', AdminFeedbackResolveView.as_view(), name='feedback-resolve'), + path('/replies/', FeedbackReplyListCreateView.as_view(), name='feedback-replies'), +] diff --git a/services/core/apps/feedback/views/__init__.py b/services/core/apps/feedback/views/__init__.py new file mode 100644 index 0000000..e1ea28f --- /dev/null +++ b/services/core/apps/feedback/views/__init__.py @@ -0,0 +1,10 @@ +from .feedback import FeedbackCreateView, FeedbackListView, AdminFeedbackView, AdminFeedbackResolveView +from .replies import FeedbackReplyListCreateView + +__all__ = [ + 'FeedbackCreateView', + 'FeedbackListView', + 'AdminFeedbackView', + 'AdminFeedbackResolveView', + 'FeedbackReplyListCreateView', +] diff --git a/services/core/apps/feedback/views/feedback.py b/services/core/apps/feedback/views/feedback.py new file mode 100644 index 0000000..1ab5353 --- /dev/null +++ b/services/core/apps/feedback/views/feedback.py @@ -0,0 +1,100 @@ +from django.db.models import Q +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..models import Feedback +from ..serializers import FeedbackSerializer +from ..tasks import send_feedback_confirmation_task + + +class FeedbackCreateView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + serializer = FeedbackSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + feedback = serializer.save(user=request.user) + send_feedback_confirmation_task.delay(feedback.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class FeedbackListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + qs = Feedback.objects.filter(user=request.user).prefetch_related('replies__user') + + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(category__icontains=search) | + Q(message__icontains=search) + ) + + for field in ['category', 'is_resolved']: + val = request.query_params.get(field) + if val is not None: + qs = qs.filter(**{field: val}) + + ordering = request.query_params.get('ordering', '-created_at') + allowed = ['created_at', 'category', '-created_at', '-category'] + if ordering not in allowed: + ordering = '-created_at' + qs = qs.order_by(ordering) + + serializer = FeedbackSerializer(qs, many=True) + return Response(serializer.data) + + +class AdminFeedbackView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + user = request.user + if not (user.is_staff or user.is_admin): + return Response([]) + + qs = Feedback.objects.select_related('user', 'project').prefetch_related('replies__user').all() + + category = request.query_params.get('category') + if category: + qs = qs.filter(category=category) + + resolved = request.query_params.get('resolved') + if resolved is not None: + qs = qs.filter(is_resolved=resolved.lower() == 'true') + + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(message__icontains=search) | + Q(user__name__icontains=search) | + Q(user__email__icontains=search) | + Q(category__icontains=search) + ) + + ordering = request.query_params.get('ordering', '-created_at') + allowed = ['created_at', 'category', 'is_resolved', '-created_at', '-category', '-is_resolved'] + if ordering not in allowed: + ordering = '-created_at' + qs = qs.order_by(ordering) + + serializer = FeedbackSerializer(qs, many=True) + return Response(serializer.data) + + +class AdminFeedbackResolveView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def patch(self, request, pk): + if not (request.user.is_staff or request.user.is_admin): + return Response({'detail': 'Forbidden.'}, status=status.HTTP_403_FORBIDDEN) + try: + fb = Feedback.objects.prefetch_related('replies__user').get(pk=pk) + except Feedback.DoesNotExist: + return Response({'detail': 'Not found.'}, status=status.HTTP_404_NOT_FOUND) + fb.is_resolved = True + fb.save() + return Response(FeedbackSerializer(fb).data) diff --git a/services/core/apps/feedback/views/replies.py b/services/core/apps/feedback/views/replies.py new file mode 100644 index 0000000..5b97caa --- /dev/null +++ b/services/core/apps/feedback/views/replies.py @@ -0,0 +1,29 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..models import Feedback, FeedbackReply +from ..serializers import FeedbackReplySerializer +from ..tasks import send_feedback_reply_task + + +class FeedbackReplyListCreateView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, feedback_pk): + qs = FeedbackReply.objects.filter( + feedback_id=feedback_pk + ).select_related('user') + serializer = FeedbackReplySerializer(qs, many=True) + return Response(serializer.data) + + def post(self, request, feedback_pk): + feedback = get_object_or_404(Feedback, pk=feedback_pk) + serializer = FeedbackReplySerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + reply = serializer.save(feedback=feedback, user=request.user) + if reply.user != feedback.user: + send_feedback_reply_task.delay(reply.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/services/core/apps/github_integration/__init__.py b/services/core/apps/github_integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/github_integration/apps.py b/services/core/apps/github_integration/apps.py new file mode 100644 index 0000000..5015dd9 --- /dev/null +++ b/services/core/apps/github_integration/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GithubIntegrationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.github_integration' diff --git a/services/core/apps/github_integration/fetcher.py b/services/core/apps/github_integration/fetcher.py new file mode 100644 index 0000000..cf9988a --- /dev/null +++ b/services/core/apps/github_integration/fetcher.py @@ -0,0 +1,98 @@ +from django.conf import settings + +from apps.common.github import ( + download_zipball, + fetch_public_repo_api, + get_github_client, + get_public_repo_tree_items, + get_repo_tree_items, +) + + +def get_user_repos(github_token: str) -> list: + g = get_github_client(github_token) + user = g.get_user() + repos = [] + for repo in user.get_repos(sort='updated'): + repos.append({ + 'id': repo.id, + 'name': repo.name, + 'full_name': repo.full_name, + 'description': repo.description, + 'private': repo.private, + 'url': repo.html_url, + 'updated_at': repo.updated_at.isoformat(), + 'language': repo.language, + 'default_branch': repo.default_branch, + }) + return repos + + +def get_public_repo(full_name: str, github_token=None) -> dict: + data = fetch_public_repo_api(full_name) + return { + 'id': data['id'], + 'name': data['name'], + 'full_name': data['full_name'], + 'description': data.get('description') or '', + 'private': data['private'], + 'url': data['html_url'], + 'default_branch': data.get('default_branch') or 'main', + 'language': data.get('language') or '', + 'stargazers_count': data.get('stargazers_count', 0), + 'forks_count': data.get('forks_count', 0), + } + + +def get_repo_tree(github_token: str, full_name: str, branch: str = None) -> list: + return get_repo_tree_items(github_token, full_name, branch) + + +def get_public_repo_tree(full_name: str, branch: str = None, github_token=None) -> list: + return get_public_repo_tree_items(full_name, branch) + + +def _py_file_filter(name): + return name.endswith('.py') + + +def get_repo_folders(github_token: str, full_name: str, branch: str = None) -> list: + tree = get_repo_tree(github_token, full_name, branch) + folders = [item for item in tree if item['type'] == 'tree'] + folders.insert(0, {'path': '/', 'type': 'tree', 'size': 0}) + return folders + + +def get_public_repo_folders(full_name: str, branch: str = None, github_token=None) -> list: + tree = get_public_repo_tree(full_name, branch, github_token) + folders = [item for item in tree if item['type'] == 'tree'] + folders.insert(0, {'path': '/', 'type': 'tree', 'size': 0}) + return folders + + +def import_folder_from_repo(github_token, full_name, folder_path, branch=None): + g = get_github_client(github_token) + repo = g.get_repo(full_name) + branch = branch or repo.default_branch + + url = f'https://api.github.com/repos/{full_name}/zipball/{branch}' + headers = { + 'Accept': 'application/vnd.github+json', + 'Authorization': f'token {github_token}', + } + return download_zipball(url, headers, folder_path, file_filter=_py_file_filter) + + +def import_public_folder_from_repo(full_name, folder_path, branch=None, github_token=None): + repo_data = fetch_public_repo_api(full_name) + branch = branch or repo_data.get('default_branch') or 'main' + + api_token = github_token or getattr(settings, 'GITHUB_API_TOKEN', None) + headers = {'Accept': 'application/vnd.github+json'} + if api_token and api_token.strip(): + headers['Authorization'] = f'token {api_token}' + url = f'https://api.github.com/repos/{full_name}/zipball/{branch}' + else: + url = f'https://github.com/{full_name}/archive/refs/heads/{branch}.zip' + + return download_zipball(url, headers, folder_path, file_filter=_py_file_filter) diff --git a/services/core/apps/github_integration/migrations/__init__.py b/services/core/apps/github_integration/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/github_integration/serializers/__init__.py b/services/core/apps/github_integration/serializers/__init__.py new file mode 100644 index 0000000..8ff5dd9 --- /dev/null +++ b/services/core/apps/github_integration/serializers/__init__.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + + +class RepoImportSerializer(serializers.Serializer): + full_name = serializers.CharField() + folder_path = serializers.CharField(default='/') + branch = serializers.CharField(required=False, allow_blank=True) + name = serializers.CharField(required=False, allow_blank=True) + description = serializers.CharField(required=False, allow_blank=True) + custom_info = serializers.JSONField(required=False) diff --git a/services/core/apps/github_integration/tasks.py b/services/core/apps/github_integration/tasks.py new file mode 100644 index 0000000..051efd4 --- /dev/null +++ b/services/core/apps/github_integration/tasks.py @@ -0,0 +1,100 @@ +import base64 +import io +import logging +import zipfile + +from celery import shared_task + +from apps.parser.tasks import parse_folder_task +from apps.parser.validators import should_exclude +from apps.projects.models import Project + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=3) +def import_github_repo_task(self, project_id, github_token, full_name, folder_path, branch, description, custom_info): + try: + from .fetcher import import_folder_from_repo + + project = Project.objects.get(id=project_id) + + files = import_folder_from_repo(github_token, full_name, folder_path, branch) + + if not files: + project.status = Project.Status.FAILED + project.save(update_fields=['status']) + logger.warning(f"No Python files found in {full_name}/{folder_path}") + return + + # Build zip in memory + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + for f in files: + zf.writestr(f['file_path'], f['content']) + zip_buffer.seek(0) + zip_base64 = base64.b64encode(zip_buffer.read()).decode('utf-8') + + py_files = [f['file_path'] for f in files if not should_exclude(f['file_path'])] + + parse_folder_task.delay( + project.id, + py_files, + zip_base64, + user_description=description, + custom_info=custom_info, + ) + + except Exception as e: + logger.exception(f"import_github_repo_task failed for project {project_id}: {e}") + try: + Project.objects.filter(id=project_id).update(status=Project.Status.FAILED) + except Exception: + pass + raise self.retry(exc=e, countdown=10) + + +@shared_task(bind=True, max_retries=3) +def import_public_repo_task(self, project_id, full_name, folder_path, branch, description, custom_info): + """Import from a public GitHub repo without user OAuth token.""" + try: + from .fetcher import import_public_folder_from_repo + + project = Project.objects.get(id=project_id) + + files = import_public_folder_from_repo(full_name, folder_path, branch) + + if not files: + project.status = Project.Status.FAILED + project.error_message = "No Python files found or repository is private" + project.save(update_fields=['status', 'error_message']) + logger.warning(f"No Python files found in {full_name}/{folder_path}") + return + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: + for f in files: + zf.writestr(f['file_path'], f['content']) + zip_buffer.seek(0) + zip_base64 = base64.b64encode(zip_buffer.read()).decode('utf-8') + + py_files = [f['file_path'] for f in files if not should_exclude(f['file_path'])] + + parse_folder_task.delay( + project.id, + py_files, + zip_base64, + user_description=description, + custom_info=custom_info, + ) + + except Exception as e: + logger.exception(f"import_public_repo_task failed for project {project_id}: {e}") + try: + Project.objects.filter(id=project_id).update( + status=Project.Status.FAILED, + error_message=str(e), + ) + except Exception: + pass + raise self.retry(exc=e, countdown=10) diff --git a/services/core/apps/github_integration/tests/__init__.py b/services/core/apps/github_integration/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/github_integration/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/github_integration/tests/test_serializers.py b/services/core/apps/github_integration/tests/test_serializers.py new file mode 100644 index 0000000..2fc00b6 --- /dev/null +++ b/services/core/apps/github_integration/tests/test_serializers.py @@ -0,0 +1,26 @@ +from apps.github_integration.serializers import RepoImportSerializer + + +class TestRepoImportSerializer: + def test_valid_data(self): + serializer = RepoImportSerializer(data={'full_name': 'owner/repo'}) + assert serializer.is_valid() + assert serializer.validated_data['full_name'] == 'owner/repo' + assert serializer.validated_data['folder_path'] == '/' + + def test_all_fields(self): + data = { + 'full_name': 'owner/repo', + 'folder_path': 'src/', + 'branch': 'main', + 'name': 'My Repo', + 'description': 'A test repo', + 'custom_info': {'key': 'value'}, + } + serializer = RepoImportSerializer(data=data) + assert serializer.is_valid() + + def test_missing_full_name(self): + serializer = RepoImportSerializer(data={}) + assert not serializer.is_valid() + assert 'full_name' in serializer.errors diff --git a/services/core/apps/github_integration/tests/test_views.py b/services/core/apps/github_integration/tests/test_views.py new file mode 100644 index 0000000..912824a --- /dev/null +++ b/services/core/apps/github_integration/tests/test_views.py @@ -0,0 +1,119 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestUserReposView: + def test_unauthenticated(self, api_client): + url = reverse('github-repos') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_no_token(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('github-repos') + response = api_client.get(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'GitHub account not connected' in response.data['detail'] + + def test_with_token(self, api_client, user): + user.github_token = 'test-token' + user.save() + api_client.force_authenticate(user=user) + with patch('apps.github_integration.views.user.get_user_repos', return_value=[{'id': 1, 'name': 'repo1'}]): + url = reverse('github-repos') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data[0]['name'] == 'repo1' + + def test_github_error(self, api_client, user): + user.github_token = 'bad-token' + user.save() + api_client.force_authenticate(user=user) + with patch('apps.github_integration.views.user.get_user_repos', side_effect=Exception('API error')): + url = reverse('github-repos') + response = api_client.get(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestRepoFoldersView: + def test_missing_repo_param(self, api_client, user): + user.github_token = 'token' + user.save() + api_client.force_authenticate(user=user) + url = reverse('github-repo-folders') + response = api_client.get(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_returns_folders(self, api_client, user): + user.github_token = 'token' + user.save() + api_client.force_authenticate(user=user) + with patch('apps.github_integration.views.user.get_repo_folders', return_value=[{'path': '/', 'type': 'tree'}]): + url = reverse('github-repo-folders') + '?repo=owner/repo' + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + + +class TestImportRepoView: + def test_import(self, api_client, user): + user.github_token = 'token' + user.save() + api_client.force_authenticate(user=user) + with patch('apps.github_integration.views.user.import_github_repo_task.delay'): + url = reverse('github-repo-import') + response = api_client.post(url, {'full_name': 'owner/repo', 'name': 'My Repo'}, format='json') + assert response.status_code == status.HTTP_202_ACCEPTED + assert 'project_id' in response.data + + +class TestParseGithubUrl: + def test_valid_url(self): + from apps.github_integration.views.user import parse_github_url + result = parse_github_url('https://github.com/owner/repo') + assert result == 'owner/repo' + + def test_valid_url_with_git(self): + from apps.github_integration.views.user import parse_github_url + result = parse_github_url('https://github.com/owner/repo.git') + assert result == 'owner/repo' + + def test_invalid_url(self): + from apps.github_integration.views.user import parse_github_url + result = parse_github_url('https://example.com/not-github') + assert result is None + + def test_empty_url(self): + from apps.github_integration.views.user import parse_github_url + result = parse_github_url('') + assert result is None + + +class TestPublicRepoInfoView: + def test_with_url(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('apps.github_integration.views.public.get_public_repo', return_value={'id': 1, 'name': 'repo'}): + url = reverse('github-public-repo-info') + '?url=https://github.com/owner/repo' + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + + def test_with_full_name(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('apps.github_integration.views.public.get_public_repo', return_value={'id': 1, 'name': 'repo'}): + url = reverse('github-public-repo-info') + '?full_name=owner/repo' + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + + +class TestImportPublicRepoView: + def test_import(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('apps.github_integration.views.public.import_public_repo_task.delay'): + url = reverse('github-public-repo-import') + response = api_client.post(url, {'full_name': 'owner/repo'}, format='json') + assert response.status_code == status.HTTP_202_ACCEPTED diff --git a/services/core/apps/github_integration/urls.py b/services/core/apps/github_integration/urls.py new file mode 100644 index 0000000..339f8ca --- /dev/null +++ b/services/core/apps/github_integration/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .views import ( + ImportPublicRepoView, + ImportRepoView, + PublicRepoFoldersView, + PublicRepoInfoView, + RepoFoldersView, + UserReposView, +) + +urlpatterns = [ + path('repos/', UserReposView.as_view(), name='github-repos'), + path('repos/folders/', RepoFoldersView.as_view(), name='github-repo-folders'), + path('repos/import/', ImportRepoView.as_view(), name='github-repo-import'), + path('public-repo/info/', PublicRepoInfoView.as_view(), name='github-public-repo-info'), + path('public-repo/folders/', PublicRepoFoldersView.as_view(), name='github-public-repo-folders'), + path('public-repo/import/', ImportPublicRepoView.as_view(), name='github-public-repo-import'), +] diff --git a/services/core/apps/github_integration/views/__init__.py b/services/core/apps/github_integration/views/__init__.py new file mode 100644 index 0000000..be48655 --- /dev/null +++ b/services/core/apps/github_integration/views/__init__.py @@ -0,0 +1,12 @@ +from .user import UserReposView, RepoFoldersView, ImportRepoView, parse_github_url +from .public import PublicRepoInfoView, PublicRepoFoldersView, ImportPublicRepoView + +__all__ = [ + 'UserReposView', + 'RepoFoldersView', + 'ImportRepoView', + 'parse_github_url', + 'PublicRepoInfoView', + 'PublicRepoFoldersView', + 'ImportPublicRepoView', +] diff --git a/services/core/apps/github_integration/views/public.py b/services/core/apps/github_integration/views/public.py new file mode 100644 index 0000000..e190d98 --- /dev/null +++ b/services/core/apps/github_integration/views/public.py @@ -0,0 +1,110 @@ +from github import GithubException +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.projects.models import Project + +from .user import parse_github_url +from ..fetcher import get_public_repo, get_public_repo_folders +from ..tasks import import_public_repo_task + + +class PublicRepoInfoView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + url = request.query_params.get('url') + full_name = request.query_params.get('full_name') + + if url: + full_name = parse_github_url(url) + if not full_name: + return Response({'detail': 'Invalid GitHub URL. Expected: https://github.com/owner/repo'}, status=status.HTTP_400_BAD_REQUEST) + elif not full_name: + return Response({'detail': 'url or full_name parameter is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + repo = get_public_repo(full_name) + return Response(repo) + except GithubException as e: + if e.status == 404: + return Response({'detail': 'Repository not found. It may be private or does not exist.'}, status=status.HTTP_404_NOT_FOUND) + if e.status == 403: + return Response({'detail': 'Rate limit exceeded or repository is private.'}, status=status.HTTP_429_TOO_MANY_REQUESTS) + return Response({'detail': f'GitHub API error: {e.data.get("message", str(e))}'}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response({'detail': f'Unexpected error: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class PublicRepoFoldersView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + full_name = request.query_params.get('full_name') + url = request.query_params.get('url') + branch = request.query_params.get('branch', None) + + if url: + full_name = parse_github_url(url) + if not full_name: + return Response({'detail': 'Invalid GitHub URL'}, status=status.HTTP_400_BAD_REQUEST) + elif not full_name: + return Response({'detail': 'full_name or url parameter is required'}, status=status.HTTP_400_BAD_REQUEST) + + try: + folders = get_public_repo_folders(full_name, branch) + return Response(folders) + except GithubException as e: + if e.status == 404: + return Response({'detail': 'Repository not found'}, status=status.HTTP_404_NOT_FOUND) + if e.status == 403: + return Response({'detail': 'Rate limit exceeded or repository is private.'}, status=status.HTTP_429_TOO_MANY_REQUESTS) + return Response({'detail': f'GitHub API error: {e.data.get("message", str(e))}'}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + return Response({'detail': f'Unexpected error: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class ImportPublicRepoView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + url = request.data.get('url') + full_name = request.data.get('full_name') + folder_path = request.data.get('folder_path', '/') + branch = request.data.get('branch') or None + name = request.data.get('name') + description = request.data.get('description', '') + custom_info = request.data.get('custom_info', {}) + + if url: + full_name = parse_github_url(url) + if not full_name: + return Response({'detail': 'Invalid GitHub URL. Expected: https://github.com/owner/repo'}, status=status.HTTP_400_BAD_REQUEST) + elif not full_name: + return Response({'detail': 'url or full_name is required'}, status=status.HTTP_400_BAD_REQUEST) + + if not name: + name = full_name.split('/')[-1] + + project = Project.objects.create( + user=request.user, + name=name, + description=description, + source_type=Project.SourceType.GITHUB, + github_url=f'https://github.com/{full_name}', + status=Project.Status.PENDING, + custom_details=custom_info or None, + ) + + import_public_repo_task.delay( + project.id, + full_name, + folder_path, + branch, + description, + custom_info or None, + ) + + return Response({'project_id': str(project.id)}, status=status.HTTP_202_ACCEPTED) diff --git a/services/core/apps/github_integration/views/user.py b/services/core/apps/github_integration/views/user.py new file mode 100644 index 0000000..96a50d7 --- /dev/null +++ b/services/core/apps/github_integration/views/user.py @@ -0,0 +1,103 @@ +import re + +from github import GithubException +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.projects.models import Project + +from ..fetcher import get_repo_folders, get_user_repos +from ..serializers import RepoImportSerializer +from ..tasks import import_github_repo_task + + +def parse_github_url(url: str) -> str | None: + patterns = [ + r'github\.com[:/]([^/]+/[^/]+?)(?:\.git)?(?:/|$)', + r'github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$', + ] + for pattern in patterns: + match = re.search(pattern, url) + if match: + full_name = match.group(1) + full_name = full_name.rstrip('/.') + if '/' in full_name and not full_name.endswith('.'): + return full_name + return None + + +class UserReposView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + if not user.github_token: + return Response({'detail': 'GitHub account not connected.'}, status=status.HTTP_400_BAD_REQUEST) + try: + repos = get_user_repos(user.github_token) + return Response(repos) + except Exception as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class RepoFoldersView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + full_name = request.query_params.get('repo') + branch = request.query_params.get('branch', None) + if not full_name: + return Response({'detail': 'repo parameter is required'}, status=status.HTTP_400_BAD_REQUEST) + user = request.user + if not user.github_token: + return Response({'detail': 'GitHub account not connected.'}, status=status.HTTP_400_BAD_REQUEST) + try: + folders = get_repo_folders(user.github_token, full_name, branch) + return Response(folders) + except Exception as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class ImportRepoView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = RepoImportSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + data = serializer.validated_data + full_name = data['full_name'] + folder_path = data.get('folder_path', '/') + branch = data.get('branch') or None + name = data.get('name') or full_name.split('/')[-1] + description = data.get('description', '') + custom_info = data.get('custom_info', {}) + + user = request.user + if not user.github_token: + return Response({'detail': 'GitHub account not connected.'}, status=status.HTTP_400_BAD_REQUEST) + + project = Project.objects.create( + user=user, + name=name, + description=description, + source_type=Project.SourceType.GITHUB, + github_url=f'https://github.com/{full_name}', + status=Project.Status.PENDING, + custom_details=custom_info or None, + ) + + import_github_repo_task.delay( + project.id, + user.github_token, + full_name, + folder_path, + branch, + description, + custom_info or None, + ) + + return Response({'project_id': str(project.id)}, status=status.HTTP_202_ACCEPTED) diff --git a/services/core/apps/internal/__init__.py b/services/core/apps/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/internal/tests/__init__.py b/services/core/apps/internal/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/internal/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/internal/tests/test_views.py b/services/core/apps/internal/tests/test_views.py new file mode 100644 index 0000000..d4f0faa --- /dev/null +++ b/services/core/apps/internal/tests/test_views.py @@ -0,0 +1,137 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +INTERNAL_KEY = 'test-internal-key' + + +@pytest.fixture(autouse=True) +def internal_key(): + with patch('apps.internal.views.receive.INTERNAL_API_KEY', INTERNAL_KEY): + yield + + +def _auth_header(): + return {'HTTP_X_INTERNAL_API_KEY': INTERNAL_KEY} + + +class TestProjectDetail: + def test_get_project(self, api_client, project): + url = reverse('internal-project', args=[project.id]) + response = api_client.get(url, **_auth_header()) + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == str(project.id) + assert response.data['name'] == project.name + + def test_get_not_found(self, api_client): + url = reverse('internal-project', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.get(url, **_auth_header()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_patch_project(self, api_client, project): + url = reverse('internal-project', args=[project.id]) + response = api_client.patch(url, {'name': 'Updated'}, format='json', **_auth_header()) + assert response.status_code == status.HTTP_200_OK + assert response.data['name'] == 'Updated' + + def test_forbidden_without_key(self, api_client, project): + url = reverse('internal-project', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_forbidden_with_wrong_key(self, api_client, project): + url = reverse('internal-project', args=[project.id]) + response = api_client.get(url, HTTP_X_INTERNAL_API_KEY='wrong-key') + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestProjectFileList: + def test_get_files(self, api_client, project, project_file): + url = reverse('internal-project-files', args=[project.id]) + response = api_client.get(url, **_auth_header()) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]['file_name'] == project_file.file_name + + def test_get_empty_file_list(self, api_client, project): + url = reverse('internal-project-files', args=[project.id]) + response = api_client.get(url, **_auth_header()) + assert response.status_code == status.HTTP_200_OK + assert response.data == [] + + def test_post_file(self, api_client, project): + url = reverse('internal-project-files', args=[project.id]) + data = {'file_name': 'test.py', 'file_path': 'src/test.py', 'content': 'print("hello")'} + response = api_client.post(url, data, format='json', **_auth_header()) + assert response.status_code == status.HTTP_201_CREATED + assert response.data['file_name'] == 'test.py' + + def test_forbidden_without_key(self, api_client, project): + url = reverse('internal-project-files', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestReceiveParsedData: + def test_receive_parsed_data(self, api_client, project): + url = reverse('internal-parsed', args=[project.id]) + data = {'parsed_data': {'functions': ['foo']}, 'file_count': 5} + response = api_client.post(url, data, format='json', **_auth_header()) + assert response.status_code == status.HTTP_200_OK + assert response.data['status'] == 'ok' + project.refresh_from_db() + assert project.parsed_data == {'functions': ['foo']} + assert project.project_info['files_parsed'] == 5 + assert project.status == 'processing' + + def test_receive_with_parsed_key(self, api_client, project): + url = reverse('internal-parsed', args=[project.id]) + data = {'parsed': {'classes': ['Bar']}, 'file_count': 3} + response = api_client.post(url, data, format='json', **_auth_header()) + assert response.status_code == status.HTTP_200_OK + project.refresh_from_db() + assert project.parsed_data == {'classes': ['Bar']} + + def test_project_not_found(self, api_client): + url = reverse('internal-parsed', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.post(url, {}, format='json', **_auth_header()) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestReceiveAIDocs: + def test_receive_docs_done(self, api_client, project): + url = reverse('internal-ai-docs', args=[project.id]) + data = { + 'generated_docs': '# Docs', + 'readme_docs': '# README', + 'api_docs': '# API', + 'project_info': {'summary': 'A project'}, + 'status': 'done', + } + response = api_client.post(url, data, format='json', **_auth_header()) + assert response.status_code == status.HTTP_200_OK + project.refresh_from_db() + assert project.generated_docs == '# Docs' + assert project.readme_docs == '# README' + assert project.api_docs == '# API' + assert project.status == 'done' + + def test_receive_docs_failed(self, api_client, project): + url = reverse('internal-ai-docs', args=[project.id]) + data = {'status': 'failed', 'error_message': 'AI error'} + response = api_client.post(url, data, format='json', **_auth_header()) + assert response.status_code == status.HTTP_200_OK + project.refresh_from_db() + assert project.status == 'failed' + assert project.error_message == 'AI error' + + def test_project_not_found(self, api_client): + url = reverse('internal-ai-docs', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.post(url, {}, format='json', **_auth_header()) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/services/core/apps/internal/urls.py b/services/core/apps/internal/urls.py new file mode 100644 index 0000000..d1c4e1a --- /dev/null +++ b/services/core/apps/internal/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('projects//', views.ProjectDetail.as_view(), name='internal-project'), + path('projects//files/', views.ProjectFileList.as_view(), name='internal-project-files'), + path('projects//parsed/', views.ReceiveParsedData.as_view(), name='internal-parsed'), + path('projects//ai-docs/', views.ReceiveAIDocs.as_view(), name='internal-ai-docs'), +] diff --git a/services/core/apps/internal/views/__init__.py b/services/core/apps/internal/views/__init__.py new file mode 100644 index 0000000..7da4243 --- /dev/null +++ b/services/core/apps/internal/views/__init__.py @@ -0,0 +1,8 @@ +from .receive import ProjectDetail, ProjectFileList, ReceiveParsedData, ReceiveAIDocs + +__all__ = [ + 'ProjectDetail', + 'ProjectFileList', + 'ReceiveParsedData', + 'ReceiveAIDocs', +] diff --git a/services/core/apps/internal/views/receive.py b/services/core/apps/internal/views/receive.py new file mode 100644 index 0000000..a0df7ae --- /dev/null +++ b/services/core/apps/internal/views/receive.py @@ -0,0 +1,152 @@ +import logging + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import AllowAny + +from apps.common.config import INTERNAL_API_KEY +from apps.projects.models import Project, ProjectFile +from apps.projects.serializers import ProjectSerializer, ProjectFileSerializer + +logger = logging.getLogger(__name__) + + +class InternalAuthMixin: + def _verify_key(self, request): + key = request.META.get("HTTP_X_INTERNAL_API_KEY", "") + if INTERNAL_API_KEY and key != INTERNAL_API_KEY: + return Response({"error": "Forbidden"}, status=403) + return None + + +class ProjectDetail(InternalAuthMixin, APIView): + permission_classes = [AllowAny] + + def get(self, request, project_id): + forbidden = self._verify_key(request) + if forbidden: + return forbidden + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return Response({"error": "Project not found"}, status=404) + serializer = ProjectSerializer(project) + return Response(serializer.data) + + def patch(self, request, project_id): + forbidden = self._verify_key(request) + if forbidden: + return forbidden + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return Response({"error": "Project not found"}, status=404) + serializer = ProjectSerializer(project, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=400) + + +class ProjectFileList(InternalAuthMixin, APIView): + permission_classes = [AllowAny] + + def get(self, request, project_id): + forbidden = self._verify_key(request) + if forbidden: + return forbidden + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return Response({"error": "Project not found"}, status=404) + files = project.files.all() + serializer = ProjectFileSerializer(files, many=True) + return Response(serializer.data) + + def post(self, request, project_id): + forbidden = self._verify_key(request) + if forbidden: + return forbidden + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return Response({"error": "Project not found"}, status=404) + data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) + data["project"] = str(project.id) + serializer = ProjectFileSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=201) + return Response(serializer.errors, status=400) + + +class ReceiveParsedData(InternalAuthMixin, APIView): + permission_classes = [AllowAny] + + def post(self, request, project_id): + forbidden = self._verify_key(request) + if forbidden: + return forbidden + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return Response({"error": "Project not found"}, status=404) + + parsed_data = request.data.get("parsed_data") or request.data.get("parsed") + file_count = request.data.get("file_count", 0) + + if parsed_data: + project.parsed_data = parsed_data + if file_count: + project.project_info = { + **(project.project_info or {}), + "files_parsed": file_count, + } + + project.status = Project.Status.PROCESSING + project.save() + + logger.info("Internal: Parsed data received for project %s, %d files", project_id, file_count) + + return Response({"status": "ok", "project_id": project_id}) + + +class ReceiveAIDocs(InternalAuthMixin, APIView): + permission_classes = [AllowAny] + + def post(self, request, project_id): + forbidden = self._verify_key(request) + if forbidden: + return forbidden + try: + project = Project.objects.get(id=project_id) + except Project.DoesNotExist: + return Response({"error": "Project not found"}, status=404) + + generated_docs = request.data.get("generated_docs") + readme_docs = request.data.get("readme_docs") + api_docs = request.data.get("api_docs") + project_info = request.data.get("project_info") + status_str = request.data.get("status", "done") + error_message = request.data.get("error_message") + + if generated_docs: + project.generated_docs = generated_docs + if readme_docs: + project.readme_docs = readme_docs + if api_docs: + project.api_docs = api_docs + if project_info: + project.project_info = project_info + + if status_str == "done": + project.status = Project.Status.DONE + elif status_str == "failed": + project.status = Project.Status.FAILED + project.error_message = error_message or "AI generation failed" + + project.save() + + logger.info("Internal: AI docs received for project %s, status=%s", project_id, status_str) + + return Response({"status": "ok", "project_id": project_id}) diff --git a/services/core/apps/notifications/__init__.py b/services/core/apps/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/notifications/apps.py b/services/core/apps/notifications/apps.py new file mode 100644 index 0000000..03a81ff --- /dev/null +++ b/services/core/apps/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.notifications' diff --git a/services/core/apps/notifications/migrations/0001_initial.py b/services/core/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..1478a67 --- /dev/null +++ b/services/core/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.13 on 2026-06-16 13:40 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('comments', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('message', models.TextField()), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='comments.comment')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'notifications', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['user', 'is_read', 'created_at'], name='notificatio_user_id_5cf777_idx')], + }, + ), + ] diff --git a/services/core/apps/notifications/migrations/__init__.py b/services/core/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/notifications/models.py b/services/core/apps/notifications/models.py new file mode 100644 index 0000000..1b5f1e5 --- /dev/null +++ b/services/core/apps/notifications/models.py @@ -0,0 +1,27 @@ +import uuid + +from django.db import models + +from apps.comments.models import Comment +from apps.users.models import User + + +class Notification(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications') + comment = models.ForeignKey( + Comment, on_delete=models.CASCADE, null=True, blank=True, related_name='notifications' + ) + message = models.TextField() + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'notifications' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['user', 'is_read', 'created_at']), + ] + + def __str__(self): + return f'{self.user.email}: {self.message[:50]}' diff --git a/services/core/apps/notifications/serializers/__init__.py b/services/core/apps/notifications/serializers/__init__.py new file mode 100644 index 0000000..9cf9cb1 --- /dev/null +++ b/services/core/apps/notifications/serializers/__init__.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from apps.comments.serializers import CommentSerializer + +from ..models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + comment = CommentSerializer(read_only=True) + project_slug = serializers.CharField(source='comment.project.public_slug', read_only=True) + + class Meta: + model = Notification + fields = ['id', 'message', 'is_read', 'comment', 'project_slug', 'created_at'] diff --git a/services/core/apps/notifications/tasks.py b/services/core/apps/notifications/tasks.py new file mode 100644 index 0000000..02ab9e4 --- /dev/null +++ b/services/core/apps/notifications/tasks.py @@ -0,0 +1,17 @@ +from celery import shared_task +from django.conf import settings +from django.core.mail import send_mail + + +@shared_task +def send_email_task(subject, message, recipient_list, html_message=None): + if not settings.EMAIL_HOST_USER: + return + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=recipient_list, + html_message=html_message, + fail_silently=True, + ) diff --git a/services/core/apps/notifications/tests/__init__.py b/services/core/apps/notifications/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/notifications/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/notifications/tests/test_models.py b/services/core/apps/notifications/tests/test_models.py new file mode 100644 index 0000000..8fdab6a --- /dev/null +++ b/services/core/apps/notifications/tests/test_models.py @@ -0,0 +1,34 @@ +import pytest + +from apps.notifications.models import Notification + + +class TestNotificationModel: + def test_create(self, user): + notification = Notification.objects.create( + user=user, + message='Someone commented on your project', + ) + assert notification.user == user + assert notification.message == 'Someone commented on your project' + assert notification.is_read is False + + def test_str(self, notification): + assert notification.user.email in str(notification) + assert notification.message[:50] in str(notification) + + def test_mark_read(self, notification): + notification.is_read = True + notification.save(update_fields=['is_read']) + notification.refresh_from_db() + assert notification.is_read is True + + def test_ordering(self): + assert Notification._meta.ordering == ['-created_at'] + + def test_db_table(self): + assert Notification._meta.db_table == 'notifications' + + def test_indexes(self): + field_names = [list(idx.fields) for idx in Notification._meta.indexes] + assert ['user', 'is_read', 'created_at'] in field_names diff --git a/services/core/apps/notifications/tests/test_serializers.py b/services/core/apps/notifications/tests/test_serializers.py new file mode 100644 index 0000000..10cb238 --- /dev/null +++ b/services/core/apps/notifications/tests/test_serializers.py @@ -0,0 +1,29 @@ +import pytest + +from apps.notifications.serializers import NotificationSerializer + + +class TestNotificationSerializer: + def test_serialize(self, notification): + serializer = NotificationSerializer(notification) + assert serializer.data['id'] == str(notification.id) + assert serializer.data['message'] == notification.message + assert serializer.data['is_read'] is False + assert 'comment' in serializer.data + assert 'created_at' in serializer.data + + def test_serialize_with_comment(self, notification_with_comment): + serializer = NotificationSerializer(notification_with_comment) + assert serializer.data['comment'] is not None + assert serializer.data['comment']['content'] == 'Great project!' + assert serializer.data['project_slug'] == str(notification_with_comment.comment.project.public_slug) + + def test_read_only_fields(self, notification): + serializer = NotificationSerializer(notification) + for field in ['id', 'message', 'is_read', 'created_at']: + assert field in serializer.data + + def test_project_slug_with_comment(self, notification_with_comment): + serializer = NotificationSerializer(notification_with_comment) + assert 'project_slug' in serializer.data + assert serializer.data['project_slug'] == str(notification_with_comment.comment.project.public_slug) diff --git a/services/core/apps/notifications/tests/test_tasks.py b/services/core/apps/notifications/tests/test_tasks.py new file mode 100644 index 0000000..9af2df3 --- /dev/null +++ b/services/core/apps/notifications/tests/test_tasks.py @@ -0,0 +1,35 @@ +from unittest.mock import patch + +import pytest +from django.conf import settings + + +pytestmark = pytest.mark.django_db + + +class TestSendEmailTask: + def test_sends_email(self): + from apps.notifications.tasks import send_email_task + with patch('django.conf.settings.EMAIL_HOST_USER', 'test@example.com'): + with patch('apps.notifications.tasks.send_mail') as mock: + send_email_task( + subject='Test', + message='Body', + recipient_list=['user@test.com'], + html_message='

Body

', + ) + mock.assert_called_once_with( + subject='Test', + message='Body', + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=['user@test.com'], + html_message='

Body

', + fail_silently=True, + ) + + def test_skips_when_no_email_host(self): + from apps.notifications.tasks import send_email_task + with patch('django.conf.settings.EMAIL_HOST_USER', None): + with patch('apps.notifications.tasks.send_mail') as mock: + send_email_task('Test', 'Body', ['user@test.com']) + mock.assert_not_called() diff --git a/services/core/apps/notifications/tests/test_utils.py b/services/core/apps/notifications/tests/test_utils.py new file mode 100644 index 0000000..5c76e4b --- /dev/null +++ b/services/core/apps/notifications/tests/test_utils.py @@ -0,0 +1,99 @@ +from unittest.mock import patch + +import pytest + +from apps.notifications.models import Notification + + +pytestmark = pytest.mark.django_db + + +class TestNotifyComment: + def test_creates_notification_for_project_owner(self, project, other_user): + from apps.comments.models import Comment + comment = Comment.objects.create(project=project, user=other_user, content='Nice!') + from apps.notifications.utils import notify_comment + notify_comment(comment) + assert Notification.objects.filter(user=project.user).count() == 1 + + def test_skips_when_commenter_is_owner(self, project, user): + from apps.comments.models import Comment + comment = Comment.objects.create(project=project, user=user, content='Self comment') + from apps.notifications.utils import notify_comment + notify_comment(comment) + assert Notification.objects.filter(user=project.user).count() == 0 + + def test_message_format(self, project, other_user): + from apps.comments.models import Comment + comment = Comment.objects.create(project=project, user=other_user, content='Nice project!') + from apps.notifications.utils import notify_comment + notify_comment(comment) + notification = Notification.objects.get(user=project.user) + assert comment.user.name in notification.message + assert project.name in notification.message + assert comment.content[:80] in notification.message + + def test_sends_email_when_configured(self, project, other_user): + from apps.comments.models import Comment + comment = Comment.objects.create(project=project, user=other_user, content='Nice!') + from apps.notifications.utils import notify_comment + with patch('django.conf.settings.EMAIL_HOST_USER', 'test@example.com'): + with patch('apps.notifications.utils.send_email_task.delay') as mock: + notify_comment(comment) + mock.assert_called_once() + args = mock.call_args[1] + assert 'New comment' in args['subject'] + assert project.user.email in args['recipient_list'] + + def test_skips_email_when_not_configured(self, project, other_user): + from apps.comments.models import Comment + comment = Comment.objects.create(project=project, user=other_user, content='Nice!') + from apps.notifications.utils import notify_comment + with patch('django.conf.settings.EMAIL_HOST_USER', None): + with patch('apps.notifications.utils.send_email_task.delay') as mock: + notify_comment(comment) + mock.assert_not_called() + + def test_links_comment_to_notification(self, project, other_user): + from apps.comments.models import Comment + comment = Comment.objects.create(project=project, user=other_user, content='Nice!') + from apps.notifications.utils import notify_comment + notify_comment(comment) + notification = Notification.objects.get(user=project.user) + assert notification.comment == comment + + +class TestNotifyReply: + def test_creates_notification_for_parent_author(self, project, other_user): + from apps.comments.models import Comment + parent = Comment.objects.create(project=project, user=other_user, content='Original') + reply = Comment.objects.create(project=project, user=project.user, parent=parent, content='A reply') + from apps.notifications.utils import notify_reply + notify_reply(reply) + assert Notification.objects.filter(user=other_user).count() == 1 + + def test_skips_if_no_parent(self, project, other_user): + from apps.comments.models import Comment + comment = Comment.objects.create(project=project, user=other_user, content='No parent') + from apps.notifications.utils import notify_reply + notify_reply(comment) + assert Notification.objects.count() == 0 + + def test_skips_if_replier_is_parent(self, project, user): + from apps.comments.models import Comment + parent = Comment.objects.create(project=project, user=user, content='My comment') + reply = Comment.objects.create(project=project, user=user, parent=parent, content='Self reply') + from apps.notifications.utils import notify_reply + notify_reply(reply) + assert Notification.objects.count() == 0 + + def test_sends_email_when_configured(self, project, other_user): + from apps.comments.models import Comment + parent = Comment.objects.create(project=project, user=other_user, content='Original') + reply = Comment.objects.create(project=project, user=project.user, parent=parent, content='Reply') + from apps.notifications.utils import notify_reply + with patch('django.conf.settings.EMAIL_HOST_USER', 'test@example.com'): + with patch('apps.notifications.utils.send_email_task.delay') as mock: + notify_reply(reply) + mock.assert_called_once() + assert 'New reply' in mock.call_args[1]['subject'] diff --git a/services/core/apps/notifications/tests/test_views.py b/services/core/apps/notifications/tests/test_views.py new file mode 100644 index 0000000..6d4d2df --- /dev/null +++ b/services/core/apps/notifications/tests/test_views.py @@ -0,0 +1,100 @@ +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestNotificationListView: + def test_unauthenticated(self, api_client): + url = reverse('notification_list') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_returns_user_notifications(self, api_client, user, notification): + api_client.force_authenticate(user=user) + url = reverse('notification_list') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]['message'] == notification.message + + def test_does_not_return_other_user_notifications(self, api_client, other_user, notification): + api_client.force_authenticate(user=other_user) + url = reverse('notification_list') + response = api_client.get(url) + assert response.data == [] + + def test_limit_param(self, api_client, user): + for i in range(5): + from apps.notifications.models import Notification + Notification.objects.create(user=user, message=f'Notification {i}') + api_client.force_authenticate(user=user) + url = reverse('notification_list') + '?limit=2' + response = api_client.get(url) + assert len(response.data) == 2 + + +class TestUnreadCountView: + def test_unauthenticated(self, api_client): + url = reverse('notification_unread_count') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_count(self, api_client, user, notification): + api_client.force_authenticate(user=user) + url = reverse('notification_unread_count') + response = api_client.get(url) + assert response.data['unread_count'] == 1 + + def test_zero_when_all_read(self, api_client, user, notification): + notification.is_read = True + notification.save() + api_client.force_authenticate(user=user) + url = reverse('notification_unread_count') + response = api_client.get(url) + assert response.data['unread_count'] == 0 + + +class TestMarkReadView: + def test_mark_read(self, api_client, user, notification): + api_client.force_authenticate(user=user) + url = reverse('notification_mark_read', args=[notification.id]) + response = api_client.patch(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + notification.refresh_from_db() + assert notification.is_read is True + + +class TestMarkAllReadView: + def test_mark_all_read(self, api_client, user): + from apps.notifications.models import Notification + Notification.objects.create(user=user, message='N1') + Notification.objects.create(user=user, message='N2') + api_client.force_authenticate(user=user) + url = reverse('notification_mark_all_read') + response = api_client.patch(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert Notification.objects.filter(user=user, is_read=False).count() == 0 + + +class TestNotificationDeleteView: + def test_delete(self, api_client, user, notification): + api_client.force_authenticate(user=user) + url = reverse('notification_delete', args=[notification.id]) + response = api_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not type(notification).objects.filter(id=notification.id).exists() + + +class TestClearAllNotificationsView: + def test_clear_all(self, api_client, user): + from apps.notifications.models import Notification + Notification.objects.create(user=user, message='N1') + Notification.objects.create(user=user, message='N2') + api_client.force_authenticate(user=user) + url = reverse('notification_clear_all') + response = api_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert Notification.objects.filter(user=user).count() == 0 diff --git a/services/core/apps/notifications/urls.py b/services/core/apps/notifications/urls.py new file mode 100644 index 0000000..b45eb9c --- /dev/null +++ b/services/core/apps/notifications/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from .views import ( + ClearAllNotificationsView, + MarkAllReadView, + MarkReadView, + NotificationDeleteView, + NotificationListView, + UnreadCountView, +) + +urlpatterns = [ + path('', NotificationListView.as_view(), name='notification_list'), + path('unread-count/', UnreadCountView.as_view(), name='notification_unread_count'), + path('/read/', MarkReadView.as_view(), name='notification_mark_read'), + path('read-all/', MarkAllReadView.as_view(), name='notification_mark_all_read'), + path('/', NotificationDeleteView.as_view(), name='notification_delete'), + path('clear-all/', ClearAllNotificationsView.as_view(), name='notification_clear_all'), +] diff --git a/services/core/apps/notifications/utils.py b/services/core/apps/notifications/utils.py new file mode 100644 index 0000000..74540df --- /dev/null +++ b/services/core/apps/notifications/utils.py @@ -0,0 +1,66 @@ +from django.conf import settings +from django.template.loader import render_to_string + +from .models import Notification +from .tasks import send_email_task + + +def _snippet(text, maxlen=80): + if not text: + return '' + return text[:maxlen] + ('...' if len(text) > maxlen else '') + + +def notify_comment(comment): + project = comment.project + owner = project.user + if owner == comment.user: + return + + commenter = comment.user.name or comment.user.email + snippet = _snippet(comment.content) + message = f'{commenter} commented on "{project.name}": "{snippet}"' + Notification.objects.create(user=owner, comment=comment, message=message) + + if settings.EMAIL_HOST_USER: + public_url = f'{settings.SITE_URL}/public/{project.public_slug}#comment-{comment.id}' + send_email_task.delay( + subject=f'New comment on "{project.name}"', + message=comment.content, + recipient_list=[owner.email], + html_message=render_to_string('emails/notification_comment.html', { + 'project_name': project.name, + 'commenter': commenter, + 'comment_content': comment.content, + 'public_url': public_url, + }), + ) + + +def notify_reply(comment): + parent = comment.parent + if not parent or not parent.user: + return + if parent.user == comment.user: + return + + project = comment.project + replier = comment.user.name or comment.user.email + snippet = _snippet(comment.content) + message = f'{replier} replied to your comment on "{project.name}": "{snippet}"' + Notification.objects.create(user=parent.user, comment=comment, message=message) + + if settings.EMAIL_HOST_USER: + public_url = f'{settings.SITE_URL}/public/{project.public_slug}#comment-{comment.id}' + send_email_task.delay( + subject=f'New reply on "{project.name}"', + message=comment.content, + recipient_list=[parent.user.email], + html_message=render_to_string('emails/notification_reply.html', { + 'project_name': project.name, + 'replier': replier, + 'parent_content': parent.content, + 'reply_content': comment.content, + 'public_url': public_url, + }), + ) diff --git a/services/core/apps/notifications/views/__init__.py b/services/core/apps/notifications/views/__init__.py new file mode 100644 index 0000000..771e77d --- /dev/null +++ b/services/core/apps/notifications/views/__init__.py @@ -0,0 +1,8 @@ +from .notifications import ( + ClearAllNotificationsView, + MarkAllReadView, + MarkReadView, + NotificationDeleteView, + NotificationListView, + UnreadCountView, +) diff --git a/services/core/apps/notifications/views/notifications.py b/services/core/apps/notifications/views/notifications.py new file mode 100644 index 0000000..3dc5df9 --- /dev/null +++ b/services/core/apps/notifications/views/notifications.py @@ -0,0 +1,56 @@ +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..models import Notification +from ..serializers import NotificationSerializer + + +class NotificationListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + limit = min(int(request.query_params.get('limit', 20)), 50) + qs = Notification.objects.filter(user=request.user)[:limit] + data = NotificationSerializer(qs, many=True).data + return Response(data) + + +class UnreadCountView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + count = Notification.objects.filter(user=request.user, is_read=False).count() + return Response({'unread_count': count}) + + +class MarkReadView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def patch(self, request, pk): + Notification.objects.filter(pk=pk, user=request.user).update(is_read=True) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class MarkAllReadView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def patch(self, request): + Notification.objects.filter(user=request.user, is_read=False).update(is_read=True) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class NotificationDeleteView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def delete(self, request, pk): + Notification.objects.filter(pk=pk, user=request.user).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ClearAllNotificationsView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def delete(self, request): + Notification.objects.filter(user=request.user).delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/core/apps/parser/__init__.py b/services/core/apps/parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/parser/apps.py b/services/core/apps/parser/apps.py new file mode 100644 index 0000000..4365e8f --- /dev/null +++ b/services/core/apps/parser/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ParserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.parser' diff --git a/services/core/apps/parser/ast_parser.py b/services/core/apps/parser/ast_parser.py new file mode 100644 index 0000000..6811f2f --- /dev/null +++ b/services/core/apps/parser/ast_parser.py @@ -0,0 +1,165 @@ +import ast +from typing import Any + + +def parse_python_file(source_code: str) -> dict: + try: + tree = ast.parse(source_code) + except SyntaxError as e: + return { + "error": True, + "error_message": f"SyntaxError on line {e.lineno}: {e.msg}" + } + + result = { + "error": False, + "module_docstring": ast.get_docstring(tree), + "imports": [], + "functions": [], + "classes": [], + "ordered_items": [] # Preserves source order: [{"type": "import"|"function"|"class", "data": ...}] + } + + # Collect all top-level names for cross-reference detection + top_level_names = set() + + # First pass: collect top-level definitions to know what names exist + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + top_level_names.add(node.name) + elif isinstance(node, ast.ClassDef): + top_level_names.add(node.name) + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + top_level_names.add(item.name) + + # Second pass: ordered traversal of tree.body + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + import_entry = _extract_import(node) + result["imports"].append(import_entry) + result["ordered_items"].append({ + "type": "import", + "line": node.lineno, + "data": import_entry + }) + + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + func_data = _extract_function(node, top_level_names) + result["functions"].append(func_data) + result["ordered_items"].append({ + "type": "function", + "line": node.lineno, + "data": func_data + }) + + elif isinstance(node, ast.ClassDef): + class_data = _extract_class(node, top_level_names) + result["classes"].append(class_data) + result["ordered_items"].append({ + "type": "class", + "line": node.lineno, + "data": class_data + }) + + return result + + +def _extract_import(node) -> dict[str, Any]: + """Extract import information with line number.""" + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + return { + "line": node.lineno, + "type": "import", + "module": names[0] if len(names) == 1 else None, + "names": names, + "display": ', '.join(names) + } + else: # ImportFrom + names = [alias.name for alias in node.names] + module = node.module or '' + return { + "line": node.lineno, + "type": "import-from", + "module": module, + "names": names, + "display": f"from {module} import {', '.join(names)}" + } + + +def _extract_function(node, available_names: set) -> dict[str, Any]: + """Extract function definition with connections.""" + connections = _find_connections(node, available_names) + return { + "name": node.name, + "args": extract_args(node), + "returns": extract_return_type(node), + "docstring": ast.get_docstring(node), + "decorators": [ast.unparse(d) for d in node.decorator_list], + "line": node.lineno, + "is_async": isinstance(node, ast.AsyncFunctionDef), + "connections": connections + } + + +def _extract_class(node, available_names: set) -> dict[str, Any]: + """Extract class definition with methods in order and connections.""" + methods = [] + class_connections = _find_connections(node, available_names) + + # Process class body in order + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + method_connections = _find_connections(item, available_names) + methods.append({ + "name": item.name, + "args": extract_args(item), + "returns": extract_return_type(item), + "docstring": ast.get_docstring(item), + "is_private": item.name.startswith("_"), + "line": item.lineno, + "connections": method_connections, + "parent_class": node.name + }) + + return { + "name": node.name, + "docstring": ast.get_docstring(node), + "bases": [ast.unparse(b) for b in node.bases], + "methods": methods, + "line": node.lineno, + "connections": class_connections + } + + +def _find_connections(node, available_names: set) -> list[str]: + """Find references to other top-level names within a node's body.""" + connections = set() + for child in ast.walk(node): + if isinstance(child, ast.Call): + # Function calls: foo(...) + if isinstance(child.func, ast.Name): + if child.func.id in available_names: + connections.add(child.func.id) + elif isinstance(child, ast.Name): + # Variable references: x = foo + if child.id in available_names: + connections.add(child.id) + return sorted(connections) + + +def extract_args(func_node) -> list: + args = [] + for arg in func_node.args.args: + args.append({ + "name": arg.arg, + "type": ast.unparse(arg.annotation) if arg.annotation else None + }) + return args + + +def extract_return_type(func_node) -> str | None: + if func_node.returns: + return ast.unparse(func_node.returns) + return None diff --git a/services/core/apps/parser/migrations/__init__.py b/services/core/apps/parser/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/parser/tasks.py b/services/core/apps/parser/tasks.py new file mode 100644 index 0000000..ef42f94 --- /dev/null +++ b/services/core/apps/parser/tasks.py @@ -0,0 +1,115 @@ +import base64 +import io +import logging +import os +import zipfile + +from celery import shared_task + +from apps.common.config import INTERNAL_API_KEY +from apps.parser.validators import should_exclude +from apps.projects.models import Project + +logger = logging.getLogger(__name__) + +PARSER_URL = os.getenv("PARSER_URL", "http://fastapi-parser:8002") +AI_URL = os.getenv("AI_URL", "http://fastapi-ai:8003") + + +def _call_fastapi(method: str, url: str, **kwargs): + import requests + headers = kwargs.pop("headers", {}) + headers["X-Internal-Api-Key"] = INTERNAL_API_KEY + resp = requests.request(method, url, headers=headers, timeout=300, **kwargs) + resp.raise_for_status() + return resp.json() + + +@shared_task(bind=True, max_retries=3, default_retry_delay=30) +def parse_folder_task(self, project_id, py_files, zip_base64=None, user_description=None, custom_info=None): + project = None + try: + project = Project.objects.get(id=project_id) + project.status = Project.Status.PROCESSING + project.save() + + if not zip_base64: + project.status = Project.Status.FAILED + project.error_message = "No ZIP data provided" + project.save() + return {"error": "No ZIP data provided"} + + zip_data = base64.b64decode(zip_base64) + zf = zipfile.ZipFile(io.BytesIO(zip_data)) + + parsed_count = 0 + for file_path in py_files: + if should_exclude(file_path): + continue + try: + content = zf.read(file_path).decode("utf-8", errors="ignore") + files = {"file": (file_path.split("/")[-1], content.encode("utf-8"), "text/x-python")} + data = {"project_id": str(project_id), "name": project.name, "file_path": file_path} + _call_fastapi("POST", f"{PARSER_URL}/api/parser/file/", files=files, data=data) + parsed_count += 1 + except Exception as e: + logger.warning(f"Error sending {file_path} to parser: {e}") + + ai_resp = _call_fastapi("POST", f"{AI_URL}/api/ai/generate/", json={"project_id": str(project_id)}) + + project.refresh_from_db() + + if ai_resp.get("status") == "failed": + project.status = Project.Status.FAILED + if not project.error_message: + project.error_message = ai_resp.get("error_message", "AI generation failed") + else: + project.status = Project.Status.DONE + project.save() + + return {"project_id": str(project.id), "files_parsed": parsed_count} + + except Exception as e: + logger.error(f"parse_folder_task error: {e}") + if project: + project.status = Project.Status.FAILED + project.error_message = str(e) + project.save() + return {"error": str(e)} + + +@shared_task(bind=True, max_retries=3, default_retry_delay=30) +def parse_and_generate_docs_task(self, project_id, source_code, file_name, file_size): + project = None + try: + project = Project.objects.get(id=project_id) + project.status = Project.Status.PROCESSING + project.save() + + files = {"file": (file_name, source_code.encode("utf-8"), "text/x-python")} + data = {"project_id": str(project_id), "name": project.name} + _call_fastapi("POST", f"{PARSER_URL}/api/parser/file/", files=files, data=data) + + ai_resp = _call_fastapi("POST", f"{AI_URL}/api/ai/generate/", json={"project_id": str(project_id)}) + + project.refresh_from_db() + + if ai_resp.get("status") == "failed": + project.status = Project.Status.FAILED + if not project.error_message: + project.error_message = ai_resp.get("error_message", "AI generation failed") + else: + project.status = Project.Status.DONE + project.save() + + return {"project_id": str(project.id)} + + except Exception as e: + if "rate_limit" in str(e).lower(): + raise self.retry(exc=e, countdown=60) + logger.error(f"parse_and_generate_docs_task error: {e}") + if project: + project.status = Project.Status.FAILED + project.error_message = str(e) + project.save() + return {"error": str(e)} diff --git a/services/core/apps/parser/tests/__init__.py b/services/core/apps/parser/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/parser/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/parser/tests/test_ast_parser.py b/services/core/apps/parser/tests/test_ast_parser.py new file mode 100644 index 0000000..60b1416 --- /dev/null +++ b/services/core/apps/parser/tests/test_ast_parser.py @@ -0,0 +1,100 @@ +from apps.parser.ast_parser import parse_python_file + + +class TestParsePythonFile: + def test_empty_file(self): + result = parse_python_file('') + assert result['error'] is False + assert result['imports'] == [] + assert result['functions'] == [] + assert result['classes'] == [] + assert result['ordered_items'] == [] + + def test_syntax_error(self): + result = parse_python_file('def foo(:') + assert result['error'] is True + assert 'SyntaxError' in result['error_message'] + + def test_simple_function(self): + code = ''' +def greet(name): + """Say hello.""" + return f"Hello {name}" +''' + result = parse_python_file(code) + assert result['error'] is False + assert len(result['functions']) == 1 + assert result['functions'][0]['name'] == 'greet' + assert result['functions'][0]['docstring'] == 'Say hello.' + assert result['functions'][0]['args'][0]['name'] == 'name' + assert result['functions'][0]['returns'] is None + + def test_class_with_methods(self): + code = ''' +class Calculator: + """A simple calculator.""" + + def add(self, a, b): + return a + b + + def subtract(self, a, b): + return a - b +''' + result = parse_python_file(code) + assert result['error'] is False + assert len(result['classes']) == 1 + cls = result['classes'][0] + assert cls['name'] == 'Calculator' + assert cls['docstring'] == 'A simple calculator.' + assert len(cls['methods']) == 2 + assert cls['methods'][0]['name'] == 'add' + assert cls['methods'][1]['name'] == 'subtract' + + def test_imports(self): + code = ''' +import os +import sys +from datetime import datetime, timedelta +''' + result = parse_python_file(code) + assert len(result['imports']) == 3 + + def test_module_docstring(self): + code = '''"""This module does things.""" +def foo(): + pass +''' + result = parse_python_file(code) + assert result['module_docstring'] == 'This module does things.' + + def test_async_function(self): + code = ''' +async def fetch_data(url): + """Fetch data from URL.""" + return await request(url) +''' + result = parse_python_file(code) + assert len(result['functions']) == 1 + assert result['functions'][0]['is_async'] is True + + def test_connections(self): + code = ''' +def helper(): + pass + +def caller(): + return helper() +''' + result = parse_python_file(code) + assert 'helper' in result['functions'][1]['connections'] + + def test_decorators(self): + code = ''' +@staticmethod +@log +def method(): + pass +''' + result = parse_python_file(code) + assert len(result['functions'][0]['decorators']) == 2 + assert 'staticmethod' in result['functions'][0]['decorators'][0] diff --git a/services/core/apps/parser/tests/test_tasks.py b/services/core/apps/parser/tests/test_tasks.py new file mode 100644 index 0000000..596b657 --- /dev/null +++ b/services/core/apps/parser/tests/test_tasks.py @@ -0,0 +1,20 @@ +import pytest + + +pytestmark = pytest.mark.django_db + + +class TestParseFolderTask: + def test_missing_zip(self, project): + from apps.parser.tasks import parse_folder_task + result = parse_folder_task(project.id, ['main.py'], zip_base64=None) + assert result['error'] == 'No ZIP data provided' + project.refresh_from_db() + assert project.status == 'failed' + + +class TestParseAndGenerateDocsTask: + def test_project_not_found(self): + from apps.parser.tasks import parse_and_generate_docs_task + result = parse_and_generate_docs_task(999, 'print("x")', 'test.py', 10) + assert 'error' in result diff --git a/services/core/apps/parser/tests/test_validators.py b/services/core/apps/parser/tests/test_validators.py new file mode 100644 index 0000000..5b73cb2 --- /dev/null +++ b/services/core/apps/parser/tests/test_validators.py @@ -0,0 +1,55 @@ +from apps.parser.validators import should_exclude, validate_python_code + + +class TestValidatePythonCode: + def test_valid_code(self): + is_valid, error = validate_python_code('print("hello")') + assert is_valid is True + assert error is None + + def test_empty_code(self): + is_valid, error = validate_python_code('') + assert is_valid is False + assert 'empty' in error + + def test_whitespace_only(self): + is_valid, error = validate_python_code(' ') + assert is_valid is False + + def test_syntax_error(self): + is_valid, error = validate_python_code('def foo(:') + assert is_valid is False + assert 'SyntaxError' in error + + +class TestShouldExclude: + def test_normal_file(self): + assert should_exclude('myproject/utils/helpers.py') is False + assert should_exclude('src/main.py') is False + + def test_venv(self): + assert should_exclude('venv/lib/site.py') is True + + def test_node_modules(self): + assert should_exclude('project/node_modules/react/index.js') is True + + def test_pycache(self): + assert should_exclude('project/__pycache__/main.cpython-310.pyc') is True + + def test_git(self): + assert should_exclude('.git/config') is True + assert should_exclude('.github/workflows/build.yml') is True + + def test_egg_info(self): + assert should_exclude('src/foo.egg-info/PKG-INFO') is True + + def test_build_dirs(self): + assert should_exclude('build/output.o') is True + assert should_exclude('dist/bundle.js') is True + + def test_migrations(self): + assert should_exclude('app/migrations/0001_initial.py') is True + + def test_mixed_path(self): + assert should_exclude('project/sub/venv/lib/file.py') is True + assert should_exclude('project/sub/not-venv/lib/file.py') is False diff --git a/services/core/apps/parser/tests/test_views.py b/services/core/apps/parser/tests/test_views.py new file mode 100644 index 0000000..d83574f --- /dev/null +++ b/services/core/apps/parser/tests/test_views.py @@ -0,0 +1,113 @@ +import io +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestAnalyseSingleFileView: + def test_unauthenticated(self, api_client): + url = reverse('analyse_file') + response = api_client.post(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_no_file(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('analyse_file') + response = api_client.post(url, {'name': 'Test'}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_non_py_file(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('analyse_file') + f = io.BytesIO(b'print("hello")') + f.name = 'test.js' + response = api_client.post(url, {'file': f, 'name': 'Test'}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_valid_file(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('apps.parser.views.file.parse_and_generate_docs_task.delay'): + url = reverse('analyse_file') + f = io.BytesIO(b'print("hello")') + f.name = 'test.py' + response = api_client.post(url, {'file': f, 'name': 'Test'}) + assert response.status_code == status.HTTP_202_ACCEPTED + assert 'project_id' in response.data + + def test_non_utf8(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('analyse_file') + f = io.BytesIO(b'\xff\xfe\x00\x01') + f.name = 'test.py' + response = api_client.post(url, {'file': f, 'name': 'Test'}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestAnalyseFolderView: + def test_unauthenticated(self, api_client): + url = reverse('analyse_folder') + response = api_client.post(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_no_zip(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('analyse_folder') + response = api_client.post(url, {'name': 'Test', 'custom_info': '{}'}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_no_custom_info(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('analyse_folder') + response = api_client.post(url, {'name': 'Test'}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_valid_zip(self, api_client, user): + import zipfile + from django.core.files.uploadedfile import SimpleUploadedFile + api_client.force_authenticate(user=user) + with patch('apps.parser.views.folder.parse_folder_task.delay'): + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr('main.py', 'print("hello")') + buf.seek(0) + uploaded = SimpleUploadedFile('test.zip', buf.read(), content_type='application/zip') + url = reverse('analyse_folder') + response = api_client.post(url, { + 'folder': uploaded, + 'name': 'Test', + 'custom_info': '{"details": "test"}', + }) + assert response.status_code == status.HTTP_202_ACCEPTED + assert 'project_id' in response.data + + def test_invalid_zip(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('analyse_folder') + f = io.BytesIO(b'not a zip file') + f.name = 'test.zip' + response = api_client.post(url, { + 'folder': f, + 'name': 'Test', + 'custom_info': '{"details": "test"}', + }) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_no_py_files_in_zip(self, api_client, user): + import zipfile + api_client.force_authenticate(user=user) + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w') as zf: + zf.writestr('readme.txt', 'Hello') + buf.seek(0) + url = reverse('analyse_folder') + response = api_client.post(url, { + 'folder': buf, + 'name': 'Test', + 'custom_info': '{"details": "test"}', + }) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/services/core/apps/parser/urls.py b/services/core/apps/parser/urls.py new file mode 100644 index 0000000..0c9f5c8 --- /dev/null +++ b/services/core/apps/parser/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import AnalyseFolderView, AnalyseSingleFileView + +urlpatterns = [ + path('file/', AnalyseSingleFileView.as_view(), name='analyse_file'), + path('folder/', AnalyseFolderView.as_view(), name='analyse_folder'), +] diff --git a/services/core/apps/parser/validators.py b/services/core/apps/parser/validators.py new file mode 100644 index 0000000..59aa8fe --- /dev/null +++ b/services/core/apps/parser/validators.py @@ -0,0 +1,45 @@ +import ast + + +def validate_python_code(source_code: str) -> tuple: + """ + Returns (is_valid, error_message) + Call this before parse_python_file() + """ + if not source_code or not source_code.strip(): + return False, "Code cannot be empty" + + try: + ast.parse(source_code) + return True, None + except SyntaxError as e: + return False, f"SyntaxError on line {e.lineno}: {e.msg}" + + +EXCLUDED_DIRS = { + 'venv', '.venv', 'env', '.env', + 'virtualenv', '.virtualenv', + 'node_modules', 'site-packages', + '__pycache__', '.pytest_cache', + 'build', 'dist', '*.egg-info', + '.git', '.github', '.gitignore', + '.vscode', '.idea', + 'migrations', + 'media', + 'staticfiles', +} + +def should_exclude(file_path: str) -> bool: + """ + Returns True if the file should be skipped. + Checks every part of the path against EXCLUDED_DIRS. + e.g. 'myproject/venv/lib/site.py' → True + 'myproject/utils/helpers.py' → False + """ + parts = file_path.replace('\\', '/').split('/') + for part in parts: + if part in EXCLUDED_DIRS: + return True + if part.endswith('.egg-info') or part.endswith('.dist-info'): + return True + return False diff --git a/services/core/apps/parser/views/__init__.py b/services/core/apps/parser/views/__init__.py new file mode 100644 index 0000000..21e915c --- /dev/null +++ b/services/core/apps/parser/views/__init__.py @@ -0,0 +1,7 @@ +from .folder import AnalyseFolderView +from .file import AnalyseSingleFileView + +__all__ = [ + 'AnalyseFolderView', + 'AnalyseSingleFileView', +] diff --git a/services/core/apps/parser/views/file.py b/services/core/apps/parser/views/file.py new file mode 100644 index 0000000..6c00f2b --- /dev/null +++ b/services/core/apps/parser/views/file.py @@ -0,0 +1,46 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.parser.tasks import parse_and_generate_docs_task +from apps.projects.models import Project + + +class AnalyseSingleFileView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + uploaded_file = request.FILES.get('file') + name = request.data.get('name', 'Untitled Project') + description = request.data.get('description', '') + + if not uploaded_file: + return Response({'error': 'No file uploaded'}, status=400) + + if not uploaded_file.name.endswith('.py'): + return Response({'error': 'Only .py files are allowed'}, status=400) + + try: + source_code = uploaded_file.read().decode('utf-8') + fname = uploaded_file.name + fsize = uploaded_file.size + except UnicodeDecodeError: + return Response({'error': 'File must be UTF-8 encoded'}, status=400) + + project = Project.objects.create( + user=request.user, + name=name, + description=description, + source_type=Project.SourceType.FILE, + file_name=fname, + file_size=fsize, + status=Project.Status.PENDING, + ) + + parse_and_generate_docs_task.delay(project.id, source_code, fname, fsize) + + return Response( + {'project_id': str(project.id)}, + status=status.HTTP_202_ACCEPTED + ) diff --git a/services/core/apps/parser/views/folder.py b/services/core/apps/parser/views/folder.py new file mode 100644 index 0000000..b4d35cc --- /dev/null +++ b/services/core/apps/parser/views/folder.py @@ -0,0 +1,78 @@ +import base64 +import io +import json +import zipfile + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.parser.tasks import parse_folder_task +from apps.parser.validators import should_exclude +from apps.projects.models import Project + + +class AnalyseFolderView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + zip_file = request.FILES.get('folder') + name = request.data.get('name', 'Untitled Project') + description = request.data.get('description', '') + + user_description = request.data.get('user_description', None) + custom_info = request.data.get('custom_info', None) + if not custom_info: + return Response({'error': 'additional project details (custom_info) are mandatory for folder uploads'}, status=400) + + if isinstance(custom_info, str): + try: + custom_info = json.loads(custom_info) + except json.JSONDecodeError: + custom_info = {"details": custom_info} + + if not zip_file: + return Response({'error': 'No zip file provided'}, status=400) + + if not zip_file.name.endswith('.zip'): + return Response({'error': 'File must be a .zip'}, status=400) + + try: + zip_content = zip_file.read() + zf = zipfile.ZipFile(io.BytesIO(zip_content)) + + py_files = [ + f for f in zf.namelist() + if f.endswith('.py') and not should_exclude(f) + ] + + if not py_files: + return Response({'error': 'No Python files found after filtering'}, status=400) + + project = Project.objects.create( + user=request.user, + name=name, + description=description, + source_type=Project.SourceType.FOLDER, + status=Project.Status.PENDING, + custom_details=custom_info + ) + + zip_base64 = base64.b64encode(zip_content).decode('utf-8') + + parse_folder_task.delay( + project.id, + py_files, + zip_base64, + user_description=user_description, + custom_info=custom_info + ) + + return Response( + {'project_id': str(project.id)}, + status=status.HTTP_202_ACCEPTED + ) + + except zipfile.BadZipFile: + return Response({'error': 'Invalid zip file'}, status=400) diff --git a/services/core/apps/projects/__init__.py b/services/core/apps/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/projects/admin.py b/services/core/apps/projects/admin.py new file mode 100644 index 0000000..b97a94f --- /dev/null +++ b/services/core/apps/projects/admin.py @@ -0,0 +1,2 @@ + +# Register your models here. diff --git a/services/core/apps/projects/apps.py b/services/core/apps/projects/apps.py new file mode 100644 index 0000000..46a1b48 --- /dev/null +++ b/services/core/apps/projects/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.projects' diff --git a/services/core/apps/projects/migrations/0001_initial.py b/services/core/apps/projects/migrations/0001_initial.py new file mode 100644 index 0000000..b587d65 --- /dev/null +++ b/services/core/apps/projects/migrations/0001_initial.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.13 on 2026-05-02 10:44 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=20)), + ('source_type', models.CharField(choices=[('file', 'Single File'), ('folder', 'Folder / ZIP'), ('github', 'GitHub Link')], default='file', max_length=20)), + ('file', models.FileField(blank=True, null=True, upload_to='uploads/%Y/%m/%d/')), + ('file_name', models.CharField(blank=True, max_length=255)), + ('file_size', models.PositiveIntegerField(blank=True, null=True)), + ('zip_file', models.FileField(blank=True, null=True, upload_to='zips/%Y/%m/%d/')), + ('github_url', models.URLField(blank=True, null=True)), + ('github_branch', models.CharField(blank=True, default='main', max_length=100)), + ('parsed_data', models.JSONField(blank=True, null=True)), + ('generated_docs', models.TextField(blank=True, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'projects', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ProjectFile', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file_path', models.CharField(max_length=500)), + ('file_name', models.CharField(max_length=255)), + ('file_size', models.PositiveIntegerField(blank=True, null=True)), + ('content', models.TextField(blank=True)), + ('parsed_data', models.JSONField(blank=True, null=True)), + ('generated_docs', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='projects.project')), + ], + options={ + 'db_table': 'project_files', + 'ordering': ['file_path'], + }, + ), + ] diff --git a/services/core/apps/projects/migrations/0002_project_project_info_project_readme_docs.py b/services/core/apps/projects/migrations/0002_project_project_info_project_readme_docs.py new file mode 100644 index 0000000..d56295c --- /dev/null +++ b/services/core/apps/projects/migrations/0002_project_project_info_project_readme_docs.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.13 on 2026-05-08 12:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='project_info', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='readme_docs', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/services/core/apps/projects/migrations/0003_project_api_docs.py b/services/core/apps/projects/migrations/0003_project_api_docs.py new file mode 100644 index 0000000..6bda2c0 --- /dev/null +++ b/services/core/apps/projects/migrations/0003_project_api_docs.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-05-08 13:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0002_project_project_info_project_readme_docs'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='api_docs', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/services/core/apps/projects/migrations/0004_project_custom_details.py b/services/core/apps/projects/migrations/0004_project_custom_details.py new file mode 100644 index 0000000..ea1ae3f --- /dev/null +++ b/services/core/apps/projects/migrations/0004_project_custom_details.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-05-09 07:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0003_project_api_docs'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='custom_details', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/services/core/apps/projects/migrations/0005_remove_file_fields.py b/services/core/apps/projects/migrations/0005_remove_file_fields.py new file mode 100644 index 0000000..df61c1c --- /dev/null +++ b/services/core/apps/projects/migrations/0005_remove_file_fields.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.13 on 2026-05-21 04:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0004_project_custom_details'), + ] + + operations = [ + migrations.RemoveField( + model_name='project', + name='file', + ), + migrations.RemoveField( + model_name='project', + name='zip_file', + ), + ] diff --git a/services/core/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py b/services/core/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py new file mode 100644 index 0000000..5c59f5c --- /dev/null +++ b/services/core/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.13 on 2026-06-16 13:40 + +import uuid + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0005_remove_file_fields'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='framework_info', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='project', + name='is_published', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='project', + name='public_slug', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AddField( + model_name='project', + name='published_description', + field=models.TextField(blank=True), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['status'], name='projects_status_6303d7_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['source_type'], name='projects_source__8aaacb_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['created_at'], name='projects_created_40bcd1_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['user', 'status'], name='projects_user_id_05c680_idx'), + ), + migrations.AddIndex( + model_name='project', + index=models.Index(fields=['is_published', 'created_at'], name='projects_is_publ_4fac0d_idx'), + ), + ] diff --git a/services/core/apps/projects/migrations/__init__.py b/services/core/apps/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/projects/models/__init__.py b/services/core/apps/projects/models/__init__.py new file mode 100644 index 0000000..c7173e9 --- /dev/null +++ b/services/core/apps/projects/models/__init__.py @@ -0,0 +1,4 @@ +from .project import Project +from .file import ProjectFile + +__all__ = ['Project', 'ProjectFile'] diff --git a/services/core/apps/projects/models/file.py b/services/core/apps/projects/models/file.py new file mode 100644 index 0000000..1bb0041 --- /dev/null +++ b/services/core/apps/projects/models/file.py @@ -0,0 +1,26 @@ +import uuid + +from django.db import models + + +class ProjectFile(models.Model): + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + project = models.ForeignKey('projects.Project', on_delete=models.CASCADE, related_name='files') + + file_path = models.CharField(max_length=500) + file_name = models.CharField(max_length=255) + file_size = models.PositiveIntegerField(blank=True, null=True) + content = models.TextField(blank=True) + + parsed_data = models.JSONField(blank=True, null=True) + generated_docs = models.TextField(blank=True, null=True) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'project_files' + ordering = ['file_path'] + + def __str__(self): + return f'{self.project.name} / {self.file_path}' diff --git a/services/core/apps/projects/models/project.py b/services/core/apps/projects/models/project.py new file mode 100644 index 0000000..4a13c9c --- /dev/null +++ b/services/core/apps/projects/models/project.py @@ -0,0 +1,68 @@ +import uuid + +from django.db import models + + +class Project(models.Model): + + class Status(models.TextChoices): + PENDING = 'pending', 'Pending' + PROCESSING = 'processing', 'Processing' + DONE = 'done', 'Done' + FAILED = 'failed', 'Failed' + + class SourceType(models.TextChoices): + FILE = 'file', 'Single File' + FOLDER = 'folder', 'Folder / ZIP' + GITHUB = 'github', 'GitHub Link' + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='projects') + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + source_type = models.CharField(max_length=20, choices=SourceType.choices, default=SourceType.FILE) + + file_name = models.CharField(max_length=255, blank=True) + file_size = models.PositiveIntegerField(blank=True, null=True) + + github_url = models.URLField(blank=True, null=True) + github_branch = models.CharField(max_length=100, blank=True, default='main') + + parsed_data = models.JSONField(blank=True, null=True) + generated_docs = models.TextField(blank=True, null=True) + readme_docs = models.TextField(blank=True, null=True) + api_docs = models.TextField(blank=True, null=True) + project_info = models.JSONField(blank=True, null=True) + custom_details = models.JSONField(blank=True, null=True) + framework_info = models.JSONField(blank=True, null=True) + error_message = models.TextField(blank=True, null=True) + + is_published = models.BooleanField(default=False) + public_slug = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + published_description = models.TextField(blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'projects' + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['status']), + models.Index(fields=['source_type']), + models.Index(fields=['created_at']), + models.Index(fields=['user', 'status']), + models.Index(fields=['is_published', 'created_at']), + ] + + def __str__(self): + return f'{self.name} ({self.source_type}) \u2014 {self.user.email}' + + @property + def is_done(self): + return self.status == self.Status.DONE + + @property + def is_failed(self): + return self.status == self.Status.FAILED diff --git a/services/core/apps/projects/serializers/__init__.py b/services/core/apps/projects/serializers/__init__.py new file mode 100644 index 0000000..c23b2e0 --- /dev/null +++ b/services/core/apps/projects/serializers/__init__.py @@ -0,0 +1,10 @@ +from .project import ProjectFileSerializer, ProjectSerializer, ProjectListSerializer +from .public import PublicProjectSerializer, PublicProjectListSerializer + +__all__ = [ + 'ProjectFileSerializer', + 'ProjectSerializer', + 'ProjectListSerializer', + 'PublicProjectSerializer', + 'PublicProjectListSerializer', +] diff --git a/services/core/apps/projects/serializers/project.py b/services/core/apps/projects/serializers/project.py new file mode 100644 index 0000000..b423d32 --- /dev/null +++ b/services/core/apps/projects/serializers/project.py @@ -0,0 +1,46 @@ +from rest_framework import serializers + +from ..models import Project, ProjectFile + + +class ProjectFileSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectFile + fields = '__all__' + + +class ProjectSerializer(serializers.ModelSerializer): + files = ProjectFileSerializer(many=True, read_only=True) + + class Meta: + model = Project + fields = [ + 'id', 'user', 'name', 'description', 'status', 'source_type', + 'file_name', 'file_size', 'github_url', + 'github_branch', 'parsed_data', 'generated_docs', 'readme_docs', + 'api_docs', 'project_info', 'custom_details', 'framework_info', 'error_message', + 'is_published', 'public_slug', 'published_description', + 'created_at', 'updated_at', 'files', + ] + read_only_fields = [ + 'user', 'status', 'parsed_data', 'generated_docs', + 'readme_docs', 'api_docs', 'project_info', 'error_message', + 'public_slug', + ] + + +class ProjectListSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + user_email = serializers.EmailField(source='user.email', read_only=True) + file_count = serializers.SerializerMethodField() + + def get_file_count(self, obj): + return getattr(obj, 'file_count', obj.files.count()) + + class Meta: + model = Project + fields = [ + 'id', 'name', 'description', 'status', 'source_type', + 'is_published', 'public_slug', 'published_description', + 'created_at', 'updated_at', 'user_name', 'user_email', 'file_count', 'framework_info', + ] diff --git a/services/core/apps/projects/serializers/public.py b/services/core/apps/projects/serializers/public.py new file mode 100644 index 0000000..044caf7 --- /dev/null +++ b/services/core/apps/projects/serializers/public.py @@ -0,0 +1,37 @@ +from rest_framework import serializers + +from ..models import Project + + +class PublicProjectSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + file_count = serializers.SerializerMethodField() + + def get_file_count(self, obj): + return getattr(obj, 'file_count', obj.files.count()) + + class Meta: + model = Project + fields = [ + 'id', 'name', 'description', 'published_description', + 'generated_docs', 'readme_docs', 'api_docs', + 'user_name', 'file_count', 'is_published', 'public_slug', 'source_type', + 'github_url', 'github_branch', + 'created_at', 'updated_at', + ] + + +class PublicProjectListSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + file_count = serializers.SerializerMethodField() + + def get_file_count(self, obj): + return getattr(obj, 'file_count', obj.files.count()) + + class Meta: + model = Project + fields = [ + 'id', 'name', 'description', 'published_description', + 'user_name', 'file_count', 'public_slug', 'source_type', + 'created_at', 'updated_at', + ] diff --git a/services/core/apps/projects/tests/__init__.py b/services/core/apps/projects/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/projects/tests/test_models.py b/services/core/apps/projects/tests/test_models.py new file mode 100644 index 0000000..f493c57 --- /dev/null +++ b/services/core/apps/projects/tests/test_models.py @@ -0,0 +1,58 @@ +import pytest + +from apps.projects.models import Project, ProjectFile + + +class TestProjectModel: + def test_create(self, user): + project = Project.objects.create( + user=user, + name='Test Project', + source_type=Project.SourceType.FILE, + ) + assert project.user == user + assert project.name == 'Test Project' + assert project.status == 'pending' + assert project.is_published is False + assert project.public_slug is not None + + def test_status_choices(self): + assert Project.Status.PENDING == 'pending' + assert Project.Status.PROCESSING == 'processing' + assert Project.Status.DONE == 'done' + assert Project.Status.FAILED == 'failed' + + def test_source_type_choices(self): + assert Project.SourceType.FILE == 'file' + assert Project.SourceType.FOLDER == 'folder' + assert Project.SourceType.GITHUB == 'github' + + def test_str(self, project): + expected = f'{project.name} ({project.source_type}) — {project.user.email}' + assert str(project) == expected + + def test_ordering(self): + assert Project._meta.ordering == ['-created_at'] + + def test_db_table(self): + assert Project._meta.db_table == 'projects' + + +class TestProjectFileModel: + def test_create(self, project): + pf = ProjectFile.objects.create( + project=project, + file_name='main.py', + file_path='src/main.py', + content='print("hello")', + ) + assert pf.project == project + assert pf.file_name == 'main.py' + assert pf.file_path == 'src/main.py' + assert pf.content == 'print("hello")' + + def test_str(self, project_file): + assert project_file.file_name in str(project_file) + + def test_db_table(self): + assert ProjectFile._meta.db_table == 'project_files' diff --git a/services/core/apps/projects/tests/test_serializers.py b/services/core/apps/projects/tests/test_serializers.py new file mode 100644 index 0000000..5f52692 --- /dev/null +++ b/services/core/apps/projects/tests/test_serializers.py @@ -0,0 +1,62 @@ +import pytest + +from apps.projects.serializers import ( + ProjectFileSerializer, + ProjectListSerializer, + ProjectSerializer, + PublicProjectListSerializer, + PublicProjectSerializer, +) + + +class TestProjectSerializer: + def test_serialize(self, project): + serializer = ProjectSerializer(project) + assert serializer.data['id'] == str(project.id) + assert serializer.data['name'] == project.name + assert serializer.data['status'] == project.status + + def test_includes_files(self, project, project_file): + serializer = ProjectSerializer(project) + assert 'files' in serializer.data + assert len(serializer.data['files']) >= 1 + + def test_read_only_fields(self, project): + serializer = ProjectSerializer(project) + for field in ['id', 'user', 'status', 'created_at', 'updated_at', 'public_slug']: + assert field in serializer.data + + +class TestProjectListSerializer: + def test_serialize(self, project): + serializer = ProjectListSerializer(project) + assert serializer.data['id'] == str(project.id) + assert serializer.data['name'] == project.name + + def test_file_count(self, project, project_file): + serializer = ProjectListSerializer(project) + assert serializer.data['file_count'] >= 1 + + +class TestProjectFileSerializer: + def test_serialize(self, project_file): + serializer = ProjectFileSerializer(project_file) + assert serializer.data['file_name'] == project_file.file_name + assert serializer.data['file_path'] == project_file.file_path + + def test_content_excluded_in_list(self, project_file): + serializer = ProjectFileSerializer(project_file) + assert 'content' in serializer.data + + +class TestPublicProjectSerializer: + def test_serialize(self, published_project): + serializer = PublicProjectSerializer(published_project) + assert serializer.data['name'] == published_project.name + assert serializer.data['is_published'] is True + + +class TestPublicProjectListSerializer: + def test_serialize(self, published_project): + serializer = PublicProjectListSerializer(published_project) + assert serializer.data['name'] == published_project.name diff --git a/services/core/apps/projects/tests/test_views.py b/services/core/apps/projects/tests/test_views.py new file mode 100644 index 0000000..6ea7e70 --- /dev/null +++ b/services/core/apps/projects/tests/test_views.py @@ -0,0 +1,125 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestProjectListView: + def test_unauthenticated(self, api_client): + url = reverse('project_list') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_own_projects(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('project_list') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['count'] >= 1 + assert 'stats' in response.data + assert 'results' in response.data + + def test_does_not_show_others_projects(self, api_client, other_user, project): + api_client.force_authenticate(user=other_user) + url = reverse('project_list') + response = api_client.get(url) + assert response.data['count'] == 0 + + def test_filter_by_status(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('project_list') + '?status=done' + response = api_client.get(url) + assert response.data['count'] >= 1 + + +class TestProjectDetailView: + def test_get_own_project(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('project_detail', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == str(project.id) + + def test_cannot_get_others_project(self, api_client, other_user, project): + api_client.force_authenticate(user=other_user) + url = reverse('project_detail', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_not_found(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('project_detail', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_project(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('project_detail', args=[project.id]) + response = api_client.delete(url) + assert response.status_code == status.HTTP_200_OK + + +class TestPublishProjectView: + def test_publish(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('project_publish', args=[project.id]) + response = api_client.patch(url, {'is_published': True, 'published_description': 'My docs'}, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['is_published'] is True + + def test_unpublish(self, api_client, user, project): + project.is_published = True + project.save() + api_client.force_authenticate(user=user) + url = reverse('project_publish', args=[project.id]) + response = api_client.patch(url, {'is_published': False}, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['is_published'] is False + + def test_missing_field(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('project_publish', args=[project.id]) + response = api_client.patch(url, {}, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_not_owner(self, api_client, other_user, project): + api_client.force_authenticate(user=other_user) + url = reverse('project_publish', args=[project.id]) + response = api_client.patch(url, {'is_published': True}, format='json') + assert response.status_code == status.HTTP_404_NOT_FOUND + + +class TestPublicProjectListView: + def test_list_published(self, api_client, published_project): + url = reverse('public_project_list') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert len(response.data['results']) >= 1 + + def test_unpublished_not_listed(self, api_client, project): + url = reverse('public_project_list') + response = api_client.get(url) + for p in response.data['results']: + assert p['is_published'] is True + + def test_search(self, api_client, published_project): + url = reverse('public_project_list') + '?search=Test' + response = api_client.get(url) + assert len(response.data['results']) >= 1 + + +class TestPublicProjectDetailView: + def test_get_published(self, api_client, published_project): + url = reverse('public_project_detail', args=[published_project.public_slug]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['name'] == published_project.name + + def test_not_found(self, api_client): + url = reverse('public_project_detail', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/services/core/apps/projects/throttles.py b/services/core/apps/projects/throttles.py new file mode 100644 index 0000000..c20a3f3 --- /dev/null +++ b/services/core/apps/projects/throttles.py @@ -0,0 +1,17 @@ +from rest_framework.throttling import SimpleRateThrottle + + +class PublishRateThrottle(SimpleRateThrottle): + scope = 'publish' + + def get_cache_key(self, request, view): + if request.user.is_authenticated: + return f'publish:{request.user.pk}' + return None + + +class PublicRateThrottle(SimpleRateThrottle): + scope = 'public' + + def get_cache_key(self, request, view): + return self.get_ident(request) diff --git a/services/core/apps/projects/urls.py b/services/core/apps/projects/urls.py new file mode 100644 index 0000000..001ca64 --- /dev/null +++ b/services/core/apps/projects/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from .views import ( + ProjectDetailView, + ProjectListView, + PublicProjectDetailView, + PublicProjectListView, + PublishProjectView, +) + +urlpatterns = [ + path('', ProjectListView.as_view(), name='project_list'), + path('/', ProjectDetailView.as_view(), name='project_detail'), + path('/publish/', PublishProjectView.as_view(), name='project_publish'), +] + +public_urlpatterns = [ + path('', PublicProjectListView.as_view(), name='public_project_list'), + path('/', PublicProjectDetailView.as_view(), name='public_project_detail'), +] diff --git a/services/core/apps/projects/views/__init__.py b/services/core/apps/projects/views/__init__.py new file mode 100644 index 0000000..53148af --- /dev/null +++ b/services/core/apps/projects/views/__init__.py @@ -0,0 +1,10 @@ +from .project import ProjectListView, ProjectDetailView, PublishProjectView +from .public import PublicProjectListView, PublicProjectDetailView + +__all__ = [ + 'ProjectListView', + 'ProjectDetailView', + 'PublishProjectView', + 'PublicProjectListView', + 'PublicProjectDetailView', +] diff --git a/services/core/apps/projects/views/project.py b/services/core/apps/projects/views/project.py new file mode 100644 index 0000000..b1e6558 --- /dev/null +++ b/services/core/apps/projects/views/project.py @@ -0,0 +1,137 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.db import connection +from django.db.models import Count, Q +from rest_framework import permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.pagination import NoPagination +from ..models import Project +from ..serializers import ProjectListSerializer, ProjectSerializer +from ..throttles import PublishRateThrottle + +User = get_user_model() + + +class ProjectListView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + qs = (Project.objects + .filter(user=request.user) + .select_related('user') + .annotate(file_count=Count('files')) + .order_by('-created_at')) + + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(name__icontains=search) | + Q(description__icontains=search) | + Q(status__icontains=search) | + Q(source_type__icontains=search) + ) + + for field in ['status', 'source_type', 'is_published']: + val = request.query_params.get(field) + if val is not None: + qs = qs.filter(**{field: val}) + + ordering = request.query_params.get('ordering', '-created_at') + allowed = ['created_at', 'name', 'status', 'source_type', + '-created_at', '-name', '-status', '-source_type'] + if ordering not in allowed: + ordering = '-created_at' + qs = qs.order_by(ordering) + + cache_key = f'project_stats_{request.user.id}' + stats = cache.get(cache_key) + if not stats: + base = Project.objects.filter(user=request.user).order_by() + counts = base.values('status').annotate(count=Count('id')) + status_map = {c['status']: c['count'] for c in counts} + stats = { + 'total': sum(status_map.values()), + 'done': status_map.get('done', 0), + 'processing': status_map.get('processing', 0), + 'failed': status_map.get('failed', 0), + 'pending': status_map.get('pending', 0), + 'published': base.filter(is_published=True).count(), + 'total_files': base.aggregate(total=Count('files', distinct=True))['total'] or 0, + 'by_source': list( + base.values('source_type') + .annotate(count=Count('id', distinct=True)) + .order_by('-count') + ), + } + cache.set(cache_key, stats, 60) + + page_size = int(request.query_params.get('page_size', 25)) + page = int(request.query_params.get('page', 1)) + start = (page - 1) * page_size + end = start + page_size + total = qs.count() + page_qs = qs[start:end] + serializer = ProjectListSerializer(page_qs, many=True) + + return Response({ + 'stats': stats, + 'results': serializer.data, + 'count': total, + 'page': page, + 'page_size': page_size, + }) + + +class ProjectDetailView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get_object(self, pk, user): + if user.is_staff or getattr(user, 'is_admin', False): + qs = Project.objects.all() + else: + qs = Project.objects.filter(user=user) + try: + return qs.get(pk=pk) + except Project.DoesNotExist: + return None + + def get(self, request, id): + project = self.get_object(id, request.user) + if not project: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + serializer = ProjectSerializer(project) + return Response(serializer.data) + + def delete(self, request, id): + project = self.get_object(id, request.user) + if not project: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + with connection.cursor() as cursor: + cursor.execute("DELETE FROM feedback WHERE project_id = %s", [str(project.id)]) + project.files.all().delete() + project.delete() + return Response({"detail": "Project has been deleted successfully"}, status=status.HTTP_200_OK) + + +class PublishProjectView(APIView): + permission_classes = [permissions.IsAuthenticated] + throttle_classes = [PublishRateThrottle] + + def patch(self, request, pk): + try: + project = Project.objects.get(pk=pk, user=request.user) + except Project.DoesNotExist: + return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) + + is_published = request.data.get("is_published") + if is_published is None: + return Response({"detail": "is_published is required."}, status=status.HTTP_400_BAD_REQUEST) + + project.is_published = is_published + if is_published and request.data.get("published_description"): + project.published_description = request.data["published_description"] + project.save(update_fields=["is_published", "published_description", "updated_at"]) + cache.delete(f'project_stats_{request.user.id}') + return Response(ProjectSerializer(project).data) diff --git a/services/core/apps/projects/views/public.py b/services/core/apps/projects/views/public.py new file mode 100644 index 0000000..5ce8dcf --- /dev/null +++ b/services/core/apps/projects/views/public.py @@ -0,0 +1,58 @@ +from django.core.cache import cache +from django.db.models import Count, Q +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.pagination import PublicProjectPage +from ..models import Project +from ..serializers import PublicProjectListSerializer, PublicProjectSerializer +from ..throttles import PublicRateThrottle + + +class PublicProjectListView(APIView): + permission_classes = [] + throttle_classes = [PublicRateThrottle] + + def get(self, request): + qs = (Project.objects + .filter(is_published=True, status='done') + .select_related('user') + .annotate(file_count=Count('files')) + .order_by('-updated_at')) + + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(name__icontains=search) | + Q(description__icontains=search) + ) + + paginator = PublicProjectPage() + page = paginator.paginate_queryset(qs, request, view=self) + if page is not None: + serializer = PublicProjectListSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + serializer = PublicProjectListSerializer(qs, many=True) + return Response(serializer.data) + + +class PublicProjectDetailView(APIView): + permission_classes = [] + throttle_classes = [PublicRateThrottle] + + def get(self, request, slug): + cache_key = f'public_project_{slug}' + data = cache.get(cache_key) + if not data: + try: + project = (Project.objects + .filter(public_slug=slug, is_published=True) + .select_related('user') + .prefetch_related('files') + .get()) + except Project.DoesNotExist: + return Response({"detail": "Not found."}, status=404) + data = PublicProjectSerializer(project).data + cache.set(cache_key, data, 300) + return Response(data) diff --git a/services/core/apps/universal/__init__.py b/services/core/apps/universal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/universal/apps.py b/services/core/apps/universal/apps.py new file mode 100644 index 0000000..489b915 --- /dev/null +++ b/services/core/apps/universal/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UniversalConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.universal' diff --git a/services/core/apps/universal/prompts.py b/services/core/apps/universal/prompts.py new file mode 100644 index 0000000..a916d64 --- /dev/null +++ b/services/core/apps/universal/prompts.py @@ -0,0 +1,196 @@ +UNIVERSAL_PROMPT = """You are an expert technical documentation generator. Given the source code and project metadata below, produce COMPREHENSIVE, DETAILED documentation. + +First, analyze the code to determine: +1. All languages and frameworks used +2. Whether this is **frontend-only**, **backend-only**, or **full-stack** (CHECK file names for frontend indicators like .jsx, .tsx, components/, pages/, .vue, .svelte, .css, .scss; check for backend indicators like views.py, controllers, API routes, models) +3. Key architectural patterns and design decisions + +Then generate documentation with the sections below. Be THOROUGH and SPECIFIC — use real code details, not placeholders. + +--- + +## SECTION RULES (read carefully) + +**If the project is FRONTEND-ONLY:** +- Output ONLY these sections: Detected, Project Overview, Project Structure (with mermaid), Components/Modules, Getting Started. +- Skip API/Endpoints, Data/State Management, Configuration, Error Handling & Logging, Deployment entirely — do not even mention them. +- The goal is a clean README-style overview, not exhaustive infra docs. + +**If the project is BACKEND or FULL-STACK:** +- Output ALL sections listed below. +- For the API section, include concrete examples: curl commands, example request bodies (JSON), and example response bodies for EVERY endpoint. +- Be exhaustive — document every route, model, and config detail. + +--- + +## Detected +- Language: [detected language(s)] +- Framework: [detected framework(s)] +- Type: Frontend-only / Backend-only / Full-stack +- File count: [approximate number of meaningful source files] + +## Project Overview +- Language, framework, purpose (3-4 paragraphs) +- Architecture summary: monolith, microservices, serverless, etc. +- Key design patterns observed (MVC, REST, event-driven, etc.) +- Major dependencies and what each is used for + +## Project Structure +Here is the actual file tree of the project: +``` +{file_tree} +``` +For each key directory, explain its purpose. If the tree is very large, group related directories and summarize. + +Include a mermaid flowchart diagram showing the project architecture and workflow: +```mermaid +flowchart TD + [Create a detailed diagram showing how components interact, data flow, request lifecycle, etc.] +``` + +## Components / Modules +For each major component/module/app found in the code: +- **Purpose**: What it does +- **Key files**: Important files in this component +- **Inputs/Outputs**: What data it receives and produces +- **Dependencies**: What other components it relies on +- **Error handling**: How errors are handled in this component +- **Testing**: Testing strategy if tests are found + +## API / Endpoints (backend or full-stack only) +For EVERY endpoint found, provide a detailed table: + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | /api/example/ | Token | Lists resources | + +Then for each endpoint, document: +- **Request Headers**: What headers are required +- **Path Parameters**: Variables in the URL path +- **Query Parameters**: Filtering, pagination, sorting +- **Request Body**: JSON schema of the request payload +- **Response**: JSON schema of the response +- **Status Codes**: What each status code means for this endpoint +- **Example request**: + ```bash + curl -X GET https://api.example.com/api/example/ \ + -H "Authorization: Bearer " + ``` +- **Example response**: + ```json + { + "id": 1, + "name": "example" + } + ``` + +If the code has frontend API calls, list which endpoints the frontend consumes. + +## Data / State Management +- Database models / tables with their fields, types, and relationships +- State management approach (Redux, Context, Vuex, etc.) +- Data flow diagrams (how data moves through the system) +- Caching strategy if present +- Migrations and seed data + +## Configuration +- Build tools and configuration files +- Environment variables and their purpose +- Development, staging, and production configuration differences +- How to configure the project for local development + +## Error Handling & Logging +- Error handling patterns (try/catch, middleware, exception handlers) +- Logging setup (log levels, log files, external services) +- Common errors and how they are handled +- Monitoring and alerting if present + +## Deployment +- Deployment configuration (Docker, docker-compose, CI/CD) +- Infrastructure requirements +- How to build and run in production +- Database migration strategy + +## Getting Started +- **Clone the repository**: + ``` + git clone {github_url} + cd {repo_dir} + ``` +- **Install dependencies**: + Use the correct package manager for this project. Detected requirements files: {req_files}. Provide the exact install commands. +- **Environment setup**: Configuration steps, .env setup +- **Database setup**: Migration commands +- **Run the project**: Exact commands to start the project +- **Run tests**: How to execute tests + +Rules: +- Output ONLY valid markdown. No preamble or wrapper text. +- Use actual code details from the provided source. Do NOT hallucinate. +- Use code blocks with language-specific syntax highlighting. +- If the code contains frontend AND backend code, document BOTH thoroughly. +- For mermaid diagrams, ALWAYS start with 'flowchart TD' on its own line. Use 'A --> B' or 'A -->|label| B' syntax only. +- For API tables, use proper markdown table syntax with | pipes. +- If a section has no content to document, output "Not applicable or not detected in the provided code." instead of making things up. +- Be specific about file paths, function names, class names, and variable names found in the code. +- **For EVERY API endpoint in backend/full-stack projects, include a curl example and a JSON response example.** + +Quality validation (review your own output before finalizing): +1. **No invented directory names**: In `cd` and path commands, use only names that exist in the provided file tree `{file_tree}`. +2. **No typos in commands**: Every `git clone`, `cd`, `npm install`, `pip install`, or similar command must be syntactically correct. Verify directory names against the file tree. +3. **No invented file paths**: Any file path referenced in the docs (e.g., `src/components/Foo.jsx`) must actually appear in the file tree or file list. +4. **Accuracy over length**: A short correct answer is better than a long invented one. If unsure about a detail, say "not detected" rather than guessing. +5. **`cd` directory rule**: The `cd` directory after `git clone` MUST be `{repo_dir}`. Never use any other name. This is the exact directory created by the clone. +6. **Frontend-only projects**: Must NOT contain API, Data/State Management, Configuration, Error Handling & Logging, or Deployment sections. +7. **Backend/full-stack projects**: Every API endpoint MUST have a curl example and example JSON response.""" + +MAX_SOURCE_CHARS = 25_000 + + +def get_prompt(mode, source_code, project_name, file_list=None, max_chars=None, + github_url='', file_tree='', req_files=None): + safe_source = source_code.replace("```", "\u200b`\u200b`\u200b`\u200b") + limit = max_chars if max_chars is not None else MAX_SOURCE_CHARS + if len(safe_source) > limit: + safe_source = safe_source[:limit] + "\n\n... [truncated]" + + prefix = f"""Project: {project_name} +Source code to document: +``` +{safe_source} +``` +""" + + file_list_text = "" + if file_list: + file_list_text = "\n".join(f" - {f}" for f in file_list) + file_list_text = f"\n\nAll files in project:\n{file_list_text}" + + meta_lines = [ + "## Metadata", + f"- Project name: {project_name}", + f"- GitHub URL: {github_url}", + ] + if req_files: + meta_lines.append(f"- Detected requirements files: {', '.join(req_files)}") + meta_lines.append("") + + repo_dir = '' + if github_url: + repo_dir = github_url.rstrip('/').split('/')[-1].replace('.git', '') + if not repo_dir: + repo_dir = project_name + + prompt_kwargs = { + 'project_name': project_name, + 'github_url': github_url, + 'repo_dir': repo_dir, + 'file_tree': file_tree, + 'req_files': ', '.join(req_files) if req_files else 'None detected', + } + + prompt_body = UNIVERSAL_PROMPT + for k, v in prompt_kwargs.items(): + prompt_body = prompt_body.replace(f'{{{k}}}', str(v)) + + return prefix + file_list_text + "\n\n" + "\n".join(meta_lines) + prompt_body diff --git a/services/core/apps/universal/tasks.py b/services/core/apps/universal/tasks.py new file mode 100644 index 0000000..2e985e8 --- /dev/null +++ b/services/core/apps/universal/tasks.py @@ -0,0 +1,304 @@ +import logging +import os + +from celery import shared_task +from django.conf import settings +from groq import Groq + +from apps.projects.models import Project, ProjectFile +from apps.universal.prompts import MAX_SOURCE_CHARS, get_prompt + +logger = logging.getLogger(__name__) + +FILE_PRIORITY_KEYWORDS = [ + (10, ['urls.py', 'routes.py', 'router', 'main.py', 'app.py', + 'manage.py', 'wsgi.py', 'asgi.py', 'index.js', 'index.ts', + 'index.jsx', 'index.tsx', 'entry', 'server.js', 'server.ts', + 'app.js', 'app.ts', 'app.jsx', 'app.tsx']), + (8, ['views.py', 'controllers.py', 'handlers.py', 'serializers.py', + 'schemas.py', 'resolver']), + (7, ['models.py', 'entities.py', 'database.py', 'schema.prisma', + 'migration']), + (6, ['requirements.txt', 'pyproject.toml', 'package.json', 'Dockerfile', + 'docker-compose.yml', '.env.example', 'settings', 'config', + 'tailwind.config', 'vite.config', 'next.config', 'webpack.config']), + (5, ['services.py', 'repositories.py', 'middleware.py', 'middleware.', + 'decorators.py', 'permissions.py', 'auth.py', 'authentication']), + (4, ['admin.py', 'forms.py', 'tasks.py', 'apps.py', + 'tests.py', 'test_', '_test.', 'spec.', 'cypress', 'jest']), + (3, ['utils.py', 'helpers.py', 'constants.py', 'types.py', + 'exceptions.py', 'filters.py', 'pagination.py', + 'signals.py', 'context_processors.py']), + (2, ['css', '.scss', '.less', '.svg', '.png', '.jpg', '.ico', + 'locale', 'translation', 'messages.']), +] + + +def _file_priority(path): + name = (path or '').lower() + for score, keywords in FILE_PRIORITY_KEYWORDS: + for kw in keywords: + if kw.lower() in name: + return score + return 1 + + +def _build_file_tree(file_paths): + tree = {} + for path in sorted(file_paths): + parts = path.replace('\\', '/').split('/') + node = tree + for part in parts: + node = node.setdefault(part, {}) + return tree + + +def _format_tree(tree, prefix=''): + lines = [] + items = sorted(tree.items(), key=lambda x: (isinstance(x[1], dict) and len(x[1]) == 0, x[0])) + for i, (name, subtree) in enumerate(items): + is_last = i == len(items) - 1 + connector = '└── ' if is_last else '├── ' + if isinstance(subtree, dict) and subtree: + lines.append(f'{prefix}{connector}{name}/') + extension = ' ' if is_last else '│ ' + lines.extend(_format_tree(subtree, prefix + extension)) + else: + lines.append(f'{prefix}{connector}{name}') + return lines + + +def _detect_req_files(file_list): + req_patterns = {p.lower() for p in [ + 'requirements.txt', 'pyproject.toml', 'Pipfile', + 'package.json', 'composer.json', 'Cargo.toml', + 'Gemfile', 'go.mod', 'build.gradle', 'pom.xml', + ]} + found = [] + for f in file_list: + name = f.lower().split('/')[-1] + if name in req_patterns: + found.append(f) + return found + + +def _validate_and_fix_output(output, github_url, project_name, file_tree, file_list): + """Check generated docs for common errors and fix via a corrective prompt.""" + repo_dir = '' + if github_url: + repo_dir = github_url.rstrip('/').split('/')[-1].replace('.git', '') + if not repo_dir: + repo_dir = project_name + + errors = [] + + # Check 1: cd command after git clone uses the wrong directory + import re + clone_blocks = re.findall(r'```(?:bash|sh|shell)?\s*\n(git clone[^\n]*)\n(cd [^\n]*)', output) + for clone_line, cd_line in clone_blocks: + cd_dir = cd_line.replace('cd ', '').strip() + if cd_dir != repo_dir: + errors.append( + f"Wrong cd directory '{cd_dir}' after `{clone_line}`. " + f"Must be '{repo_dir}' (the cloned repo name)." + ) + + # Check 2: referenced paths exist in file tree + path_refs = re.findall(r'`([^`]+)`', output) + file_set = set(f.lower() for f in file_list) + for ref in path_refs: + ref_lower = ref.lower() + if '/' in ref and not ref_lower.startswith('http') and not ref_lower.startswith('api'): + # Skip common false positives + if any(ref_lower.endswith(ext) for ext in ['.png', '.jpg', '.svg', '.ico', '.md']): + continue + if ref_lower in file_set: + continue + parts = ref_lower.split('/') + if len(parts) >= 2: + matched = any(parts[-1] in f or f.endswith('/' + parts[-1]) for f in file_set) + if not matched and len(parts) >= 3: + # Might be a hallucinated path — flag it + pass + + if errors: + logger.warning(f"Output validation found {len(errors)} issue(s), sending corrective prompt") + correction_prompt = ( + "The following documentation was generated but has errors. " + "Fix ONLY the specific issues listed below. Output ONLY the corrected markdown, nothing else.\n\n" + "Errors:\n" + "\n".join(f"- {e}" for e in errors) + + f"\n\nFile tree for reference:\n{file_tree}\n\n" + f"Here is the documentation to fix:\n\n{output}" + ) + try: + fixed = _call_groq(correction_prompt) + return fixed + except Exception as e: + logger.warning(f"Correction attempt failed: {e}, returning original output") + + return output + + +def _call_groq(prompt, model="llama-3.3-70b-versatile"): + keys = [ + os.getenv("GROQ_API_KEY"), + os.getenv("GROQ_API_KEY_2"), + ] + last_error = None + for key in keys: + if not key: + continue + try: + client = Groq(api_key=key) + resp = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=0.7, + max_tokens=3000, + ) + return resp.choices[0].message.content.strip() + except Exception as e: + last_error = e + logger.warning(f"Groq key failed: {e}") + raise last_error or Exception("No Groq API keys available") + + +@shared_task(bind=True, max_retries=2, default_retry_delay=30) +def generate_universal_docs_task(self, project_id, mode): + project = None + try: + project = Project.objects.get(id=project_id) + project.status = Project.Status.PROCESSING + project.save() + + files = project.files.all() + skip_dirs = {'node_modules', 'vendor', '.git', '__pycache__', '.next', 'dist', 'build', + '.venv', 'venv', 'env', '.tox', 'eggs', 'bundle', '.gem', + '.gradle', 'target', 'bin', 'obj', '.idea', '.vscode', + '.settings', '.metadata', 'tmp', 'temp', 'logs', + '.npm', '.yarn', '.pytest_cache', '.mypy_cache', + '.serverless', '.terraform', 'Pods', '.swiftpm'} + files = [f for f in files if not any( + seg in (f.file_path or '').lower().split('/') for seg in skip_dirs + )] + source_parts = [] + file_list = [] + for f in files: + file_list.append(f.file_path or f.file_name) + content = (f.content or '').replace('\x00', '') + if content: + source_parts.append((f.file_path or f.file_name, content)) + + source_parts.sort(key=lambda x: _file_priority(x[0]), reverse=True) + + source_code_lines = [] + for path, content in source_parts: + source_code_lines.append(f"--- {path} ---\n{content}") + + source_code = "\n\n".join(source_code_lines) if source_code_lines else "" + + tree = _build_file_tree(file_list) + tree_text = '\n'.join(_format_tree(tree)) + req_files = _detect_req_files(file_list) + + max_chars = MAX_SOURCE_CHARS + for attempt in range(3): + prompt = get_prompt(mode, source_code, project.name, file_list, + max_chars, github_url=project.github_url or '', + file_tree=tree_text, req_files=req_files) + try: + result = _call_groq(prompt).replace('\x00', '') + break + except Exception as e: + err_str = str(e) + is_too_large = '413' in err_str or 'rate_limit_exceeded' in err_str or 'Request too large' in err_str + if is_too_large and attempt < 2: + max_chars = max_chars // 2 + logger.warning(f"Request too large, retrying with MAX_SOURCE_CHARS={max_chars}") + continue + raise + + if result.startswith("REJECT:"): + project.status = Project.Status.FAILED + project.error_message = result + project.save() + return {"error": result} + + # Validate output for common errors and retry if needed + result = _validate_and_fix_output(result, project.github_url or '', + project.name, tree_text, file_list) + + project.generated_docs = result + + project.status = Project.Status.DONE + project.save() + return {"project_id": str(project.id)} + + except Exception as e: + logger.error(f"generate_universal_docs_task error: {e}") + if project: + project.status = Project.Status.FAILED + project.error_message = str(e) + project.save() + return {"error": str(e)} + + +# ── GitHub Import (Universal, all file types) ──────────────── + +from apps.common.github import download_zipball, fetch_public_repo_api + + +@shared_task(bind=True, max_retries=2, default_retry_delay=30) +def import_universal_github_task(self, project_id, mode, full_name, folder_path, branch, github_token=None): + """Download ALL files from a GitHub repo (any language), create ProjectFiles, + then delegate to generate_universal_docs_task.""" + project = None + try: + project = Project.objects.get(id=project_id) + project.status = Project.Status.PROCESSING + project.save(update_fields=['status']) + + if github_token: + url = f'https://api.github.com/repos/{full_name}/zipball/{branch}' + headers = { + 'Accept': 'application/vnd.github+json', + 'Authorization': f'token {github_token}', + } + else: + repo_data = fetch_public_repo_api(full_name) + branch = branch or repo_data.get('default_branch') or 'main' + api_token = getattr(settings, 'GITHUB_API_TOKEN', None) + headers = {'Accept': 'application/vnd.github+json'} + if api_token and api_token.strip(): + headers['Authorization'] = f'token {api_token}' + url = f'https://api.github.com/repos/{full_name}/zipball/{branch}' + else: + url = f'https://github.com/{full_name}/archive/refs/heads/{branch}.zip' + + raw_files = download_zipball(url, headers, folder_path) + + if not raw_files: + project.status = Project.Status.FAILED + project.error_message = "No files found in the specified repository path." + project.save(update_fields=['status', 'error_message']) + return {"error": "No files found"} + + for f in raw_files: + ProjectFile.objects.create( + project=project, + file_name=os.path.basename(f['file_path']), + file_path=f['file_path'], + content=f['content'], + ) + + # Delegate AI generation to the existing task + generate_universal_docs_task.delay(str(project.id), mode) + return {"project_id": str(project.id)} + + except Exception as e: + logger.error(f"import_universal_github_task error: {e}") + if project: + project.status = Project.Status.FAILED + project.error_message = str(e) + project.save(update_fields=['status', 'error_message']) + return {"error": str(e)} diff --git a/services/core/apps/universal/tests/__init__.py b/services/core/apps/universal/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/services/core/apps/universal/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/services/core/apps/universal/tests/test_prompts.py b/services/core/apps/universal/tests/test_prompts.py new file mode 100644 index 0000000..9906b49 --- /dev/null +++ b/services/core/apps/universal/tests/test_prompts.py @@ -0,0 +1,47 @@ +from apps.universal.prompts import MAX_SOURCE_CHARS, get_prompt + + +class TestGetPrompt: + def test_basic_prompt(self): + prompt = get_prompt( + mode='universal', + source_code='print("hello")', + project_name='Test Project', + file_list=['main.py'], + github_url='https://github.com/owner/repo', + file_tree='main.py', + req_files=['requirements.txt'], + ) + assert 'Test Project' in prompt + assert 'print("hello")' in prompt + assert 'https://github.com/owner/repo' in prompt + assert 'requirements.txt' in prompt + assert 'main.py' in prompt + + def test_truncates_large_source(self): + large_source = 'x' * (MAX_SOURCE_CHARS + 1000) + prompt = get_prompt( + mode='universal', + source_code=large_source, + project_name='Test', + file_list=['main.py'], + ) + assert len(prompt) < len(large_source) + 5000 + assert '[truncated]' in prompt + + def test_escapes_code_blocks(self): + prompt = get_prompt( + mode='universal', + source_code='```dangerous```', + project_name='Test', + ) + assert '```' not in prompt + + def test_max_chars_param(self): + prompt = get_prompt( + mode='universal', + source_code='test code', + project_name='Test', + max_chars=10, + ) + assert 'test code' in prompt diff --git a/services/core/apps/universal/tests/test_tasks.py b/services/core/apps/universal/tests/test_tasks.py new file mode 100644 index 0000000..4a6addd --- /dev/null +++ b/services/core/apps/universal/tests/test_tasks.py @@ -0,0 +1,68 @@ +from unittest.mock import patch + +import pytest + + +pytestmark = pytest.mark.django_db + + +class TestGenerateUniversalDocsTask: + def test_project_not_found(self): + from apps.universal.tasks import generate_universal_docs_task + result = generate_universal_docs_task(999, 'universal') + assert 'error' in result + + def test_sets_processing_status(self, project): + from apps.universal.tasks import generate_universal_docs_task + with patch('apps.universal.tasks.generate_universal_docs_task.retry'): + result = generate_universal_docs_task(project.id, 'universal') + project.refresh_from_db() + assert 'error' in result or project.status is not None + + def test_rejected_response(self, project): + from apps.universal.tasks import generate_universal_docs_task + with patch('apps.universal.tasks._call_groq', return_value='REJECT: Not a valid project'): + generate_universal_docs_task(project.id, 'universal') + project.refresh_from_db() + assert project.status == 'failed' + assert 'REJECT' in project.error_message + + +class TestFilePriority: + def test_high_priority(self): + from apps.universal.tasks import _file_priority + assert _file_priority('urls.py') == 10 + assert _file_priority('app.py') == 10 + assert _file_priority('models.py') == 7 + + def test_low_priority(self): + from apps.universal.tasks import _file_priority + assert _file_priority('styles.css') == 2 + assert _file_priority('icon.svg') == 2 + + def test_default_priority(self): + from apps.universal.tasks import _file_priority + assert _file_priority('unknown.xyz') == 1 + + +class TestBuildFileTree: + def test_single_file(self): + from apps.universal.tasks import _build_file_tree + tree = _build_file_tree(['main.py']) + assert tree == {'main.py': {}} + + def test_nested_files(self): + from apps.universal.tasks import _build_file_tree + tree = _build_file_tree(['src/main.py', 'src/utils.py', 'README.md']) + assert 'src' in tree + assert 'main.py' in tree['src'] + assert 'utils.py' in tree['src'] + assert 'README.md' in tree + + +class TestFormatTree: + def test_simple_tree(self): + from apps.universal.tasks import _build_file_tree, _format_tree + tree = _build_file_tree(['main.py', 'README.md']) + lines = _format_tree(tree) + assert len(lines) == 2 diff --git a/services/core/apps/universal/tests/test_views.py b/services/core/apps/universal/tests/test_views.py new file mode 100644 index 0000000..d1c8b3c --- /dev/null +++ b/services/core/apps/universal/tests/test_views.py @@ -0,0 +1,71 @@ +import io +from unittest.mock import patch + +import pytest +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestUniversalUploadView: + def test_unauthenticated(self, api_client): + url = reverse('universal-upload') + response = api_client.post(url, {'name': 'Test'}) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_no_name(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('universal-upload') + response = api_client.post(url, {}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_invalid_mode(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('universal-upload') + response = api_client.post(url, {'name': 'Test', 'mode': 'invalid'}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_upload_file(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('apps.universal.views.upload.generate_universal_docs_task.delay'): + url = reverse('universal-upload') + f = io.BytesIO(b'print("hello")') + f.name = 'test.py' + response = api_client.post(url, {'name': 'Test', 'file': f}) + assert response.status_code == status.HTTP_202_ACCEPTED + + def test_source_code(self, api_client, user): + api_client.force_authenticate(user=user) + with patch('apps.universal.views.upload.generate_universal_docs_task.delay'): + url = reverse('universal-upload') + response = api_client.post(url, {'name': 'Test', 'source_code': 'print("hello")'}) + assert response.status_code == status.HTTP_202_ACCEPTED + + def test_no_input(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('universal-upload') + response = api_client.post(url, {'name': 'Test'}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestUniversalStatusView: + def test_unauthenticated(self, api_client): + url = reverse('universal-status', args=['00000000-0000-0000-0000-000000000000']) + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_own_project(self, api_client, user, project): + api_client.force_authenticate(user=user) + url = reverse('universal-status', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['status'] == project.status + assert response.data['name'] == project.name + + def test_not_own_project(self, api_client, other_user, project): + api_client.force_authenticate(user=other_user) + url = reverse('universal-status', args=[project.id]) + response = api_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/services/core/apps/universal/urls.py b/services/core/apps/universal/urls.py new file mode 100644 index 0000000..78b324f --- /dev/null +++ b/services/core/apps/universal/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.universal.views import UniversalStatusView, UniversalUploadView + +urlpatterns = [ + path('upload/', UniversalUploadView.as_view(), name='universal-upload'), + path('status//', UniversalStatusView.as_view(), name='universal-status'), +] diff --git a/services/core/apps/universal/views/__init__.py b/services/core/apps/universal/views/__init__.py new file mode 100644 index 0000000..08775f5 --- /dev/null +++ b/services/core/apps/universal/views/__init__.py @@ -0,0 +1,7 @@ +from .upload import UniversalUploadView +from .status import UniversalStatusView + +__all__ = [ + 'UniversalUploadView', + 'UniversalStatusView', +] diff --git a/services/core/apps/universal/views/status.py b/services/core/apps/universal/views/status.py new file mode 100644 index 0000000..49213b1 --- /dev/null +++ b/services/core/apps/universal/views/status.py @@ -0,0 +1,27 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.projects.models import Project + + +class UniversalStatusView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, project_id): + try: + project = Project.objects.get(id=project_id, user=request.user) + data = { + 'id': project.id, + 'name': project.name, + 'description': project.description or '', + 'status': project.status, + 'mode': 'universal', + 'error': project.error_message or '', + 'docs': project.generated_docs or '', + 'created_at': project.created_at, + } + return Response(data) + except Project.DoesNotExist: + return Response({'detail': 'Project not found.'}, status=status.HTTP_404_NOT_FOUND) diff --git a/services/core/apps/universal/views/upload.py b/services/core/apps/universal/views/upload.py new file mode 100644 index 0000000..6bbecd3 --- /dev/null +++ b/services/core/apps/universal/views/upload.py @@ -0,0 +1,124 @@ +import logging +import os +import zipfile + +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.projects.models import Project, ProjectFile +from apps.universal.tasks import generate_universal_docs_task, import_universal_github_task + +logger = logging.getLogger(__name__) + + +class UniversalUploadView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + mode = request.data.get('mode', 'universal') + name = request.data.get('name', '').strip() + description = request.data.get('description', '').strip() + github_url = request.data.get('github_url', '').strip() + + if not name: + return Response({'detail': 'Project name is required.'}, status=status.HTTP_400_BAD_REQUEST) + + valid_modes = ['universal'] + if mode not in valid_modes: + return Response( + {'detail': f'Invalid mode. Choose from: {", ".join(valid_modes)}'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project = Project.objects.create( + user=request.user, + name=name, + description=description, + source_type='file', + status=Project.Status.PENDING, + ) + + if github_url: + project.source_type = 'github' + project.save(update_fields=['source_type']) + return self._handle_github(project, mode, github_url, request) + + uploaded_file = request.FILES.get('file') + if uploaded_file and uploaded_file.name.endswith('.zip'): + project.source_type = 'folder' + project.save(update_fields=['source_type']) + self._process_zip(project, uploaded_file) + elif uploaded_file: + self._process_single_file(project, uploaded_file) + else: + source_code = request.data.get('source_code', '') + if source_code: + ProjectFile.objects.create( + project=project, + file_name='source.txt', + file_path='source.txt', + content=source_code, + ) + else: + project.status = Project.Status.FAILED + project.error_message = "No file, GitHub URL, or source code provided." + project.save() + return Response( + {'detail': 'No file, GitHub URL, or source code provided.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + project.save() + generate_universal_docs_task.delay(str(project.id), mode) + return Response({'project_id': project.id}, status=status.HTTP_202_ACCEPTED) + + def _handle_github(self, project, mode, github_url, request): + from apps.github_integration.views import parse_github_url + + full_name = parse_github_url(github_url) + if not full_name: + project.delete() + return Response({'detail': 'Invalid GitHub URL.'}, status=status.HTTP_400_BAD_REQUEST) + + folder_path = request.data.get('folder_path', '/') + branch = request.data.get('branch', 'main') or 'main' + project.github_url = f'https://github.com/{full_name}' + + github_token = request.user.github_token if hasattr(request.user, 'github_token') else None + project.save(update_fields=['github_url']) + + import_universal_github_task.delay( + str(project.id), mode, full_name, folder_path, branch, github_token, + ) + return Response({'project_id': project.id}, status=status.HTTP_202_ACCEPTED) + + def _process_zip(self, project, zip_file): + try: + with zipfile.ZipFile(zip_file) as zf: + for file_path in zf.namelist(): + if file_path.startswith('__MACOSX/') or file_path.startswith('.') or file_path.endswith('/'): + continue + try: + content = zf.read(file_path).decode('utf-8', errors='ignore').replace('\x00', '') + ProjectFile.objects.create( + project=project, + file_name=os.path.basename(file_path), + file_path=file_path, + content=content, + ) + except Exception as e: + logger.warning(f"Error reading {file_path}: {e}") + except Exception as e: + logger.error(f"Error processing zip: {e}") + raise + + def _process_single_file(self, project, uploaded_file): + content = uploaded_file.read().decode('utf-8', errors='ignore').replace('\x00', '') + ProjectFile.objects.create( + project=project, + file_name=uploaded_file.name, + file_path=uploaded_file.name, + content=content, + ) diff --git a/services/core/apps/users/__init__.py b/services/core/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/users/admin.py b/services/core/apps/users/admin.py new file mode 100644 index 0000000..0ca260a --- /dev/null +++ b/services/core/apps/users/admin.py @@ -0,0 +1,54 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from .models import PasswordResetToken, User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + list_display = ['email', 'name', 'role', 'is_verified', 'github_connected', 'is_active', 'created_at'] + list_filter = ['role', 'is_verified', 'is_active', 'created_at'] + search_fields = ['email', 'name', 'username'] + ordering = ['-created_at'] + readonly_fields = ['created_at', 'updated_at', 'github_connected'] + actions = ['verify_users', 'deactivate_users'] + + fieldsets = ( + (None, {'fields': ('email', 'password')}), + ('Info', {'fields': ('name', 'username', 'github_connected')}), + ('Role', {'fields': ('role', 'is_verified')}), + ('Access', {'fields': ('is_active', 'is_staff', 'is_superuser')}), + ('Dates', {'fields': ('created_at', 'updated_at')}), + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'name', 'password1', 'password2'), + }), + ) + + @admin.display(boolean=True, description='GitHub') + def github_connected(self, obj): + return bool(obj.github_token) + + @admin.action(description='Mark selected users as verified') + def verify_users(self, request, queryset): + updated = queryset.update(is_verified=True) + self.message_user(request, f'{updated} user(s) verified.') + + @admin.action(description='Deactivate selected users') + def deactivate_users(self, request, queryset): + updated = queryset.update(is_active=False) + self.message_user(request, f'{updated} user(s) deactivated.') + + +@admin.register(PasswordResetToken) +class PasswordResetTokenAdmin(admin.ModelAdmin): + list_display = ['user', 'created_at', 'used', 'is_valid'] + list_filter = ['used'] + search_fields = ['user__email'] + readonly_fields = ['token', 'created_at'] + + @admin.display(boolean=True) + def is_valid(self, obj): + return obj.is_valid() diff --git a/services/core/apps/users/apps.py b/services/core/apps/users/apps.py new file mode 100644 index 0000000..2bb189c --- /dev/null +++ b/services/core/apps/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.users' diff --git a/services/core/apps/users/email_utils.py b/services/core/apps/users/email_utils.py new file mode 100644 index 0000000..ed0d2b2 --- /dev/null +++ b/services/core/apps/users/email_utils.py @@ -0,0 +1,60 @@ +import logging + +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string + +logger = logging.getLogger(__name__) + + +def send_welcome_email(user): + """Send welcome email after successful registration.""" + subject = 'Welcome to PyDocAI!' + message = render_to_string('emails/welcome.txt', { + 'user': user, + 'frontend_url': settings.FRONTEND_URL, + }) + html_message = render_to_string('emails/welcome.html', { + 'user': user, + 'frontend_url': settings.FRONTEND_URL, + }) + try: + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + logger.info(f"Welcome email sent to {user.email}") + except Exception as e: + logger.error(f"Failed to send welcome email to {user.email}: {e}") + + +def send_password_reset_email(user, reset_token): + """Send password reset email with token link.""" + reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}&email={user.email}" + subject = 'Reset Your PyDocAI Password' + message = render_to_string('emails/password_reset.txt', { + 'user': user, + 'reset_url': reset_url, + 'timeout_hours': settings.PASSWORD_RESET_TIMEOUT // 3600, + }) + html_message = render_to_string('emails/password_reset.html', { + 'user': user, + 'reset_url': reset_url, + 'timeout_hours': settings.PASSWORD_RESET_TIMEOUT // 3600, + }) + try: + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + logger.info(f"Password reset email sent to {user.email}") + except Exception as e: + logger.error(f"Failed to send password reset email to {user.email}: {e}") diff --git a/services/core/apps/users/github.py b/services/core/apps/users/github.py new file mode 100644 index 0000000..2b7ce3b --- /dev/null +++ b/services/core/apps/users/github.py @@ -0,0 +1,46 @@ +import requests +from django.conf import settings + + +def exchange_code_for_token(code: str) -> str | None: + resp = requests.post( + 'https://github.com/login/oauth/access_token', + data={ + 'client_id': settings.GITHUB_CLIENT_ID, + 'client_secret': settings.GITHUB_CLIENT_SECRET, + 'code': code, + }, + headers={'Accept': 'application/json'}, + timeout=10, + ) + resp.raise_for_status() + return resp.json().get('access_token') + + +def get_github_user(access_token: str) -> dict: + headers = { + 'Authorization': f'Bearer {access_token}', + 'Accept': 'application/vnd.github+json', + } + profile = requests.get( + 'https://api.github.com/user', + headers=headers, + timeout=10, + ) + profile.raise_for_status() + data = profile.json() + + if not data.get('email'): + emails_resp = requests.get( + 'https://api.github.com/user/emails', + headers=headers, + timeout=10, + ) + emails_resp.raise_for_status() + primary = next( + (e['email'] for e in emails_resp.json() if e['primary'] and e['verified']), + None, + ) + data['email'] = primary + + return data diff --git a/services/core/apps/users/management/commands/create_test_user.py b/services/core/apps/users/management/commands/create_test_user.py new file mode 100644 index 0000000..a592e34 --- /dev/null +++ b/services/core/apps/users/management/commands/create_test_user.py @@ -0,0 +1,26 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = 'Create a test user for API testing' + + def handle(self, *args, **options): + user_model = get_user_model() + + # Delete existing test user if exists + user_model.objects.filter(username='testuser').delete() + + # Create fresh user + user = user_model.objects.create( + username='testuser', + email='test@example.com', + ) + user.password = make_password('testpass123') + user.save() + + self.stdout.write(self.style.SUCCESS('Test user created successfully!')) + self.stdout.write(f'Username: {user.username}') + self.stdout.write(f'Email: {user.email}') + self.stdout.write('Password: testpass123') diff --git a/services/core/apps/users/migrations/0001_initial.py b/services/core/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..a28688a --- /dev/null +++ b/services/core/apps/users/migrations/0001_initial.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.13 on 2026-05-02 07:58 + +import uuid + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254, unique=True)), + ('username', models.CharField(blank=True, max_length=50, null=True, unique=True)), + ('role', models.CharField(choices=[('admin', 'Admin'), ('user', 'User')], default='user', max_length=20)), + ('github_token', models.CharField(blank=True, max_length=255, null=True)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('is_verified', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'db_table': 'users', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/services/core/apps/users/migrations/0002_user_name.py b/services/core/apps/users/migrations/0002_user_name.py new file mode 100644 index 0000000..2d33143 --- /dev/null +++ b/services/core/apps/users/migrations/0002_user_name.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.13 on 2026-05-02 08:04 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='name', + field=models.CharField(default=django.utils.timezone.now, max_length=100), + preserve_default=False, + ), + ] diff --git a/services/core/apps/users/migrations/0003_passwordresettoken.py b/services/core/apps/users/migrations/0003_passwordresettoken.py new file mode 100644 index 0000000..84cb8c6 --- /dev/null +++ b/services/core/apps/users/migrations/0003_passwordresettoken.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.13 on 2026-05-23 06:22 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_user_name'), + ] + + operations = [ + migrations.CreateModel( + name='PasswordResetToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('used', models.BooleanField(default=False)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reset_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'password_reset_tokens', + }, + ), + ] diff --git a/services/core/apps/users/migrations/__init__.py b/services/core/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/users/models/__init__.py b/services/core/apps/users/models/__init__.py new file mode 100644 index 0000000..6b82cbd --- /dev/null +++ b/services/core/apps/users/models/__init__.py @@ -0,0 +1,4 @@ +from .user import User, UserManager +from .password_reset import PasswordResetToken + +__all__ = ['User', 'UserManager', 'PasswordResetToken'] diff --git a/services/core/apps/users/models/password_reset.py b/services/core/apps/users/models/password_reset.py new file mode 100644 index 0000000..e9b7bf9 --- /dev/null +++ b/services/core/apps/users/models/password_reset.py @@ -0,0 +1,22 @@ +import uuid + +from django.conf import settings +from django.db import models +from django.utils import timezone + + +class PasswordResetToken(models.Model): + user = models.ForeignKey('users.User', on_delete=models.CASCADE, related_name='reset_tokens') + token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + used = models.BooleanField(default=False) + + class Meta: + db_table = 'password_reset_tokens' + + def is_valid(self): + expiry = self.created_at + timezone.timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT) + return not self.used and timezone.now() < expiry + + def __str__(self): + return f"Reset token for {self.user.email}" diff --git a/services/core/apps/users/models/user.py b/services/core/apps/users/models/user.py new file mode 100644 index 0000000..93819ea --- /dev/null +++ b/services/core/apps/users/models/user.py @@ -0,0 +1,62 @@ +import uuid + +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.db import models + + +class UserManager(BaseUserManager): + + def create_user(self, email, password=None, **extra): + if not email: + raise ValueError('Email is required') + email = self.normalize_email(email) + user = self.model(email=email, **extra) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password=None, **extra): + extra.setdefault('is_staff', True) + extra.setdefault('is_superuser', True) + extra.setdefault('is_verified', True) + return self.create_user(email, password, **extra) + + +class User(AbstractBaseUser, PermissionsMixin): + + class Role(models.TextChoices): + ADMIN = 'admin', 'Admin' + USER = 'user', 'User' + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + email = models.EmailField(unique=True) + username = models.CharField(max_length=50, unique=True, blank=True, null=True) + role = models.CharField(max_length=20, choices=Role.choices, default=Role.USER) + github_token = models.CharField(max_length=255, blank=True, null=True) + name = models.CharField(max_length=100) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + is_verified = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = UserManager() + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['name'] + + class Meta: + db_table = 'users' + ordering = ['-created_at'] + + def __str__(self): + return self.email + + @property + def is_admin(self): + return self.role == self.Role.ADMIN + + @property + def has_password(self): + return self.password and not self.password.startswith('!') diff --git a/services/core/apps/users/serializers/__init__.py b/services/core/apps/users/serializers/__init__.py new file mode 100644 index 0000000..49f25fd --- /dev/null +++ b/services/core/apps/users/serializers/__init__.py @@ -0,0 +1,14 @@ +from .auth import RegisterSerializer, LoginSerializer, GithubAuthSerializer +from .profile import UserSerializer, AdminUserSerializer, ChangePasswordSerializer +from .password_reset import PasswordResetRequestSerializer, PasswordResetConfirmSerializer + +__all__ = [ + 'RegisterSerializer', + 'LoginSerializer', + 'GithubAuthSerializer', + 'UserSerializer', + 'AdminUserSerializer', + 'ChangePasswordSerializer', + 'PasswordResetRequestSerializer', + 'PasswordResetConfirmSerializer', +] diff --git a/services/core/apps/users/serializers/auth.py b/services/core/apps/users/serializers/auth.py new file mode 100644 index 0000000..f96bc1d --- /dev/null +++ b/services/core/apps/users/serializers/auth.py @@ -0,0 +1,40 @@ +from django.contrib.auth import authenticate +from rest_framework import serializers + +from ..models import User + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8) + password2 = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ['email', 'name', 'username', 'password', 'password2'] + + def validate(self, data): + if data['password'] != data['password2']: + raise serializers.ValidationError({'password': 'Passwords do not match'}) + return data + + def create(self, validated_data): + validated_data.pop('password2') + return User.objects.create_user(**validated_data) + + +class LoginSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField(write_only=True) + + def validate(self, data): + user = authenticate(email=data['email'], password=data['password']) + if not user: + raise serializers.ValidationError('Invalid credentials') + if not user.is_active: + raise serializers.ValidationError('Account is disabled') + data['user'] = user + return data + + +class GithubAuthSerializer(serializers.Serializer): + code = serializers.CharField() diff --git a/services/core/apps/users/serializers/password_reset.py b/services/core/apps/users/serializers/password_reset.py new file mode 100644 index 0000000..82004a5 --- /dev/null +++ b/services/core/apps/users/serializers/password_reset.py @@ -0,0 +1,11 @@ +from rest_framework import serializers + + +class PasswordResetRequestSerializer(serializers.Serializer): + email = serializers.EmailField() + + +class PasswordResetConfirmSerializer(serializers.Serializer): + email = serializers.EmailField() + token = serializers.UUIDField() + new_password = serializers.CharField(write_only=True, min_length=8) diff --git a/services/core/apps/users/serializers/profile.py b/services/core/apps/users/serializers/profile.py new file mode 100644 index 0000000..550e830 --- /dev/null +++ b/services/core/apps/users/serializers/profile.py @@ -0,0 +1,56 @@ +from rest_framework import serializers + +from ..models import User + + +class UserSerializer(serializers.ModelSerializer): + has_password = serializers.BooleanField(read_only=True) + + class Meta: + model = User + fields = [ + 'id', 'email', 'name', 'username', 'role', + 'is_verified', 'has_password', 'created_at', 'updated_at' + ] + read_only_fields = [ + 'id', 'email', 'role', 'is_verified', + 'has_password', 'created_at', 'updated_at' + ] + + +class AdminUserSerializer(UserSerializer): + project_count = serializers.IntegerField(read_only=True) + published_count = serializers.IntegerField(read_only=True) + github_connected = serializers.SerializerMethodField() + + class Meta(UserSerializer.Meta): + fields = UserSerializer.Meta.fields + ['project_count', 'published_count', 'github_connected'] + + def get_github_connected(self, obj): + return bool(obj.github_token) + + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(write_only=True, required=False, allow_blank=True) + new_password = serializers.CharField(write_only=True, min_length=8) + + def validate(self, data): + user = self.context['request'].user + + if user.has_password: + old_password = data.get('old_password') + if not old_password: + raise serializers.ValidationError( + {'old_password': 'Old password is required'} + ) + if not user.check_password(old_password): + raise serializers.ValidationError( + {'old_password': 'Old password is incorrect'} + ) + + return data + + def save(self): + user = self.context['request'].user + user.set_password(self.validated_data['new_password']) + user.save() diff --git a/services/core/apps/users/tasks.py b/services/core/apps/users/tasks.py new file mode 100644 index 0000000..1ec2f11 --- /dev/null +++ b/services/core/apps/users/tasks.py @@ -0,0 +1,27 @@ +import logging + +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task +def send_welcome_email_task(user_id): + from .email_utils import send_welcome_email + from .models import User + try: + user = User.objects.get(id=user_id) + send_welcome_email(user) + except Exception as e: + logger.error(f"send_welcome_email_task failed for user {user_id}: {e}") + + +@shared_task +def send_password_reset_email_task(user_id, token_str): + from .email_utils import send_password_reset_email + from .models import User + try: + user = User.objects.get(id=user_id) + send_password_reset_email(user, token_str) + except Exception as e: + logger.error(f"send_password_reset_email_task failed for user {user_id}: {e}") diff --git a/services/core/apps/users/tests/__init__.py b/services/core/apps/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/apps/users/tests/test_models.py b/services/core/apps/users/tests/test_models.py new file mode 100644 index 0000000..e38b748 --- /dev/null +++ b/services/core/apps/users/tests/test_models.py @@ -0,0 +1,54 @@ +import pytest + +from apps.users.models import PasswordResetToken, User + + +class TestUserModel: + def test_create_user(self, db): + user = User.objects.create_user( + email='user@test.com', + password='testpass123', + name='Test User', + username='testuser', + ) + assert user.email == 'user@test.com' + assert user.check_password('testpass123') + assert user.is_active is True + assert user.is_staff is False + assert user.is_verified is False + + def test_create_superuser(self, db): + admin = User.objects.create_superuser( + email='admin@test.com', + password='admin123', + ) + assert admin.is_staff is True + assert admin.is_superuser is True + + def test_email_as_username_field(self): + assert User.USERNAME_FIELD == 'email' + + def test_str(self, user): + assert str(user) == user.email + + +class TestPasswordResetToken: + def test_create_token(self, user): + token = PasswordResetToken.objects.create(user=user) + assert token.user == user + assert token.used is False + assert token.is_valid() is True + + def test_used_token_invalid(self, user): + token = PasswordResetToken.objects.create(user=user) + token.used = True + token.save() + assert token.is_valid() is False + + def test_expired_token_invalid(self, user): + from datetime import timedelta + from django.utils import timezone + token = PasswordResetToken.objects.create(user=user) + token.created_at = timezone.now() - timedelta(hours=25) + token.save() + assert token.is_valid() is False diff --git a/services/core/apps/users/tests/test_serializers.py b/services/core/apps/users/tests/test_serializers.py new file mode 100644 index 0000000..0cc76e4 --- /dev/null +++ b/services/core/apps/users/tests/test_serializers.py @@ -0,0 +1,77 @@ +import pytest + +from apps.users.serializers import ( + ChangePasswordSerializer, + LoginSerializer, + RegisterSerializer, + UserSerializer, +) + + +class TestRegisterSerializer: + def test_valid_data(self): + data = { + 'email': 'new@test.com', + 'password': 'SecurePass123!', + 'password2': 'SecurePass123!', + 'name': 'New User', + 'username': 'newuser', + } + serializer = RegisterSerializer(data=data) + assert serializer.is_valid() + + def test_password_mismatch(self): + data = { + 'email': 'new@test.com', + 'password': 'pass123', + 'password2': 'pass456', + 'username': 'newuser', + } + serializer = RegisterSerializer(data=data) + assert not serializer.is_valid() + + def test_missing_email(self): + serializer = RegisterSerializer(data={'password': 'pass123', 'password2': 'pass123'}) + assert not serializer.is_valid() + + +class TestLoginSerializer: + def test_valid_credentials(self, user): + data = {'email': user.email, 'password': 'testpass123'} + serializer = LoginSerializer(data=data) + assert serializer.is_valid() + assert serializer.validated_data['user'] == user + + def test_invalid_password(self, user): + data = {'email': user.email, 'password': 'wrong'} + serializer = LoginSerializer(data=data) + assert not serializer.is_valid() + + def test_nonexistent_user(self): + data = {'email': 'nobody@test.com', 'password': 'pass123'} + serializer = LoginSerializer(data=data) + assert not serializer.is_valid() + + +class TestUserSerializer: + def test_serialize(self, user): + serializer = UserSerializer(user) + assert serializer.data['email'] == user.email + assert serializer.data['name'] == user.name + assert 'password' not in serializer.data + + def test_deserialize(self): + data = {'email': 'test@test.com', 'name': 'Test', 'username': 'test'} + serializer = UserSerializer(data=data) + assert serializer.is_valid() + + +class TestChangePasswordSerializer: + def test_valid(self, user): + data = { + 'old_password': 'testpass123', + 'new_password': 'NewPass123!', + 'new_password2': 'NewPass123!', + } + serializer = ChangePasswordSerializer(data=data, context={'request': type('req', (), {'user': user})()}) + assert serializer.is_valid() diff --git a/services/core/apps/users/tests/test_views.py b/services/core/apps/users/tests/test_views.py new file mode 100644 index 0000000..4b02517 --- /dev/null +++ b/services/core/apps/users/tests/test_views.py @@ -0,0 +1,157 @@ +from unittest.mock import patch + +import pytest +import requests +from django.urls import reverse +from rest_framework import status + + +pytestmark = pytest.mark.django_db + + +class TestRegisterView: + def test_register_success(self, api_client): + url = reverse('register') + data = { + 'email': 'newuser@example.com', + 'password': 'SecurePass123!', + 'password2': 'SecurePass123!', + 'name': 'New User', + 'username': 'newuser', + } + with patch('apps.users.tasks.send_welcome_email_task.delay'): + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_201_CREATED + assert 'user' in response.data + assert 'tokens' in response.data + assert response.data['user']['email'] == 'newuser@example.com' + + def test_register_password_mismatch(self, api_client): + url = reverse('register') + data = { + 'email': 'test@example.com', + 'password': 'pass123', + 'password2': 'pass456', + 'username': 'testuser', + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_register_duplicate_email(self, api_client, user): + url = reverse('register') + data = { + 'email': user.email, + 'password': 'Pass123!', + 'password2': 'Pass123!', + 'username': 'another', + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestLoginView: + def test_login_success(self, api_client, user): + url = reverse('login') + response = api_client.post(url, {'email': user.email, 'password': 'testpass123'}, format='json') + assert response.status_code == status.HTTP_200_OK + assert 'tokens' in response.data + assert 'access' in response.data['tokens'] + assert 'refresh' in response.data['tokens'] + + def test_login_wrong_password(self, api_client, user): + url = reverse('login') + response = api_client.post(url, {'email': user.email, 'password': 'wrong'}, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_login_nonexistent(self, api_client): + url = reverse('login') + response = api_client.post(url, {'email': 'nobody@test.com', 'password': 'pass'}, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestLogoutView: + def test_logout_success(self, api_client, user): + api_client.force_authenticate(user=user) + from rest_framework_simplejwt.tokens import RefreshToken + token = RefreshToken.for_user(user) + url = reverse('logout') + response = api_client.post(url, {'refresh': str(token)}, format='json') + assert response.status_code == status.HTTP_200_OK + + def test_logout_unauthenticated(self, api_client): + url = reverse('logout') + response = api_client.post(url, {'refresh': 'some-token'}, format='json') + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_logout_no_token(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('logout') + response = api_client.post(url, {}, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestProfileView: + def test_get_profile(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('profile') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['email'] == user.email + + def test_update_profile(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('profile') + response = api_client.patch(url, {'name': 'Updated Name'}, format='json') + assert response.status_code == status.HTTP_200_OK + assert response.data['name'] == 'Updated Name' + + def test_unauthenticated(self, api_client): + url = reverse('profile') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestChangePasswordView: + def test_change_password(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('change_password') + data = { + 'old_password': 'testpass123', + 'new_password': 'NewPass123!', + 'new_password2': 'NewPass123!', + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + + def test_wrong_old_password(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('change_password') + data = { + 'old_password': 'wrong', + 'new_password': 'NewPass123!', + 'new_password2': 'NewPass123!', + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestGithubAuthView: + def test_missing_code(self, api_client): + url = reverse('github-auth') + response = api_client.post(url, {}, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_github_error(self, api_client): + url = reverse('github-auth') + with patch('apps.users.views.auth.exchange_code_for_token', side_effect=requests.RequestException('Network error')): + response = api_client.post(url, {'code': 'bad-code'}, format='json') + assert response.status_code == status.HTTP_502_BAD_GATEWAY + + +class TestUserListView: + def test_non_admin_returns_empty(self, api_client, user): + api_client.force_authenticate(user=user) + url = reverse('user_list') + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.data['results'] == [] diff --git a/services/core/apps/users/urls.py b/services/core/apps/users/urls.py new file mode 100644 index 0000000..05e50e1 --- /dev/null +++ b/services/core/apps/users/urls.py @@ -0,0 +1,27 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView + +from .views import ( + ChangePasswordView, + GithubAuthView, + LoginView, + LogoutView, + PasswordResetConfirmView, + PasswordResetRequestView, + ProfileView, + RegisterView, + UserListView, +) + +urlpatterns = [ + path('register/', RegisterView.as_view(), name='register'), + path('login/', LoginView.as_view(), name='login'), + path('logout/', LogoutView.as_view(), name='logout'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('profile/', ProfileView.as_view(), name='profile'), + path('change-password/', ChangePasswordView.as_view(), name='change_password'), + path('list/', UserListView.as_view(), name='user_list'), + path('auth/github/', GithubAuthView.as_view(), name='github-auth'), + path('password-reset/', PasswordResetRequestView.as_view(), name='password-reset-request'), + path('password-reset/confirm/',PasswordResetConfirmView.as_view(), name='password-reset-confirm'), +] diff --git a/services/core/apps/users/views/__init__.py b/services/core/apps/users/views/__init__.py new file mode 100644 index 0000000..760efc9 --- /dev/null +++ b/services/core/apps/users/views/__init__.py @@ -0,0 +1,17 @@ +from .auth import RegisterView, LoginView, LogoutView, GithubAuthView, get_tokens +from .profile import ProfileView, ChangePasswordView +from .admin import UserListView +from .password_reset import PasswordResetRequestView, PasswordResetConfirmView + +__all__ = [ + 'RegisterView', + 'LoginView', + 'LogoutView', + 'GithubAuthView', + 'get_tokens', + 'ProfileView', + 'ChangePasswordView', + 'UserListView', + 'PasswordResetRequestView', + 'PasswordResetConfirmView', +] diff --git a/services/core/apps/users/views/admin.py b/services/core/apps/users/views/admin.py new file mode 100644 index 0000000..44697d1 --- /dev/null +++ b/services/core/apps/users/views/admin.py @@ -0,0 +1,51 @@ +from django.db.models import Count, Q +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.pagination import AdminUserPage +from ..models import User +from ..serializers import AdminUserSerializer, UserSerializer + + +class UserListView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + if not request.user.is_admin: + return Response({"results": [], "stats": {}}) + + qs = User.objects.annotate( + project_count=Count('projects'), + published_count=Count('projects', filter=Q(projects__is_published=True)), + ).all() + + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(email__icontains=search) | + Q(name__icontains=search) | + Q(username__icontains=search) + ) + + filterset_fields = ['role', 'is_verified', 'is_active'] + for field in filterset_fields: + val = request.query_params.get(field) + if val is not None: + qs = qs.filter(**{field: val}) + + ordering = request.query_params.get('ordering', '-created_at') + allowed = ['created_at', 'email', 'name', '-created_at', '-email', '-name'] + if ordering not in allowed: + ordering = '-created_at' + qs = qs.order_by(ordering) + + paginator = AdminUserPage() + page = paginator.paginate_queryset(qs, request) + serializer = AdminUserSerializer(page, many=True) if page is not None else AdminUserSerializer(qs, many=True) + + if page is not None: + return paginator.get_paginated_response(serializer.data) + + return Response({"results": serializer.data}) diff --git a/services/core/apps/users/views/auth.py b/services/core/apps/users/views/auth.py new file mode 100644 index 0000000..9173789 --- /dev/null +++ b/services/core/apps/users/views/auth.py @@ -0,0 +1,136 @@ +import requests +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.tokens import RefreshToken + +from ..github import exchange_code_for_token, get_github_user +from ..models import User +from ..serializers import ( + GithubAuthSerializer, + LoginSerializer, + RegisterSerializer, + UserSerializer, +) + + +def get_tokens(user): + refresh = RefreshToken.for_user(user) + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + } + + +class RegisterView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = RegisterSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + tokens = get_tokens(user) + from ..tasks import send_welcome_email_task + send_welcome_email_task.delay(user.id) + return Response({ + 'user': UserSerializer(user).data, + 'tokens': tokens, + }, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LoginView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = LoginSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.validated_data['user'] + tokens = get_tokens(user) + return Response({ + 'user': UserSerializer(user).data, + 'tokens': tokens, + }) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LogoutView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + try: + token = RefreshToken(request.data['refresh']) + token.blacklist() + return Response({'detail': 'Logged out successfully'}) + except KeyError: + return Response({'detail': 'Refresh token required'}, status=status.HTTP_400_BAD_REQUEST) + except TokenError: + return Response({'detail': 'Invalid or expired token'}, status=status.HTTP_400_BAD_REQUEST) + + +class GithubAuthView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = GithubAuthSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + code = serializer.validated_data['code'] + + try: + github_token = exchange_code_for_token(code) + except requests.RequestException: + return Response( + {'detail': 'Failed to reach GitHub. Try again.'}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + if not github_token: + return Response( + {'detail': 'Invalid or expired OAuth code.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + github_user = get_github_user(github_token) + except requests.RequestException: + return Response( + {'detail': 'Failed to fetch GitHub profile.'}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + email = github_user.get('email') + if not email: + return Response( + {'detail': 'Your GitHub account has no verified public email.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user, created = User.objects.get_or_create( + email=email, + defaults={ + 'name': github_user.get('name') or github_user.get('login', ''), + 'username': github_user.get('login'), + 'is_verified': True, + }, + ) + + update_fields = ['github_token'] + user.github_token = github_token + if not user.is_verified: + user.is_verified = True + update_fields.append('is_verified') + user.save(update_fields=update_fields) + + tokens = get_tokens(user) + return Response( + { + 'user': UserSerializer(user).data, + 'tokens': tokens, + 'created': created, + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) diff --git a/services/core/apps/users/views/password_reset.py b/services/core/apps/users/views/password_reset.py new file mode 100644 index 0000000..5e2cb39 --- /dev/null +++ b/services/core/apps/users/views/password_reset.py @@ -0,0 +1,47 @@ +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..models import PasswordResetToken, User +from ..serializers import PasswordResetConfirmSerializer, PasswordResetRequestSerializer + + +class PasswordResetRequestView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = PasswordResetRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + email = serializer.validated_data['email'] + try: + user = User.objects.get(email=email) + token = PasswordResetToken.objects.create(user=user) + from ..tasks import send_password_reset_email_task + send_password_reset_email_task.delay(user.id, str(token.token)) + except User.DoesNotExist: + pass + return Response({'detail': 'If that email exists, a reset link has been sent.'}) + + +class PasswordResetConfirmView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + serializer = PasswordResetConfirmSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + data = serializer.validated_data + try: + user = User.objects.get(email=data['email']) + token = PasswordResetToken.objects.get(token=data['token'], user=user) + except (User.DoesNotExist, PasswordResetToken.DoesNotExist): + return Response({'detail': 'Invalid or expired token.'}, status=status.HTTP_400_BAD_REQUEST) + if not token.is_valid(): + return Response({'detail': 'Token has expired or already been used.'}, status=status.HTTP_400_BAD_REQUEST) + user.set_password(data['new_password']) + user.save() + token.used = True + token.save() + return Response({'detail': 'Password reset successfully.'}) diff --git a/services/core/apps/users/views/profile.py b/services/core/apps/users/views/profile.py new file mode 100644 index 0000000..45f908b --- /dev/null +++ b/services/core/apps/users/views/profile.py @@ -0,0 +1,31 @@ +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..serializers import ChangePasswordSerializer, UserSerializer + + +class ProfileView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + return Response(UserSerializer(request.user).data) + + def patch(self, request): + serializer = UserSerializer(request.user, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ChangePasswordView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + serializer = ChangePasswordSerializer(data=request.data, context={'request': request}) + if serializer.is_valid(): + serializer.save() + return Response({'detail': 'Password changed successfully'}) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/services/core/config/__init__.py b/services/core/config/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/services/core/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/services/core/config/asgi.py b/services/core/config/asgi.py new file mode 100644 index 0000000..0d8fb96 --- /dev/null +++ b/services/core/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') + +application = get_asgi_application() diff --git a/services/core/config/celery.py b/services/core/config/celery.py new file mode 100644 index 0000000..3623618 --- /dev/null +++ b/services/core/config/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') + +app = Celery('config') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/services/core/config/settings/__init__.py b/services/core/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/core/config/settings/base.py b/services/core/config/settings/base.py new file mode 100644 index 0000000..ed3af89 --- /dev/null +++ b/services/core/config/settings/base.py @@ -0,0 +1,210 @@ +from pathlib import Path + +from decouple import config +from dotenv import load_dotenv + +env_path = Path(__file__).resolve().parent.parent.parent / 'env/.env' +load_dotenv(env_path) + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = config('DJANGO_SECRET_KEY') + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'corsheaders', + 'rest_framework_simplejwt', + 'rest_framework_simplejwt.token_blacklist', + 'apps.users', + 'apps.projects', + 'apps.parser', + 'apps.ai', + 'apps.github_integration', + 'apps.exports', + 'apps.admin_dashboard', + 'apps.feedback', + 'apps.internal', + 'apps.comments', + 'apps.notifications', + 'apps.universal', + 'apps.common', + 'drf_spectacular', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'corsheaders.middleware.CorsMiddleware', + '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', +] + +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' +ROOT_URLCONF = 'config.urls' +WSGI_APPLICATION = 'config.wsgi.application' +ASGI_APPLICATION = 'config.asgi.application' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +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'}, +] + +AUTH_USER_MODEL = 'users.User' +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'DEFAULT_FILTER_BACKENDS': [ + 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 6, + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'anon': '30/hour', + 'user': '1000/hour', + 'comment_create': '20/hour', + 'publish': '10/hour', + 'public': '100/hour', + }, + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'PyDocAi API', + 'DESCRIPTION': 'AI-powered documentation generation API', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +# JWT SETTINGS +from datetime import timedelta + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +CELERY_TIMEZONE = 'UTC' +CELERY_TASK_TRACK_STARTED = True +CELERY_TASK_TIME_LIMIT = 30 * 60 + +GROQ_API_KEY = config('GROQ_API_KEY', default=None) +GROQ_API_KEY_2 = config('GROQ_API_KEY_2', default=None) + +GITHUB_CLIENT_ID = config('GITHUB_CLIENT_ID', default=None) +GITHUB_CLIENT_SECRET = config('GITHUB_CLIENT_SECRET', default=None) +GITHUB_API_TOKEN = config('GITHUB_API_TOKEN', default=None) + + +# ── EMAIL SETTINGS ────────────────────────────────────────── + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com') +EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) +EMAIL_USE_TLS = True +EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') +DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@pydocai.com') +FRONTEND_URL = config('FRONTEND_URL', default='http://localhost:5173') +SITE_URL = config('SITE_URL', default=FRONTEND_URL) + +PASSWORD_RESET_TIMEOUT = 3600 # 1 hour + +# ── STRUCTURED LOGGING ───────────────────────────────────── +# Override LOGGING['handlers']['console']['formatter'] to 'json' in production +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'json': { + '()': 'pythonjsonlogger.json.JsonFormatter', + 'format': '%(asctime)s %(name)s %(levelname)s %(message)s %(module)s %(process)d %(thread)d', + }, + 'verbose': { + 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, + 'django.security': { + 'handlers': ['console'], + 'level': 'WARNING', + 'propagate': False, + }, + 'django.request': { + 'handlers': ['console'], + 'level': 'WARNING', + 'propagate': False, + }, + }, +} + +# ── DATABASE POOL SETTINGS ───────────────────────────────── +CONN_MAX_AGE = 300 # 5 minutes persistent connection +CONN_HEALTH_CHECKS = True diff --git a/services/core/config/settings/development.py b/services/core/config/settings/development.py new file mode 100644 index 0000000..b573db6 --- /dev/null +++ b/services/core/config/settings/development.py @@ -0,0 +1,39 @@ +from decouple import config + +from config.settings.base import * + +DEBUG = True +ALLOWED_HOSTS = ['localhost', '127.0.0.1'] + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +CELERY_BROKER_URL = config('CELERY_BROKER_URL', default='redis://localhost:6379/0') +CELERY_RESULT_BACKEND = config('CELERY_RESULT_BACKEND', default='redis://localhost:6379/0') +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True + +CORS_ALLOWED_ORIGINS = [ + 'http://localhost:5173', + 'http://127.0.0.1:5173', +] + +CORS_ALLOW_CREDENTIALS = True + +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +# EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' diff --git a/services/core/config/settings/production.py b/services/core/config/settings/production.py new file mode 100644 index 0000000..7696cbf --- /dev/null +++ b/services/core/config/settings/production.py @@ -0,0 +1,97 @@ +from decouple import config + +from config.settings.base import * + +DEBUG = False +ALLOWED_HOSTS = config('ALLOWED_HOSTS').split(',') + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': config('DB_NAME'), + 'USER': config('DB_USER'), + 'PASSWORD': config('DB_PASSWORD'), + 'HOST': config('DB_HOST'), + 'PORT': config('DB_PORT', default='5432'), + 'CONN_MAX_AGE': 300, + 'CONN_HEALTH_CHECKS': True, + } +} + +CELERY_BROKER_URL = config('CELERY_BROKER_URL') +CELERY_RESULT_BACKEND = config('CELERY_RESULT_BACKEND') +CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS').split(',') +CSRF_TRUSTED_ORIGINS = config('CORS_ALLOWED_ORIGINS').split(',') + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = config('EMAIL_HOST') +EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) +EMAIL_USE_TLS = True +EMAIL_HOST_USER = config('EMAIL_HOST_USER') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD') + +# ── PRODUCTION SECURITY ───────────────────────────────────── +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +CSRF_COOKIE_HTTPONLY = True +SESSION_COOKIE_HTTPONLY = True +SECURE_HSTS_SECONDS = 31536000 # 1 year +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = 'DENY' +USE_X_FORWARDED_HOST = True +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [ + 'rest_framework.throttling.AnonRateThrottle', + 'rest_framework.throttling.UserRateThrottle', +] +REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = { + 'anon': '100/day', + 'user': '1000/hour', + 'comment_create': '20/hour', + 'publish': '10/hour', + 'public': '100/hour', +} + +# ── S3 STATIC & MEDIA FILES ────────────────────────────────── +AWS_ACCESS_KEY_ID = config('AWS_ACCESS_KEY_ID', default=None) +AWS_SECRET_ACCESS_KEY = config('AWS_SECRET_ACCESS_KEY', default=None) +AWS_STORAGE_BUCKET_NAME = config('AWS_STORAGE_BUCKET_NAME', default=None) +AWS_S3_REGION_NAME = config('AWS_S3_REGION_NAME', default='us-east-1') +AWS_S3_FILE_OVERWRITE = False +AWS_DEFAULT_ACL = None +AWS_QUERYSTRING_AUTH = False +AWS_S3_VERIFY = True + +if AWS_STORAGE_BUCKET_NAME: + STORAGES = { + "default": { + "BACKEND": "config.storage_backends.MediaStorage", + }, + "staticfiles": { + "BACKEND": "storages.backends.s3.S3StaticStorage", + }, + } + cloudfront_domain = config('AWS_CLOUDFRONT_DOMAIN', default=None) + if cloudfront_domain: + STATIC_URL = f'https://{cloudfront_domain}/static/' + MEDIA_URL = f'https://{cloudfront_domain}/media/' + else: + STATIC_URL = f'https://{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com/static/' + MEDIA_URL = f'https://{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com/media/' + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": config('CELERY_BROKER_URL'), + "TIMEOUT": 300, + } +} + +# ── JSON LOGGING IN PRODUCTION ────────────────────────────── +LOGGING['handlers']['console']['formatter'] = 'json' +LOGGING['root']['level'] = 'WARNING' diff --git a/services/core/config/storage_backends.py b/services/core/config/storage_backends.py new file mode 100644 index 0000000..7d62ab1 --- /dev/null +++ b/services/core/config/storage_backends.py @@ -0,0 +1,5 @@ +from storages.backends.s3 import S3Storage + + +class MediaStorage(S3Storage): + location = 'media' diff --git a/services/core/config/urls.py b/services/core/config/urls.py new file mode 100644 index 0000000..5c643ec --- /dev/null +++ b/services/core/config/urls.py @@ -0,0 +1,29 @@ +from django.contrib import admin +from django.urls import include, path + +from apps.common.health import health_check +from apps.projects.urls import public_urlpatterns as public_project_urls +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + +urlpatterns = [ + path('api/health/', health_check, name='health'), + path('admin/', admin.site.urls), + path('api/users/', include('apps.users.urls')), + path('api/projects/', include('apps.projects.urls')), + path('api/public/projects/', include(public_project_urls)), + path('api/parser/', include('apps.parser.urls')), + path('api/ai/', include('apps.ai.urls')), + path('api/github/', include('apps.github_integration.urls')), + path('api/exports/', include('apps.exports.urls')), + path('api/admin-dashboard/', include('apps.admin_dashboard.urls')), + path('api/feedback/', include('apps.feedback.urls')), + path('api/comments/', include('apps.comments.urls')), + path('api/notifications/', include('apps.notifications.urls')), + # Internal microservice communication endpoints + path('api/internal/', include('apps.internal.urls')), + path('api/universal/', include('apps.universal.urls')), + # API documentation + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), +] diff --git a/services/core/config/wsgi.py b/services/core/config/wsgi.py new file mode 100644 index 0000000..e2f82e6 --- /dev/null +++ b/services/core/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') + +application = get_wsgi_application() diff --git a/services/core/deploy/Procfile b/services/core/deploy/Procfile new file mode 100644 index 0000000..008a017 --- /dev/null +++ b/services/core/deploy/Procfile @@ -0,0 +1,2 @@ +web: gunicorn config.wsgi:application +worker: celery -A config.celery worker diff --git a/services/core/docker/.dockerignore b/services/core/docker/.dockerignore new file mode 100644 index 0000000..7a95899 --- /dev/null +++ b/services/core/docker/.dockerignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.env +.venv/ +venv/ +db.sqlite3 +staticfiles/ +media/ +.git/ +.gitignore +*.md diff --git a/services/core/docker/Dockerfile b/services/core/docker/Dockerfile new file mode 100644 index 0000000..5cdc695 --- /dev/null +++ b/services/core/docker/Dockerfile @@ -0,0 +1,43 @@ +FROM ghcr.io/astral-sh/uv:python3.10-alpine AS builder + +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_PYTHON_DOWNLOADS=0 \ + UV_PROJECT_ENVIRONMENT=/opt/venv + +WORKDIR /app + +RUN apk add --no-cache gcc musl-dev postgresql-dev + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +COPY . . +RUN uv sync --frozen --no-dev + +FROM python:3.10-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +RUN addgroup -S django && adduser -S -G django django + +RUN apk add --no-cache libpq && pip install --no-cache-dir uv + +COPY --from=builder /opt/venv /opt/venv +RUN chown -R django:django /opt/venv + +WORKDIR /app + +COPY --from=builder /app /app + +RUN chmod +x docker/entrypoint.sh && chown -R django:django /app + +USER django + +EXPOSE 8000 + +CMD ["/bin/sh", "docker/entrypoint.sh"] diff --git a/services/core/docker/entrypoint.sh b/services/core/docker/entrypoint.sh new file mode 100644 index 0000000..1b7796b --- /dev/null +++ b/services/core/docker/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +uv run python manage.py migrate --noinput 2>&1 || true +uv run python manage.py collectstatic --noinput 2>&1 || true +exec uv run gunicorn config.wsgi:application --bind 0.0.0.0:8000 --worker-class gevent --workers 4 diff --git a/services/core/env/.env.example b/services/core/env/.env.example new file mode 100644 index 0000000..976a2c7 --- /dev/null +++ b/services/core/env/.env.example @@ -0,0 +1,30 @@ +DJANGO_SECRET_KEY= +DJANGO_SETTINGS_MODULE=config.settings.production +DB_NAME= +DB_USER= +DB_PASSWORD= +DB_HOST=db +DB_PORT=5432 +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 +CORS_ALLOWED_ORIGINS=http://localhost:5173 +ALLOWED_HOSTS=localhost,127.0.0.1 +GROQ_API_KEY= +GROQ_API_KEY_2= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_API_TOKEN= +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +DEFAULT_FROM_EMAIL=noreply@pydocai.com +FRONTEND_URL=http://localhost:5173 +INTERNAL_API_KEY=pydocai-internal-key + +# AWS S3 (production only) +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_STORAGE_BUCKET_NAME= +AWS_S3_REGION_NAME=us-east-1 +AWS_CLOUDFRONT_DOMAIN= diff --git a/services/core/manage.py b/services/core/manage.py new file mode 100644 index 0000000..cd2a05c --- /dev/null +++ b/services/core/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import os +import sys + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError("Couldn't import Django.") from exc + execute_from_command_line(sys.argv) + +if __name__ == '__main__': + main() diff --git a/services/core/pyproject.toml b/services/core/pyproject.toml new file mode 100644 index 0000000..df92585 --- /dev/null +++ b/services/core/pyproject.toml @@ -0,0 +1,75 @@ +[project] +name = "backend" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "celery>=5.6.3", + "django>=5.2.13", + "django-cors-headers>=4.9.0", + "django-environ>=0.13.0", + "djangorestframework>=3.17.1", + "djangorestframework-simplejwt>=5.5.1", + "google-genai>=1.74.0", + "groq>=1.2.0", + "markdown>=3.10.2", + "psycopg2-binary>=2.9.12", + "pygithub>=2.9.1", + "python-decouple>=3.8", + "python-dotenv>=1.2.2", + "python-json-logger>=3.0.0", + "redis>=7.4.0", + "requests>=2.31.0", + "gunicorn>=23.0.0", + "gevent>=24.10.1,<26", + "weasyprint>=68.1", + "tomli>=2.0.1", + "anthropic>=0.45.0", + "django-anymail>=15.0", + "django-filter>=25.2", + "drf-spectacular>=0.28.0", + "whitenoise>=6.7.0", + "django-storages>=1.14.0", + "boto3>=1.34.0", + "pytest>=8.0", + "pytest-django>=4.9", + "coverage>=7.6", + "ruff>=0.8", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-django>=4.9", + "coverage>=7.6", + "ruff>=0.8", +] + +[tool.ruff] +target-version = "py310" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"] +ignore = ["E501", "SIM102"] + +[tool.ruff.lint.per-file-ignores] +"*/migrations/*" = ["E501"] +"apps/universal/prompts.py" = ["E501"] +"config/settings/*" = ["F403", "F405", "E402"] +"apps/github_integration/tasks.py" = ["SIM105", "B904"] +"apps/exports/views.py" = ["B904"] +"apps/parser/tasks.py" = ["B904"] +"apps/parser/ast_parser.py" = ["SIM102"] +"apps/github_integration/views.py" = ["E501"] +"apps/parser/views.py" = ["E501"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.development" +testpaths = ["apps"] +python_files = ["tests.py", "test_*.py"] + +[tool.coverage.run] +source = ["apps"] +omit = ["*/migrations/*", "*/tests*"] diff --git a/services/core/requirements/base.txt b/services/core/requirements/base.txt new file mode 100644 index 0000000..3c9b512 --- /dev/null +++ b/services/core/requirements/base.txt @@ -0,0 +1,14 @@ +django +djangorestframework +django-cors-headers +djangorestframework-simplejwt +celery +redis +python-decouple +Pillow +psycopg2-binary +gunicorn +groq +tomli +anthropic +django-filter diff --git a/services/core/requirements/development.txt b/services/core/requirements/development.txt new file mode 100644 index 0000000..aa19018 --- /dev/null +++ b/services/core/requirements/development.txt @@ -0,0 +1,2 @@ +-r base.txt +django-debug-toolbar diff --git a/services/core/requirements/production.txt b/services/core/requirements/production.txt new file mode 100644 index 0000000..f8050d8 --- /dev/null +++ b/services/core/requirements/production.txt @@ -0,0 +1,2 @@ +-r base.txt +sentry-sdk diff --git a/services/core/seed/seed_admin.py b/services/core/seed/seed_admin.py new file mode 100644 index 0000000..bf19c81 --- /dev/null +++ b/services/core/seed/seed_admin.py @@ -0,0 +1,24 @@ +import os +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") +django.setup() + +from apps.users.models import User + +email = 'admin@gmail.com' +if not User.objects.filter(email=email).exists(): + user = User.objects.create_superuser( + email=email, + name='Admin', + username='admin', + password='admin1234' + ) + user.role = 'admin' + user.is_staff = True + user.is_superuser = True + user.is_verified = True + user.save() + print(f'Created admin user: {user.email}') +else: + print(f'User with email {email} already exists.') \ No newline at end of file diff --git a/services/core/templates/emails/account_blocked.html b/services/core/templates/emails/account_blocked.html new file mode 100644 index 0000000..eb60447 --- /dev/null +++ b/services/core/templates/emails/account_blocked.html @@ -0,0 +1,25 @@ + + +
+ + + + +
+ + + +
+PyDocAI + +ACCOUNT_{{ action|upper }} +
+
+

Account {{ action|title }}

+

+Your PyDocAI account has been {{ action }} by an administrator. +

+
+

PyDocAI · AI-generated documentation

+
+
diff --git a/services/core/templates/emails/account_deleted.html b/services/core/templates/emails/account_deleted.html new file mode 100644 index 0000000..4d4d8a7 --- /dev/null +++ b/services/core/templates/emails/account_deleted.html @@ -0,0 +1,31 @@ + + +
+ + + + + +
+ + + +
+PyDocAI + +ACCOUNT_DELETED +
+
+

Account Deleted

+

+Your PyDocAI account has been deleted by an administrator. +

+
+
+Reason: +{{ reason }} +
+
+

PyDocAI · AI-generated documentation

+
+
diff --git a/services/core/templates/emails/feedback_confirmation.html b/services/core/templates/emails/feedback_confirmation.html new file mode 100644 index 0000000..5ba5ec1 --- /dev/null +++ b/services/core/templates/emails/feedback_confirmation.html @@ -0,0 +1,30 @@ + + + + + + + +
+

Feedback Received

+

Hi {{ user.name }},

+

Thank you for sharing your feedback with us! Here's a copy of what you submitted:

+

Category

+

{{ feedback.get_category_display }}

+

Message

+

{{ feedback.message }}

+

Our team will review your feedback and get back to you if needed. You can track responses in your profile.

+

+ View My Feedback +

+ +
+ + diff --git a/services/core/templates/emails/feedback_confirmation.txt b/services/core/templates/emails/feedback_confirmation.txt new file mode 100644 index 0000000..de520aa --- /dev/null +++ b/services/core/templates/emails/feedback_confirmation.txt @@ -0,0 +1,13 @@ +Hi {{ user.name }}, + +Thank you for sharing your feedback with us! Here's a copy of what you submitted: + +Category: {{ feedback.get_category_display }} +Message: {{ feedback.message }} + +Our team will review your feedback and get back to you if needed. You can track responses in your profile. + +View your feedback: {{ frontend_url }}/my-feedback + +Thanks, +The PyDocAI Team diff --git a/services/core/templates/emails/feedback_reply.html b/services/core/templates/emails/feedback_reply.html new file mode 100644 index 0000000..4beecc6 --- /dev/null +++ b/services/core/templates/emails/feedback_reply.html @@ -0,0 +1,30 @@ + + + + + + + +
+

New Reply on Your Feedback

+

Hi {{ user.name }},

+

You have received a reply from our team regarding your feedback:

+

Your Original Message

+

{{ feedback.message }}

+

Reply from {{ reply.user.name }}

+
{{ reply.message }}
+

+ View Conversation +

+ +
+ + diff --git a/services/core/templates/emails/feedback_reply.txt b/services/core/templates/emails/feedback_reply.txt new file mode 100644 index 0000000..6835c9a --- /dev/null +++ b/services/core/templates/emails/feedback_reply.txt @@ -0,0 +1,14 @@ +Hi {{ user.name }}, + +You have received a reply from our team regarding your feedback: + +Your Original Message: +{{ feedback.message }} + +Reply from {{ reply.user.name }}: +{{ reply.message }} + +View the full conversation: {{ frontend_url }}/my-feedback + +Thanks, +The PyDocAI Team diff --git a/services/core/templates/emails/notification_comment.html b/services/core/templates/emails/notification_comment.html new file mode 100644 index 0000000..58e5ee8 --- /dev/null +++ b/services/core/templates/emails/notification_comment.html @@ -0,0 +1,33 @@ + + +
+ + + + + + +
+ + + +
+PyDocAI + +NEW_COMMENT +
+
+

New Comment

+

+on {{ project_name }} · {{ commenter }} +

+
+
+{{ comment_content }} +
+
+VIEW_COMMENT() +
+

PyDocAI · AI-generated documentation

+
+
diff --git a/services/core/templates/emails/notification_reply.html b/services/core/templates/emails/notification_reply.html new file mode 100644 index 0000000..dd52d16 --- /dev/null +++ b/services/core/templates/emails/notification_reply.html @@ -0,0 +1,38 @@ + + +
+ + + + + + +
+ + + +
+PyDocAI + +NEW_REPLY +
+
+

New Reply

+

+on {{ project_name }} · {{ replier }} +

+
+
+Your comment: +{{ parent_content }} +
+
+Reply: +{{ reply_content }} +
+
+VIEW_REPLY() +
+

PyDocAI · AI-generated documentation

+
+
diff --git a/services/core/templates/emails/password_reset.html b/services/core/templates/emails/password_reset.html new file mode 100644 index 0000000..0a861e7 --- /dev/null +++ b/services/core/templates/emails/password_reset.html @@ -0,0 +1,26 @@ + + + + + + + +
+

Reset Your Password

+

Hi {{ user.name }},

+

You requested a password reset. Click the button below to set a new password. This link expires in {{ timeout_hours }} hour(s).

+

+ Reset My Password +

+
If you didn't request this, ignore this email. Your password won't change.
+ +
+ + \ No newline at end of file diff --git a/services/core/templates/emails/password_reset.txt b/services/core/templates/emails/password_reset.txt new file mode 100644 index 0000000..38c2c71 --- /dev/null +++ b/services/core/templates/emails/password_reset.txt @@ -0,0 +1,12 @@ +Hi {{ user.name }}, + +You requested a password reset for your PyDocAI account. + +Click the link below to reset your password (expires in {{ timeout_hours }} hour(s)): + +{{ reset_url }} + +If you did not request this, you can safely ignore this email. + +Thanks, +The PyDocAI Team \ No newline at end of file diff --git a/services/core/templates/emails/welcome.html b/services/core/templates/emails/welcome.html new file mode 100644 index 0000000..d3491b6 --- /dev/null +++ b/services/core/templates/emails/welcome.html @@ -0,0 +1,24 @@ + + + + + + + +
+

Welcome to PyDocAI

+

Hi {{ user.name }},

+

Your account has been created successfully. You can now log in and start generating beautiful documentation for your Python projects automatically.

+

+ Get Started +

+ +
+ + \ No newline at end of file diff --git a/services/core/templates/emails/welcome.txt b/services/core/templates/emails/welcome.txt new file mode 100644 index 0000000..203ae12 --- /dev/null +++ b/services/core/templates/emails/welcome.txt @@ -0,0 +1,10 @@ +Hi {{ user.name }}, + +Welcome to PyDocAI! Your account has been created successfully. + +You can now log in and start generating documentation for your Python projects. + +Get Started: {{ frontend_url }}/dashboard + +Thanks, +The PyDocAI Team \ No newline at end of file diff --git a/services/core/uv.lock b/services/core/uv.lock new file mode 100644 index 0000000..ea584f3 --- /dev/null +++ b/services/core/uv.lock @@ -0,0 +1,2296 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.111.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/8a/9afc7305a2ce4b52b30e137f83cd2a6a90b918b3997073db11bb5a1de55a/anthropic-0.111.0.tar.gz", hash = "sha256:39cbda0ac17a6d423e5bf609811bd69b26eddf6299d7a468126e05bc711ce826", size = 934001, upload-time = "2026-06-18T17:31:44.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/bb/09e82a81885d787f350fb55ca9df865b63140dd28b3b5b3104c4ae261657/anthropic-0.111.0-py3-none-any.whl", hash = "sha256:c14edb36ed80da9099acbd26b5cec810d76606c31f32a0d56a4cf9d4fa9e25ae", size = 929774, upload-time = "2026-06-18T17:31:43.116Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/b5/001890774a9552aff22502b8da382593109ce0c95314abaebbb116567545/anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89", size = 253586, upload-time = "2026-06-15T22:00:49.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9", size = 123506, upload-time = "2026-06-15T22:00:47.595Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "backend" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "anthropic" }, + { name = "boto3" }, + { name = "celery" }, + { name = "coverage" }, + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django-anymail" }, + { name = "django-cors-headers" }, + { name = "django-environ" }, + { name = "django-filter" }, + { name = "django-storages" }, + { name = "djangorestframework" }, + { name = "djangorestframework-simplejwt" }, + { name = "gevent" }, + { name = "google-genai" }, + { name = "groq" }, + { name = "gunicorn" }, + { name = "markdown" }, + { name = "psycopg2-binary" }, + { name = "pygithub" }, + { name = "pytest" }, + { name = "pytest-django" }, + { name = "python-decouple" }, + { name = "python-dotenv" }, + { name = "python-json-logger" }, + { name = "redis" }, + { name = "requests" }, + { name = "ruff" }, + { name = "tomli" }, + { name = "weasyprint" }, + { name = "whitenoise" }, +] + +[package.optional-dependencies] +dev = [ + { name = "coverage" }, + { name = "pytest" }, + { name = "pytest-django" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=0.45.0" }, + { name = "boto3", specifier = ">=1.34.0" }, + { name = "celery", specifier = ">=5.6.3" }, + { name = "coverage", specifier = ">=7.6" }, + { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, + { name = "django", specifier = ">=5.2.13" }, + { name = "django-anymail", specifier = ">=15.0" }, + { name = "django-cors-headers", specifier = ">=4.9.0" }, + { name = "django-environ", specifier = ">=0.13.0" }, + { name = "django-filter", specifier = ">=25.2" }, + { name = "django-storages", specifier = ">=1.14.0" }, + { name = "djangorestframework", specifier = ">=3.17.1" }, + { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, + { name = "gevent", specifier = ">=24.10.1,<26" }, + { name = "google-genai", specifier = ">=1.74.0" }, + { name = "groq", specifier = ">=1.2.0" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "markdown", specifier = ">=3.10.2" }, + { name = "psycopg2-binary", specifier = ">=2.9.12" }, + { name = "pygithub", specifier = ">=2.9.1" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-django", specifier = ">=4.9" }, + { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" }, + { name = "python-decouple", specifier = ">=3.8" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-json-logger", specifier = ">=3.0.0" }, + { name = "redis", specifier = ">=7.4.0" }, + { name = "requests", specifier = ">=2.31.0" }, + { name = "ruff", specifier = ">=0.8" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, + { name = "tomli", specifier = ">=2.0.1" }, + { name = "weasyprint", specifier = ">=68.1" }, + { name = "whitenoise", specifier = ">=6.7.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/9f/897287e955db0f50b12fd69ef45956e4fd2c7ddb48c736872f7ea2314443/boto3-1.43.36.tar.gz", hash = "sha256:587d7ee92a12e440ad12b0e7f11f3358f0c4d65b19f64726efc94aaf194aff28", size = 112690, upload-time = "2026-06-23T02:47:14.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/f1/274303f52483ecf199eae6f8d9b6f5951670397ee4d72c06cfd4eb644612/boto3-1.43.36-py3-none-any.whl", hash = "sha256:42942dde254673abcbc9e6e60017c88341a4f49d99d24e1f2e290fb38138c26f", size = 140031, upload-time = "2026-06-23T02:47:13.178Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/37/da9e7f6ca73ac73afd7f0bb7f238aa5daba35c081e98d7f48a7c399599c0/botocore-1.43.36.tar.gz", hash = "sha256:4cae47d1b2d426316b85a0087d9e69e048f13bc003b5177d74639fe9dfd28205", size = 15625488, upload-time = "2026-06-23T02:47:03.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/19/934f81592527a3f7f9b943c893e334c721a4644948642bc33885d584e9ec/botocore-1.43.36-py3-none-any.whl", hash = "sha256:3c65fdc39ed01d8dfde1e961b34038aed03c459f8ddf80717a12ac006475e49d", size = 15313630, upload-time = "2026-06-23T02:46:59.327Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461, upload-time = "2025-11-05T18:38:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035, upload-time = "2025-11-05T18:38:11.827Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, + { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, + { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, +] + +[[package]] +name = "celery" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, + { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, + { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, + { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, + { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, + { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, + { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[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 = "coverage" +version = "7.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" }, + { url = "https://files.pythonhosted.org/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" }, + { url = "https://files.pythonhosted.org/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" }, + { url = "https://files.pythonhosted.org/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, + { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, + { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, + { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, + { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, + { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, + { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, + { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, + { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, + { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, + { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, + { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, + { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, + { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, + { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, + { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, + { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +] + +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "cssselect2" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, +] + +[[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.15" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/e3/31722f7284c9f43333daff9aee9184678e4487adcb5506af0db8cea09ce1/django-5.2.15.tar.gz", hash = "sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7", size = 10873669, upload-time = "2026-06-03T13:03:35.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/b5/38140b1643c00d5c46ce69c78e6980fd285aee223100319631bedee4f5e7/django-5.2.15-py3-none-any.whl", hash = "sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970", size = 8311957, upload-time = "2026-06-03T13:03:31.329Z" }, +] + +[[package]] +name = "django" +version = "6.0.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, +] + +[[package]] +name = "django-anymail" +version = "15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "idna" }, + { name = "requests" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/43/f0aadb31f2c58afcd9f001f4291998cbd6d289898167e79d908506fc6faf/django_anymail-15.0.tar.gz", hash = "sha256:23d8ab6589afe8cc1ae7665c26879814ad192f4c3ed837a2a1868b0a056869e0", size = 106985, upload-time = "2026-04-18T20:44:19.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/d1/daae99ec3b30886010a499975880ec20c32c622bee6b92c226b715e42f0c/django_anymail-15.0-py3-none-any.whl", hash = "sha256:64d33dd1084bfc8e4e12245f56629be40aa0b0498fc7fc7544d87b9b2048be1e", size = 147229, upload-time = "2026-04-18T20:44:17.323Z" }, +] + +[[package]] +name = "django-cors-headers" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, +] + +[[package]] +name = "django-environ" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/e1/4c4ddcf6e90f023e89edb1d92f9a7fffce61ad4b691df497f2f5c0be6e26/django_environ-0.14.0.tar.gz", hash = "sha256:b6c48d93b9d2ff8a3ea14099e90c35aa4f101c1b4d5f262dfee0d27b06742ed7", size = 60417, upload-time = "2026-06-18T22:49:55.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/fe/67b423fc30f16d10259e901320fbc121746bf168aaaad9043e6ebb0110c1/django_environ-0.14.0-py3-none-any.whl", hash = "sha256:8dbe8a57f0a540ab8abd6f54f230de5e99e3a2c9d797cb9caecb037bca3d47d8", size = 20934, upload-time = "2026-06-18T22:49:54.355Z" }, +] + +[[package]] +name = "django-filter" +version = "25.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" }, +] + +[[package]] +name = "django-storages" +version = "1.14.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, +] + +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "5.2.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "djangorestframework" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[package.optional-dependencies] +woff = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, + { name = "zopfli" }, +] + +[[package]] +name = "gevent" +version = "25.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/c7/2c60fc4e5c9144f2b91e23af8d87c626870ad3183cfd09d2b3ba6d699178/gevent-25.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:856b990be5590e44c3a3dc6c8d48a40eaccbb42e99d2b791d11d1e7711a4297e", size = 1831980, upload-time = "2025-09-17T15:41:22.597Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ae/49bf0a01f95a1c92c001d7b3f482a2301626b8a0617f448c4cd14ca9b5d4/gevent-25.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fe1599d0b30e6093eb3213551751b24feeb43db79f07e89d98dd2f3330c9063e", size = 1918777, upload-time = "2025-09-17T15:48:57.223Z" }, + { url = "https://files.pythonhosted.org/packages/88/3f/266d2eb9f5d75c184a55a39e886b53a4ea7f42ff31f195220a363f0e3f9e/gevent-25.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:f0d8b64057b4bf1529b9ef9bd2259495747fba93d1f836c77bfeaacfec373fd0", size = 1869235, upload-time = "2025-09-17T15:49:18.255Z" }, + { url = "https://files.pythonhosted.org/packages/76/24/c0c7c7db70ca74c7b1918388ebda7c8c2a3c3bff0bbfbaa9280ed04b3340/gevent-25.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56cbc820e3136ba52cd690bdf77e47a4c239964d5f80dc657c1068e0fe9521c", size = 2177334, upload-time = "2025-09-17T15:15:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1e/de96bd033c03955f54c455b51a5127b1d540afcfc97838d1801fafce6d2e/gevent-25.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5fa9ce5122c085983e33e0dc058f81f5264cebe746de5c401654ab96dddfca8", size = 1847708, upload-time = "2025-09-17T15:52:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/26/8b/6851e9cd3e4f322fa15c1d196cbf1a8a123da69788b078227dd13dd4208f/gevent-25.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:03c74fec58eda4b4edc043311fca8ba4f8744ad1632eb0a41d5ec25413581975", size = 2234274, upload-time = "2025-09-17T15:24:07.797Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d8/b1178b70538c91493bec283018b47c16eab4bac9ddf5a3d4b7dd905dab60/gevent-25.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8ae9f895e8651d10b0a8328a61c9c53da11ea51b666388aa99b0ce90f9fdc27", size = 1695326, upload-time = "2025-09-17T20:10:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/81/86/03f8db0704fed41b0fa830425845f1eb4e20c92efa3f18751ee17809e9c6/gevent-25.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5aff9e8342dc954adb9c9c524db56c2f3557999463445ba3d9cbe3dada7b7", size = 1792418, upload-time = "2025-09-17T15:41:24.384Z" }, + { url = "https://files.pythonhosted.org/packages/5f/35/f6b3a31f0849a62cfa2c64574bcc68a781d5499c3195e296e892a121a3cf/gevent-25.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1cdf6db28f050ee103441caa8b0448ace545364f775059d5e2de089da975c457", size = 1875700, upload-time = "2025-09-17T15:48:59.652Z" }, + { url = "https://files.pythonhosted.org/packages/66/1e/75055950aa9b48f553e061afa9e3728061b5ccecca358cef19166e4ab74a/gevent-25.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:812debe235a8295be3b2a63b136c2474241fa5c58af55e6a0f8cfc29d4936235", size = 1831365, upload-time = "2025-09-17T15:49:19.426Z" }, + { url = "https://files.pythonhosted.org/packages/31/e8/5c1f6968e5547e501cfa03dcb0239dff55e44c3660a37ec534e32a0c008f/gevent-25.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b28b61ff9216a3d73fe8f35669eefcafa957f143ac534faf77e8a19eb9e6883a", size = 2122087, upload-time = "2025-09-17T15:15:12.329Z" }, + { url = "https://files.pythonhosted.org/packages/c0/2c/ebc5d38a7542af9fb7657bfe10932a558bb98c8a94e4748e827d3823fced/gevent-25.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e4b6278b37373306fc6b1e5f0f1cf56339a1377f67c35972775143d8d7776ff", size = 1808776, upload-time = "2025-09-17T15:52:40.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/26/e1d7d6c8ffbf76fe1fbb4e77bdb7f47d419206adc391ec40a8ace6ebbbf0/gevent-25.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d99f0cb2ce43c2e8305bf75bee61a8bde06619d21b9d0316ea190fc7a0620a56", size = 2179141, upload-time = "2025-09-17T15:24:09.895Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6c/bb21fd9c095506aeeaa616579a356aa50935165cc0f1e250e1e0575620a7/gevent-25.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:72152517ecf548e2f838c61b4be76637d99279dbaa7e01b3924df040aa996586", size = 1677941, upload-time = "2025-09-17T19:59:50.185Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/e55930ba5259629eb28ac7ee1abbca971996a9165f902f0249b561602f24/gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86", size = 2955991, upload-time = "2025-09-17T14:52:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/aa/88/63dc9e903980e1da1e16541ec5c70f2b224ec0a8e34088cb42794f1c7f52/gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692", size = 1808503, upload-time = "2025-09-17T15:41:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/7a/8d/7236c3a8f6ef7e94c22e658397009596fa90f24c7d19da11ad7ab3a9248e/gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2", size = 1890001, upload-time = "2025-09-17T15:49:01.227Z" }, + { url = "https://files.pythonhosted.org/packages/4f/63/0d7f38c4a2085ecce26b50492fc6161aa67250d381e26d6a7322c309b00f/gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74", size = 1855335, upload-time = "2025-09-17T15:49:20.582Z" }, + { url = "https://files.pythonhosted.org/packages/95/18/da5211dfc54c7a57e7432fd9a6ffeae1ce36fe5a313fa782b1c96529ea3d/gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51", size = 2109046, upload-time = "2025-09-17T15:15:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/7bb5ec8e43a2c6444853c4a9f955f3e72f479d7c24ea86c95fb264a2de65/gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5", size = 1827099, upload-time = "2025-09-17T15:52:41.384Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d4/b63a0a60635470d7d986ef19897e893c15326dd69e8fb342c76a4f07fe9e/gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f", size = 2172623, upload-time = "2025-09-17T15:24:12.03Z" }, + { url = "https://files.pythonhosted.org/packages/d5/98/caf06d5d22a7c129c1fb2fc1477306902a2c8ddfd399cd26bbbd4caf2141/gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3", size = 1682837, upload-time = "2025-09-17T19:48:47.318Z" }, + { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" }, + { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" }, + { url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" }, + { url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" }, + { url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" }, + { url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" }, + { url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" }, +] + +[[package]] +name = "google-auth" +version = "2.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/75/81c01294db3a3005dc8a807ed889a10ecd66ef89462c118adcffa5f7981c/google_genai-2.9.0.tar.gz", hash = "sha256:a8a10e9113f460cc668c1d9deeb62ba393ad1ba704bf3166d5a0f32a434f9415", size = 595700, upload-time = "2026-06-19T08:23:42.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/17/bb2cdd0a6c6fec32f14e85735917d1052f82430b1de58c2b606740740419/google_genai-2.9.0-py3-none-any.whl", hash = "sha256:2a79e2b08e8439f5f25c2b42f98e3f3e8ea4be9c9265f5d7321580dbaf2764f4", size = 950790, upload-time = "2026-06-19T08:23:40.995Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/3a/cd99db55dc908568f6b91845747b98b3b17a06052fa1803d091dc91da27d/greenlet-3.5.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9df9daae96848508450011d0d86ed7c95f8829a354ce438284a77b24896fd1f8", size = 285626, upload-time = "2026-06-17T17:33:33.231Z" }, + { url = "https://files.pythonhosted.org/packages/ce/09/fd997a19cbb97641233c7d5f8fc89314c132be2c8867c4f14beff979996f/greenlet-3.5.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01e32e9d2b1714a2b06184cb3071ff2a2fd9bc7d065e39198ab21f7253dad421", size = 601821, upload-time = "2026-06-17T18:07:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b0/62abd204addd913ad9856e091f5d8baaedc7c85df151f22f093b8a207c20/greenlet-3.5.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0488ca77c94da5e09d1d9958f98b58cebba1b8fd9664c24898499133de927574", size = 615044, upload-time = "2026-06-17T18:29:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/9f/5f/0f1db88a69c427e57091079b1478ae8e704de289b4f564ec573b3cdac38a/greenlet-3.5.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc18b8d33e6976804b9b792fe11cb3b1fee8b646e8a9e20bf521a429ddf73520", size = 621981, upload-time = "2026-06-17T18:39:23.961Z" }, + { url = "https://files.pythonhosted.org/packages/34/67/ceaab731b51611a8238b0af2d4abb4fd727ec09b16cd499fca5295603f46/greenlet-3.5.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d9e19257794e28821c9ebd5e23f86d7c267cd9d390089374f068d2049f949e3", size = 615176, upload-time = "2026-06-17T17:39:25.134Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/8c0c0768765eb57851bf65202e675e5ce6615fc4ce11d0e10be903cdc919/greenlet-3.5.2-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:2c6d6bfa4fdd7c39a0dbf112cdf28edbd19c517c810eefb6e4e71b0d55933a4c", size = 417918, upload-time = "2026-06-17T18:41:16.46Z" }, + { url = "https://files.pythonhosted.org/packages/1c/40/51a0ee73b72a7e4a65b54433316bbd7b3b7902a585310cd4e3051d411ee3/greenlet-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bf493b3c1c0a2324c49b0472e2280ba4665f3510d8115f6f807759a6163b15f7", size = 1574580, upload-time = "2026-06-17T18:22:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/41/d3/a3a2163b1fe73042d3e72cfcb9920f2481d5188a1df2645587a9b83a903f/greenlet-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:561dd919c02236a613fbf226791cbd77ee5002cbd5cb7e838869aa3ac7a71e16", size = 1641192, upload-time = "2026-06-17T17:40:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/b4d83fb451e2f7266cb45ccef23857f8a800e0a5d9a73263fafdf7ba7904/greenlet-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:049827baab63dda8ab8ec5a6d07fc6eb0f418319cfc757fc8737a605e99ca1ad", size = 238247, upload-time = "2026-06-17T17:34:54.794Z" }, + { url = "https://files.pythonhosted.org/packages/21/68/371ee6dad168be3386c46030bedaa8e3e7e3cf3d203621d4529e78ff36ef/greenlet-3.5.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d7792398872f89466c6671d5d193537eff163ecf7fac78d82e6ddc25017fb4f5", size = 286925, upload-time = "2026-06-17T17:33:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/ed5706c26b4d26f3fabceb79abca992654eac8b0fa435def2ac6dbd92122/greenlet-3.5.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:711028c953cd6ce5dc01bbb5a1747e3ad6bd8b2f7ded73778bb936e8dab9e3b6", size = 606036, upload-time = "2026-06-17T18:07:18.538Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/f9c77093af9f5f96615922b7e3fe3690a9faff02adb89f1d74e21578b147/greenlet-3.5.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5eba55076d79e8a5176e6925295cfb901ebc95dae493342ede22230f75d8bee2", size = 617821, upload-time = "2026-06-17T18:29:41.317Z" }, + { url = "https://files.pythonhosted.org/packages/27/f5/a963a939039aa5acafc2f9535f6cc8958ad30afe1478e2e37ab5098af74d/greenlet-3.5.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1724499fc08388208408681c53c5062e9803c334e5a0bdaeb616228ba882aac8", size = 625675, upload-time = "2026-06-17T18:39:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d4/642833e778c17d32b5cabb793e14ce7364c55952462fc506fecdee55d485/greenlet-3.5.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1c1e5ad80f1f38ea479b83b39dccb20874cfe9ad5e52f87225fa294ba4d39a1", size = 616877, upload-time = "2026-06-17T17:39:26.564Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c8/995a898ebbf44e3da0b7ea6fbc1631518c185fb83467a5d6cf408d6d3ced/greenlet-3.5.2-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:e976f9f6941f57d87a194c91868622c8b22a142a741d2fde31655c319133ade6", size = 420572, upload-time = "2026-06-17T18:41:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/7120f83e78b8be3cf7acbe2306b3b7bd2cbf99f5ad12e85e2f05d7b31961/greenlet-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e194b996aa1b89d933cfe136e5eb39b22a8b72ba59d376ef39a55bca4dbf47f", size = 1577274, upload-time = "2026-06-17T18:22:10.692Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/05a0074ee485dd51c320fd706fd7ed48006b9cad3443092d7df1a655f0d2/greenlet-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4e554809538bd4867f24421b43abde170f9c9b8192149b30df5e164bcac6124f", size = 1643566, upload-time = "2026-06-17T17:40:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/35/fe/9fe2060bdeece682e38d381184ae66045b48ed183c107ab3f88b9886a630/greenlet-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:e063263ce9047878480d7e536012fc8b7c8e1922989eb5f03b9ab998a2ee7b7e", size = 238643, upload-time = "2026-06-17T17:37:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/41/13/a9db72f5b6b700977ebd371d6a1f2984a08838357de924fcd5571607b1bf/greenlet-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:a3f76a94e2d6e1fee8f302265679d8cc47d71a203936dd03c6e2ace0f9cfd46d", size = 237135, upload-time = "2026-06-17T17:34:34.14Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7a/6bc2a7835731387ed303b9390ce68a116ab053df05450a59181239200454/greenlet-3.5.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:76dae33e97b52743a19210931ee3e78a88fe1438bc2fc4ee5e7512d289bfad4f", size = 288351, upload-time = "2026-06-17T17:36:17.019Z" }, + { url = "https://files.pythonhosted.org/packages/57/1b/bd98062fcef6d0e9d0873ab6f2d029772e6ea342972ae43275bd6177900f/greenlet-3.5.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30252d191d6959df1d040b559a38fc017139606c5ecc2ad00416557c0355d742", size = 604273, upload-time = "2026-06-17T18:07:20.296Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/fe392c522bf45d976abe7db2793f6ef4e87b053ebb869deeaae46aeb54da/greenlet-3.5.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1adc23c50f22b0f5979521909a8360ab4a3d3bef8b641ce633a04cf1b1c967ea", size = 616536, upload-time = "2026-06-17T18:29:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/42/df/cdb1f75f07214f13110e7e3879531f11c26083bd480a56a9474c430ec44c/greenlet-3.5.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87359c23eb4e8f1b16da68faad29bf5aeb80e3628d7d8e4aa2e41c36879ddedd", size = 621843, upload-time = "2026-06-17T18:39:27.507Z" }, + { url = "https://files.pythonhosted.org/packages/68/4a/399ff81fa93a19d6a9df394cef0355f082dbc19ad41aba9593cd0ad444e2/greenlet-3.5.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f052fff492c52fdfa99bd3b3c1389a53de37dae76a0562741417f0d018f02b3", size = 613749, upload-time = "2026-06-17T17:39:28.148Z" }, + { url = "https://files.pythonhosted.org/packages/2e/25/36a3628a7edcfeefddd3101dc88039c79721c5f8d688db7ebed1cbaaa789/greenlet-3.5.2-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:f4d67c1684db3f9782c37ee4bade3f86f5a23a8fcf3f8359224106018ca40728", size = 424889, upload-time = "2026-06-17T18:41:19.469Z" }, + { url = "https://files.pythonhosted.org/packages/a5/75/f519593f12ad43d08e28c03a95cfe2eeae011707dbc9dab0c4a263ce90f9/greenlet-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:120b77c2a18ebf629c3a7886f68c6d01e065654844ad468f15bb93ace66f2094", size = 1573725, upload-time = "2026-06-17T18:22:12.023Z" }, + { url = "https://files.pythonhosted.org/packages/f1/bc/bc1ea4b0754c6c51bbf9d94677b0b1f7fbda8cbb404e44a896854fc0a940/greenlet-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a850f6224088ef7dcc70f1a545cb6b3d119c35d6dca63b925b9f35da0635cdad", size = 1638132, upload-time = "2026-06-17T17:40:06.971Z" }, + { url = "https://files.pythonhosted.org/packages/36/c0/f0f5a34247df60de285f75f22e57f14027f4b3c43820981854b5b643ca6d/greenlet-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:89da99ee8345b458ea2f16831dad31c88ddcdec454b48704d569a0b8fb28f146", size = 239393, upload-time = "2026-06-17T17:33:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/09/17/a8544e165445f30aea67a8d9cf2786d2bb0eb1b0e0d224b4d9bd80e2d587/greenlet-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:ca92411942154023c65851e6077d8ca0d00f19de5fa80bb2c6f196ff6c920ba9", size = 237723, upload-time = "2026-06-17T17:36:47.776Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3c/bb37b9d40d65b0741a8b040ca5c307034d0a9822994dff5f825c88dd7a6b/greenlet-3.5.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", size = 287178, upload-time = "2026-06-17T17:35:25.132Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a6/0c5902393f492f8ceb19d0b5cf139284e3a11b333a049739643b1036b6f8/greenlet-3.5.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", size = 606900, upload-time = "2026-06-17T18:07:21.692Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7c/42899c31d4b87148ae4e3f87f63e13398824be6241f4dde42ded95768a34/greenlet-3.5.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", size = 619265, upload-time = "2026-06-17T18:29:44.837Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/28f991affb413b232b1e7d768db24c37b3f4d5daecc3f19b455d40bd2dea/greenlet-3.5.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e", size = 625044, upload-time = "2026-06-17T18:39:29.046Z" }, + { url = "https://files.pythonhosted.org/packages/d3/52/4ff8c98d3cfe62b4515f8584ae14510a58f35c549cc5292b78d9b7a40b70/greenlet-3.5.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", size = 616187, upload-time = "2026-06-17T17:39:29.473Z" }, + { url = "https://files.pythonhosted.org/packages/29/05/0cc9ec660e7acff85f93b0a048b6654371c822c884add44c02a465cf70e0/greenlet-3.5.2-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5", size = 427322, upload-time = "2026-06-17T18:41:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a6/269c8bf9aefc13361ce1088f0e392b154cb21005de7862e42b5d782b81fd/greenlet-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", size = 1573778, upload-time = "2026-06-17T18:22:13.497Z" }, + { url = "https://files.pythonhosted.org/packages/1f/9b/391d015cbc6323e81b14c02cf825fdca7e0049c9bb489bf4ac72883118ba/greenlet-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", size = 1638092, upload-time = "2026-06-17T17:40:08.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/53/5b4df711f4356c62e85d9f819d87966d526d1cfb32bae49a8f7d6fc36ea4/greenlet-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", size = 239352, upload-time = "2026-06-17T17:38:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/18efc3a329ec035c3f344b8f2b60356451950ddf9b7b64ff00023778a1dd/greenlet-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", size = 237635, upload-time = "2026-06-17T17:35:36.632Z" }, + { url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676, upload-time = "2026-06-17T17:33:31.514Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552, upload-time = "2026-06-17T18:07:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756, upload-time = "2026-06-17T18:29:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/23/5c/2664d290cbd1fef9eb3f69b5d3bc5aa91b6fa907519298ca6af93a90c6cb/greenlet-3.5.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d", size = 669989, upload-time = "2026-06-17T18:39:30.79Z" }, + { url = "https://files.pythonhosted.org/packages/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228, upload-time = "2026-06-17T17:39:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/42/d4/fcb53fa9847d7fbd4723fbed9469c3869b9e3544c4e001d9d5aa2f66162d/greenlet-3.5.2-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef", size = 472888, upload-time = "2026-06-17T18:41:22.511Z" }, + { url = "https://files.pythonhosted.org/packages/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723, upload-time = "2026-06-17T18:22:14.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227, upload-time = "2026-06-17T17:40:09.536Z" }, + { url = "https://files.pythonhosted.org/packages/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257, upload-time = "2026-06-17T17:35:23.359Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/3b62145fe917311732041a258adb218248add00542e3131c48bd047fbed5/greenlet-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", size = 239038, upload-time = "2026-06-17T17:37:56.792Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668, upload-time = "2026-06-17T17:36:02.293Z" }, + { url = "https://files.pythonhosted.org/packages/00/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820, upload-time = "2026-06-17T18:07:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697, upload-time = "2026-06-17T18:29:48.365Z" }, + { url = "https://files.pythonhosted.org/packages/35/ad/9b3058f999b81750a9c6d9ec424f509462d232b58002086fe2ba63b66407/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c", size = 658945, upload-time = "2026-06-17T18:39:32.509Z" }, + { url = "https://files.pythonhosted.org/packages/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436, upload-time = "2026-06-17T17:39:32.509Z" }, + { url = "https://files.pythonhosted.org/packages/92/75/1b6ecd8c027b69ab1b6798a84094df79aab5e69ac7e249c78b9d361dd1fa/greenlet-3.5.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e", size = 490529, upload-time = "2026-06-17T18:41:23.954Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193, upload-time = "2026-06-17T18:22:16.252Z" }, + { url = "https://files.pythonhosted.org/packages/8a/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512, upload-time = "2026-06-17T17:40:10.771Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145, upload-time = "2026-06-17T17:34:37.502Z" }, + { url = "https://files.pythonhosted.org/packages/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315, upload-time = "2026-06-17T17:34:34.04Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130, upload-time = "2026-06-17T18:07:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724, upload-time = "2026-06-17T18:29:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/53/e0/4ce3a046b51e53934eae93d7f9c13975a97285741e9e1fcadf8751314c37/greenlet-3.5.2-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f", size = 673494, upload-time = "2026-06-17T18:39:34.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089, upload-time = "2026-06-17T17:39:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/52/e0/9c18721e63445dce02ee67e4c81c0f281626604ff55ae6f7b7f4354d7129/greenlet-3.5.2-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3", size = 479721, upload-time = "2026-06-17T18:41:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684, upload-time = "2026-06-17T18:22:17.664Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043, upload-time = "2026-06-17T17:40:12.403Z" }, + { url = "https://files.pythonhosted.org/packages/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531, upload-time = "2026-06-17T17:35:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4d/86d7768bd53e9907de0333df215c2018cd01a593b3715cbd79aa82dd94b7/greenlet-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", size = 239579, upload-time = "2026-06-17T17:39:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697, upload-time = "2026-06-17T17:37:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710, upload-time = "2026-06-17T18:07:28.046Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629, upload-time = "2026-06-17T18:29:51.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/10776cd88df54d0f563e9e21e98363f2d6af94bedc553b1da0972fa87f80/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014", size = 663191, upload-time = "2026-06-17T18:39:35.639Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147, upload-time = "2026-06-17T17:39:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/02/aa/26ddf92826a99d87bfb8fdb8f3a262a6f16495a5d8e579737baa92fb4543/greenlet-3.5.2-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b", size = 498199, upload-time = "2026-06-17T18:41:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675, upload-time = "2026-06-17T18:22:18.873Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577, upload-time = "2026-06-17T17:40:14.055Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482, upload-time = "2026-06-17T17:37:34.741Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062, upload-time = "2026-06-17T17:35:39.814Z" }, +] + +[[package]] +name = "groq" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/3a/31c54e35d694ba9c263939ecae0874c78fc51f22b9fb13d2dab50edf6e3c/groq-1.5.0.tar.gz", hash = "sha256:8648388f8668629490bb0eab11252b4cc43316149fda9c6343e9a9577fe7df88", size = 158239, upload-time = "2026-06-21T22:57:30.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/c3/cac2aee75198c0382a2e6070619a798de2288e01850abb6e999746bb92e3/groq-1.5.0-py3-none-any.whl", hash = "sha256:a927fd75b4a60cb066c408cb4f9647536caf74e241ea75bc57d1810501b8203f", size = 143690, upload-time = "2026-06-21T22:57:29.213Z" }, +] + +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + +[[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 = "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 = "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.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[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 = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/da/76a2c7e510ba15fe323d9509c223ab272da79ea59f54488f4a78da6426db/jiter-0.15.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:edebcf7d1f601199084bb6e844d7dc67e03e04f6ac786b0332d616635c4ff7a4", size = 310849, upload-time = "2026-05-19T10:06:51.944Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8e/827be942883a4dc0862c48626ff41af3320b1902d136a0bf4b9041f2c567/jiter-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f924585cdacf631cd382b657966847bb537bf9ed0a6f9b991da5f05a631480f", size = 314991, upload-time = "2026-05-19T10:06:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/be2832be361ba1b9517c76f46d30b64e985be1dd43c974f4c3a4b1844436/jiter-0.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abbf258599526ad0326fe51e252e24f2bd6f24f1852681b4b78feda3808f1d18", size = 340843, upload-time = "2026-05-19T10:06:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/90f01fb83c0c7ba509303ec93e32a308fbfa167d264860b01c0fd0dbbd06/jiter-0.15.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c468136b8bd6bb18c8786e4236a1fa27362f24cb23450ba0cb204ab379b8e6f", size = 365116, upload-time = "2026-05-19T10:06:56.893Z" }, + { url = "https://files.pythonhosted.org/packages/91/38/94593d34f8c67a0b6f6cbc027f016ffa9780b3a858a7a86f6fd7a15bcc1e/jiter-0.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05906b93d72f03339e6bb7cf8dc10ebda64a0266126eed6beba79e20abcf5fd4", size = 457970, upload-time = "2026-05-19T10:06:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/df/04/d79962dd49d00c97e2a9b4cacea1947904d02135936960351f9a96d4c1a6/jiter-0.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30ce785d2adb8e32c3f7741442370a74834ec4c01f3c48f0750227a0b4ef27d6", size = 375744, upload-time = "2026-05-19T10:07:00.471Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2e/5d37abe2be0e819c21e2338bebd410e481763ce526a9138c8c3652fa0123/jiter-0.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd73e3da91a0a722d67165e849ce2cdc10de0e0d48738c142be8c6c5f310f4c", size = 349609, upload-time = "2026-05-19T10:07:01.829Z" }, + { url = "https://files.pythonhosted.org/packages/7a/90/98768ad2ed90c1fda15d64157de2dfbf73c1c074d4b1bfaca915480bc7cf/jiter-0.15.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:ceb8fc27d38793f9c97149be8302720c5b22e5c195a37bf2c45dc36c4600a512", size = 354366, upload-time = "2026-05-19T10:07:03.587Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c4/fbfb806209f1fe4b7dccdfb07bc62bb044300734a945b06fd64db446ef6a/jiter-0.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d726e3ceeb337191324b49de298142f27c3ad10886341555d1d5315b5f252c6a", size = 393519, upload-time = "2026-05-19T10:07:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/37/1c/b9c257cd70cb453b6d10f3ebf0402cdb11669ab455389096f09839670290/jiter-0.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c8aea7781d2a372227871de4e1a1332aa96f5a89fd76c5e835dafdbad102887", size = 519952, upload-time = "2026-05-19T10:07:06.589Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1a/aa85027db7ab15829c12feebbc33b404f53fc399bd559d85fd0d6365ff0d/jiter-0.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf4bd113a69c0a740e27cb962ce10630c36d2b8f59d759a651b955ee9d18a823", size = 550770, upload-time = "2026-05-19T10:07:08.228Z" }, + { url = "https://files.pythonhosted.org/packages/d4/54/8c3f65c8a5687925e84708f19d63f7f37d28e2b86a48d951702ad94424d8/jiter-0.15.0-cp310-cp310-win32.whl", hash = "sha256:d92a5cd21fdb083931d546c207aa29633787c5dc5b02daab2d32b843f88a2c53", size = 209303, upload-time = "2026-05-19T10:07:10.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/0528a1eb9f42dd2d8228a0711458628f35924d131f623eaebc35fd23d3d4/jiter-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:e58585a58209d72691ce2d62a9147445f5a87beb0bde97fde284c96ae392a3d1", size = 200404, upload-time = "2026-05-19T10:07:11.426Z" }, + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[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 = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/80/49bacf9e51617d8309f6f0123e29edc793f6f5f6700c7d1f1b20782fbb37/psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4", size = 3712314, upload-time = "2026-04-20T23:33:31.363Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/98eeac7d60c43df9338287834edf9b3e69be68a2db78a57b1b81d705e735/psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56", size = 3822389, upload-time = "2026-04-20T23:33:34.178Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7c/30575e75f14d5351a56a1971bb43fe7f8bf7edf1b654fb1bec65c42a8812/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433", size = 4578448, upload-time = "2026-04-20T23:33:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/4df366d89f28c527dc39d0b6c98a5ca74e30d37ac097b73f3352147568ae/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e", size = 4273705, upload-time = "2026-04-20T23:33:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/c566803818eb03161ba869b6ba612bf7ad56816d98b9e5121e0a22ad6b0b/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3", size = 5893784, upload-time = "2026-04-20T23:33:41.658Z" }, + { url = "https://files.pythonhosted.org/packages/63/fe/0dfa5797e0b229e0567bc378695224caf14d547f73b05be0c80549089772/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4", size = 4109306, upload-time = "2026-04-20T23:33:43.953Z" }, + { url = "https://files.pythonhosted.org/packages/3c/89/28063adf17a4ba501eedd9890feab0c649ee4d8bd0a97df0ff1e9584feab/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256", size = 3654400, upload-time = "2026-04-20T23:33:46.115Z" }, + { url = "https://files.pythonhosted.org/packages/84/94/5a01de0aa4ead0b8d8d1aa4ec18cec0bd36d03fa714eaa5bb8a0b1b50020/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c", size = 3299215, upload-time = "2026-04-20T23:33:48.202Z" }, + { url = "https://files.pythonhosted.org/packages/7a/85/723bb085a61c6ac2dc0a0043f375f2fe7365363e27b073bad56ca5bda979/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463", size = 3047724, upload-time = "2026-04-20T23:33:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/b4/67/4d8b1e0d2fc4166677380eac0edf9cdff91013aca2546e8ef7bc04b56158/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1", size = 3349183, upload-time = "2026-04-20T23:33:59.635Z" }, + { url = "https://files.pythonhosted.org/packages/73/99/21af7a5498637ea4dc91a17c281a53bc1d632fbafe00f6689fbfb32a9fed/psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03", size = 2757036, upload-time = "2026-04-20T23:34:01.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", size = 3712338, upload-time = "2026-04-20T23:34:03.961Z" }, + { url = "https://files.pythonhosted.org/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", size = 3822407, upload-time = "2026-04-20T23:34:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", size = 4578425, upload-time = "2026-04-20T23:34:08.246Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", size = 4273709, upload-time = "2026-04-20T23:34:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", size = 5893779, upload-time = "2026-04-20T23:34:13.905Z" }, + { url = "https://files.pythonhosted.org/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", size = 4109308, upload-time = "2026-04-20T23:34:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", size = 3654405, upload-time = "2026-04-20T23:34:19.303Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", size = 3299187, upload-time = "2026-04-20T23:34:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", size = 3047716, upload-time = "2026-04-20T23:34:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", size = 3349237, upload-time = "2026-04-20T23:34:25.211Z" }, + { url = "https://files.pythonhosted.org/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", size = 2757036, upload-time = "2026-04-20T23:34:27.77Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydyf" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" }, +] + +[[package]] +name = "pygithub" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyphen" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, +] + +[[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-decouple" +version = "3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/97/373dcd5844ec0ea5893e13c39a2c67e7537987ad8de3842fe078db4582fa/python-decouple-3.8.tar.gz", hash = "sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f", size = 9612, upload-time = "2023-03-01T19:38:38.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/d4/9193206c4563ec771faf2ccf54815ca7918529fe81f6adb22ee6d0e06622/python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66", size = 9947, upload-time = "2023-03-01T19:38:36.015Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, +] + +[[package]] +name = "redis" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ae/ed461cca5780b5fc8b9fe8ca0ed98d89508645fb9d880c24cc42c087678f/redis-8.0.0.tar.gz", hash = "sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49", size = 5101591, upload-time = "2026-05-28T12:45:13.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/e3/b519734372d305bd547534a9f32e4ce9f98552af753dce72cf3483a0ff0b/redis-8.0.0-py3-none-any.whl", hash = "sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7", size = 499870, upload-time = "2026-05-28T12:45:11.697Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" }, + { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" }, + { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" }, + { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" }, + { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" }, + { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/94/dcdaeb1713cab9c84def276cfac7388b17c7d9855bbcfe88d77e4dbafd44/s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685", size = 165171, upload-time = "2026-06-16T19:44:51.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/5f/4c174edad94f82de888ac00a5ddd8d07b35609b6c94f0bdf4d74af57703e/s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262", size = 90101, upload-time = "2026-06-16T19:44:50.439Z" }, +] + +[[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.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tinyhtml5" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/55/15e2340963d2bfedcc6042da3911438fd336f8ae96b65bdbe3a29766da0c/tzlocal-5.4.3.tar.gz", hash = "sha256:3a8c9bc18cf47e1dcde252ea0e6a72a6cde320a400b6ac6db1f1f8cccd553c00", size = 30873, upload-time = "2026-06-17T04:17:41.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/28/fc144409c71569e928585f8f3c629d80d1ca3ef40175e9222f01588f98c9/tzlocal-5.4.3-py3-none-any.whl", hash = "sha256:24ce97bb58e2a973f7640ec2553ab4e6f6d5a0d0d1aa9dc43bca21d89e1feb82", size = 18039, upload-time = "2026-06-17T04:17:40.027Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, +] + +[[package]] +name = "weasyprint" +version = "69.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "cssselect2" }, + { name = "fonttools", extra = ["woff"] }, + { name = "pillow" }, + { name = "pydyf" }, + { name = "pyphen" }, + { name = "tinycss2" }, + { name = "tinyhtml5" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/53/dcc3885c2f7a47faa45f6b8b801412f5f9e055173a52801ef01c09943c5a/weasyprint-69.0.tar.gz", hash = "sha256:a7a32f39ca16bd82ef11de99c92ea4b5f14951c9033af035e451ce4f4ee0a88c", size = 1549834, upload-time = "2026-06-02T14:42:17.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/cb/208525c6bd5033d7b2589b55e07bec23d9c61bb00703cbaf20ef52c3811f/weasyprint-69.0-py3-none-any.whl", hash = "sha256:475951cfd917014de6d4d005caff48c6aa867e7e42b80cd5b16a0484a1609ee6", size = 322872, upload-time = "2026-06-02T14:42:15.871Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +] + +[[package]] +name = "zope-event" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/43/9cd98bee951d23848de690ba2809f87e3b22c67c370987acc960da15ad37/zope_interface-8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c8aa2bf8f3911ef37b87deb1bbe225a310e6eb6522a16d77f5d8330c4f6fbe", size = 210951, upload-time = "2026-05-26T06:49:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/8f1a29966bcf863e3a2121edcafb81c55715de7886bcc9544749cc79e7da/zope_interface-8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:efe234a0fafb4b6b1602e9be9245b97c2bf06d67c07af5a4bc3c0438978b555c", size = 211309, upload-time = "2026-05-26T06:49:02.732Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9f/37e564eaaf85e3abc1ada40a79fa43f2ab45bdb67431b0ec0fe29e4763e2/zope_interface-8.5-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dabeb6fe1228d411994f300811edc6866fff0cdcbc9cef98a78f05ea0da42e37", size = 254881, upload-time = "2026-05-26T06:49:04.303Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/e6501d8ea7a2cac3217e03f404e1f98c1df7191d83cfe86b1895fbba5dac/zope_interface-8.5-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:147a9442dcc2b7339ecdb1be2b3cdb098e90462e39425054053ebfb50d99125a", size = 259811, upload-time = "2026-05-26T06:49:06.373Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/bfa25ef480b02af6e9452c478483fec75e87c9e2b60c407fd0b1f6054b9c/zope_interface-8.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a17e681224267880707c9ec9e730ad9a1ad2d65c371256843efba6cf48711b58", size = 260358, upload-time = "2026-05-26T06:49:08.317Z" }, + { url = "https://files.pythonhosted.org/packages/64/51/2b518072fea76242da64451d501c69b7b5ccdef9b57fead584ccf1c180d5/zope_interface-8.5-cp310-cp310-win_amd64.whl", hash = "sha256:d178968a1a611df30549a717d1624cb38ca810347339e3e37b7baa6f6781a170", size = 214822, upload-time = "2026-05-26T06:49:10.441Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/83ad110fb847413affe71609bb50e59e1aa082e1236030122227c7c283d3/zope_interface-8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:afc66ccaef2a3c0bef6ca02aad40d29a39276389dad16a8eac36f9f385e4d057", size = 211426, upload-time = "2026-05-26T06:49:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a7/6b6e0c31ac240cb9fc015ae9ed45ca54be886c18fcf7bfa2377a4d7a8785/zope_interface-8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28044972187245d7a309e4699319bfdbd2ffcbf7176d1d4ddf5adffb2dea80f", size = 211850, upload-time = "2026-05-26T06:49:14.474Z" }, + { url = "https://files.pythonhosted.org/packages/37/36/7599ecabcf80ce4fef2e1ef3c5ac0d4696b61f03f724cc44022f4d226af9/zope_interface-8.5-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03bbecc7982af713d7499d4084bc03916413d17ffd45f89009348cc0c1d9e376", size = 260711, upload-time = "2026-05-26T06:49:16.568Z" }, + { url = "https://files.pythonhosted.org/packages/03/3e/1774b0ee46ccbb5498ee3c33ece40315b6ef58bc71957be94bd345340bc1/zope_interface-8.5-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf917009a4a7457c7290225a019f4a0aa706d96accd2cfdba2418d3bc1fcde2f", size = 265277, upload-time = "2026-05-26T06:49:18.656Z" }, + { url = "https://files.pythonhosted.org/packages/b6/09/e533b2ffabaae4e5d5730d6768a591cf335defe8e37bec2ad905d09be656/zope_interface-8.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31cff25b2aaedb5267e6e77b1e9be6b0ec4f622032de8a069202b8ffacda7dc2", size = 266369, upload-time = "2026-05-26T06:49:20.174Z" }, + { url = "https://files.pythonhosted.org/packages/49/4a/3ebe6a4c122b2d5340db45cbe7e490663d3228b172710ec71060cd5d541e/zope_interface-8.5-cp311-cp311-win_amd64.whl", hash = "sha256:17a3114bbdddb5e75e5784cdf318944636190cbbc72d357ef9fb1a8b0351f955", size = 215161, upload-time = "2026-05-26T06:49:21.799Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/056ad97af5b16db1975ee98ec7ab03d2ce3f3355efad904ced1dbce0e39f/zope_interface-8.5-cp311-cp311-win_arm64.whl", hash = "sha256:aab6bb5bee10f38ea688b95ba054396b67f613552d2c8378be7fcb2d2fba7646", size = 213481, upload-time = "2026-05-26T06:49:25.085Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/b84123a948f3162a34623e188922827cd845244fdd043ed20f8d02228caa/zope_interface-8.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e6ee90c2e6de7c37058d5fa41f123c8b13a312db8d1e0fb5840d7f4bcdff9c9", size = 212165, upload-time = "2026-05-26T06:49:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/cbceec44f1b27208a76c1a688c131302685852406a23df5aab68324109cc/zope_interface-8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1adc90d3576b3b4c4de4953e6002c37bef28b78d7fa54c1bbfd0c50f022fe7c", size = 212341, upload-time = "2026-05-26T06:49:28.182Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c3/005032195ff3b210c139b7c560ed5c534e844b0907d8e44d2b3d8919305e/zope_interface-8.5-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e6347b8d8d12c5eca6502450a92be30079b7acfade2c4f693efa0deb8871b06e", size = 265296, upload-time = "2026-05-26T06:49:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/c5/66/1036543d6a66bc04c19df3cf650f3ad938a002ab0a443c24e23e8de5e8b9/zope_interface-8.5-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e970dabea777a24b0b0bbf9dae3ab75ce8b2d8e948edf4875627034b21f3560", size = 270689, upload-time = "2026-05-26T06:49:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/30/4c/8b56259558cace4414e753ca6740396a1f59d4a95ddb55b4658600408670/zope_interface-8.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0b48ccadaa9839e09ff81e969703cecb3f402c813bfe8b958652e699bea69f5", size = 270280, upload-time = "2026-05-26T06:49:33.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ea/649908c83aa8fdb7faf2ddca4d3cf6fb8f2157121267dc56e8f72681e26c/zope_interface-8.5-cp312-cp312-win_amd64.whl", hash = "sha256:e0e311f1277468c08fd59a2b41f71b43d25dff639789d364747acd1705c0df6e", size = 215019, upload-time = "2026-05-26T06:49:35.607Z" }, + { url = "https://files.pythonhosted.org/packages/9f/97/da13037b4c563e4df32eedbc819f8c00b754af494f68211e3dffd48d52da/zope_interface-8.5-cp312-cp312-win_arm64.whl", hash = "sha256:652b73107a04159ec6c020db6c1543d4f1e8f4d069bd2aac88a947820923517b", size = 213569, upload-time = "2026-05-26T06:49:37.317Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, + { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, +] + +[[package]] +name = "zopfli" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, + { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bf/403da5a753731d9a4e4d65a494c4a9ae5a0fe62e7afffe5ab49915adc9a3/zopfli-0.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d", size = 147045, upload-time = "2026-06-10T09:10:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5f/afaa18db62ab44da01a3fc39b6cb110478d26cd2287baa83461c6454ed45/zopfli-0.4.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e", size = 127265, upload-time = "2026-06-10T09:10:16.911Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/76bdfd8b35300391666b090357d059ce4c555b9d9ce9878dd551a9ad63a0/zopfli-0.4.3-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94", size = 124288, upload-time = "2026-06-10T09:10:17.891Z" }, + { url = "https://files.pythonhosted.org/packages/c5/95/5781bfb29782c39918686070dbf2ad1425c21384c3223f5c6bd911a806f8/zopfli-0.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3", size = 304624, upload-time = "2026-06-10T09:10:18.944Z" }, +] From d4cb0239159be520da70c9eb01e6ea6960fbf668 Mon Sep 17 00:00:00 2001 From: dennisjoseph2025-dotcom Date: Wed, 24 Jun 2026 14:57:40 +0530 Subject: [PATCH 2/4] feat: add FastAPI AI service under services/ai/ --- services/ai/__init__.py | 0 services/ai/api/__init__.py | 0 services/ai/api/deps.py | 21 ++ services/ai/api/routes/__init__.py | 0 services/ai/api/routes/generate.py | 237 +++++++++++++++++++++++ services/ai/api/routes/health.py | 14 ++ services/ai/api/routes/status.py | 17 ++ services/ai/common/__init__.py | 0 services/ai/common/auth.py | 27 +++ services/ai/common/django_client.py | 62 ++++++ services/ai/config/__init__.py | 0 services/ai/config/config.py | 16 ++ services/ai/database.py | 34 ++++ services/ai/docker/Dockerfile | 36 ++++ services/ai/main.py | 57 ++++++ services/ai/models/__init__.py | 3 + services/ai/models/code_embedding.py | 19 ++ services/ai/rag.py | 226 ++++++++++++++++++++++ services/ai/requirements.txt | 11 ++ services/ai/schemas/__init__.py | 0 services/ai/schemas/requests.py | 8 + services/ai/schemas/responses.py | 10 + services/ai/services/__init__.py | 0 services/ai/services/docs_builder.py | 195 +++++++++++++++++++ services/ai/services/generation.py | 278 +++++++++++++++++++++++++++ services/ai/services/groq.py | 43 +++++ services/ai/services/markdown.py | 45 +++++ services/ai/services/prompts.py | 103 ++++++++++ services/ai/tests/__init__.py | 0 services/ai/tests/test_routes.py | 95 +++++++++ 30 files changed, 1557 insertions(+) create mode 100644 services/ai/__init__.py create mode 100644 services/ai/api/__init__.py create mode 100644 services/ai/api/deps.py create mode 100644 services/ai/api/routes/__init__.py create mode 100644 services/ai/api/routes/generate.py create mode 100644 services/ai/api/routes/health.py create mode 100644 services/ai/api/routes/status.py create mode 100644 services/ai/common/__init__.py create mode 100644 services/ai/common/auth.py create mode 100644 services/ai/common/django_client.py create mode 100644 services/ai/config/__init__.py create mode 100644 services/ai/config/config.py create mode 100644 services/ai/database.py create mode 100644 services/ai/docker/Dockerfile create mode 100644 services/ai/main.py create mode 100644 services/ai/models/__init__.py create mode 100644 services/ai/models/code_embedding.py create mode 100644 services/ai/rag.py create mode 100644 services/ai/requirements.txt create mode 100644 services/ai/schemas/__init__.py create mode 100644 services/ai/schemas/requests.py create mode 100644 services/ai/schemas/responses.py create mode 100644 services/ai/services/__init__.py create mode 100644 services/ai/services/docs_builder.py create mode 100644 services/ai/services/generation.py create mode 100644 services/ai/services/groq.py create mode 100644 services/ai/services/markdown.py create mode 100644 services/ai/services/prompts.py create mode 100644 services/ai/tests/__init__.py create mode 100644 services/ai/tests/test_routes.py diff --git a/services/ai/__init__.py b/services/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/api/__init__.py b/services/ai/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/api/deps.py b/services/ai/api/deps.py new file mode 100644 index 0000000..25993f5 --- /dev/null +++ b/services/ai/api/deps.py @@ -0,0 +1,21 @@ +from fastapi import HTTPException, Depends +from fastapi.security import APIKeyHeader +from sqlalchemy.orm import Session + +from ..config.config import settings +from ..database import get_db as _get_db + +api_key_header = APIKeyHeader(name="X-Internal-Api-Key", auto_error=False) + + +def verify_internal_key(key: str = Depends(api_key_header)): + if settings.INTERNAL_API_KEY and key != settings.INTERNAL_API_KEY: + raise HTTPException(401, "Invalid or missing internal API key") + return key + + +def get_db() -> Session: + """Returns DB session for AI service's own pgvector storage only. + Project/file data is fetched from Django via internal API. + """ + yield from _get_db() diff --git a/services/ai/api/routes/__init__.py b/services/ai/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/api/routes/generate.py b/services/ai/api/routes/generate.py new file mode 100644 index 0000000..3e53aa8 --- /dev/null +++ b/services/ai/api/routes/generate.py @@ -0,0 +1,237 @@ +import os +import tempfile +import shutil +import logging +import time as _time + +from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends +from sqlalchemy.orm import Session + +from ...schemas.requests import GenerateRequest +from ...schemas.responses import GenerateResponse +from ...services.groq import call_groq +from ...services.markdown import sanitize_markdown +from ...services.generation import generate_file_docs, generate_project_summary, mock_docs, retrieve_project_context +from ...services.docs_builder import build_api_docs +from ...common.django_client import get_project, get_project_files, send_ai_docs, update_project +from ...rag import embed_and_store_chunks +from ..deps import get_db, verify_internal_key +from ...config.config import settings + +router = APIRouter() +logger = logging.getLogger("ai.generate") + + +def _call_ai_section(prompt_body: str, key_offset: int, label: str, max_tokens: int = 4096) -> str: + plen = len(prompt_body) + if plen > 12000: + prompt_body = prompt_body[:12000] + "\n... (truncated)" + return call_groq(prompt_body, max_tokens=max_tokens, model="llama-3.3-70b-versatile", key_start=key_offset) + + +def _build_project_context( + project: dict, summary: dict, fw_name: str, fw_block: str, + tree_trunc: str, deps: list, clone_hint: str, db: Session, +) -> str: + return ( + f"Project Name: {project.get('name')}\n" + f"Description: {project.get('description') or 'No description provided'}\n" + f"Framework: {fw_name}\n" + f"Architecture: {summary.get('architecture', 'monolith')}\n" + f"Total Files: {summary['file_count']}{fw_block}\n\n" + f"Project Structure:\n{tree_trunc}\n\n" + f"Dependencies:\n{chr(10).join(deps)}\n\n" + f"Apps/Modules: {', '.join(summary.get('apps', [])) or 'None detected'}\n" + f"Package Manager: {summary.get('package_manager', 'pip')}\n" + f"Relevant Code Context:\n{retrieve_project_context(str(project.get('id')), project.get('name', ''), db)}\n" + f"{clone_hint}\n" + ) + + +@router.post("/generate/", response_model=GenerateResponse) +def generate_docs(req: GenerateRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), _auth: str = Depends(verify_internal_key)): + project = get_project(req.project_id) + if not project: + raise HTTPException(404, "Project not found") + + update_project(req.project_id, {"status": "processing"}) + + files_data = get_project_files(req.project_id) + if not files_data: + raise HTTPException(400, "No parsed files found. Run parser first.") + + try: + fw_info = project.get("framework_info") or {} + source_type = project.get("source_type", "file") + + if source_type == "file" and len(files_data) == 1: + f = files_data[0] + parsed = f.get("parsed_data") or {} + file_name = f.get("file_name", "untitled.py") + + if req.use_ai and settings.GROQ_API_KEY: + try: + embed_and_store_chunks(req.project_id, files_data, db) + except Exception as e: + logger.warning("Embedding failed (continuing without RAG): %s", e) + docs = generate_file_docs(parsed, file_name, fw_info, db, req.project_id) + else: + docs = mock_docs(parsed, file_name, fw_info) + + send_ai_docs(req.project_id, { + "generated_docs": sanitize_markdown(docs), + "status": "done", + }) + else: + temp_dir = tempfile.mkdtemp() + try: + for f in files_data: + content = f.get("content") or "" + if content: + fp = os.path.join(temp_dir, f["file_path"]) + os.makedirs(os.path.dirname(fp), exist_ok=True) + with open(fp, "w") as fh: + fh.write(content) + + summary = generate_project_summary(temp_dir, project.get("name")) + update_project(req.project_id, {"project_info": summary}) + + if req.use_ai and settings.GROQ_API_KEY: + try: + embed_and_store_chunks(req.project_id, files_data, db) + except Exception as e: + logger.warning("Embedding failed (continuing without RAG): %s", e) + + fw_name = fw_info.get("primary_framework", summary.get("framework", "python")) + fw_summary = fw_info.get("summary", "") + fw_block = f"\nDetected Frameworks: {fw_summary}\n" if fw_summary else "" + + tree_lines = (summary.get("project_tree") or "").splitlines() + if len(tree_lines) > 30: + tree_lines = tree_lines[:28] + ["... (truncated)"] + tree_trunc = "\n".join(tree_lines) + + deps = summary.get("dependencies", []) + if len(deps) > 20: + deps = deps[:18] + ["... (truncated)"] + + clone_url = (project.get("github_url") or "").strip() + repo_dir = project.get("name", "project") + clone_hint = "" + if clone_url: + clone_hint = f"\nClone URL: {clone_url}" + repo_dir = clone_url.rstrip("/").split("/")[-1].replace(".git", "") or repo_dir + + api_docs = build_api_docs(files_data) + update_project(req.project_id, {"api_docs": api_docs}) + _time.sleep(3) + + has_root_req = any(f.get("file_path") == "requirements.txt" for f in files_data) + has_root_manage = any(f.get("file_path") == "manage.py" for f in files_data) + cd_dir = "." if (has_root_req or has_root_manage) else (repo_dir or ".") + + project_context = _build_project_context( + project, summary, fw_name, fw_block, tree_trunc, + deps, clone_hint, db, + ) + + detected_apps = summary.get("apps", []) + app_labels = [a.split(".")[-1].replace("_", " ").title() for a in detected_apps] + mermaid_lines = ["flowchart TD", " A[Client] --> B[Backend]"] + for i, label in enumerate(app_labels): + node = chr(67 + i) if i < 24 else f"N{i}" + mermaid_lines.append(f" B --> {node}[{label}]") + mermaid_block = "\n".join(mermaid_lines) + app_list_str = ", ".join(f"`{a}`" for a in detected_apps) if detected_apps else "the detected modules" + + summary_prompt = f"""{project_context} + +Generate a project summary with two parts. + +Part 1 — Overview: 2-3 paragraphs on the project's purpose, architecture, data flow. Include exactly ONE detailed mermaid flowchart based on the actual apps above (do NOT add a second simplified version): + +```mermaid +{mermaid_block} +``` + +Part 2 — App-by-App Breakdown: For EACH app/module detected in this project, write one paragraph explaining what that app does, its key files, and how it fits the architecture. Include ALL of: {app_list_str}. Do NOT skip any. + +Output ONLY the summary content.""" + summary_result = _call_ai_section(summary_prompt, 1, "SUMMARY", max_tokens=2048) + tree_block = "\n\n---\n\n## Project Structure\n\n```\n" + summary.get("project_tree", "") + "\n```\n" + generated = sanitize_markdown(summary_result + tree_block) if summary_result else tree_block.strip() + + _time.sleep(3) + + readme_prompt = f"""{project_context} + +Write a README with: + +## Title & Description +What the project is and who it's for (3-4 sentences). + +## Key Features +5-7 bullet features from the app modules. + +## Quick Start +```bash +git clone {clone_url or ''} +cd {cd_dir} +{pm_cmd} +``` +Then run: `python manage.py migrate && python manage.py runserver` (or equivalent for the framework). + +## Architecture & Project Structure +Describe the monolithic layout, the apps under `backend/apps/`, and the data flow (2-3 paragraphs). + +Output ONLY the README content.""" + readme_result = _call_ai_section(readme_prompt, 0, "README", max_tokens=2048) + readme = sanitize_markdown(readme_result) if readme_result else f"# {project.get('name')}\n\nNo README generated." + + send_ai_docs(req.project_id, { + "generated_docs": generated, + "readme_docs": readme, + "status": "done", + }) + else: + fallback = f"""# {project.get('name')} + +## Overview +Mock project documentation for {project.get('name')}. + +## Project Structure +{summary['project_tree']} + +## Dependencies +{' '.join(summary.get('dependencies', [])) or 'Not detected'} + +> Configure GROQ_API_KEY to generate AI-powered documentation.""" + send_ai_docs(req.project_id, { + "generated_docs": sanitize_markdown(fallback), + "readme_docs": sanitize_markdown(fallback), + "status": "done", + }) + + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + project = get_project(req.project_id) + return GenerateResponse( + project_id=req.project_id, + status="done", + generated_docs=project.get("generated_docs"), + readme_docs=project.get("readme_docs"), + api_docs=project.get("api_docs"), + ) + + except Exception as e: + import traceback + logger.error("Generation failed: %s\n%s", str(e), traceback.format_exc()) + send_ai_docs(req.project_id, { + "status": "failed", + "error_message": str(e), + }) + return GenerateResponse( + project_id=req.project_id, + status="failed", + ) diff --git a/services/ai/api/routes/health.py b/services/ai/api/routes/health.py new file mode 100644 index 0000000..05ba17e --- /dev/null +++ b/services/ai/api/routes/health.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter +from ...config.config import settings + +router = APIRouter() + + +@router.get("/health") +def health(): + return { + "status": "ok", + "service": "ai", + "groq_configured": bool(settings.GROQ_API_KEY), + "embedding_model": settings.EMBEDDING_MODEL, + } diff --git a/services/ai/api/routes/status.py b/services/ai/api/routes/status.py new file mode 100644 index 0000000..72c3e48 --- /dev/null +++ b/services/ai/api/routes/status.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, HTTPException, Depends +from ...common.django_client import get_project +from ..deps import verify_internal_key + +router = APIRouter() + + +@router.get("/status/{project_id}") +def ai_status(project_id: str, _auth: str = Depends(verify_internal_key)): + project = get_project(project_id) + if not project: + raise HTTPException(404, "Project not found") + return { + "project_id": project_id, + "status": project.get("status"), + "has_docs": bool(project.get("generated_docs")), + } diff --git a/services/ai/common/__init__.py b/services/ai/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/common/auth.py b/services/ai/common/auth.py new file mode 100644 index 0000000..3f77041 --- /dev/null +++ b/services/ai/common/auth.py @@ -0,0 +1,27 @@ +import os +import jwt +from fastapi import HTTPException, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +security = HTTPBearer(auto_error=False) + + +def verify_django_jwt(credentials: HTTPAuthorizationCredentials = Depends(security)): + """Verify a Django SimpleJWT access token. + Shared across FastAPI services for authentication via Django-issued JWTs. + """ + if credentials is None: + raise HTTPException(401, "Missing authorization header") + token = credentials.credentials + try: + payload = jwt.decode( + token, + os.getenv("DJANGO_SECRET_KEY", ""), + algorithms=["HS256"], + options={"verify_exp": True}, + ) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(401, "Token expired") + except jwt.InvalidTokenError: + raise HTTPException(401, "Invalid token") diff --git a/services/ai/common/django_client.py b/services/ai/common/django_client.py new file mode 100644 index 0000000..d6390bc --- /dev/null +++ b/services/ai/common/django_client.py @@ -0,0 +1,62 @@ +import os +import httpx +from typing import Any + +DJANGO_INTERNAL_URL = os.getenv( + "DJANGO_INTERNAL_URL", + "http://django:8000/api/internal", +) +INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "") + + +def _headers() -> dict: + return { + "X-Internal-Api-Key": INTERNAL_API_KEY, + "Content-Type": "application/json", + } + + +def get_project(project_id: str) -> dict[str, Any] | None: + resp = httpx.get( + f"{DJANGO_INTERNAL_URL}/projects/{project_id}/", + headers=_headers(), + timeout=30, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +def update_project(project_id: str, data: dict) -> dict[str, Any]: + resp = httpx.patch( + f"{DJANGO_INTERNAL_URL}/projects/{project_id}/", + headers=_headers(), + json=data, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +def get_project_files(project_id: str) -> list[dict[str, Any]]: + resp = httpx.get( + f"{DJANGO_INTERNAL_URL}/projects/{project_id}/files/", + headers=_headers(), + timeout=30, + ) + if resp.status_code == 404: + return [] + resp.raise_for_status() + return resp.json() + + +def send_ai_docs(project_id: str, data: dict): + resp = httpx.post( + f"{DJANGO_INTERNAL_URL}/projects/{project_id}/ai-docs/", + headers=_headers(), + json=data, + timeout=30, + ) + resp.raise_for_status() + return resp.json() diff --git a/services/ai/config/__init__.py b/services/ai/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/config/config.py b/services/ai/config/config.py new file mode 100644 index 0000000..333004f --- /dev/null +++ b/services/ai/config/config.py @@ -0,0 +1,16 @@ +import os + + +class Settings: + GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "") + GROQ_API_KEY_2: str = os.getenv("GROQ_API_KEY_2", "") + INTERNAL_API_KEY: str = os.getenv("INTERNAL_API_KEY", "") + EMBEDDING_MODEL: str = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2") + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + "postgresql://pydocai_user:pydocai_pass@localhost:5433/pydocai" + ) + CORS_ALLOWED_ORIGINS: str = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost:5173") + + +settings = Settings() diff --git a/services/ai/database.py b/services/ai/database.py new file mode 100644 index 0000000..a105d21 --- /dev/null +++ b/services/ai/database.py @@ -0,0 +1,34 @@ +import os +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker, declarative_base + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://pydocai_user:pydocai_pass@localhost:5433/pydocai" +) + +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + pool_recycle=3600, +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def create_tables(): + with engine.connect() as conn: + conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + conn.commit() + from models import Project, ProjectFile, CodeEmbedding + Base.metadata.create_all(bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/services/ai/docker/Dockerfile b/services/ai/docker/Dockerfile new file mode 100644 index 0000000..eb7954e --- /dev/null +++ b/services/ai/docker/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.11-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends gcc && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +FROM python:3.11-slim + +RUN groupadd -r app && useradd -r -g app app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +WORKDIR /app + +COPY . . + +RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" && \ + chown -R app:app /app + +USER app + +EXPOSE 8003 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8003/health')" || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8003", "--workers", "2"] diff --git a/services/ai/main.py b/services/ai/main.py new file mode 100644 index 0000000..fb50e4e --- /dev/null +++ b/services/ai/main.py @@ -0,0 +1,57 @@ +import os +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from database import create_tables, engine +from config.config import settings + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starting AI service — creating tables if needed") + create_tables() + yield + logger.info("Shutting down AI service — disposing connection pool") + engine.dispose() + + +app = FastAPI( + title="PyDocAI AI Generator Service", + version="0.2.0", + lifespan=lifespan, +) + +origins = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost:5173").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=origins if origins != ["*"] else ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.exception("Unhandled exception on %s %s", request.method, request.url.path) + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"}, + ) + + +from api.routes.health import router as health_router +from api.routes.generate import router as generate_router +from api.routes.status import router as status_router + +app.include_router(health_router) +app.include_router(generate_router, prefix="/api/ai") +app.include_router(status_router, prefix="/api/ai") diff --git a/services/ai/models/__init__.py b/services/ai/models/__init__.py new file mode 100644 index 0000000..aafb569 --- /dev/null +++ b/services/ai/models/__init__.py @@ -0,0 +1,3 @@ +from .code_embedding import CodeEmbedding + +__all__ = ["CodeEmbedding"] diff --git a/services/ai/models/code_embedding.py b/services/ai/models/code_embedding.py new file mode 100644 index 0000000..974833c --- /dev/null +++ b/services/ai/models/code_embedding.py @@ -0,0 +1,19 @@ +import uuid +from datetime import datetime +from sqlalchemy import Column, String, Text, DateTime, JSON, ForeignKey, Integer +from sqlalchemy.dialects.postgresql import UUID +from pgvector.sqlalchemy import Vector +from ..database import Base + + +class CodeEmbedding(Base): + __tablename__ = "code_embeddings" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False, index=True) + file_path = Column(String(500), nullable=False) + chunk_type = Column(String(20), nullable=False) + chunk_text = Column(Text, nullable=False) + embedding = Column(Vector(384), nullable=False) + chunk_metadata = Column("metadata", JSON, default=dict) + created_at = Column(DateTime, default=datetime.utcnow) diff --git a/services/ai/rag.py b/services/ai/rag.py new file mode 100644 index 0000000..d08581b --- /dev/null +++ b/services/ai/rag.py @@ -0,0 +1,226 @@ +import os +import logging +from typing import List, Optional +from sqlalchemy.orm import Session + +logger = logging.getLogger("ai.rag") + +_model = None + + +def _ensure_dict(f): + if isinstance(f, dict): + return f + return { + "file_path": f.file_path, + "parsed_data": getattr(f, "parsed_data", {}), + "content": getattr(f, "content", ""), + "file_name": getattr(f, "file_name", ""), + } + + +def get_embedding_model(): + global _model + if _model is None: + from sentence_transformers import SentenceTransformer + model_name = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2") + logger.info("Loading embedding model: %s", model_name) + _model = SentenceTransformer(model_name) + return _model + + +def embed_text(text: str) -> list[float]: + model = get_embedding_model() + return model.encode(text).tolist() + + +def chunk_parsed_data(project_id: str, files: list) -> list[dict]: + chunks = [] + for f in files: + d = _ensure_dict(f) + file_path = d["file_path"] + parsed = d.get("parsed_data") or {} + + if parsed.get("module_docstring") or parsed.get("imports"): + lines = [f"File: {file_path}"] + if parsed.get("module_docstring"): + lines.append(f"Module: {parsed['module_docstring']}") + imports = parsed.get("imports", []) + if imports: + imp_strs = [] + for i in imports: + if isinstance(i, dict): + imp_strs.append(i.get("display", str(i))) + else: + imp_strs.append(str(i)) + lines.append(f"Imports: {' '.join(imp_strs)}") + chunks.append({ + "project_id": project_id, + "file_path": file_path, + "chunk_type": "module", + "chunk_text": "\n".join(lines), + "metadata": {"file_path": file_path, "type": "module"}, + }) + + for item in parsed.get("ordered_items", []): + typ = item["type"] + data = item["data"] + if typ == "import": + continue + if typ == "function": + chunk_text = _function_to_text(data, file_path) + elif typ == "class": + chunk_text = _class_to_text(data, file_path) + else: + continue + chunks.append({ + "project_id": project_id, + "file_path": file_path, + "chunk_type": typ, + "chunk_text": chunk_text, + "metadata": { + "file_path": file_path, + "type": typ, + "name": data.get("name", ""), + "line": data.get("line", 0), + }, + }) + return chunks + + +def _function_to_text(data: dict, file_path: str) -> str: + args = ", ".join(a["name"] for a in data.get("args", [])) + decorators = ", ".join(data.get("decorators", [])) + connections = ", ".join(data.get("connections", [])) + return ( + f"File: {file_path}\n" + f"Function: {data['name']}({args})\n" + f"Returns: {data.get('returns', 'None')}\n" + f"Async: {data.get('is_async', False)}\n" + f"Decorators: {decorators}\n" + f"Lines: {data.get('line', '?')}-{data.get('end_line', '?')}\n" + f"Calls: {connections}\n" + f"Docstring: {data.get('docstring', '')}" + ) + + +def _class_to_text(data: dict, file_path: str) -> str: + methods = [] + for m in data.get("methods", []): + margs = ", ".join(a["name"] for a in m.get("args", [])) + methods.append(f" {m['name']}({margs}) -> {m.get('returns', 'None')}") + bases = ", ".join(data.get("bases", [])) + connections = ", ".join(data.get("connections", [])) + return ( + f"File: {file_path}\n" + f"Class: {data['name']}({bases})\n" + f"Lines: {data.get('line', '?')}-{data.get('end_line', '?')}\n" + f"Methods:\n" + "\n".join(methods) + "\n" + f"Uses: {connections}\n" + f"Docstring: {data.get('docstring', '')}" + ) + + +def embed_and_store_chunks(project_id, files, db: Session): + from models import CodeEmbedding + + chunks = chunk_parsed_data(project_id, files) + if not chunks: + return + + db.query(CodeEmbedding).filter( + CodeEmbedding.project_id == project_id + ).delete() + db.flush() + + model = get_embedding_model() + texts = [c["chunk_text"] for c in chunks] + embeddings = model.encode(texts).tolist() + + for chunk, embedding in zip(chunks, embeddings): + ce = CodeEmbedding( + project_id=chunk["project_id"], + file_path=chunk["file_path"], + chunk_type=chunk["chunk_type"], + chunk_text=chunk["chunk_text"], + embedding=embedding, + metadata=chunk["metadata"], + ) + db.add(ce) + db.commit() + logger.info("Stored %d embeddings for project %s", len(chunks), project_id) + + +def _detect_pattern(results) -> tuple[bool, str]: + if len(results) < 3: + return False, "" + types = {} + for r in results: + t = r.chunk_type + if t not in types: + types[t] = [] + types[t].append(r) + for typ, items in types.items(): + if len(items) >= 3 and typ in ("function", "class"): + names = [r.chunk_metadata.get("name", "?") if r.chunk_metadata else "?" for r in items] + display = ", ".join(names[:5]) + if len(names) > 5: + display += "..." + return True, f"Pattern: {len(items)} similar {typ}s ({display}). Write concise differential docs focusing on what makes each unique." + return False, "" + + +def retrieve_context(project_id, query: str, top_k: int = 5, db: Session = None) -> tuple[str, bool]: + from models import CodeEmbedding + + query_emb = embed_text(query) + + results = ( + db.query(CodeEmbedding) + .filter(CodeEmbedding.project_id == project_id) + .order_by(CodeEmbedding.embedding.cosine_distance(query_emb)) + .limit(top_k) + .all() + ) + + if not results: + return "", False + + is_pattern, pattern_desc = _detect_pattern(results) + + context_parts = [] + for r in results: + meta = r.chunk_metadata or {} + name = meta.get("name", "unknown") + prev_doc = meta.get("prev_doc", "") + header = f"Related code in {r.file_path} ({r.chunk_type}: {name})" + chunk = f"{header}\n{r.chunk_text}" + if prev_doc: + chunk += f"\nPreviously generated doc:\n{prev_doc}" + context_parts.append(chunk) + + context_text = "\n\n".join(context_parts) + if is_pattern and pattern_desc: + context_text = pattern_desc + "\n\n" + context_text + + return context_text, is_pattern + + +def store_generated_doc(project_id, chunk_type: str, item_name: str, file_path: str, generated_text: str, db: Session): + from models import CodeEmbedding + embeddings = ( + db.query(CodeEmbedding) + .filter( + CodeEmbedding.project_id == project_id, + CodeEmbedding.file_path == file_path, + CodeEmbedding.chunk_type == chunk_type, + ) + .all() + ) + for emb in embeddings: + meta = emb.chunk_metadata or {} + if meta.get("name") == item_name: + meta["prev_doc"] = generated_text[:500] + emb.chunk_metadata = meta + db.commit() + return diff --git a/services/ai/requirements.txt b/services/ai/requirements.txt new file mode 100644 index 0000000..89c8332 --- /dev/null +++ b/services/ai/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.34.0 +sqlalchemy>=2.0.36 +psycopg2-binary>=2.9.12 +groq>=1.2.0 +redis>=7.4.0 +httpx>=0.28.0 +pydantic>=2.10.0 +python-dotenv>=1.2.2 +sentence-transformers>=3.0.0 +pgvector>=0.3.0 diff --git a/services/ai/schemas/__init__.py b/services/ai/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/schemas/requests.py b/services/ai/schemas/requests.py new file mode 100644 index 0000000..c4a7e76 --- /dev/null +++ b/services/ai/schemas/requests.py @@ -0,0 +1,8 @@ +from typing import Optional +from pydantic import BaseModel + + +class GenerateRequest(BaseModel): + project_id: str + file_path: Optional[str] = None + use_ai: bool = True diff --git a/services/ai/schemas/responses.py b/services/ai/schemas/responses.py new file mode 100644 index 0000000..b5c2b5c --- /dev/null +++ b/services/ai/schemas/responses.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import BaseModel + + +class GenerateResponse(BaseModel): + project_id: str + status: str + generated_docs: Optional[str] = None + readme_docs: Optional[str] = None + api_docs: Optional[str] = None diff --git a/services/ai/services/__init__.py b/services/ai/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/services/docs_builder.py b/services/ai/services/docs_builder.py new file mode 100644 index 0000000..c39fbc9 --- /dev/null +++ b/services/ai/services/docs_builder.py @@ -0,0 +1,195 @@ +def _ensure_dict(f): + """Normalize file data to dict (works with SQLAlchemy objects or API dicts).""" + if isinstance(f, dict): + return f + return { + "file_path": f.file_path, + "content": getattr(f, "content", ""), + "parsed_data": getattr(f, "parsed_data", {}), + "file_name": getattr(f, "file_name", ""), + "file_size": getattr(f, "file_size", 0), + } + + +def build_structure_tree(ordered_items: list) -> str: + lines = [] + for item in ordered_items: + typ = item["type"] + data = item["data"] + prefix = "├── " if item != ordered_items[-1] else "└── " + if typ == "import": + lines.append(f"{prefix}[Import] {data.get('display', '')}") + elif typ == "function": + args = ", ".join(a["name"] for a in data.get("args", [])) + lines.append(f"{prefix}[Function] {data['name']}({args}) -> {data.get('returns', 'None')}") + elif typ == "class": + bases = ", ".join(data.get("bases", [])) + base_str = f"({bases})" if bases else "" + lines.append(f"{prefix}[Class] {data['name']}{base_str}") + for i, m in enumerate(data.get("methods", [])): + m_prefix = " ├── " if i < len(data["methods"]) - 1 else " └── " + m_args = ", ".join(a["name"] for a in m.get("args", [])) + lines.append(f"{m_prefix}{m['name']}({m_args})") + return "\n".join(lines) + + +def postman_body_example(route: str) -> str: + examples = { + "register": '\n\n{\n "username": "johndoe",\n "email": "john@example.com",\n "password": "********"\n}', + "login": '\n\n{\n "username": "johndoe",\n "password": "********"\n}', + "change-password": '\n\n{\n "old_password": "********",\n "new_password": "********"\n}', + "password-reset": '\n\n{\n "email": "john@example.com"\n}', + "password-reset/confirm": '\n\n{\n "token": "...",\n "new_password": "********"\n}', + "import": '\n\n{\n "repo_url": "https://github.com/user/repo"\n}', + "folder": '\n\n{\n "folder_path": "src/"\n}', + "file": '\n\n{\n "file_path": "src/main.py",\n "content": "# code here"\n}', + "auth/github": '\n\n{\n "code": "github_oauth_code"\n}', + } + for key, body in examples.items(): + if key in route: + return body + return '\n\n{\n \n}' + + +def build_api_docs(files: list) -> str: + import re as _re + view_routes = {} + url_prefixes = {} + + dict_files = [_ensure_dict(f) for f in files] + + for f in dict_files: + fp = f["file_path"] + content = f.get("content") or "" + if fp.endswith("urls.py") and content: + for m in _re.finditer( + r"(?:path|re_path)\(\s*(['\"])(.+?)\1\s*,\s*include\((['\"])(.+?)\3\)", + content, + ): + route, included = m.group(2), m.group(4) + included_path = included.replace(".urls", "").replace(".", "/") + url_prefixes[included_path] = route + + for f in dict_files: + fp = f["file_path"] + content = f.get("content") or "" + if fp.endswith("urls.py") and content: + file_key = fp.replace("\\", "/") + parts = file_key.split("/") + prefix = "" + for p in range(len(parts)): + candidate = "/".join(parts[p:]).replace("/urls.py", "").replace(".", "/") + if candidate in url_prefixes: + prefix = url_prefixes[candidate] + break + + for m in _re.finditer( + r"(?:path|re_path)\(\s*(['\"])(.+?)\1\s*,\s*([^)]+)", + content, + ): + route = m.group(2) + view_expr = m.group(3).strip() + if view_expr.startswith("include") or view_expr.startswith("("): + continue + name_match = _re.search(r'(\w+)\.as_view\(', view_expr) + if name_match: + view_name = name_match.group(1) + else: + name_match = _re.search(r'(\w+)$', view_expr) + if name_match: + view_name = name_match.group(1) + else: + continue + full_route = f"{prefix.strip('/')}/{route.strip('/')}" + view_routes[view_name] = full_route + + app_docs = {} + for f in dict_files: + parsed = f.get("parsed_data") or {} + if not parsed: + continue + fp = f["file_path"] + if not (fp.endswith("views.py") or fp.endswith("admin.py")): + continue + parts = fp.replace("\\", "/").split("/") + app_name = "other" + for i, p in enumerate(parts): + if p == "apps" and i + 1 < len(parts): + app_name = parts[i + 1] + break + if p == "config": + app_name = "config" + break + if app_name not in app_docs: + app_docs[app_name] = [] + + for item in parsed.get("ordered_items", []): + if item["type"] not in ("function", "class"): + continue + data = item["data"] + name = data["name"] + docstring = (data.get("docstring") or "")[:150] + route = view_routes.get(name, "") + methods = [] + if item["type"] == "class": + for m in data.get("methods", []): + mname = m["name"] + if mname in ("get", "post", "put", "patch", "delete", "head", "options"): + methods.append(mname.upper()) + else: + methods.append(mname) + desc = docstring.replace("\n", " ") if docstring else "" + if not route and not methods: + continue + dedup_methods = list(dict.fromkeys(methods)) + http_verbs = [m for m in dedup_methods if m in ("GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS")] + custom_methods = [m for m in dedup_methods if m not in ("GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS")] + app_docs[app_name].append({ + "name": name, "type": item["type"], "route": route, + "http_verbs": http_verbs, "custom_methods": custom_methods, + "description": desc, "file": fp, + }) + + if not app_docs: + return "# API Documentation\n\nNo API endpoints found." + + lines = ["# API Documentation\n"] + for app_name in sorted(app_docs.keys()): + entries = app_docs[app_name] + if not entries: + continue + lines.append(f"## {app_name}\n") + for e in entries: + name = e["name"] + desc = e["description"] if e["description"] else "—" + file_path = e["file"] + + if e["route"]: + clean_route = f"/{e['route'].strip('/')}" + methods_line = "`, `".join(e["http_verbs"]) if e["http_verbs"] else "—" + lines.append(f"### {name}\n") + lines.append(f"`{methods_line}` `{clean_route}`\n") + lines.append(f"{desc}\n") + if e["custom_methods"]: + lines.append(f"**Custom methods:** `{', '.join(e['custom_methods'])}`\n") + methods_lower = set(m.lower() for m in e["http_verbs"]) + body = postman_body_example(clean_route) if any(v in methods_lower for v in ("post","put","patch")) else "" + if "post" in methods_lower: + lines.append(f"**Postman:**\n```\nPOST http://localhost:8000{clean_route}\nAuthorization: Bearer \nContent-Type: application/json{body}\n```\n") + elif "put" in methods_lower: + lines.append(f"**Postman:**\n```\nPUT http://localhost:8000{clean_route}\nAuthorization: Bearer \nContent-Type: application/json{body}\n```\n") + elif "patch" in methods_lower: + lines.append(f"**Postman:**\n```\nPATCH http://localhost:8000{clean_route}\nAuthorization: Bearer \nContent-Type: application/json{body}\n```\n") + elif "delete" in methods_lower: + lines.append(f"**Postman:**\n```\nDELETE http://localhost:8000{clean_route}\nAuthorization: Bearer \n```\n") + elif "get" in methods_lower: + lines.append(f"**Postman:**\n```\nGET http://localhost:8000{clean_route}\nAuthorization: Bearer \n```\n") + else: + methods_str = ", ".join(e["http_verbs"] + e["custom_methods"]) if (e["http_verbs"] or e["custom_methods"]) else "—" + lines.append(f"### {name}\n") + lines.append(f"**Methods:** `{methods_str}`\n") + lines.append(f"{desc}\n") + + lines.append(f"**File:** `{file_path}`\n") + lines.append("---\n") + return "\n".join(lines) diff --git a/services/ai/services/generation.py b/services/ai/services/generation.py new file mode 100644 index 0000000..738fc0e --- /dev/null +++ b/services/ai/services/generation.py @@ -0,0 +1,278 @@ +import os +import json +import logging +from typing import Optional +from sqlalchemy.orm import Session + +from ..services.groq import call_groq +from ..services.markdown import sanitize_markdown +from ..services.prompts import get_item_docs_prompt +from ..services.docs_builder import build_structure_tree, build_api_docs +from ..rag import embed_and_store_chunks, retrieve_context, store_generated_doc + + +def retrieve_project_context(project_id: str, project_name: str, db: Session) -> str: + try: + rag_text, _ = retrieve_context( + project_id, + f"Project {project_name}: architecture, components, data flow, dependencies", + top_k=5, + db=db, + ) + if rag_text: + return rag_text + return "No additional code context available." + except Exception: + return "No additional code context available." + + +def mock_docs(parsed: dict, file_path: str, _framework_info: Optional[dict] = None) -> str: + imports = parsed.get("imports", []) + ordered = parsed.get("ordered_items", []) + + import_lines = [] + for imp in imports: + if isinstance(imp, dict): + import_lines.append(f'- `{imp.get("display", str(imp))}` (line {imp.get("line", "?")})') + else: + import_lines.append(f"- `{imp}`") + + doc_sections = [] + for item in ordered: + typ = item["type"] + data = item["data"] + if typ == "import": + pass + elif typ == "function": + args = ", ".join(a["name"] for a in data.get("args", [])) + returns = data.get("returns") or "None" + line = data.get("line", "?") + connections = data.get("connections", []) + conn_str = f" (calls: {', '.join(connections)})" if connections else "" + doc_sections.append( + f'### `{data["name"]}({args}) -> {returns}`\n' + f'- **Line:** {line}{conn_str}\n' + f'- **Purpose:** Mock documentation' + ) + elif typ == "class": + line = data.get("line", "?") + bases = ", ".join(data.get("bases", [])) + base_str = f"({bases})" if bases else "" + connections = data.get("connections", []) + conn_str = f" (uses: {', '.join(connections)})" if connections else "" + doc_sections.append( + f'### `{data["name"]}{base_str}`\n' + f'- **Line:** {line}{conn_str}\n' + f'- **Methods:** {", ".join(m["name"] for m in data.get("methods", []))}' + ) + + return f"""# {file_path} + +## Overview +Mock documentation generated for development purposes. This shows how the documentation will be structured with source order preserved. + +## Imports +{chr(10).join(import_lines) or 'No imports'} + +## Detailed Documentation (IN SOURCE ORDER) + +{chr(10).join(doc_sections) or 'No functions or classes'} + +> This is mock documentation. Configure GROQ_API_KEY to generate real AI-powered docs. +""" + + +def generate_file_docs(parsed: dict, file_path: str, framework_info: Optional[dict] = None, db: Session = None, project_id: str = None) -> str: + ordered_items = parsed.get("ordered_items", []) + module_doc = parsed.get("module_docstring") or "No module docstring" + imports = parsed.get("imports", []) + + import_displays = [] + for imp in imports: + if isinstance(imp, dict): + import_displays.append(imp.get("display", str(imp))) + else: + import_displays.append(str(imp)) + + structure_tree = build_structure_tree(ordered_items) + + fw_header = "" + fw_instructions = "" + if framework_info and framework_info.get("primary_framework"): + fw = framework_info["primary_framework"] + fw_header = f"\nFramework: {fw}\n" + fw_type = framework_info.get("primary_type", "") + if fw_type == "web": + fw_instructions = "\nSpecial instructions: When documenting Python web framework code, include details about routes, middleware, request/response handling, and dependency injection where applicable." + elif fw_type == "task_queue": + fw_instructions = "\nSpecial instructions: When documenting task queue code, include details about task signatures, queues, retries, and result backends where applicable." + elif fw_type == "orm": + fw_instructions = "\nSpecial instructions: When documenting ORM code, include details about model definitions, relationships, sessions, and query patterns where applicable." + elif fw_type == "ai": + fw_instructions = "\nSpecial instructions: When documenting AI/LLM code, include details about model configuration, prompts, streaming, and error handling where applicable." + + rag_context = "" + if db and project_id: + try: + rag_context, _ = retrieve_context(project_id, f"Module overview of {file_path}: {module_doc}", top_k=3, db=db) + except Exception: + pass + + rag_block = f"\n\nRelevant code context from project:\n{rag_context}\n\n---\n" if rag_context else "" + + overview_prompt = f"""{rag_block} +Generate the beginning sections for the Python file {file_path}.{fw_header} +Module docstring: {module_doc} +Imports: {', '.join(import_displays)} + +Output ONLY the following sections in markdown (nothing else): + +# {file_path} + +## Overview +3-5 detailed paragraphs on what this module does, its purpose, architecture, and key components. Include a mermaid flowchart showing the module architecture and data flow. + +## Code Structure (Source Order) +Paste the structure tree below EXACTLY as shown: + +{structure_tree} + +## Imports +For each import, provide a DETAILED table row with ALL columns: +| Import | Purpose | Where Used | Notes | +|--------|---------|----------|-------| +| ... | ... | ... | ... | + +## Notes +Any additional observations about the module. +""" + overview_docs = call_groq(overview_prompt, max_tokens=2048, key_start=0) + + item_docs = [] + for i, item in enumerate(ordered_items): + typ = item["type"] + data = item["data"] + if typ == "import": + continue + elif typ in ("function", "class"): + item_prompt = get_item_docs_prompt(typ, data, file_path) + if db and project_id: + try: + item_rag, is_pattern = retrieve_context( + project_id, f"{typ}: {data.get('name', '')} in {file_path}", top_k=3, db=db + ) + if item_rag: + item_prompt = get_item_docs_prompt(typ, data, file_path, is_pattern=is_pattern) + item_prompt = f"Relevant code context from project:\n{item_rag}\n\n---\n\n" + item_prompt + except Exception: + pass + docs = call_groq(item_prompt, max_tokens=2048, key_start=(i + 1) % 2) + item_docs.append(docs) + if db and project_id and docs: + try: + store_generated_doc(project_id, typ, data["name"], file_path, docs, db) + except Exception: + pass + + result = overview_docs + "\n\n" + result += "## Detailed Documentation (IN SOURCE ORDER)\n\n" + result += "\n\n---\n\n".join(item_docs) + result += "\n\n## End of Documentation" + + return result.strip() + + +def generate_project_summary(project_path: str, project_name: str = None) -> dict: + summary = { + "name": project_name or os.path.basename(project_path), + "framework": "python", + "architecture": "monolith", + "apps": [], + "dependencies": [], + "file_count": 0, + "project_tree": "", + "package_manager": "pip", + } + + root_contents = set(os.listdir(project_path)) + if "uv.lock" in root_contents or "uv.toml" in root_contents: + summary["package_manager"] = "uv" + elif "poetry.lock" in root_contents: + summary["package_manager"] = "poetry" + elif "Pipfile" in root_contents: + summary["package_manager"] = "pipenv" + elif "pyproject.toml" in root_contents: + summary["package_manager"] = "pip" + else: + summary["package_manager"] = "pip" + + dir_map = {} + for root, dirs, files in os.walk(project_path): + dirs[:] = [d for d in dirs if d not in { + "venv", ".venv", "__pycache__", "node_modules", + "migrations", ".git", "build", "dist", ".egg-info" + }] + rel_dir = os.path.relpath(root, project_path) + if rel_dir == ".": + rel_dir = "" + for f in files: + if f.endswith(".py"): + dir_map.setdefault(rel_dir, []).append(f) + + summary["file_count"] = sum(len(v) for v in dir_map.values()) + + def _build_tree(dir_map): + root = {} + for dir_path, files in dir_map.items(): + if not dir_path: + continue + parts = dir_path.replace("\\", "/").split("/") + node = root + for p in parts: + node = node.setdefault(p, {}) + node["__files__"] = files + return root + + def _render_tree(node, prefix="", is_last=True): + lines = [] + items = list(node.items()) + items.sort(key=lambda x: (x[0] == "__files__", x[0])) + for i, (key, val) in enumerate(items): + if key == "__files__": + files = sorted(val) + for fi, f in enumerate(files): + conn = "└── " if fi == len(files) - 1 else "├── " + lines.append(f"{prefix}{conn}{f}") + else: + conn = "└── " if i == len(items) - 1 else "├── " + lines.append(f"{prefix}{conn}{key}/") + ext = " " if i == len(items) - 1 else "│ " + sub_lines = _render_tree(val, prefix + ext, i == len(items) - 1) + lines.extend(sub_lines) + return lines + + tree = _build_tree(dir_map) + tree_lines = [] + top_dirs = sorted(k for k in tree if k != "__files__") + for ti, td in enumerate(top_dirs): + tree_lines.append(f"{td}/") + sub = _render_tree(tree[td], "", ti == len(top_dirs) - 1) + tree_lines.extend(sub) + summary["project_tree"] = "\n".join(tree_lines) + + for root, dirs, files in os.walk(project_path): + rel = os.path.relpath(root, project_path) + if rel == ".": + continue + if "apps.py" in files or "models.py" in files: + summary["apps"].append(rel.replace(os.sep, ".")) + + req_files = ["requirements.txt", "pyproject.toml", "Pipfile"] + for rf in req_files: + rpath = os.path.join(project_path, rf) + if os.path.exists(rpath): + with open(rpath) as f: + summary["dependencies"] = f.read().splitlines() + break + + return summary diff --git a/services/ai/services/groq.py b/services/ai/services/groq.py new file mode 100644 index 0000000..e90696e --- /dev/null +++ b/services/ai/services/groq.py @@ -0,0 +1,43 @@ +import os +import time +from fastapi import HTTPException +from ..config.config import settings + + +def call_groq(prompt: str, max_tokens: int = 2048, key_start: int = 0, model: str = "llama-3.1-8b-instant") -> str: + from groq import Groq + + key_pool = [] + if settings.GROQ_API_KEY: + key_pool.append(("key1", settings.GROQ_API_KEY)) + if settings.GROQ_API_KEY_2: + key_pool.append(("key2", settings.GROQ_API_KEY_2)) + if not key_pool: + raise HTTPException(503, "No Groq API keys configured") + keys_to_try = key_pool[key_start:] + key_pool[:key_start] + for name, key in keys_to_try: + for attempt in range(3): + try: + client = Groq(api_key=key) + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + max_tokens=max_tokens, + ) + result = response.choices[0].message.content.strip() + if result.startswith("```"): + result = result.split("```", 2)[-1].strip() + if result.endswith("```"): + result = result[:-3].strip() + return result + except Exception as e: + status_code = getattr(e, 'status_code', 0) or getattr(e, 'status', 0) + body = getattr(e, 'body', '') or (str(e.args) if e.args else str(e))[:200] + if status_code in (401, 403): + break + if status_code == 429: + break + wait = 2 ** attempt + time.sleep(wait) + continue + raise HTTPException(503, "All Groq API keys failed") diff --git a/services/ai/services/markdown.py b/services/ai/services/markdown.py new file mode 100644 index 0000000..214e4a0 --- /dev/null +++ b/services/ai/services/markdown.py @@ -0,0 +1,45 @@ +import re + + +def sanitize_markdown(text: str) -> str: + if not text: + return text + text = re.sub(r'(^|\n)mermaid\s*\n', r'\1```mermaid\n', text) + lines = text.split("\n") + result = [] + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith("```mermaid"): + result.append(line) + i += 1 + while i < len(lines): + if lines[i].strip() == "```": + result.append(lines[i]) + i += 1 + break + elif lines[i].startswith("```mermaid"): + result.append("```") + break + elif lines[i].startswith("##") or lines[i].startswith("# "): + result.append("```") + result.append(lines[i]) + i += 1 + break + else: + result.append(lines[i]) + i += 1 + else: + result.append("```") + else: + result.append(lines[i]) + i += 1 + text = "\n".join(result) + text = re.sub(r'\bcode\s*\n\s*Copy\s*\n\s*python\s*\n', '```python\n', text) + text = re.sub(r'\bcode\s*\n\s*Copy\s*\n\s*(\w+)\s*\n', r'```\1\n', text) + text = re.sub(r'\bcode\s*\n\s*Copy\s*\n', '```\n', text) + text = re.sub(r'\n\s*```\s*\n\s*```\s*\n', '\n```\n', text) + text = re.sub(r'([^\n])\n(#{1,6} )', r'\1\n\n\2', text) + text = re.sub(r'(#{1,6} .+)\n([^\n#])', r'\1\n\n\2', text) + text = re.sub(r'\n{3,}', '\n\n', text) + return text.strip() diff --git a/services/ai/services/prompts.py b/services/ai/services/prompts.py new file mode 100644 index 0000000..b979130 --- /dev/null +++ b/services/ai/services/prompts.py @@ -0,0 +1,103 @@ +from typing import Optional + + +def get_item_docs_prompt(item_type: str, data: dict, file_path: str, is_pattern: bool = False) -> str: + if item_type == "function": + args_table = "| Parameter | Type | Description | Default | Constraints |\n|---|---|---|---|---|\n" + for a in data.get("args", []): + args_table += f"| {a['name']} | {a['type'] or 'Any'} | ... | ... | ... |\n" + connections = data.get("connections", []) + conn_str = ", ".join(connections) if connections else "None" + + if is_pattern: + return f"""This function follows the same pattern as other functions in this project. +Write a SHORT, differential doc focusing only on what makes THIS one unique. + +Function: `{data['name']}` +File: `{file_path}` +Line: {data.get('line', '?')} +Async: {data.get('is_async', False)} +Decorators: {', '.join(data.get('decorators', [])) or 'None'} +Parameters: +{args_table} +Returns: `{data.get('returns', 'None')}` +Calls/References: {conn_str} + +Provide ONLY: +- ### Purpose (1 sentence) +- ### Unique behavior (what differs from the pattern) +- ### Parameters table (just name and type) +- ### Returns (1 line) + +Output in clean markdown with headings. Keep it short.""" + return f"""Document the following Python function in detail using markdown. + +Function: `{data['name']}` +File: `{file_path}` +Line: {data.get('line', '?')} +Async: {data.get('is_async', False)} +Decorators: {', '.join(data.get('decorators', [])) or 'None'} +Parameters: +{args_table} +Returns: `{data.get('returns', 'None')}` +Calls/References: {conn_str} + +Provide: +- ### Purpose (2-3 sentences) +- ### Behavior (step-by-step) +- ### Parameters table (Parameter | Type | Description | Default | Constraints) +- ### Returns (type, description, possible values) +- ### Raises (all exceptions that can be raised) +- ### Relationships (Calls, Called By, Uses) +- ### Example Usage (Input/Output) +- ### Edge Cases +- ### Complexity (Big O) + +Output in clean markdown with headings.""" + elif item_type == "class": + methods_str = "" + for m in data.get("methods", []): + m_args = ", ".join(a["name"] for a in m.get("args", [])) + methods_str += f"- `{m['name']}({m_args}) -> {m.get('returns', 'None')}` (line {m.get('line', '?')})\n" + connections = data.get("connections", []) + conn_str = ", ".join(connections) if connections else "None" + + if is_pattern: + return f"""This class follows the same pattern as other classes in this project. +Write a SHORT, differential doc focusing only on what makes THIS one unique. + +Class: `{data['name']}` +File: `{file_path}` +Line: {data.get('line', '?')} +Bases: {', '.join(data.get('bases', [])) or 'None'} +Methods: +{methods_str} +Uses/References: {conn_str} + +Provide ONLY: +- ### Purpose (1 sentence) +- ### Unique behavior (what differs from the pattern) +- ### Attributes table (just name and type) +- ### Methods (1 line summary per method) + +Output in clean markdown with headings. Keep it short.""" + return f"""Document the following Python class in detail using markdown. + +Class: `{data['name']}` +File: `{file_path}` +Line: {data.get('line', '?')} +Bases: {', '.join(data.get('bases', [])) or 'None'} +Methods: +{methods_str} +Uses/References: {conn_str} + +Provide: +- ### Purpose (2-3 sentences) +- ### Attributes table (Attribute | Type | Description | Default) +- ### Methods (for each: purpose, parameters, returns, example) +- ### Inherits from (bases, inherited methods) +- ### Usage Example +- ### Relationships to other classes + +Output in clean markdown with headings.""" + return "" diff --git a/services/ai/tests/__init__.py b/services/ai/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai/tests/test_routes.py b/services/ai/tests/test_routes.py new file mode 100644 index 0000000..d8540e8 --- /dev/null +++ b/services/ai/tests/test_routes.py @@ -0,0 +1,95 @@ +from uuid import uuid4 +from unittest.mock import patch, MagicMock + +import pytest +from fastapi.testclient import TestClient + +from main import app + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def mock_env(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "") + monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:") + monkeypatch.setenv("DJANGO_INTERNAL_URL", "http://localhost:8000/api/internal") + + +@pytest.fixture +def mock_db(): + session = MagicMock() + + def gen(): + yield session + + with patch("api.deps.get_db", return_value=gen()): + yield session + + +class TestHealth: + def test_health_endpoint(self): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["service"] == "ai" + + +class TestGenerateDocs: + @patch("api.routes.generate.get_project") + def test_404_for_missing_project(self, mock_get): + mock_get.return_value = None + resp = client.post( + "/api/ai/generate/", + json={"project_id": str(uuid4())}, + ) + assert resp.status_code == 404 + + @patch("api.routes.generate.get_project") + @patch("api.routes.generate.get_project_files") + @patch("api.routes.generate.send_ai_docs") + @patch("api.routes.generate.update_project") + @patch("config.config.settings.GROQ_API_KEY", None) + def test_no_groq_key_uses_mock(self, mock_update, mock_send, mock_files, mock_get, mock_db): + pid = str(uuid4()) + mock_get.return_value = {"id": pid, "name": "Test", "source_type": "file", "framework_info": {}} + mock_files.return_value = [{ + "file_name": "test.py", + "file_path": "test.py", + "parsed_data": { + "functions": [{"name": "foo", "args": []}], + "classes": [], "imports": [], "error": False, + }, + "content": "def foo(): pass", + }] + + resp = client.post( + "/api/ai/generate/", + json={"project_id": pid}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "done" + + +class TestAIStatus: + @patch("api.routes.status.get_project") + def test_returns_project_status(self, mock_get): + mock_get.return_value = { + "id": str(uuid4()), + "status": "done", + "generated_docs": "# Docs", + } + + resp = client.get(f"/api/ai/status/{uuid4()}") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "done" + assert data["has_docs"] is True + + @patch("api.routes.status.get_project") + def test_404_for_unknown(self, mock_get): + mock_get.return_value = None + resp = client.get("/api/ai/status/00000000-0000-0000-0000-000000000000") + assert resp.status_code == 404 From 244f4377283febc29427d1637d54f61870522401 Mon Sep 17 00:00:00 2001 From: dennisjoseph2025-dotcom Date: Wed, 24 Jun 2026 14:57:47 +0530 Subject: [PATCH 3/4] feat: add FastAPI parser service under services/parser/ --- services/parser/__init__.py | 0 services/parser/api/__init__.py | 0 services/parser/api/deps.py | 12 ++ services/parser/api/routes/__init__.py | 0 services/parser/api/routes/file.py | 63 ++++++++ services/parser/api/routes/folder.py | 93 +++++++++++ services/parser/api/routes/health.py | 8 + services/parser/api/routes/status.py | 18 +++ services/parser/ast_parser.py | 153 +++++++++++++++++++ services/parser/common/__init__.py | 0 services/parser/common/auth.py | 24 +++ services/parser/common/django_client.py | 61 ++++++++ services/parser/config/__init__.py | 0 services/parser/config/config.py | 13 ++ services/parser/docker/Dockerfile | 31 ++++ services/parser/framework_detector.py | 195 ++++++++++++++++++++++++ services/parser/main.py | 55 +++++++ services/parser/requirements.txt | 6 + services/parser/schemas/__init__.py | 0 services/parser/schemas/requests.py | 21 +++ services/parser/services/__init__.py | 0 services/parser/tests/__init__.py | 0 services/parser/tests/test_routes.py | 114 ++++++++++++++ services/parser/validators.py | 36 +++++ 24 files changed, 903 insertions(+) create mode 100644 services/parser/__init__.py create mode 100644 services/parser/api/__init__.py create mode 100644 services/parser/api/deps.py create mode 100644 services/parser/api/routes/__init__.py create mode 100644 services/parser/api/routes/file.py create mode 100644 services/parser/api/routes/folder.py create mode 100644 services/parser/api/routes/health.py create mode 100644 services/parser/api/routes/status.py create mode 100644 services/parser/ast_parser.py create mode 100644 services/parser/common/__init__.py create mode 100644 services/parser/common/auth.py create mode 100644 services/parser/common/django_client.py create mode 100644 services/parser/config/__init__.py create mode 100644 services/parser/config/config.py create mode 100644 services/parser/docker/Dockerfile create mode 100644 services/parser/framework_detector.py create mode 100644 services/parser/main.py create mode 100644 services/parser/requirements.txt create mode 100644 services/parser/schemas/__init__.py create mode 100644 services/parser/schemas/requests.py create mode 100644 services/parser/services/__init__.py create mode 100644 services/parser/tests/__init__.py create mode 100644 services/parser/tests/test_routes.py create mode 100644 services/parser/validators.py diff --git a/services/parser/__init__.py b/services/parser/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/parser/api/__init__.py b/services/parser/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/parser/api/deps.py b/services/parser/api/deps.py new file mode 100644 index 0000000..af52d52 --- /dev/null +++ b/services/parser/api/deps.py @@ -0,0 +1,12 @@ +from fastapi import HTTPException, Depends +from fastapi.security import APIKeyHeader + +from config.config import settings + +api_key_header = APIKeyHeader(name="X-Internal-Api-Key", auto_error=False) + + +def verify_internal_key(key: str = Depends(api_key_header)): + if settings.INTERNAL_API_KEY and key != settings.INTERNAL_API_KEY: + raise HTTPException(401, "Invalid or missing internal API key") + return key diff --git a/services/parser/api/routes/__init__.py b/services/parser/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/parser/api/routes/file.py b/services/parser/api/routes/file.py new file mode 100644 index 0000000..fc6da3e --- /dev/null +++ b/services/parser/api/routes/file.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends + +from ast_parser import parse_python_file +from validators import validate_python_code +from framework_detector import detect_framework +from common.django_client import get_project, update_project, create_project_file +from api.deps import verify_internal_key + +router = APIRouter() + + +@router.post("/file/") +async def analyze_file( + _auth: str = Depends(verify_internal_key), + file: UploadFile = File(...), + project_id: str = Form(...), + name: str = Form("Untitled Project"), + description: str = Form(""), + file_path: str = Form(None), +): + if not file.filename.endswith(".py"): + raise HTTPException(400, "Only .py files are allowed") + + try: + source_code = (await file.read()).decode("utf-8") + except UnicodeDecodeError: + raise HTTPException(400, "File must be UTF-8 encoded") + + is_valid, err = validate_python_code(source_code) + if not is_valid: + raise HTTPException(400, err) + + parsed = parse_python_file(source_code) + + project = get_project(project_id) + if not project: + raise HTTPException(404, "Project not found") + + imports = [imp.get("display", str(imp)) if isinstance(imp, dict) else str(imp) for imp in parsed.get("imports", [])] + fw_info = detect_framework(imports, [file_path or file.filename], [source_code]) + + final_path = file_path or file.filename + create_project_file(project_id, { + "file_path": final_path, + "file_name": file.filename.split("/")[-1], + "file_size": len(source_code), + "content": source_code, + "parsed_data": parsed, + "generated_docs": "", + }) + + update_project(project_id, { + "framework_info": fw_info, + "parsed_data": parsed, + "status": "processing", + }) + + return { + "project_id": project_id, + "parsed": parsed, + "file_count": 1, + "framework": fw_info, + } diff --git a/services/parser/api/routes/folder.py b/services/parser/api/routes/folder.py new file mode 100644 index 0000000..511ca05 --- /dev/null +++ b/services/parser/api/routes/folder.py @@ -0,0 +1,93 @@ +import io +import json +import zipfile +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends + +from ast_parser import parse_python_file +from validators import validate_python_code, should_exclude +from framework_detector import detect_framework +from common.django_client import get_project, update_project, create_project_file +from api.deps import verify_internal_key + +router = APIRouter() + + +@router.post("/folder/") +async def analyze_folder( + folder: UploadFile = File(...), + _auth: str = Depends(verify_internal_key), + project_id: str = Form(...), + name: str = Form("Untitled Project"), + description: str = Form(""), + custom_info: str = Form(None), +): + if not folder.filename.endswith(".zip"): + raise HTTPException(400, "File must be a .zip") + + try: + zip_content = await folder.read() + zf = zipfile.ZipFile(io.BytesIO(zip_content)) + except zipfile.BadZipFile: + raise HTTPException(400, "Invalid zip file") + + py_files = [ + f for f in zf.namelist() + if f.endswith(".py") and not should_exclude(f) + ] + + if not py_files: + raise HTTPException(400, "No Python files found after filtering") + + project = get_project(project_id) + if not project: + raise HTTPException(404, "Project not found") + + if custom_info: + try: + update_project(project_id, {"custom_details": json.loads(custom_info)}) + except json.JSONDecodeError: + update_project(project_id, {"custom_details": {"details": custom_info}}) + + all_imports: list[str] = [] + all_paths: list[str] = [] + all_sources: list[str] = [] + + results = [] + for file_path in py_files: + try: + content = zf.read(file_path).decode("utf-8", errors="ignore") + is_valid, _ = validate_python_code(content) + if not is_valid: + continue + + parsed = parse_python_file(content) + create_project_file(project_id, { + "file_path": file_path, + "file_name": file_path.split("/")[-1], + "file_size": len(content), + "content": content, + "parsed_data": parsed, + "generated_docs": "", + }) + + imports = [imp.get("display", str(imp)) if isinstance(imp, dict) else str(imp) for imp in parsed.get("imports", [])] + all_imports.extend(imports) + all_paths.append(file_path) + all_sources.append(content) + results.append({"file_path": file_path, "parsed": parsed}) + except Exception: + continue + + fw_info = detect_framework(all_imports, all_paths, all_sources) + + update_project(project_id, { + "framework_info": fw_info, + "parsed_data": results, + "status": "processing", + }) + + return { + "project_id": project_id, + "files_parsed": len(results), + "framework": fw_info, + } diff --git a/services/parser/api/routes/health.py b/services/parser/api/routes/health.py new file mode 100644 index 0000000..1dc66ab --- /dev/null +++ b/services/parser/api/routes/health.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +def health(): + return {"status": "ok", "service": "parser"} diff --git a/services/parser/api/routes/status.py b/services/parser/api/routes/status.py new file mode 100644 index 0000000..b947a49 --- /dev/null +++ b/services/parser/api/routes/status.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, HTTPException, Depends +from common.django_client import get_project +from api.deps import verify_internal_key + +router = APIRouter() + + +@router.get("/status/{project_id}") +def parser_status(project_id: str, _auth: str = Depends(verify_internal_key)): + project = get_project(project_id) + if not project: + raise HTTPException(404, "Project not found") + files = project.get("files", []) + return { + "project_id": project_id, + "status": project.get("status"), + "files_count": len(files), + } diff --git a/services/parser/ast_parser.py b/services/parser/ast_parser.py new file mode 100644 index 0000000..e16b3de --- /dev/null +++ b/services/parser/ast_parser.py @@ -0,0 +1,153 @@ +import ast +from typing import Any + + +def parse_python_file(source_code: str) -> dict: + try: + tree = ast.parse(source_code) + except SyntaxError as e: + return { + "error": True, + "error_message": f"SyntaxError on line {e.lineno}: {e.msg}" + } + + result = { + "error": False, + "module_docstring": ast.get_docstring(tree), + "imports": [], + "functions": [], + "classes": [], + "ordered_items": [] + } + + top_level_names = set() + + for node in tree.body: + if isinstance(node, ast.FunctionDef): + top_level_names.add(node.name) + elif isinstance(node, ast.ClassDef): + top_level_names.add(node.name) + for item in node.body: + if isinstance(item, ast.FunctionDef): + top_level_names.add(item.name) + + for node in tree.body: + if isinstance(node, (ast.Import, ast.ImportFrom)): + import_entry = _extract_import(node) + result["imports"].append(import_entry) + result["ordered_items"].append({ + "type": "import", + "line": node.lineno, + "data": import_entry + }) + elif isinstance(node, ast.FunctionDef): + func_data = _extract_function(node, top_level_names) + result["functions"].append(func_data) + result["ordered_items"].append({ + "type": "function", + "line": node.lineno, + "data": func_data + }) + elif isinstance(node, ast.ClassDef): + class_data = _extract_class(node, top_level_names) + result["classes"].append(class_data) + result["ordered_items"].append({ + "type": "class", + "line": node.lineno, + "data": class_data + }) + + return result + + +def _extract_import(node) -> dict[str, Any]: + if isinstance(node, ast.Import): + names = [alias.name for alias in node.names] + return { + "line": node.lineno, + "type": "import", + "module": names[0] if len(names) == 1 else None, + "names": names, + "display": ', '.join(names) + } + else: + names = [alias.name for alias in node.names] + module = node.module or '' + return { + "line": node.lineno, + "type": "import-from", + "module": module, + "names": names, + "display": f"from {module} import {', '.join(names)}" + } + + +def _extract_function(node, available_names: set) -> dict[str, Any]: + connections = _find_connections(node, available_names) + return { + "name": node.name, + "args": extract_args(node), + "returns": extract_return_type(node), + "docstring": ast.get_docstring(node), + "decorators": [ast.unparse(d) for d in node.decorator_list], + "line": node.lineno, + "is_async": isinstance(node, ast.AsyncFunctionDef), + "connections": connections + } + + +def _extract_class(node, available_names: set) -> dict[str, Any]: + methods = [] + class_connections = _find_connections(node, available_names) + + for item in node.body: + if isinstance(item, ast.FunctionDef): + method_connections = _find_connections(item, available_names) + methods.append({ + "name": item.name, + "args": extract_args(item), + "returns": extract_return_type(item), + "docstring": ast.get_docstring(item), + "is_private": item.name.startswith("_"), + "line": item.lineno, + "connections": method_connections, + "parent_class": node.name + }) + + return { + "name": node.name, + "docstring": ast.get_docstring(node), + "bases": [ast.unparse(b) for b in node.bases], + "methods": methods, + "line": node.lineno, + "connections": class_connections + } + + +def _find_connections(node, available_names: set) -> list[str]: + connections = set() + for child in ast.walk(node): + if isinstance(child, ast.Call): + if isinstance(child.func, ast.Name): + if child.func.id in available_names: + connections.add(child.func.id) + elif isinstance(child, ast.Name): + if child.id in available_names: + connections.add(child.id) + return sorted(connections) + + +def extract_args(func_node) -> list: + args = [] + for arg in func_node.args.args: + args.append({ + "name": arg.arg, + "type": ast.unparse(arg.annotation) if arg.annotation else None + }) + return args + + +def extract_return_type(func_node) -> str | None: + if func_node.returns: + return ast.unparse(func_node.returns) + return None diff --git a/services/parser/common/__init__.py b/services/parser/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/parser/common/auth.py b/services/parser/common/auth.py new file mode 100644 index 0000000..1afba61 --- /dev/null +++ b/services/parser/common/auth.py @@ -0,0 +1,24 @@ +import os +import jwt +from fastapi import HTTPException, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +security = HTTPBearer(auto_error=False) + + +def verify_django_jwt(credentials: HTTPAuthorizationCredentials = Depends(security)): + if credentials is None: + raise HTTPException(401, "Missing authorization header") + token = credentials.credentials + try: + payload = jwt.decode( + token, + os.getenv("DJANGO_SECRET_KEY", ""), + algorithms=["HS256"], + options={"verify_exp": True}, + ) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(401, "Token expired") + except jwt.InvalidTokenError: + raise HTTPException(401, "Invalid token") diff --git a/services/parser/common/django_client.py b/services/parser/common/django_client.py new file mode 100644 index 0000000..bca467c --- /dev/null +++ b/services/parser/common/django_client.py @@ -0,0 +1,61 @@ +import os +import httpx +from typing import Any + +DJANGO_INTERNAL_URL = os.getenv( + "DJANGO_INTERNAL_URL", + "http://django:8000/api/internal", +) +INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "") + + +def _headers() -> dict: + return { + "X-Internal-Api-Key": INTERNAL_API_KEY, + "Content-Type": "application/json", + } + + +def get_project(project_id: str) -> dict[str, Any] | None: + resp = httpx.get( + f"{DJANGO_INTERNAL_URL}/projects/{project_id}/", + headers=_headers(), + timeout=30, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + return resp.json() + + +def update_project(project_id: str, data: dict) -> dict[str, Any]: + resp = httpx.patch( + f"{DJANGO_INTERNAL_URL}/projects/{project_id}/", + headers=_headers(), + json=data, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +def create_project_file(project_id: str, file_data: dict) -> dict[str, Any]: + resp = httpx.post( + f"{DJANGO_INTERNAL_URL}/projects/{project_id}/files/", + headers=_headers(), + json=file_data, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + + +def send_parsed_data(project_id: str, parsed: dict, file_count: int): + resp = httpx.post( + f"{DJANGO_INTERNAL_URL}/projects/{project_id}/parsed/", + headers=_headers(), + json={"parsed": parsed, "file_count": file_count}, + timeout=30, + ) + resp.raise_for_status() + return resp.json() diff --git a/services/parser/config/__init__.py b/services/parser/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/parser/config/config.py b/services/parser/config/config.py new file mode 100644 index 0000000..21019be --- /dev/null +++ b/services/parser/config/config.py @@ -0,0 +1,13 @@ +import os + + +class Settings: + INTERNAL_API_KEY: str = os.getenv("INTERNAL_API_KEY", "") + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + "postgresql://pydocai_user:pydocai_pass@localhost:5433/pydocai" + ) + CORS_ALLOWED_ORIGINS: str = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost:5173") + + +settings = Settings() diff --git a/services/parser/docker/Dockerfile b/services/parser/docker/Dockerfile new file mode 100644 index 0000000..033e747 --- /dev/null +++ b/services/parser/docker/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.11-slim AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends gcc && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +FROM python:3.11-slim + +RUN groupadd -r app && useradd -r -g app app + +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +WORKDIR /app + +COPY . . + +RUN chown -R app:app /app + +USER app + +EXPOSE 8002 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8002/health')" || exit 1 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002", "--workers", "2"] diff --git a/services/parser/framework_detector.py b/services/parser/framework_detector.py new file mode 100644 index 0000000..0a0cbf9 --- /dev/null +++ b/services/parser/framework_detector.py @@ -0,0 +1,195 @@ +from typing import Any + +TYPE_PRIORITY = { + "web": 10, + "api": 8, + "server": 6, + "pdf": 5, + "cli": 4, + "ai": 4, + "task_queue": 3, + "orm": 2, + "migration": 1, + "testing": 1, + "validation": 1, + "middleware": 1, + "http": 0, +} + +FRAMEWORK_PATTERNS = [ + { + "name": "Django", + "imports": ["django", "django.db", "django.urls", "django.shortcuts"], + "files": ["manage.py", "wsgi.py", "asgi.py", "settings.py"], + "dirs": ["migrations", "templates"], + "type": "web", + }, + { + "name": "FastAPI", + "imports": ["fastapi", "fastapi.routing"], + "config": ["FastAPI("], + "type": "web", + }, + { + "name": "Flask", + "imports": ["flask", "flask.blueprints"], + "config": ["Flask("], + "type": "web", + }, + { + "name": "Django REST Framework", + "imports": ["rest_framework"], + "type": "api", + }, + { + "name": "Celery", + "imports": ["celery", "celery.task"], + "files": ["celery.py"], + "type": "task_queue", + }, + { + "name": "SQLAlchemy", + "imports": ["sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext"], + "type": "orm", + }, + { + "name": "Alembic", + "imports": ["alembic", "alembic.config"], + "files": ["alembic.ini"], + "dirs": ["alembic"], + "type": "migration", + }, + { + "name": "Pydantic", + "imports": ["pydantic"], + "type": "validation", + }, + { + "name": "Pytest", + "imports": ["pytest"], + "files": ["pytest.ini", "conftest.py"], + "type": "testing", + }, + { + "name": "Groq", + "imports": ["groq"], + "type": "ai", + }, + { + "name": "PydanticAI", + "imports": ["pydantic_ai"], + "type": "ai", + }, + { + "name": "LangChain", + "imports": ["langchain", "langchain_core", "langchain_groq"], + "type": "ai", + }, + { + "name": "WeasyPrint", + "imports": ["weasyprint"], + "type": "pdf", + }, + { + "name": "Gunicorn", + "imports": ["gunicorn"], + "files": ["gunicorn.conf.py"], + "type": "server", + }, + { + "name": "httpx", + "imports": ["httpx"], + "type": "http", + }, + { + "name": "requests", + "imports": ["requests"], + "type": "http", + }, + { + "name": "Click", + "imports": ["click"], + "type": "cli", + }, + { + "name": "Typer", + "imports": ["typer"], + "type": "cli", + }, + { + "name": "CORS", + "imports": ["corsheaders"], + "type": "middleware", + }, +] + + +def detect_framework( + imports: list[str], file_paths: list[str], source_codes: list[str] +) -> dict[str, Any]: + detected = {} + file_set = set(file_paths) + all_source = "\n".join(source_codes) + + for fw in FRAMEWORK_PATTERNS: + score = 0 + reasons = [] + + for imp in fw.get("imports", []): + if any(imp in i for i in imports): + score += 2 + reasons.append(f"import:{imp}") + + for pat in fw.get("config", []): + if pat.lower() in all_source.lower(): + score += 3 + reasons.append(f"config:{pat}") + + for fn in fw.get("files", []): + if fn in file_set: + score += 3 + reasons.append(f"file:{fn}") + + for dn in fw.get("dirs", []): + if any(f"/{dn}/" in f or f.startswith(f"{dn}/") for f in file_paths): + score += 2 + reasons.append(f"dir:{dn}") + + if score > 0: + detected[fw["name"]] = { + "score": score, + "type": fw.get("type", "unknown"), + "reasons": reasons, + } + + primary = ( + max( + detected.items(), + key=lambda x: (x[1]["score"] * 2 + TYPE_PRIORITY.get(x[1]["type"], 0)), + ) + if detected + else None + ) + + return { + "frameworks": detected, + "primary_framework": primary[0] if primary else None, + "primary_type": primary[1]["type"] if primary else None, + "summary": _build_summary(detected), + } + + +def _build_summary(detected: dict) -> str: + if not detected: + return "No known frameworks detected" + parts = [] + weighted = sorted( + detected.items(), + key=lambda x: ( + x[1]["score"] * 2 + TYPE_PRIORITY.get(x[1]["type"], 0) + ), + reverse=True, + ) + for name, info in weighted: + parts.append(f"{name}:{info['type']}") + return ", ".join(parts) diff --git a/services/parser/main.py b/services/parser/main.py new file mode 100644 index 0000000..b5f9bde --- /dev/null +++ b/services/parser/main.py @@ -0,0 +1,55 @@ +import os +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starting Parser service") + yield + logger.info("Shutting down Parser service") + + +app = FastAPI( + title="PyDocAI Parser Service", + version="0.2.0", + lifespan=lifespan, +) + +origins = os.getenv("CORS_ALLOWED_ORIGINS", "http://localhost:5173").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=origins if origins != ["*"] else ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.exception("Unhandled exception on %s %s", request.method, request.url.path) + return JSONResponse( + status_code=500, + content={"detail": "Internal server error"}, + ) + + +from api.routes.health import router as health_router +from api.routes.file import router as file_router +from api.routes.folder import router as folder_router +from api.routes.status import router as status_router + +app.include_router(health_router) +app.include_router(file_router, prefix="/api/parser") +app.include_router(folder_router, prefix="/api/parser") +app.include_router(status_router, prefix="/api/parser") diff --git a/services/parser/requirements.txt b/services/parser/requirements.txt new file mode 100644 index 0000000..7529897 --- /dev/null +++ b/services/parser/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.34.0 +python-multipart>=0.0.18 +httpx>=0.28.0 +pydantic>=2.10.0 +python-dotenv>=1.2.2 diff --git a/services/parser/schemas/__init__.py b/services/parser/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/parser/schemas/requests.py b/services/parser/schemas/requests.py new file mode 100644 index 0000000..d36bd89 --- /dev/null +++ b/services/parser/schemas/requests.py @@ -0,0 +1,21 @@ +from typing import Optional +from pydantic import BaseModel + + +class AnalyzeFileResponse(BaseModel): + project_id: str + parsed: dict + file_count: int + framework: dict + + +class AnalyzeFolderResponse(BaseModel): + project_id: str + files_parsed: int + framework: dict + + +class ParserStatusResponse(BaseModel): + project_id: str + status: str + files_count: int diff --git a/services/parser/services/__init__.py b/services/parser/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/parser/tests/__init__.py b/services/parser/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/parser/tests/test_routes.py b/services/parser/tests/test_routes.py new file mode 100644 index 0000000..7699121 --- /dev/null +++ b/services/parser/tests/test_routes.py @@ -0,0 +1,114 @@ +import io +import zipfile +from uuid import uuid4 +from unittest.mock import patch, MagicMock + +import pytest +from fastapi.testclient import TestClient + +from main import app + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def mock_env(monkeypatch): + monkeypatch.setenv("INTERNAL_API_KEY", "") + monkeypatch.setenv("DJANGO_INTERNAL_URL", "http://localhost:8000/api/internal") + + +class TestHealth: + def test_health_endpoint(self): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +class TestAnalyzeFile: + def test_rejects_non_py_files(self): + resp = client.post( + "/api/parser/file/", + data={"project_id": str(uuid4()), "name": "Test"}, + files={"file": ("test.txt", b"not python")}, + ) + assert resp.status_code == 400 + + def test_rejects_invalid_python(self): + resp = client.post( + "/api/parser/file/", + data={"project_id": str(uuid4()), "name": "Test"}, + files={"file": ("bad.py", b"def foo() pass")}, + ) + assert resp.status_code == 400 + + @patch("api.routes.file.get_project") + @patch("api.routes.file.create_project_file") + @patch("api.routes.file.update_project") + def test_parses_valid_file(self, mock_update, mock_create, mock_get): + mock_get.return_value = {"id": str(uuid4()), "name": "Test"} + mock_create.return_value = {"id": str(uuid4())} + mock_update.return_value = {"id": str(uuid4())} + + pid = str(uuid4()) + resp = client.post( + "/api/parser/file/", + data={"project_id": pid, "name": "Test"}, + files={"file": ("test.py", b"def foo(): pass")}, + ) + assert resp.status_code == 200 + assert resp.json()["project_id"] == pid + assert resp.json()["file_count"] == 1 + + +class TestAnalyzeFolder: + def test_rejects_non_zip(self): + resp = client.post( + "/api/parser/folder/", + data={"project_id": str(uuid4()), "name": "Test"}, + files={"folder": ("test.txt", b"content")}, + ) + assert resp.status_code == 400 + + @patch("api.routes.folder.should_exclude") + @patch("api.routes.folder.get_project") + @patch("api.routes.folder.create_project_file") + @patch("api.routes.folder.update_project") + def test_parses_zip(self, mock_update, mock_create, mock_get, mock_exclude): + mock_exclude.return_value = False + mock_get.return_value = {"id": str(uuid4()), "name": "Test"} + mock_create.return_value = {"id": str(uuid4())} + mock_update.return_value = {"id": str(uuid4())} + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w") as zf: + zf.writestr("app/main.py", "def bar(): pass\n") + zip_bytes = zip_buffer.getvalue() + + resp = client.post( + "/api/parser/folder/", + data={"project_id": str(uuid4()), "name": "Test"}, + files={"folder": ("proj.zip", zip_bytes, "application/zip")}, + ) + assert resp.status_code == 200 + assert resp.json()["files_parsed"] == 1 + + +class TestParserStatus: + @patch("api.routes.status.get_project") + def test_returns_status(self, mock_get): + mock_get.return_value = { + "id": str(uuid4()), + "status": "done", + "files": [{"id": "f1"}, {"id": "f2"}, {"id": "f3"}], + } + + resp = client.get(f"/api/parser/status/{uuid4()}") + assert resp.status_code == 200 + assert resp.json()["status"] == "done" + assert resp.json()["files_count"] == 3 + + @patch("api.routes.status.get_project") + def test_404_for_unknown(self, mock_get): + mock_get.return_value = None + resp = client.get("/api/parser/status/00000000-0000-0000-0000-000000000000") + assert resp.status_code == 404 diff --git a/services/parser/validators.py b/services/parser/validators.py new file mode 100644 index 0000000..b3db568 --- /dev/null +++ b/services/parser/validators.py @@ -0,0 +1,36 @@ +import ast + + +def validate_python_code(source_code: str) -> tuple: + if not source_code or not source_code.strip(): + return False, "Code cannot be empty" + + try: + ast.parse(source_code) + return True, None + except SyntaxError as e: + return False, f"SyntaxError on line {e.lineno}: {e.msg}" + + +EXCLUDED_DIRS = { + 'venv', '.venv', 'env', '.env', + 'virtualenv', '.virtualenv', + 'node_modules', 'site-packages', + '__pycache__', '.pytest_cache', + 'build', 'dist', '*.egg-info', + '.git', '.github', '.gitignore', + '.vscode', '.idea', + 'migrations', + 'media', + 'staticfiles', +} + + +def should_exclude(file_path: str) -> bool: + parts = file_path.replace('\\', '/').split('/') + for part in parts: + if part in EXCLUDED_DIRS: + return True + if part.endswith('.egg-info') or part.endswith('.dist-info'): + return True + return False From d97533a5c915916e9a8ac4df891a669f62fb6bad Mon Sep 17 00:00:00 2001 From: dennisjoseph2025-dotcom Date: Wed, 24 Jun 2026 14:57:54 +0530 Subject: [PATCH 4/4] chore: remove old backend/, update compose and nginx for services/ --- backend/.dockerignore | 13 - backend/.env.example | 29 - backend/.gitignore | 14 - backend/.python-version | 1 - backend/Dockerfile | 27 - backend/Procfile | 2 - backend/README.md | 0 backend/apps/__init__.py | 0 backend/apps/admin_dashboard/__init__.py | 0 backend/apps/admin_dashboard/apps.py | 6 - backend/apps/admin_dashboard/tests.py | 28 - backend/apps/admin_dashboard/urls.py | 23 - backend/apps/admin_dashboard/views.py | 225 -- backend/apps/ai/__init__.py | 0 backend/apps/ai/apps.py | 6 - backend/apps/ai/migrations/__init__.py | 0 backend/apps/ai/urls.py | 7 - backend/apps/ai/views.py | 58 - backend/apps/comments/__init__.py | 0 backend/apps/comments/apps.py | 6 - .../apps/comments/migrations/0001_initial.py | 37 - backend/apps/comments/migrations/__init__.py | 0 backend/apps/comments/models.py | 29 - backend/apps/comments/serializers.py | 49 - backend/apps/comments/throttles.py | 10 - backend/apps/comments/urls.py | 9 - backend/apps/comments/views.py | 76 - backend/apps/exports/__init__.py | 0 backend/apps/exports/apps.py | 6 - backend/apps/exports/generators.py | 51 - backend/apps/exports/migrations/__init__.py | 0 backend/apps/exports/tests.py | 29 - backend/apps/exports/urls.py | 8 - backend/apps/exports/views.py | 74 - backend/apps/feedback/__init__.py | 0 backend/apps/feedback/admin.py | 33 - backend/apps/feedback/apps.py | 6 - backend/apps/feedback/email_utils.py | 66 - .../apps/feedback/migrations/0001_initial.py | 39 - .../0002_feedback_admin_response.py | 18 - ...remove_feedback_admin_response_and_more.py | 40 - .../0004_remove_feedback_is_public.py | 17 - backend/apps/feedback/migrations/__init__.py | 0 backend/apps/feedback/models.py | 48 - backend/apps/feedback/serializers.py | 31 - backend/apps/feedback/tasks.py | 27 - backend/apps/feedback/tests.py | 32 - backend/apps/feedback/urls.py | 17 - backend/apps/feedback/views.py | 82 - backend/apps/github_integration/__init__.py | 0 backend/apps/github_integration/apps.py | 6 - backend/apps/github_integration/fetcher.py | 216 -- .../github_integration/migrations/__init__.py | 0 .../apps/github_integration/serializers.py | 10 - backend/apps/github_integration/tasks.py | 100 - backend/apps/github_integration/tests.py | 33 - backend/apps/github_integration/urls.py | 19 - backend/apps/github_integration/views.py | 208 -- backend/apps/internal/__init__.py | 0 backend/apps/internal/urls.py | 8 - backend/apps/internal/views.py | 96 - backend/apps/notifications/__init__.py | 0 backend/apps/notifications/apps.py | 6 - .../notifications/migrations/0001_initial.py | 36 - .../apps/notifications/migrations/__init__.py | 0 backend/apps/notifications/models.py | 27 - backend/apps/notifications/serializers.py | 14 - backend/apps/notifications/tasks.py | 17 - backend/apps/notifications/urls.py | 19 - backend/apps/notifications/utils.py | 123 - backend/apps/notifications/views.py | 56 - backend/apps/parser/__init__.py | 0 backend/apps/parser/apps.py | 6 - backend/apps/parser/ast_parser.py | 165 -- backend/apps/parser/migrations/__init__.py | 0 backend/apps/parser/tasks.py | 115 - backend/apps/parser/tests.py | 127 - backend/apps/parser/urls.py | 8 - backend/apps/parser/validators.py | 45 - backend/apps/parser/views.py | 121 - backend/apps/projects/__init__.py | 0 backend/apps/projects/admin.py | 2 - backend/apps/projects/apps.py | 6 - .../apps/projects/migrations/0001_initial.py | 63 - ...roject_project_info_project_readme_docs.py | 23 - .../migrations/0003_project_api_docs.py | 18 - .../migrations/0004_project_custom_details.py | 18 - .../migrations/0005_remove_file_fields.py | 21 - ...work_info_project_is_published_and_more.py | 57 - backend/apps/projects/migrations/__init__.py | 0 backend/apps/projects/models.py | 99 - backend/apps/projects/serializers.py | 67 - backend/apps/projects/tests.py | 51 - backend/apps/projects/throttles.py | 17 - backend/apps/projects/urls.py | 20 - backend/apps/projects/views.py | 159 -- backend/apps/universal/__init__.py | 0 backend/apps/universal/apps.py | 6 - backend/apps/universal/prompts.py | 196 -- backend/apps/universal/tasks.py | 345 --- backend/apps/universal/tests.py | 284 -- backend/apps/universal/urls.py | 8 - backend/apps/universal/views.py | 146 -- backend/apps/users/__init__.py | 0 backend/apps/users/admin.py | 54 - backend/apps/users/apps.py | 6 - backend/apps/users/email_utils.py | 60 - backend/apps/users/github.py | 46 - .../management/commands/create_test_user.py | 26 - backend/apps/users/migrations/0001_initial.py | 41 - .../apps/users/migrations/0002_user_name.py | 20 - .../migrations/0003_passwordresettoken.py | 30 - backend/apps/users/migrations/__init__.py | 0 backend/apps/users/models.py | 83 - backend/apps/users/serializers.py | 105 - backend/apps/users/tasks.py | 27 - backend/apps/users/tests.py | 66 - backend/apps/users/urls.py | 27 - backend/apps/users/views.py | 237 -- backend/celerybeat-schedule.db | Bin 12288 -> 0 bytes backend/config/__init__.py | 3 - backend/config/asgi.py | 7 - backend/config/celery.py | 9 - backend/config/settings/__init__.py | 0 backend/config/settings/base.py | 155 -- backend/config/settings/development.py | 37 - backend/config/settings/production.py | 83 - backend/config/storage_backends.py | 5 - backend/config/urls.py | 22 - backend/config/wsgi.py | 7 - backend/entrypoint.sh | 5 - backend/manage.py | 14 - backend/pyproject.toml | 73 - backend/requirements/base.txt | 14 - backend/requirements/development.txt | 2 - backend/requirements/production.txt | 2 - backend/services/ai/Dockerfile | 14 - backend/services/ai/database.py | 28 - backend/services/ai/main.py | 943 ------- backend/services/ai/models.py | 62 - backend/services/ai/rag.py | 214 -- backend/services/ai/requirements.txt | 11 - backend/services/ai/test_main.py | 109 - backend/services/parser/Dockerfile | 12 - backend/services/parser/ast_parser.py | 153 -- backend/services/parser/database.py | 28 - backend/services/parser/framework_detector.py | 195 -- backend/services/parser/main.py | 197 -- backend/services/parser/models.py | 57 - backend/services/parser/requirements.txt | 9 - backend/services/parser/test_main.py | 133 - backend/services/parser/validators.py | 36 - .../emails/feedback_confirmation.html | 30 - .../emails/feedback_confirmation.txt | 13 - backend/templates/emails/feedback_reply.html | 30 - backend/templates/emails/feedback_reply.txt | 14 - backend/templates/emails/password_reset.html | 26 - backend/templates/emails/password_reset.txt | 12 - backend/templates/emails/welcome.html | 24 - backend/templates/emails/welcome.txt | 10 - backend/uv.lock | 2314 ----------------- deploy/nginx.conf | 21 +- docker-compose.v2.yml | 4 +- docker-compose.yml | 227 +- 164 files changed, 185 insertions(+), 10473 deletions(-) delete mode 100644 backend/.dockerignore delete mode 100644 backend/.env.example delete mode 100644 backend/.gitignore delete mode 100644 backend/.python-version delete mode 100644 backend/Dockerfile delete mode 100644 backend/Procfile delete mode 100644 backend/README.md delete mode 100644 backend/apps/__init__.py delete mode 100644 backend/apps/admin_dashboard/__init__.py delete mode 100644 backend/apps/admin_dashboard/apps.py delete mode 100644 backend/apps/admin_dashboard/tests.py delete mode 100644 backend/apps/admin_dashboard/urls.py delete mode 100644 backend/apps/admin_dashboard/views.py delete mode 100644 backend/apps/ai/__init__.py delete mode 100644 backend/apps/ai/apps.py delete mode 100644 backend/apps/ai/migrations/__init__.py delete mode 100644 backend/apps/ai/urls.py delete mode 100644 backend/apps/ai/views.py delete mode 100644 backend/apps/comments/__init__.py delete mode 100644 backend/apps/comments/apps.py delete mode 100644 backend/apps/comments/migrations/0001_initial.py delete mode 100644 backend/apps/comments/migrations/__init__.py delete mode 100644 backend/apps/comments/models.py delete mode 100644 backend/apps/comments/serializers.py delete mode 100644 backend/apps/comments/throttles.py delete mode 100644 backend/apps/comments/urls.py delete mode 100644 backend/apps/comments/views.py delete mode 100644 backend/apps/exports/__init__.py delete mode 100644 backend/apps/exports/apps.py delete mode 100644 backend/apps/exports/generators.py delete mode 100644 backend/apps/exports/migrations/__init__.py delete mode 100644 backend/apps/exports/tests.py delete mode 100644 backend/apps/exports/urls.py delete mode 100644 backend/apps/exports/views.py delete mode 100644 backend/apps/feedback/__init__.py delete mode 100644 backend/apps/feedback/admin.py delete mode 100644 backend/apps/feedback/apps.py delete mode 100644 backend/apps/feedback/email_utils.py delete mode 100644 backend/apps/feedback/migrations/0001_initial.py delete mode 100644 backend/apps/feedback/migrations/0002_feedback_admin_response.py delete mode 100644 backend/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py delete mode 100644 backend/apps/feedback/migrations/0004_remove_feedback_is_public.py delete mode 100644 backend/apps/feedback/migrations/__init__.py delete mode 100644 backend/apps/feedback/models.py delete mode 100644 backend/apps/feedback/serializers.py delete mode 100644 backend/apps/feedback/tasks.py delete mode 100644 backend/apps/feedback/tests.py delete mode 100644 backend/apps/feedback/urls.py delete mode 100644 backend/apps/feedback/views.py delete mode 100644 backend/apps/github_integration/__init__.py delete mode 100644 backend/apps/github_integration/apps.py delete mode 100644 backend/apps/github_integration/fetcher.py delete mode 100644 backend/apps/github_integration/migrations/__init__.py delete mode 100644 backend/apps/github_integration/serializers.py delete mode 100644 backend/apps/github_integration/tasks.py delete mode 100644 backend/apps/github_integration/tests.py delete mode 100644 backend/apps/github_integration/urls.py delete mode 100644 backend/apps/github_integration/views.py delete mode 100644 backend/apps/internal/__init__.py delete mode 100644 backend/apps/internal/urls.py delete mode 100644 backend/apps/internal/views.py delete mode 100644 backend/apps/notifications/__init__.py delete mode 100644 backend/apps/notifications/apps.py delete mode 100644 backend/apps/notifications/migrations/0001_initial.py delete mode 100644 backend/apps/notifications/migrations/__init__.py delete mode 100644 backend/apps/notifications/models.py delete mode 100644 backend/apps/notifications/serializers.py delete mode 100644 backend/apps/notifications/tasks.py delete mode 100644 backend/apps/notifications/urls.py delete mode 100644 backend/apps/notifications/utils.py delete mode 100644 backend/apps/notifications/views.py delete mode 100644 backend/apps/parser/__init__.py delete mode 100644 backend/apps/parser/apps.py delete mode 100644 backend/apps/parser/ast_parser.py delete mode 100644 backend/apps/parser/migrations/__init__.py delete mode 100644 backend/apps/parser/tasks.py delete mode 100644 backend/apps/parser/tests.py delete mode 100644 backend/apps/parser/urls.py delete mode 100644 backend/apps/parser/validators.py delete mode 100644 backend/apps/parser/views.py delete mode 100644 backend/apps/projects/__init__.py delete mode 100644 backend/apps/projects/admin.py delete mode 100644 backend/apps/projects/apps.py delete mode 100644 backend/apps/projects/migrations/0001_initial.py delete mode 100644 backend/apps/projects/migrations/0002_project_project_info_project_readme_docs.py delete mode 100644 backend/apps/projects/migrations/0003_project_api_docs.py delete mode 100644 backend/apps/projects/migrations/0004_project_custom_details.py delete mode 100644 backend/apps/projects/migrations/0005_remove_file_fields.py delete mode 100644 backend/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py delete mode 100644 backend/apps/projects/migrations/__init__.py delete mode 100644 backend/apps/projects/models.py delete mode 100644 backend/apps/projects/serializers.py delete mode 100644 backend/apps/projects/tests.py delete mode 100644 backend/apps/projects/throttles.py delete mode 100644 backend/apps/projects/urls.py delete mode 100644 backend/apps/projects/views.py delete mode 100644 backend/apps/universal/__init__.py delete mode 100644 backend/apps/universal/apps.py delete mode 100644 backend/apps/universal/prompts.py delete mode 100644 backend/apps/universal/tasks.py delete mode 100644 backend/apps/universal/tests.py delete mode 100644 backend/apps/universal/urls.py delete mode 100644 backend/apps/universal/views.py delete mode 100644 backend/apps/users/__init__.py delete mode 100644 backend/apps/users/admin.py delete mode 100644 backend/apps/users/apps.py delete mode 100644 backend/apps/users/email_utils.py delete mode 100644 backend/apps/users/github.py delete mode 100644 backend/apps/users/management/commands/create_test_user.py delete mode 100644 backend/apps/users/migrations/0001_initial.py delete mode 100644 backend/apps/users/migrations/0002_user_name.py delete mode 100644 backend/apps/users/migrations/0003_passwordresettoken.py delete mode 100644 backend/apps/users/migrations/__init__.py delete mode 100644 backend/apps/users/models.py delete mode 100644 backend/apps/users/serializers.py delete mode 100644 backend/apps/users/tasks.py delete mode 100644 backend/apps/users/tests.py delete mode 100644 backend/apps/users/urls.py delete mode 100644 backend/apps/users/views.py delete mode 100644 backend/celerybeat-schedule.db delete mode 100644 backend/config/__init__.py delete mode 100644 backend/config/asgi.py delete mode 100644 backend/config/celery.py delete mode 100644 backend/config/settings/__init__.py delete mode 100644 backend/config/settings/base.py delete mode 100644 backend/config/settings/development.py delete mode 100644 backend/config/settings/production.py delete mode 100644 backend/config/storage_backends.py delete mode 100644 backend/config/urls.py delete mode 100644 backend/config/wsgi.py delete mode 100644 backend/entrypoint.sh delete mode 100644 backend/manage.py delete mode 100644 backend/pyproject.toml delete mode 100644 backend/requirements/base.txt delete mode 100644 backend/requirements/development.txt delete mode 100644 backend/requirements/production.txt delete mode 100644 backend/services/ai/Dockerfile delete mode 100644 backend/services/ai/database.py delete mode 100644 backend/services/ai/main.py delete mode 100644 backend/services/ai/models.py delete mode 100644 backend/services/ai/rag.py delete mode 100644 backend/services/ai/requirements.txt delete mode 100644 backend/services/ai/test_main.py delete mode 100644 backend/services/parser/Dockerfile delete mode 100644 backend/services/parser/ast_parser.py delete mode 100644 backend/services/parser/database.py delete mode 100644 backend/services/parser/framework_detector.py delete mode 100644 backend/services/parser/main.py delete mode 100644 backend/services/parser/models.py delete mode 100644 backend/services/parser/requirements.txt delete mode 100644 backend/services/parser/test_main.py delete mode 100644 backend/services/parser/validators.py delete mode 100644 backend/templates/emails/feedback_confirmation.html delete mode 100644 backend/templates/emails/feedback_confirmation.txt delete mode 100644 backend/templates/emails/feedback_reply.html delete mode 100644 backend/templates/emails/feedback_reply.txt delete mode 100644 backend/templates/emails/password_reset.html delete mode 100644 backend/templates/emails/password_reset.txt delete mode 100644 backend/templates/emails/welcome.html delete mode 100644 backend/templates/emails/welcome.txt delete mode 100644 backend/uv.lock diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index 7a95899..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,13 +0,0 @@ -__pycache__/ -*.pyc -*.pyo -*.pyd -.env -.venv/ -venv/ -db.sqlite3 -staticfiles/ -media/ -.git/ -.gitignore -*.md diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 007b7b1..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -DJANGO_SECRET_KEY= -DJANGO_SETTINGS_MODULE=config.settings.production -DB_NAME= -DB_USER= -DB_PASSWORD= -DB_HOST=db -DB_PORT=5432 -CELERY_BROKER_URL=redis://redis:6379/0 -CELERY_RESULT_BACKEND=redis://redis:6379/0 -CORS_ALLOWED_ORIGINS=http://localhost:5173 -ALLOWED_HOSTS=localhost,127.0.0.1 -GROQ_API_KEY= -GROQ_API_KEY_2= -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -GITHUB_API_TOKEN= -EMAIL_HOST= -EMAIL_PORT=587 -EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= -DEFAULT_FROM_EMAIL=noreply@pydocai.com -FRONTEND_URL=http://localhost:5173 - -# AWS S3 (production only) -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_STORAGE_BUCKET_NAME= -AWS_S3_REGION_NAME=us-east-1 -AWS_CLOUDFRONT_DOMAIN= diff --git a/backend/.gitignore b/backend/.gitignore deleted file mode 100644 index d23b4fd..0000000 --- a/backend/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -.env -__pycache__/ -*.pyc -*.pyo -db.sqlite3 -staticfiles/ -media/ -.venv/ -venv/ -*.egg-info/ -dist/ -build/ -.DS_Store -tr.md \ No newline at end of file diff --git a/backend/.python-version b/backend/.python-version deleted file mode 100644 index c8cfe39..0000000 --- a/backend/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 69d52d2..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM python:3.12-slim - -ENV PYTHONDONTWRITEBYTECODE=1 -ENV PYTHONUNBUFFERED=1 -ENV UV_LINK_MODE=copy -ENV UV_PROJECT_ENVIRONMENT=/opt/venv - -WORKDIR /app - -RUN apt-get update && apt-get install -y \ - netcat-openbsd \ - gcc \ - libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv - -COPY pyproject.toml uv.lock ./ -RUN uv sync --frozen - -COPY . . - -RUN chmod +x entrypoint.sh - -EXPOSE 8000 - -CMD ["bash", "entrypoint.sh"] \ No newline at end of file diff --git a/backend/Procfile b/backend/Procfile deleted file mode 100644 index 008a017..0000000 --- a/backend/Procfile +++ /dev/null @@ -1,2 +0,0 @@ -web: gunicorn config.wsgi:application -worker: celery -A config.celery worker diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/admin_dashboard/__init__.py b/backend/apps/admin_dashboard/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/admin_dashboard/apps.py b/backend/apps/admin_dashboard/apps.py deleted file mode 100644 index 938866a..0000000 --- a/backend/apps/admin_dashboard/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AdminDashboardConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.admin_dashboard' diff --git a/backend/apps/admin_dashboard/tests.py b/backend/apps/admin_dashboard/tests.py deleted file mode 100644 index 9726400..0000000 --- a/backend/apps/admin_dashboard/tests.py +++ /dev/null @@ -1,28 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework.test import APITestCase - -User = get_user_model() - -class AdminDashboardTests(APITestCase): - def setUp(self): - self.standard_user = User.objects.create_user( - email='user@test.com', name='User', password='pwd', role=User.Role.USER - ) - self.admin_user = User.objects.create_superuser( - email='admin@test.com', name='Admin', password='pwd' - ) - - def test_admin_stats_forbidden_for_standard_user(self): - """Standard users should receive a 403 Forbidden on admin endpoints.""" - self.client.force_authenticate(user=self.standard_user) - response = self.client.get('/api/admin-dashboard/stats/') - self.assertEqual(response.status_code, 403) - - def test_admin_stats_allowed_for_admin(self): - """Admins should be able to view platform statistics.""" - self.client.force_authenticate(user=self.admin_user) - response = self.client.get('/api/admin-dashboard/stats/') - self.assertEqual(response.status_code, 200) - self.assertIn('users', response.data) - self.assertIn('projects', response.data) - self.assertEqual(response.data['users']['total'], 2) # The two we created in setUp diff --git a/backend/apps/admin_dashboard/urls.py b/backend/apps/admin_dashboard/urls.py deleted file mode 100644 index 4681ee7..0000000 --- a/backend/apps/admin_dashboard/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -from django.urls import path - -from .views import ( - AdminProjectDetailView, - AdminProjectListView, - AdminStatsView, - AdminUserBlockView, - AdminUserDeleteView, - AdminUserDetailView, - AdminUserListView, - AdminUserProjectsView, -) - -urlpatterns = [ - path('stats/', AdminStatsView.as_view(), name='admin-stats'), - path('users/', AdminUserListView.as_view(), name='admin-user-list'), - path('users//', AdminUserDetailView.as_view(), name='admin-user-detail'), - path('users//publish/', AdminUserProjectsView.as_view(), name='admin-user-projects'), - path('users//delete/', AdminUserDeleteView.as_view(), name='admin-user-delete'), - path('users//block/', AdminUserBlockView.as_view(), name='admin-user-block'), - path('projects/', AdminProjectListView.as_view(), name='admin-project-list'), - path('projects//', AdminProjectDetailView.as_view(), name='admin-project-detail'), -] diff --git a/backend/apps/admin_dashboard/views.py b/backend/apps/admin_dashboard/views.py deleted file mode 100644 index f9e06c0..0000000 --- a/backend/apps/admin_dashboard/views.py +++ /dev/null @@ -1,225 +0,0 @@ -from datetime import timedelta - -from django.conf import settings -from django.db.models import Count, Q -from django.utils import timezone -from rest_framework import status -from rest_framework.generics import ListAPIView, RetrieveAPIView -from rest_framework.permissions import IsAdminUser, IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.notifications.tasks import send_email_task -from apps.projects.models import Project -from apps.projects.serializers import ProjectListSerializer, ProjectSerializer -from apps.users.models import User -from apps.users.serializers import AdminUserSerializer, UserSerializer - - -class AdminStatsView(APIView): - """Admin-only endpoint returning platform statistics.""" - permission_classes = [IsAuthenticated] - - def get(self, request): - if not request.user.is_staff and not request.user.is_admin: - return Response({'detail': 'Forbidden.'}, status=status.HTTP_403_FORBIDDEN) - - now = timezone.now() - week = now - timedelta(days=7) - month = now - timedelta(days=30) - - stats = { - 'users': { - 'total': User.objects.count(), - 'verified': User.objects.filter(is_verified=True).count(), - 'github_connected':User.objects.exclude(github_token__isnull=True).exclude(github_token='').count(), - 'new_this_week': User.objects.filter(created_at__gte=week).count(), - 'new_this_month': User.objects.filter(created_at__gte=month).count(), - }, - 'projects': { - 'total': Project.objects.count(), - 'done': Project.objects.filter(status='done').count(), - 'processing': Project.objects.filter(status='processing').count(), - 'failed': Project.objects.filter(status='failed').count(), - 'pending': Project.objects.filter(status='pending').count(), - 'new_this_week': Project.objects.filter(created_at__gte=week).count(), - 'new_this_month': Project.objects.filter(created_at__gte=month).count(), - 'by_source': list( - Project.objects.values('source_type') - .annotate(count=Count('id')) - .order_by('-count') - ), - }, - 'top_users': list( - User.objects.annotate( - project_count=Count('projects'), - published_count=Count('projects', filter=Q(projects__is_published=True)), - ) - .order_by('-project_count') - .values('id', 'email', 'name', 'project_count', 'published_count')[:10] - ), - } - return Response(stats) - - -class AdminUserListView(ListAPIView): - permission_classes = [IsAdminUser] - serializer_class = AdminUserSerializer - filterset_fields = ['is_active', 'is_verified', 'role'] - search_fields = ['email', 'username', 'name'] - ordering_fields = ['created_at', 'email', 'username'] - - def get_queryset(self): - return User.objects.annotate( - project_count=Count('projects'), - published_count=Count('projects', filter=Q(projects__is_published=True)), - ).order_by('-created_at') - -class AdminUserDetailView(RetrieveAPIView): - permission_classes = [IsAdminUser] - serializer_class = UserSerializer - queryset = User.objects.all() - lookup_field = 'pk' - - -class AdminUserDeleteView(APIView): - """Delete a user (admin only). Sends a notification email with reason.""" - permission_classes = [IsAdminUser] - - def post(self, request, pk): - try: - target = User.objects.get(pk=pk) - except User.DoesNotExist: - return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND) - - reason = request.data.get('reason', 'No reason provided.') - - # Send deletion email - if settings.EMAIL_HOST_USER: - subject = 'Your PyDocAI account has been deleted' - send_email_task.delay( - subject=subject, - message=f'Your PyDocAI account has been deleted.\n\nReason: {reason}', - recipient_list=[target.email], - html_message=f''' - -
- - - - - -
- - - -
-PyDocAI - -ACCOUNT_DELETED -
-
-

Account Deleted

-

-Your PyDocAI account has been deleted by an administrator. -

-
-
-Reason: -{reason} -
-
-

PyDocAI · AI-generated documentation

-
-
''', - ) - - # Delete user's projects and the user - target.projects.all().delete() - target.delete() - return Response({'detail': 'User deleted successfully.'}, status=status.HTTP_200_OK) - - -class AdminUserBlockView(APIView): - """Block or unblock a user (admin only). Toggles is_active.""" - permission_classes = [IsAdminUser] - - def post(self, request, pk): - try: - target = User.objects.get(pk=pk) - except User.DoesNotExist: - return Response({'detail': 'User not found.'}, status=status.HTTP_404_NOT_FOUND) - - if target == request.user: - return Response({'detail': 'You cannot block yourself.'}, status=status.HTTP_400_BAD_REQUEST) - - target.is_active = not target.is_active - target.save(update_fields=['is_active']) - - action = 'blocked' if not target.is_active else 'unblocked' - - if settings.EMAIL_HOST_USER: - send_email_task.delay( - subject=f'Your PyDocAI account has been {action}', - message=f'Your PyDocAI account has been {action} by an administrator.', - recipient_list=[target.email], - ) - - return Response({'detail': f'User {action} successfully.', 'is_active': target.is_active}) - -class AdminProjectListView(ListAPIView): - permission_classes = [IsAdminUser] - serializer_class = ProjectListSerializer - filterset_fields = ['status', 'source_type'] - search_fields = ['name', 'user__email', 'user__name'] - ordering_fields = ['created_at', 'name', 'status'] - - def get_queryset(self): - return Project.objects.select_related('user') \ - .annotate(file_count=Count('files')) \ - .order_by('-created_at') - - def list(self, request, *args, **kwargs): - queryset = self.filter_queryset(self.get_queryset()) - - stats = { - 'total': queryset.count(), - 'done': queryset.filter(status='done').count(), - 'processing': queryset.filter(status='processing').count(), - 'failed': queryset.filter(status='failed').count(), - 'pending': queryset.filter(status='pending').count(), - 'by_source': list( - queryset.values('source_type') - .annotate(count=Count('id')) - .order_by('-count') - ), - } - - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - response = self.get_paginated_response(serializer.data) - response.data['stats'] = stats - return response - - serializer = self.get_serializer(queryset, many=True) - return Response({'stats': stats, 'results': serializer.data}) - -class AdminUserProjectsView(ListAPIView): - """List published projects for a specific user (admin only).""" - permission_classes = [IsAdminUser] - serializer_class = ProjectListSerializer - - def get_queryset(self): - return (Project.objects - .filter(user_id=self.kwargs['pk'], is_published=True) - .select_related('user') - .annotate(file_count=Count('files')) - .order_by('-updated_at')) - - -class AdminProjectDetailView(RetrieveAPIView): - permission_classes = [IsAdminUser] - serializer_class = ProjectSerializer - queryset = Project.objects.select_related('user').all() - lookup_field = 'pk' diff --git a/backend/apps/ai/__init__.py b/backend/apps/ai/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/ai/apps.py b/backend/apps/ai/apps.py deleted file mode 100644 index 4fc2e53..0000000 --- a/backend/apps/ai/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AiConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.ai' diff --git a/backend/apps/ai/migrations/__init__.py b/backend/apps/ai/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/ai/urls.py b/backend/apps/ai/urls.py deleted file mode 100644 index 04e7f53..0000000 --- a/backend/apps/ai/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from .views import AIStatusView - -urlpatterns = [ - path('status/', AIStatusView.as_view(), name='ai-status'), -] diff --git a/backend/apps/ai/views.py b/backend/apps/ai/views.py deleted file mode 100644 index 5204e92..0000000 --- a/backend/apps/ai/views.py +++ /dev/null @@ -1,58 +0,0 @@ -from django.conf import settings -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - - -class AIStatusView(APIView): - """ - Check the status and metadata of configured Groq API keys. - Provider hierarchy: Groq primary -> Groq fallback. - """ - permission_classes = [IsAuthenticated] - - def get(self, request): - groq_key = getattr(settings, 'GROQ_API_KEY', None) - groq_key_2 = getattr(settings, 'GROQ_API_KEY_2', None) - - status_data = { - 'providers': { - 'groq_primary': { - 'configured': bool(groq_key), - 'status': 'unknown', - 'role': 'primary', - 'model': 'llama-3.3-70b-versatile', - }, - 'groq_fallback': { - 'configured': bool(groq_key_2), - 'status': 'unknown', - 'role': 'fallback', - 'model': 'llama-3.3-70b-versatile', - }, - }, - 'fallback_order': ['groq_primary', 'groq_fallback'], - } - - # Check primary Groq key - if groq_key: - try: - from groq import Groq - client = Groq(api_key=groq_key) - client.models.list() - status_data['providers']['groq_primary']['status'] = 'active' - except Exception as e: - status_data['providers']['groq_primary']['status'] = 'invalid' - status_data['providers']['groq_primary']['error'] = str(e) - - # Check fallback Groq key - if groq_key_2: - try: - from groq import Groq - client = Groq(api_key=groq_key_2) - client.models.list() - status_data['providers']['groq_fallback']['status'] = 'active' - except Exception as e: - status_data['providers']['groq_fallback']['status'] = 'invalid' - status_data['providers']['groq_fallback']['error'] = str(e) - - return Response(status_data) diff --git a/backend/apps/comments/__init__.py b/backend/apps/comments/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/comments/apps.py b/backend/apps/comments/apps.py deleted file mode 100644 index 3b304b2..0000000 --- a/backend/apps/comments/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class CommentsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.comments' diff --git a/backend/apps/comments/migrations/0001_initial.py b/backend/apps/comments/migrations/0001_initial.py deleted file mode 100644 index 8d6188a..0000000 --- a/backend/apps/comments/migrations/0001_initial.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.2.13 on 2026-06-16 13:40 - -import uuid - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('projects', '0006_project_framework_info_project_is_published_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Comment', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('content', models.TextField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='comments.comment')), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='projects.project')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'db_table': 'comments', - 'ordering': ['created_at'], - 'indexes': [models.Index(fields=['project', 'created_at'], name='comments_project_7329f9_idx'), models.Index(fields=['user', 'created_at'], name='comments_user_id_88f50c_idx')], - }, - ), - ] diff --git a/backend/apps/comments/migrations/__init__.py b/backend/apps/comments/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/comments/models.py b/backend/apps/comments/models.py deleted file mode 100644 index 4081229..0000000 --- a/backend/apps/comments/models.py +++ /dev/null @@ -1,29 +0,0 @@ -import uuid - -from django.db import models - -from apps.projects.models import Project -from apps.users.models import User - - -class Comment(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments') - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='comments') - parent = models.ForeignKey( - 'self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies' - ) - content = models.TextField() - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = 'comments' - ordering = ['created_at'] - indexes = [ - models.Index(fields=['project', 'created_at']), - models.Index(fields=['user', 'created_at']), - ] - - def __str__(self): - return f'{self.user.email} on {self.project.name}' diff --git a/backend/apps/comments/serializers.py b/backend/apps/comments/serializers.py deleted file mode 100644 index e4145ad..0000000 --- a/backend/apps/comments/serializers.py +++ /dev/null @@ -1,49 +0,0 @@ -from rest_framework import serializers - -from .models import Comment - - -class CommentSerializer(serializers.ModelSerializer): - user_name = serializers.CharField(source='user.name', read_only=True) - user_email = serializers.EmailField(source='user.email', read_only=True) - replies = serializers.SerializerMethodField() - parent_content = serializers.SerializerMethodField() - - class Meta: - model = Comment - fields = [ - 'id', 'project', 'user', 'user_name', 'user_email', - 'parent', 'parent_content', 'content', 'replies', 'created_at', 'updated_at', - ] - read_only_fields = ['user', 'project'] - - def get_replies(self, obj): - replies = obj.replies.all()[:3] - return CommentSerializer(replies, many=True).data - - def get_parent_content(self, obj): - if obj.parent: - return obj.parent.content - return None - - def get_fields(self): - fields = super().get_fields() - if self.context.get('depth', 0) >= 3: - fields.pop('replies', None) - return fields - - -class CommentCreateSerializer(serializers.ModelSerializer): - user_name = serializers.CharField(source='user.name', read_only=True) - user_email = serializers.EmailField(source='user.email', read_only=True) - parent_content = serializers.SerializerMethodField() - - class Meta: - model = Comment - fields = ['id', 'content', 'parent', 'parent_content', 'user_name', 'user_email', 'created_at', 'updated_at'] - read_only_fields = ['id', 'parent_content', 'user_name', 'user_email', 'created_at', 'updated_at'] - - def get_parent_content(self, obj): - if obj.parent: - return obj.parent.content - return None diff --git a/backend/apps/comments/throttles.py b/backend/apps/comments/throttles.py deleted file mode 100644 index d14d488..0000000 --- a/backend/apps/comments/throttles.py +++ /dev/null @@ -1,10 +0,0 @@ -from rest_framework.throttling import SimpleRateThrottle - - -class CommentRateThrottle(SimpleRateThrottle): - scope = 'comment_create' - - def get_cache_key(self, request, view): - if request.user.is_authenticated: - return f'comment_create:{request.user.pk}' - return None diff --git a/backend/apps/comments/urls.py b/backend/apps/comments/urls.py deleted file mode 100644 index 1033f01..0000000 --- a/backend/apps/comments/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from .views import CommentCreateView, CommentDeleteView, CommentListView - -urlpatterns = [ - path('/comments/', CommentListView.as_view(), name='comment_list'), - path('/comments/create/', CommentCreateView.as_view(), name='comment_create'), - path('comments//', CommentDeleteView.as_view(), name='comment_delete'), -] diff --git a/backend/apps/comments/views.py b/backend/apps/comments/views.py deleted file mode 100644 index 29edb3b..0000000 --- a/backend/apps/comments/views.py +++ /dev/null @@ -1,76 +0,0 @@ -from django.db import connection -from rest_framework import generics, permissions, status -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.notifications.utils import notify_comment, notify_reply -from apps.projects.models import Project - -from .models import Comment -from .serializers import CommentCreateSerializer, CommentSerializer -from .throttles import CommentRateThrottle - - -class CommentListView(APIView): - permission_classes = [] - - def get(self, request, project_id): - try: - project = Project.objects.get(pk=project_id) - except Project.DoesNotExist: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) - - if not project.is_published and not request.user.is_authenticated: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) - - if not project.is_published and request.user.is_authenticated and project.user != request.user: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) - - offset = int(request.query_params.get('offset', 0)) - limit = min(int(request.query_params.get('limit', 20)), 50) - - top_level = Comment.objects.filter( - project=project, parent__isnull=True - ).select_related('user').order_by('created_at')[offset:offset + limit] - - comments = CommentSerializer(top_level, many=True, context={'depth': 0}).data - has_next = Comment.objects.filter( - project=project, parent__isnull=True - ).count() > offset + limit - - return Response({ - 'comments': comments, - 'has_next': has_next, - 'total': offset + len(comments), - }) - - -class CommentCreateView(generics.CreateAPIView): - permission_classes = [permissions.IsAuthenticated] - serializer_class = CommentCreateSerializer - throttle_classes = [CommentRateThrottle] - - def perform_create(self, serializer): - project = Project.objects.get(pk=self.kwargs['project_id']) - comment = serializer.save(user=self.request.user, project=project) - if comment.parent: - notify_reply(comment) - else: - notify_comment(comment) - - -class CommentDeleteView(APIView): - permission_classes = [permissions.IsAuthenticated] - - def delete(self, request, pk): - try: - comment = Comment.objects.get(pk=pk, user=request.user) - except Comment.DoesNotExist: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) - - with connection.cursor() as cursor: - cursor.execute( - "UPDATE comments SET content = '[deleted]', user_id = NULL WHERE id = %s", - [comment.id] - ) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/exports/__init__.py b/backend/apps/exports/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/exports/apps.py b/backend/apps/exports/apps.py deleted file mode 100644 index b04cb5d..0000000 --- a/backend/apps/exports/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ExportsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.exports' diff --git a/backend/apps/exports/generators.py b/backend/apps/exports/generators.py deleted file mode 100644 index 8f24bc3..0000000 --- a/backend/apps/exports/generators.py +++ /dev/null @@ -1,51 +0,0 @@ -from apps.projects.models import Project, ProjectFile - - -def export_project_as_markdown(project_id: str) -> str: - project = Project.objects.get(id=project_id) - - # Check if project-level docs exist (new feature) - if project.readme_docs or (project.project_info and project.project_info.get('summary')): - combined = "" - - # Add README if available - if project.readme_docs: - combined += f"# {project.name} — README\n\n" - combined += project.readme_docs - combined += "\n\n---\n\n" - - # Add project summary/documentation if available - if project.project_info and project.project_info.get('summary'): - combined += f"# {project.name} — Project Documentation\n\n" - combined += project.project_info.get('summary') - combined += "\n\n---\n\n" - - # Add project info as JSON if available - if project.project_info and not project.project_info.get('summary'): - combined += f"# {project.name} — Project Information\n\n" - combined += "## Project Details\n\n" - import json - combined += "```json\n" - combined += json.dumps(project.project_info, indent=2) - combined += "\n```\n\n" - - if combined: - return combined - - # Fallback to per-file docs (legacy behavior) - all_files = ProjectFile.objects.filter(project=project) - - combined = f"# {project.name} — Documentation\n\n" - - if not all_files.exists(): - combined += "_No files found for this project._\n" - return combined - - for pf in all_files: - combined += f"---\n\n## {pf.file_path}\n\n" - if pf.generated_docs: - combined += pf.generated_docs + "\n\n" - else: - combined += f"_No documentation generated. parsed_data present: {bool(pf.parsed_data)}_\n\n" - - return combined diff --git a/backend/apps/exports/migrations/__init__.py b/backend/apps/exports/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/exports/tests.py b/backend/apps/exports/tests.py deleted file mode 100644 index d829581..0000000 --- a/backend/apps/exports/tests.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework.test import APITestCase - -from apps.projects.models import Project - -User = get_user_model() - -class ExportTests(APITestCase): - def setUp(self): - self.user = User.objects.create_user(email='export@test.com', name='Exp', password='pwd') - self.project = Project.objects.create( - user=self.user, - name="Test Export Project", - readme_docs="# Mock README", - generated_docs="## Project Summary", - api_docs="### API Reference" - ) - self.client.force_authenticate(user=self.user) - - def test_export_project_as_markdown(self): - """Test that the export API combines the document segments into a Markdown file download.""" - response = self.client.get(f'/api/exports/{self.project.id}/folder/') - self.assertEqual(response.status_code, 200) - self.assertEqual(response['Content-Type'], 'text/markdown') - - content = response.content.decode('utf-8') - self.assertIn('# Mock README', content) - self.assertIn('## Project Summary', content) - self.assertIn('### API Reference', content) diff --git a/backend/apps/exports/urls.py b/backend/apps/exports/urls.py deleted file mode 100644 index 3982362..0000000 --- a/backend/apps/exports/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path - -from apps.exports.views import ExportFolderDocsView, ExportProjectMarkdownView - -urlpatterns = [ - path('/markdown/', ExportProjectMarkdownView.as_view(), name='export-markdown'), - path('/folder/', ExportFolderDocsView.as_view(), name='export-folder'), -] diff --git a/backend/apps/exports/views.py b/backend/apps/exports/views.py deleted file mode 100644 index 8db5253..0000000 --- a/backend/apps/exports/views.py +++ /dev/null @@ -1,74 +0,0 @@ -from django.http import Http404, HttpResponse -from rest_framework.views import APIView - -from apps.exports.generators import export_project_as_markdown - - -class ExportProjectMarkdownView(APIView): - # permission_classes = [IsAuthenticated] # Disabled for testing - - def get(self, request, project_id): - try: - markdown = export_project_as_markdown(str(project_id)) - except Exception as e: - raise Http404(f"Export failed: {e}") - - return HttpResponse( - markdown, - content_type='text/markdown', - headers={'Content-Disposition': f'attachment; filename="project_{project_id}_docs.md"'} - ) - - -class ExportFolderDocsView(APIView): - """Export project-level documentation for folder uploads.""" - permission_classes = [] # Disabled for testing - - def get(self, request, project_id): - import uuid - - from apps.projects.models import Project - - try: - project_id_uuid = uuid.UUID(str(project_id)) - project = Project.objects.get(id=project_id_uuid) - except (Project.DoesNotExist, ValueError): - raise Http404("Project not found") - - doc_type = request.query_params.get('type', 'all') - - if doc_type == 'readme': - if not project.readme_docs: - raise Http404("README not available yet - folder may still be processing") - content = project.readme_docs - filename = "README.md" - elif doc_type == 'summary': - if not project.generated_docs: - raise Http404("Summary not available yet - folder may still be processing") - content = project.generated_docs - filename = "project_summary.md" - elif doc_type == 'api': - if not project.api_docs: - raise Http404("API docs not available yet - folder may still be processing") - content = project.api_docs - filename = "api_docs.md" - else: - if not project.readme_docs and not project.generated_docs and not project.api_docs: - raise Http404("Project documentation not available - please ensure folder upload was completed") - - parts = [] - if project.readme_docs: - parts.append(project.readme_docs) - if project.generated_docs: - parts.append(f"# Project Documentation\n\n{project.generated_docs}") - if project.api_docs: - parts.append(f"# API Documentation\n\n{project.api_docs}") - - content = "\n\n---\n\n".join(parts) - filename = "project_docs.md" - - return HttpResponse( - content, - content_type='text/markdown', - headers={'Content-Disposition': f'attachment; filename="{filename}"'} - ) diff --git a/backend/apps/feedback/__init__.py b/backend/apps/feedback/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/feedback/admin.py b/backend/apps/feedback/admin.py deleted file mode 100644 index 313036c..0000000 --- a/backend/apps/feedback/admin.py +++ /dev/null @@ -1,33 +0,0 @@ -from django.contrib import admin - -from .models import Feedback, FeedbackReply - - -class FeedbackReplyInline(admin.TabularInline): - model = FeedbackReply - extra = 0 - readonly_fields = ['user', 'message', 'created_at'] - - -@admin.register(Feedback) -class FeedbackAdmin(admin.ModelAdmin): - list_display = ['user', 'category', 'project', 'is_resolved', 'created_at'] - list_filter = ['category', 'is_resolved', 'created_at'] - search_fields = ['user__email', 'message'] - ordering = ['-created_at'] - readonly_fields = ['created_at', 'updated_at'] - inlines = [FeedbackReplyInline] - actions = ['mark_resolved'] - - @admin.action(description='Mark selected feedback as resolved') - def mark_resolved(self, request, queryset): - updated = queryset.update(is_resolved=True) - self.message_user(request, f'{updated} feedback item(s) marked as resolved.') - - -@admin.register(FeedbackReply) -class FeedbackReplyAdmin(admin.ModelAdmin): - list_display = ['feedback', 'user', 'created_at'] - list_filter = ['created_at'] - search_fields = ['user__email', 'message'] - readonly_fields = ['feedback', 'user', 'message', 'created_at'] diff --git a/backend/apps/feedback/apps.py b/backend/apps/feedback/apps.py deleted file mode 100644 index 7d982d2..0000000 --- a/backend/apps/feedback/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class FeedbackConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.feedback' diff --git a/backend/apps/feedback/email_utils.py b/backend/apps/feedback/email_utils.py deleted file mode 100644 index 20116ed..0000000 --- a/backend/apps/feedback/email_utils.py +++ /dev/null @@ -1,66 +0,0 @@ -import logging - -from django.conf import settings -from django.core.mail import send_mail -from django.template.loader import render_to_string - -logger = logging.getLogger(__name__) - - -def send_feedback_confirmation_email(feedback): - """Send confirmation email after user submits feedback.""" - user = feedback.user - subject = 'We received your feedback — PyDocAI' - message = render_to_string('emails/feedback_confirmation.txt', { - 'user': user, - 'feedback': feedback, - 'frontend_url': settings.FRONTEND_URL, - }) - html_message = render_to_string('emails/feedback_confirmation.html', { - 'user': user, - 'feedback': feedback, - 'frontend_url': settings.FRONTEND_URL, - }) - try: - send_mail( - subject=subject, - message=message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - html_message=html_message, - fail_silently=False, - ) - logger.info(f"Feedback confirmation email sent to {user.email}") - except Exception as e: - logger.error(f"Failed to send feedback confirmation to {user.email}: {e}") - - -def send_feedback_reply_email(reply): - """Send notification to feedback owner when someone replies.""" - feedback = reply.feedback - user = feedback.user - subject = 'New reply on your feedback — PyDocAI' - message = render_to_string('emails/feedback_reply.txt', { - 'user': user, - 'feedback': feedback, - 'reply': reply, - 'frontend_url': settings.FRONTEND_URL, - }) - html_message = render_to_string('emails/feedback_reply.html', { - 'user': user, - 'feedback': feedback, - 'reply': reply, - 'frontend_url': settings.FRONTEND_URL, - }) - try: - send_mail( - subject=subject, - message=message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - html_message=html_message, - fail_silently=False, - ) - logger.info(f"Feedback reply email sent to {user.email}") - except Exception as e: - logger.error(f"Failed to send feedback reply to {user.email}: {e}") diff --git a/backend/apps/feedback/migrations/0001_initial.py b/backend/apps/feedback/migrations/0001_initial.py deleted file mode 100644 index 45f80d2..0000000 --- a/backend/apps/feedback/migrations/0001_initial.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-23 10:20 - -import uuid - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('projects', '0005_remove_file_fields'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Feedback', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('rating', models.IntegerField(choices=[(1, '1 Star'), (2, '2 Stars'), (3, '3 Stars'), (4, '4 Stars'), (5, '5 Stars')])), - ('category', models.CharField(choices=[('general', 'General'), ('docs_quality', 'Documentation Quality'), ('ui_ux', 'UI / UX'), ('performance', 'Performance'), ('bug', 'Bug Report'), ('feature', 'Feature Request')], default='general', max_length=20)), - ('message', models.TextField()), - ('is_public', models.BooleanField(default=False)), - ('is_resolved', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='projects.project')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'db_table': 'feedback', - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/backend/apps/feedback/migrations/0002_feedback_admin_response.py b/backend/apps/feedback/migrations/0002_feedback_admin_response.py deleted file mode 100644 index 83b9c50..0000000 --- a/backend/apps/feedback/migrations/0002_feedback_admin_response.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-25 05:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('feedback', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='feedback', - name='admin_response', - field=models.TextField(blank=True, default=''), - ), - ] diff --git a/backend/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py b/backend/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py deleted file mode 100644 index 6afd420..0000000 --- a/backend/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py +++ /dev/null @@ -1,40 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-26 06:20 - -import uuid - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('feedback', '0002_feedback_admin_response'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveField( - model_name='feedback', - name='admin_response', - ), - migrations.RemoveField( - model_name='feedback', - name='rating', - ), - migrations.CreateModel( - name='FeedbackReply', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('message', models.TextField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('feedback', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='feedback.feedback')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedback_replies', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'db_table': 'feedback_replies', - 'ordering': ['created_at'], - }, - ), - ] diff --git a/backend/apps/feedback/migrations/0004_remove_feedback_is_public.py b/backend/apps/feedback/migrations/0004_remove_feedback_is_public.py deleted file mode 100644 index 6d1d7d9..0000000 --- a/backend/apps/feedback/migrations/0004_remove_feedback_is_public.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.2.13 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('feedback', '0003_remove_feedback_admin_response_and_more'), - ] - - operations = [ - migrations.RemoveField( - model_name='feedback', - name='is_public', - ), - ] diff --git a/backend/apps/feedback/migrations/__init__.py b/backend/apps/feedback/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/feedback/models.py b/backend/apps/feedback/models.py deleted file mode 100644 index 473a948..0000000 --- a/backend/apps/feedback/models.py +++ /dev/null @@ -1,48 +0,0 @@ -import uuid - -from django.db import models - -from apps.projects.models import Project -from apps.users.models import User - - -class Feedback(models.Model): - - class Category(models.TextChoices): - GENERAL = 'general', 'General' - DOCS_QUALITY= 'docs_quality','Documentation Quality' - UI_UX = 'ui_ux', 'UI / UX' - PERFORMANCE = 'performance', 'Performance' - BUG = 'bug', 'Bug Report' - FEATURE = 'feature', 'Feature Request' - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feedbacks') - project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, related_name='feedbacks') - category = models.CharField(max_length=20, choices=Category.choices, default=Category.GENERAL) - message = models.TextField() - is_resolved = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = 'feedback' - ordering = ['-created_at'] - - def __str__(self): - return f"{self.user.email} — ({self.category})" - - -class FeedbackReply(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - feedback = models.ForeignKey(Feedback, on_delete=models.CASCADE, related_name='replies') - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='feedback_replies') - message = models.TextField() - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - db_table = 'feedback_replies' - ordering = ['created_at'] - - def __str__(self): - return f"Reply by {self.user.email} on {self.feedback.id}" diff --git a/backend/apps/feedback/serializers.py b/backend/apps/feedback/serializers.py deleted file mode 100644 index 747baec..0000000 --- a/backend/apps/feedback/serializers.py +++ /dev/null @@ -1,31 +0,0 @@ -from rest_framework import serializers - -from .models import Feedback, FeedbackReply - - -class FeedbackReplySerializer(serializers.ModelSerializer): - user_name = serializers.CharField(source='user.name', read_only=True) - is_admin = serializers.SerializerMethodField() - - class Meta: - model = FeedbackReply - fields = ['id', 'feedback', 'user', 'user_name', 'is_admin', 'message', 'created_at'] - read_only_fields = ['id', 'feedback', 'user', 'user_name', 'is_admin', 'created_at'] - - def get_is_admin(self, obj): - return obj.user.is_staff or getattr(obj.user, 'is_admin', False) - - -class FeedbackSerializer(serializers.ModelSerializer): - user_name = serializers.CharField(source='user.name', read_only=True) - replies = FeedbackReplySerializer(many=True, read_only=True) - - class Meta: - model = Feedback - fields = [ - 'id', 'user', 'user_name', 'project', 'category', - 'message', 'is_resolved', - 'replies', 'created_at', 'updated_at', - ] - read_only_fields = ['id', 'user', 'user_name', 'is_resolved', 'replies', 'created_at', 'updated_at'] - diff --git a/backend/apps/feedback/tasks.py b/backend/apps/feedback/tasks.py deleted file mode 100644 index 0665109..0000000 --- a/backend/apps/feedback/tasks.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging - -from celery import shared_task - -logger = logging.getLogger(__name__) - - -@shared_task -def send_feedback_confirmation_task(feedback_id): - from .email_utils import send_feedback_confirmation_email - from .models import Feedback - try: - fb = Feedback.objects.select_related('user').get(id=feedback_id) - send_feedback_confirmation_email(fb) - except Exception as e: - logger.error(f"send_feedback_confirmation_task failed for feedback {feedback_id}: {e}") - - -@shared_task -def send_feedback_reply_task(reply_id): - from .email_utils import send_feedback_reply_email - from .models import FeedbackReply - try: - reply = FeedbackReply.objects.select_related('user', 'feedback__user').get(id=reply_id) - send_feedback_reply_email(reply) - except Exception as e: - logger.error(f"send_feedback_reply_task failed for reply {reply_id}: {e}") diff --git a/backend/apps/feedback/tests.py b/backend/apps/feedback/tests.py deleted file mode 100644 index fba9a19..0000000 --- a/backend/apps/feedback/tests.py +++ /dev/null @@ -1,32 +0,0 @@ -from unittest.mock import patch - -from django.contrib.auth import get_user_model -from rest_framework.test import APITestCase - -from apps.feedback.models import Feedback - -User = get_user_model() - -class FeedbackAPITests(APITestCase): - def setUp(self): - self.user = User.objects.create_user(email='fb@test.com', name='FB', password='pwd') - self.client.force_authenticate(user=self.user) - - @patch('apps.feedback.tasks.send_feedback_confirmation_task.delay') - def test_submit_feedback(self, mock_email_task): - """Test users can submit feedback and it triggers an email task.""" - response = self.client.post('/api/feedback/', { - 'category': 'bug', - 'message': 'The parser broke on my nested dictionary.' - }) - self.assertEqual(response.status_code, 201) - self.assertEqual(Feedback.objects.count(), 1) - mock_email_task.assert_called_once() - - def test_list_my_feedback(self): - """Test users can view their feedback history.""" - Feedback.objects.create(user=self.user, category='ui_ux', message='Looks good') - response = self.client.get('/api/feedback/my/') - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.data['results']), 1) - self.assertEqual(response.data['results'][0]['message'], 'Looks good') diff --git a/backend/apps/feedback/urls.py b/backend/apps/feedback/urls.py deleted file mode 100644 index e318573..0000000 --- a/backend/apps/feedback/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.urls import path - -from .views import ( - AdminFeedbackResolveView, - AdminFeedbackView, - FeedbackCreateView, - FeedbackListView, - FeedbackReplyListCreateView, -) - -urlpatterns = [ - path('', FeedbackCreateView.as_view(), name='feedback-create'), - path('my/', FeedbackListView.as_view(), name='feedback-my'), - path('admin/', AdminFeedbackView.as_view(), name='feedback-admin'), - path('admin//resolve/', AdminFeedbackResolveView.as_view(), name='feedback-resolve'), - path('/replies/', FeedbackReplyListCreateView.as_view(), name='feedback-replies'), -] diff --git a/backend/apps/feedback/views.py b/backend/apps/feedback/views.py deleted file mode 100644 index 7cd3ce9..0000000 --- a/backend/apps/feedback/views.py +++ /dev/null @@ -1,82 +0,0 @@ -from django.shortcuts import get_object_or_404 -from rest_framework import generics, permissions, status -from rest_framework.response import Response -from rest_framework.views import APIView - -from .models import Feedback, FeedbackReply -from .serializers import FeedbackReplySerializer, FeedbackSerializer -from .tasks import send_feedback_confirmation_task, send_feedback_reply_task - - -class FeedbackCreateView(generics.CreateAPIView): - """Authenticated users submit feedback.""" - permission_classes = [permissions.IsAuthenticated] - serializer_class = FeedbackSerializer - - def perform_create(self, serializer): - feedback = serializer.save(user=self.request.user) - send_feedback_confirmation_task.delay(feedback.id) - - -class FeedbackListView(generics.ListAPIView): - """Authenticated users see their own feedback history.""" - permission_classes = [permissions.IsAuthenticated] - serializer_class = FeedbackSerializer - search_fields = ['category', 'message'] - filterset_fields = ['category', 'is_resolved'] - ordering_fields = ['created_at', 'category'] - - def get_queryset(self): - return Feedback.objects.filter(user=self.request.user).prefetch_related('replies__user') - - -class AdminFeedbackView(generics.ListAPIView): - """Admin-only view of all feedback with filter support.""" - permission_classes = [permissions.IsAuthenticated] - serializer_class = FeedbackSerializer - search_fields = ['message', 'user__name', 'user__email', 'category'] - ordering_fields = ['created_at', 'category', 'is_resolved'] - - def get_queryset(self): - user = self.request.user - if not (user.is_staff or user.is_admin): - return Feedback.objects.none() - qs = Feedback.objects.select_related('user', 'project').prefetch_related('replies__user').all() - category = self.request.query_params.get('category') - resolved = self.request.query_params.get('resolved') - if category: - qs = qs.filter(category=category) - if resolved is not None: - qs = qs.filter(is_resolved=resolved.lower() == 'true') - return qs - - -class AdminFeedbackResolveView(APIView): - """Mark feedback as resolved (admin only).""" - permission_classes = [permissions.IsAuthenticated] - - def patch(self, request, pk): - if not (request.user.is_staff or request.user.is_admin): - return Response({'detail': 'Forbidden.'}, status=status.HTTP_403_FORBIDDEN) - try: - fb = Feedback.objects.prefetch_related('replies__user').get(pk=pk) - except Feedback.DoesNotExist: - return Response({'detail': 'Not found.'}, status=status.HTTP_404_NOT_FOUND) - fb.is_resolved = True - fb.save() - return Response(FeedbackSerializer(fb).data) - - -class FeedbackReplyListCreateView(generics.ListCreateAPIView): - """List replies on a feedback or create a new reply.""" - permission_classes = [permissions.IsAuthenticated] - serializer_class = FeedbackReplySerializer - - def get_queryset(self): - return FeedbackReply.objects.filter(feedback_id=self.kwargs['feedback_pk']).select_related('user') - - def perform_create(self, serializer): - feedback = get_object_or_404(Feedback, pk=self.kwargs['feedback_pk']) - reply = serializer.save(feedback=feedback, user=self.request.user) - if reply.user != feedback.user: - send_feedback_reply_task.delay(reply.id) diff --git a/backend/apps/github_integration/__init__.py b/backend/apps/github_integration/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/github_integration/apps.py b/backend/apps/github_integration/apps.py deleted file mode 100644 index 5015dd9..0000000 --- a/backend/apps/github_integration/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class GithubIntegrationConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.github_integration' diff --git a/backend/apps/github_integration/fetcher.py b/backend/apps/github_integration/fetcher.py deleted file mode 100644 index 1500f90..0000000 --- a/backend/apps/github_integration/fetcher.py +++ /dev/null @@ -1,216 +0,0 @@ -import requests -from django.conf import settings -from github import Github, GithubException - - -def _get_github_client(github_token=None): - """Create a Github client using user token, app API token, or unauthenticated.""" - if github_token: - return Github(github_token, timeout=10, retry=0) - api_token = getattr(settings, 'GITHUB_API_TOKEN', None) - if api_token and api_token.strip(): - return Github(api_token, timeout=10, retry=0) - return Github(timeout=10, retry=0) - - -def _fetch_public_repo_api(full_name: str) -> dict: - """Fetch public repo info directly via GitHub REST API (avoids PyGithub retry loops).""" - api_token = getattr(settings, 'GITHUB_API_TOKEN', None) - headers = {'Accept': 'application/vnd.github+json'} - if api_token and api_token.strip(): - headers['Authorization'] = f'token {api_token}' - - resp = requests.get( - f'https://api.github.com/repos/{full_name}', - headers=headers, - timeout=10, - ) - if resp.status_code == 404: - raise GithubException(404, {'message': 'Not Found'}) - if resp.status_code == 403: - raise GithubException(403, {'message': 'Rate limit exceeded or forbidden'}) - resp.raise_for_status() - return resp.json() - - -def _fetch_public_tree_api(full_name: str, branch: str) -> dict: - """Fetch repo tree directly via GitHub REST API.""" - api_token = getattr(settings, 'GITHUB_API_TOKEN', None) - headers = {'Accept': 'application/vnd.github+json'} - if api_token and api_token.strip(): - headers['Authorization'] = f'token {api_token}' - - resp = requests.get( - f'https://api.github.com/repos/{full_name}/git/trees/{branch}?recursive=1', - headers=headers, - timeout=10, - ) - if resp.status_code == 404: - raise GithubException(404, {'message': 'Not Found'}) - if resp.status_code == 403: - raise GithubException(403, {'message': 'Rate limit exceeded or forbidden'}) - resp.raise_for_status() - return resp.json() - - -def get_user_repos(github_token: str) -> list: - """List all repos the user has access to.""" - g = Github(github_token) - user = g.get_user() - repos = [] - for repo in user.get_repos(sort='updated'): - repos.append({ - 'id': repo.id, - 'name': repo.name, - 'full_name': repo.full_name, - 'description': repo.description, - 'private': repo.private, - 'url': repo.html_url, - 'updated_at': repo.updated_at.isoformat(), - 'language': repo.language, - 'default_branch': repo.default_branch, - }) - return repos - - -def get_public_repo(full_name: str, github_token=None) -> dict: - """Get info about a public repository without user OAuth token.""" - data = _fetch_public_repo_api(full_name) - return { - 'id': data['id'], - 'name': data['name'], - 'full_name': data['full_name'], - 'description': data.get('description') or '', - 'private': data['private'], - 'url': data['html_url'], - 'default_branch': data.get('default_branch') or 'main', - 'language': data.get('language') or '', - 'stargazers_count': data.get('stargazers_count', 0), - 'forks_count': data.get('forks_count', 0), - } - - -def get_repo_tree(github_token: str, full_name: str, branch: str = None) -> list: - """Get the folder/file tree of a repo.""" - g = Github(github_token) - repo = g.get_repo(full_name) - branch = branch or repo.default_branch - - tree = repo.get_git_tree(branch, recursive=True) - items = [] - for item in tree.tree: - items.append({ - 'path': item.path, - 'type': item.type, - 'size': item.size, - }) - return items - - -def get_public_repo_tree(full_name: str, branch: str = None, github_token=None) -> list: - """Get the folder/file tree of a public repo without user OAuth token.""" - # First get repo to find default branch if not provided - repo_data = _fetch_public_repo_api(full_name) - branch = branch or repo_data.get('default_branch') or 'main' - - tree_data = _fetch_public_tree_api(full_name, branch) - items = [] - for item in tree_data.get('tree', []): - items.append({ - 'path': item['path'], - 'type': item['type'], # 'blob' = file, 'tree' = folder - 'size': item.get('size', 0), - }) - return items - - -def get_repo_folders(github_token: str, full_name: str, branch: str = None) -> list: - """Get only folders from a repo (for folder picker UI).""" - tree = get_repo_tree(github_token, full_name, branch) - folders = [ - item for item in tree - if item['type'] == 'tree' - ] - - folders.insert(0, {'path': '/', 'type': 'tree', 'size': 0}) - return folders - - -def get_public_repo_folders(full_name: str, branch: str = None, github_token=None) -> list: - """Get only folders from a public repo without user OAuth token.""" - tree = get_public_repo_tree(full_name, branch, github_token) - folders = [ - item for item in tree - if item['type'] == 'tree' - ] - folders.insert(0, {'path': '/', 'type': 'tree', 'size': 0}) - return folders - - -def _download_and_extract_zipball(url: str, headers: dict, folder_path: str) -> list: - """Download a GitHub zipball and extract .py files.""" - import io - import zipfile - - resp = requests.get(url, headers=headers, timeout=30, stream=True) - resp.raise_for_status() - zip_bytes = resp.content - - files = [] - with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: - names = zf.namelist() - # Zipball root is {owner}-{repo}-{sha}/ — find common top-level dir - prefix = '' - if names: - first = names[0] - if '/' in first: - prefix = first.split('/', 1)[0] + '/' - - for name in names: - if name.endswith('/'): - continue - if not name.endswith('.py'): - continue - rel_path = name[len(prefix):] if prefix else name - if folder_path and folder_path != '/': - if not rel_path.startswith(folder_path.lstrip('/')): - continue - try: - content = zf.read(name).decode('utf-8', errors='ignore') - files.append({'file_path': rel_path, 'content': content}) - except Exception: - pass - return files - - -def import_folder_from_repo(github_token, full_name, folder_path, branch=None): - from github import Github - g = Github(github_token) - repo = g.get_repo(full_name) - branch = branch or repo.default_branch - - url = f'https://api.github.com/repos/{full_name}/zipball/{branch}' - headers = { - 'Accept': 'application/vnd.github+json', - 'Authorization': f'token {github_token}', - } - return _download_and_extract_zipball(url, headers, folder_path) - - -def import_public_folder_from_repo(full_name, folder_path, branch=None, github_token=None): - """Import .py files from a public repo using GitHub Archive API (single HTTP request).""" - # Get repo to find default branch - repo_data = _fetch_public_repo_api(full_name) - branch = branch or repo_data.get('default_branch') or 'main' - - # Use GITHUB_API_TOKEN if available, otherwise download as public - api_token = github_token or getattr(settings, 'GITHUB_API_TOKEN', None) - headers = {'Accept': 'application/vnd.github+json'} - if api_token and api_token.strip(): - headers['Authorization'] = f'token {api_token}' - url = f'https://api.github.com/repos/{full_name}/zipball/{branch}' - else: - # Fallback to direct download URL (no auth needed, no rate limiting) - url = f'https://github.com/{full_name}/archive/refs/heads/{branch}.zip' - - return _download_and_extract_zipball(url, headers, folder_path) diff --git a/backend/apps/github_integration/migrations/__init__.py b/backend/apps/github_integration/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/github_integration/serializers.py b/backend/apps/github_integration/serializers.py deleted file mode 100644 index 8ff5dd9..0000000 --- a/backend/apps/github_integration/serializers.py +++ /dev/null @@ -1,10 +0,0 @@ -from rest_framework import serializers - - -class RepoImportSerializer(serializers.Serializer): - full_name = serializers.CharField() - folder_path = serializers.CharField(default='/') - branch = serializers.CharField(required=False, allow_blank=True) - name = serializers.CharField(required=False, allow_blank=True) - description = serializers.CharField(required=False, allow_blank=True) - custom_info = serializers.JSONField(required=False) diff --git a/backend/apps/github_integration/tasks.py b/backend/apps/github_integration/tasks.py deleted file mode 100644 index 051efd4..0000000 --- a/backend/apps/github_integration/tasks.py +++ /dev/null @@ -1,100 +0,0 @@ -import base64 -import io -import logging -import zipfile - -from celery import shared_task - -from apps.parser.tasks import parse_folder_task -from apps.parser.validators import should_exclude -from apps.projects.models import Project - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, max_retries=3) -def import_github_repo_task(self, project_id, github_token, full_name, folder_path, branch, description, custom_info): - try: - from .fetcher import import_folder_from_repo - - project = Project.objects.get(id=project_id) - - files = import_folder_from_repo(github_token, full_name, folder_path, branch) - - if not files: - project.status = Project.Status.FAILED - project.save(update_fields=['status']) - logger.warning(f"No Python files found in {full_name}/{folder_path}") - return - - # Build zip in memory - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: - for f in files: - zf.writestr(f['file_path'], f['content']) - zip_buffer.seek(0) - zip_base64 = base64.b64encode(zip_buffer.read()).decode('utf-8') - - py_files = [f['file_path'] for f in files if not should_exclude(f['file_path'])] - - parse_folder_task.delay( - project.id, - py_files, - zip_base64, - user_description=description, - custom_info=custom_info, - ) - - except Exception as e: - logger.exception(f"import_github_repo_task failed for project {project_id}: {e}") - try: - Project.objects.filter(id=project_id).update(status=Project.Status.FAILED) - except Exception: - pass - raise self.retry(exc=e, countdown=10) - - -@shared_task(bind=True, max_retries=3) -def import_public_repo_task(self, project_id, full_name, folder_path, branch, description, custom_info): - """Import from a public GitHub repo without user OAuth token.""" - try: - from .fetcher import import_public_folder_from_repo - - project = Project.objects.get(id=project_id) - - files = import_public_folder_from_repo(full_name, folder_path, branch) - - if not files: - project.status = Project.Status.FAILED - project.error_message = "No Python files found or repository is private" - project.save(update_fields=['status', 'error_message']) - logger.warning(f"No Python files found in {full_name}/{folder_path}") - return - - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: - for f in files: - zf.writestr(f['file_path'], f['content']) - zip_buffer.seek(0) - zip_base64 = base64.b64encode(zip_buffer.read()).decode('utf-8') - - py_files = [f['file_path'] for f in files if not should_exclude(f['file_path'])] - - parse_folder_task.delay( - project.id, - py_files, - zip_base64, - user_description=description, - custom_info=custom_info, - ) - - except Exception as e: - logger.exception(f"import_public_repo_task failed for project {project_id}: {e}") - try: - Project.objects.filter(id=project_id).update( - status=Project.Status.FAILED, - error_message=str(e), - ) - except Exception: - pass - raise self.retry(exc=e, countdown=10) diff --git a/backend/apps/github_integration/tests.py b/backend/apps/github_integration/tests.py deleted file mode 100644 index 3e6f4f4..0000000 --- a/backend/apps/github_integration/tests.py +++ /dev/null @@ -1,33 +0,0 @@ -from unittest.mock import patch - -from django.contrib.auth import get_user_model -from django.test import TestCase -from rest_framework.test import APITestCase - -from apps.github_integration.views import parse_github_url - -User = get_user_model() - -class GithubUtilsTests(TestCase): - def test_parse_github_url(self): - """Ensure standard GitHub URLs extract the 'owner/repo' format.""" - self.assertEqual(parse_github_url("https://github.com/django/django"), "django/django") - self.assertEqual(parse_github_url("https://github.com/astral-sh/uv.git"), "astral-sh/uv") - self.assertEqual(parse_github_url("https://github.com/owner/repo/"), "owner/repo") - self.assertIsNone(parse_github_url("invalid_url")) - -class GithubAPITests(APITestCase): - def setUp(self): - self.user = User.objects.create_user(email='git@test.com', name='Git', password='pwd') - self.client.force_authenticate(user=self.user) - - @patch('apps.github_integration.tasks.import_public_repo_task.delay') - def test_import_public_repo(self, mock_task): - """Test importing a public repo triggers the background task.""" - response = self.client.post('/api/github/public-repo/import/', { - 'url': 'https://github.com/pallets/flask', - 'name': 'Flask Docs' - }) - self.assertEqual(response.status_code, 202) - self.assertIn('project_id', response.data) - mock_task.assert_called_once() diff --git a/backend/apps/github_integration/urls.py b/backend/apps/github_integration/urls.py deleted file mode 100644 index 339f8ca..0000000 --- a/backend/apps/github_integration/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.urls import path - -from .views import ( - ImportPublicRepoView, - ImportRepoView, - PublicRepoFoldersView, - PublicRepoInfoView, - RepoFoldersView, - UserReposView, -) - -urlpatterns = [ - path('repos/', UserReposView.as_view(), name='github-repos'), - path('repos/folders/', RepoFoldersView.as_view(), name='github-repo-folders'), - path('repos/import/', ImportRepoView.as_view(), name='github-repo-import'), - path('public-repo/info/', PublicRepoInfoView.as_view(), name='github-public-repo-info'), - path('public-repo/folders/', PublicRepoFoldersView.as_view(), name='github-public-repo-folders'), - path('public-repo/import/', ImportPublicRepoView.as_view(), name='github-public-repo-import'), -] diff --git a/backend/apps/github_integration/views.py b/backend/apps/github_integration/views.py deleted file mode 100644 index e9f5cfe..0000000 --- a/backend/apps/github_integration/views.py +++ /dev/null @@ -1,208 +0,0 @@ -import re - -from github import GithubException -from rest_framework import status -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.projects.models import Project - -from .fetcher import get_public_repo, get_public_repo_folders, get_repo_folders, get_user_repos -from .serializers import RepoImportSerializer -from .tasks import import_github_repo_task, import_public_repo_task - - -def parse_github_url(url: str) -> str | None: - """Extract owner/repo from a GitHub URL. Returns None if invalid.""" - patterns = [ - r'github\.com[:/]([^/]+/[^/]+?)(?:\.git)?(?:/|$)', - r'github\.com[:/]([^/]+/[^/]+?)(?:\.git)?$', - ] - for pattern in patterns: - match = re.search(pattern, url) - if match: - full_name = match.group(1) - # Strip trailing slashes or dots - full_name = full_name.rstrip('/.') - if '/' in full_name and not full_name.endswith('.'): - return full_name - return None - - -class UserReposView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request): - user = request.user - if not user.github_token: - return Response({'detail': 'GitHub account not connected.'}, status=status.HTTP_400_BAD_REQUEST) - try: - repos = get_user_repos(user.github_token) - return Response(repos) - except Exception as e: - return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) - - -class RepoFoldersView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request): - full_name = request.query_params.get('repo') - branch = request.query_params.get('branch', None) - if not full_name: - return Response({'detail': 'repo parameter is required'}, status=status.HTTP_400_BAD_REQUEST) - user = request.user - if not user.github_token: - return Response({'detail': 'GitHub account not connected.'}, status=status.HTTP_400_BAD_REQUEST) - try: - folders = get_repo_folders(user.github_token, full_name, branch) - return Response(folders) - except Exception as e: - return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) - - -class ImportRepoView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - serializer = RepoImportSerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - data = serializer.validated_data - full_name = data['full_name'] - folder_path = data.get('folder_path', '/') - branch = data.get('branch') or None - name = data.get('name') or full_name.split('/')[-1] - description = data.get('description', '') - custom_info = data.get('custom_info', {}) - - user = request.user - if not user.github_token: - return Response({'detail': 'GitHub account not connected.'}, status=status.HTTP_400_BAD_REQUEST) - - # Create project immediately and return — all slow GitHub work happens in Celery - project = Project.objects.create( - user=user, - name=name, - description=description, - source_type=Project.SourceType.GITHUB, - github_url=f'https://github.com/{full_name}', - status=Project.Status.PENDING, - custom_details=custom_info or None, - ) - - import_github_repo_task.delay( - project.id, - user.github_token, - full_name, - folder_path, - branch, - description, - custom_info or None, - ) - - return Response({'project_id': str(project.id)}, status=status.HTTP_202_ACCEPTED) - - -class PublicRepoInfoView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request): - """Get info about a public repo from URL or full_name.""" - url = request.query_params.get('url') - full_name = request.query_params.get('full_name') - - if url: - full_name = parse_github_url(url) - if not full_name: - return Response({'detail': 'Invalid GitHub URL. Expected: https://github.com/owner/repo'}, status=status.HTTP_400_BAD_REQUEST) - elif not full_name: - return Response({'detail': 'url or full_name parameter is required'}, status=status.HTTP_400_BAD_REQUEST) - - try: - repo = get_public_repo(full_name) - return Response(repo) - except GithubException as e: - if e.status == 404: - return Response({'detail': 'Repository not found. It may be private or does not exist.'}, status=status.HTTP_404_NOT_FOUND) - if e.status == 403: - return Response({'detail': 'Rate limit exceeded or repository is private.'}, status=status.HTTP_429_TOO_MANY_REQUESTS) - return Response({'detail': f'GitHub API error: {e.data.get("message", str(e))}'}, status=status.HTTP_400_BAD_REQUEST) - except Exception as e: - return Response({'detail': f'Unexpected error: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class PublicRepoFoldersView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request): - """Get folders from a public repo.""" - full_name = request.query_params.get('full_name') - url = request.query_params.get('url') - branch = request.query_params.get('branch', None) - - if url: - full_name = parse_github_url(url) - if not full_name: - return Response({'detail': 'Invalid GitHub URL'}, status=status.HTTP_400_BAD_REQUEST) - elif not full_name: - return Response({'detail': 'full_name or url parameter is required'}, status=status.HTTP_400_BAD_REQUEST) - - try: - folders = get_public_repo_folders(full_name, branch) - return Response(folders) - except GithubException as e: - if e.status == 404: - return Response({'detail': 'Repository not found'}, status=status.HTTP_404_NOT_FOUND) - if e.status == 403: - return Response({'detail': 'Rate limit exceeded or repository is private.'}, status=status.HTTP_429_TOO_MANY_REQUESTS) - return Response({'detail': f'GitHub API error: {e.data.get("message", str(e))}'}, status=status.HTTP_400_BAD_REQUEST) - except Exception as e: - return Response({'detail': f'Unexpected error: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -class ImportPublicRepoView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - """Import a public GitHub repo using URL or full_name (no OAuth required).""" - url = request.data.get('url') - full_name = request.data.get('full_name') - folder_path = request.data.get('folder_path', '/') - branch = request.data.get('branch') or None - name = request.data.get('name') - description = request.data.get('description', '') - custom_info = request.data.get('custom_info', {}) - - if url: - full_name = parse_github_url(url) - if not full_name: - return Response({'detail': 'Invalid GitHub URL. Expected: https://github.com/owner/repo'}, status=status.HTTP_400_BAD_REQUEST) - elif not full_name: - return Response({'detail': 'url or full_name is required'}, status=status.HTTP_400_BAD_REQUEST) - - if not name: - name = full_name.split('/')[-1] - - project = Project.objects.create( - user=request.user, - name=name, - description=description, - source_type=Project.SourceType.GITHUB, - github_url=f'https://github.com/{full_name}', - status=Project.Status.PENDING, - custom_details=custom_info or None, - ) - - import_public_repo_task.delay( - project.id, - full_name, - folder_path, - branch, - description, - custom_info or None, - ) - - return Response({'project_id': str(project.id)}, status=status.HTTP_202_ACCEPTED) diff --git a/backend/apps/internal/__init__.py b/backend/apps/internal/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/internal/urls.py b/backend/apps/internal/urls.py deleted file mode 100644 index 591ad0c..0000000 --- a/backend/apps/internal/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path('projects//parsed/', views.receive_parsed_data, name='internal-parsed'), - path('projects//ai-docs/', views.receive_ai_docs, name='internal-ai-docs'), -] diff --git a/backend/apps/internal/views.py b/backend/apps/internal/views.py deleted file mode 100644 index 38eb345..0000000 --- a/backend/apps/internal/views.py +++ /dev/null @@ -1,96 +0,0 @@ -import logging -import os - -from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import AllowAny -from rest_framework.response import Response - -from apps.projects.models import Project - -logger = logging.getLogger(__name__) - -INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "") - - -def _verify_internal_key(request): - key = request.META.get("HTTP_X_INTERNAL_API_KEY", "") - if INTERNAL_API_KEY and key != INTERNAL_API_KEY: - return Response({"error": "Forbidden"}, status=403) - return None - - -@api_view(["POST"]) -@permission_classes([AllowAny]) -def receive_parsed_data(request, project_id): - forbidden = _verify_internal_key(request) - if forbidden: - return forbidden - """ - Internal endpoint for FastAPI Parser service to POST parsed AST data. - The Parser service calls this after it finishes parsing files. - """ - try: - project = Project.objects.get(id=project_id) - except Project.DoesNotExist: - return Response({"error": "Project not found"}, status=404) - - parsed_data = request.data.get("parsed_data") or request.data.get("parsed") - file_count = request.data.get("file_count", 0) - - if parsed_data: - project.parsed_data = parsed_data - if file_count: - project.project_info = { - **(project.project_info or {}), - "files_parsed": file_count, - } - - project.status = Project.Status.PROCESSING - project.save() - - logger.info(f"Internal: Parsed data received for project {project_id}, {file_count} files") - - return Response({"status": "ok", "project_id": project_id}) - - -@api_view(["POST"]) -@permission_classes([AllowAny]) -def receive_ai_docs(request, project_id): - forbidden = _verify_internal_key(request) - if forbidden: - return forbidden - """ - Internal endpoint for FastAPI AI service to POST generated documentation. - """ - try: - project = Project.objects.get(id=project_id) - except Project.DoesNotExist: - return Response({"error": "Project not found"}, status=404) - - generated_docs = request.data.get("generated_docs") - readme_docs = request.data.get("readme_docs") - api_docs = request.data.get("api_docs") - project_info = request.data.get("project_info") - status_str = request.data.get("status", "done") - error_message = request.data.get("error_message") - - if generated_docs: - project.generated_docs = generated_docs - if readme_docs: - project.readme_docs = readme_docs - if api_docs: - project.api_docs = api_docs - if project_info: - project.project_info = project_info - - if status_str == "done": - project.status = Project.Status.DONE - elif status_str == "failed": - project.status = Project.Status.FAILED - project.error_message = error_message or "AI generation failed" - - project.save() - - logger.info(f"Internal: AI docs received for project {project_id}, status={status_str}") - - return Response({"status": "ok", "project_id": project_id}) diff --git a/backend/apps/notifications/__init__.py b/backend/apps/notifications/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/notifications/apps.py b/backend/apps/notifications/apps.py deleted file mode 100644 index 03a81ff..0000000 --- a/backend/apps/notifications/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class NotificationsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.notifications' diff --git a/backend/apps/notifications/migrations/0001_initial.py b/backend/apps/notifications/migrations/0001_initial.py deleted file mode 100644 index 1478a67..0000000 --- a/backend/apps/notifications/migrations/0001_initial.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 5.2.13 on 2026-06-16 13:40 - -import uuid - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('comments', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Notification', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('message', models.TextField()), - ('is_read', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='comments.comment')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'db_table': 'notifications', - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['user', 'is_read', 'created_at'], name='notificatio_user_id_5cf777_idx')], - }, - ), - ] diff --git a/backend/apps/notifications/migrations/__init__.py b/backend/apps/notifications/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/notifications/models.py b/backend/apps/notifications/models.py deleted file mode 100644 index 1b5f1e5..0000000 --- a/backend/apps/notifications/models.py +++ /dev/null @@ -1,27 +0,0 @@ -import uuid - -from django.db import models - -from apps.comments.models import Comment -from apps.users.models import User - - -class Notification(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications') - comment = models.ForeignKey( - Comment, on_delete=models.CASCADE, null=True, blank=True, related_name='notifications' - ) - message = models.TextField() - is_read = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - db_table = 'notifications' - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['user', 'is_read', 'created_at']), - ] - - def __str__(self): - return f'{self.user.email}: {self.message[:50]}' diff --git a/backend/apps/notifications/serializers.py b/backend/apps/notifications/serializers.py deleted file mode 100644 index 7eb36e5..0000000 --- a/backend/apps/notifications/serializers.py +++ /dev/null @@ -1,14 +0,0 @@ -from rest_framework import serializers - -from apps.comments.serializers import CommentSerializer - -from .models import Notification - - -class NotificationSerializer(serializers.ModelSerializer): - comment = CommentSerializer(read_only=True) - project_slug = serializers.CharField(source='comment.project.public_slug', read_only=True) - - class Meta: - model = Notification - fields = ['id', 'message', 'is_read', 'comment', 'project_slug', 'created_at'] diff --git a/backend/apps/notifications/tasks.py b/backend/apps/notifications/tasks.py deleted file mode 100644 index 02ab9e4..0000000 --- a/backend/apps/notifications/tasks.py +++ /dev/null @@ -1,17 +0,0 @@ -from celery import shared_task -from django.conf import settings -from django.core.mail import send_mail - - -@shared_task -def send_email_task(subject, message, recipient_list, html_message=None): - if not settings.EMAIL_HOST_USER: - return - send_mail( - subject=subject, - message=message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=recipient_list, - html_message=html_message, - fail_silently=True, - ) diff --git a/backend/apps/notifications/urls.py b/backend/apps/notifications/urls.py deleted file mode 100644 index b45eb9c..0000000 --- a/backend/apps/notifications/urls.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.urls import path - -from .views import ( - ClearAllNotificationsView, - MarkAllReadView, - MarkReadView, - NotificationDeleteView, - NotificationListView, - UnreadCountView, -) - -urlpatterns = [ - path('', NotificationListView.as_view(), name='notification_list'), - path('unread-count/', UnreadCountView.as_view(), name='notification_unread_count'), - path('/read/', MarkReadView.as_view(), name='notification_mark_read'), - path('read-all/', MarkAllReadView.as_view(), name='notification_mark_all_read'), - path('/', NotificationDeleteView.as_view(), name='notification_delete'), - path('clear-all/', ClearAllNotificationsView.as_view(), name='notification_clear_all'), -] diff --git a/backend/apps/notifications/utils.py b/backend/apps/notifications/utils.py deleted file mode 100644 index 28e6231..0000000 --- a/backend/apps/notifications/utils.py +++ /dev/null @@ -1,123 +0,0 @@ -from django.conf import settings - -from .models import Notification -from .tasks import send_email_task - - -def _snippet(text, maxlen=80): - if not text: - return '' - return text[:maxlen] + ('...' if len(text) > maxlen else '') - - -def notify_comment(comment): - project = comment.project - owner = project.user - if owner == comment.user: - return - - commenter = comment.user.name or comment.user.email - snippet = _snippet(comment.content) - message = f'{commenter} commented on "{project.name}": "{snippet}"' - Notification.objects.create(user=owner, comment=comment, message=message) - - if settings.EMAIL_HOST_USER: - public_url = f'{settings.SITE_URL}/public/{project.public_slug}#comment-{comment.id}' - send_email_task.delay( - subject=f'New comment on "{project.name}"', - message=comment.content, - recipient_list=[owner.email], - html_message=f''' - -
- - - - - - -
- - - -
-PyDocAI - -NEW_COMMENT -
-
-

New Comment

-

-on {project.name} · {commenter} -

-
-
-{comment.content} -
-
-VIEW_COMMENT() -
-

PyDocAI · AI-generated documentation

-
-
''', - ) - - -def notify_reply(comment): - parent = comment.parent - if not parent or not parent.user: - return - if parent.user == comment.user: - return - - project = comment.project - replier = comment.user.name or comment.user.email - snippet = _snippet(comment.content) - message = f'{replier} replied to your comment on "{project.name}": "{snippet}"' - Notification.objects.create(user=parent.user, comment=comment, message=message) - - if settings.EMAIL_HOST_USER: - public_url = f'{settings.SITE_URL}/public/{project.public_slug}#comment-{comment.id}' - send_email_task.delay( - subject=f'New reply on "{project.name}"', - message=comment.content, - recipient_list=[parent.user.email], - html_message=f''' - -
- - - - - - -
- - - -
-PyDocAI - -NEW_REPLY -
-
-

New Reply

-

-on {project.name} · {replier} -

-
-
-Your comment: -{parent.content} -
-
-Reply: -{comment.content} -
-
-VIEW_REPLY() -
-

PyDocAI · AI-generated documentation

-
-
''', - ) diff --git a/backend/apps/notifications/views.py b/backend/apps/notifications/views.py deleted file mode 100644 index 6f1d1b1..0000000 --- a/backend/apps/notifications/views.py +++ /dev/null @@ -1,56 +0,0 @@ -from rest_framework import permissions, status -from rest_framework.response import Response -from rest_framework.views import APIView - -from .models import Notification -from .serializers import NotificationSerializer - - -class NotificationListView(APIView): - permission_classes = [permissions.IsAuthenticated] - - def get(self, request): - limit = min(int(request.query_params.get('limit', 20)), 50) - qs = Notification.objects.filter(user=request.user)[:limit] - data = NotificationSerializer(qs, many=True).data - return Response(data) - - -class UnreadCountView(APIView): - permission_classes = [permissions.IsAuthenticated] - - def get(self, request): - count = Notification.objects.filter(user=request.user, is_read=False).count() - return Response({'unread_count': count}) - - -class MarkReadView(APIView): - permission_classes = [permissions.IsAuthenticated] - - def patch(self, request, pk): - Notification.objects.filter(pk=pk, user=request.user).update(is_read=True) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class MarkAllReadView(APIView): - permission_classes = [permissions.IsAuthenticated] - - def patch(self, request): - Notification.objects.filter(user=request.user, is_read=False).update(is_read=True) - return Response(status=status.HTTP_204_NO_CONTENT) - - -class NotificationDeleteView(APIView): - permission_classes = [permissions.IsAuthenticated] - - def delete(self, request, pk): - Notification.objects.filter(pk=pk, user=request.user).delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class ClearAllNotificationsView(APIView): - permission_classes = [permissions.IsAuthenticated] - - def delete(self, request): - Notification.objects.filter(user=request.user).delete() - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/parser/__init__.py b/backend/apps/parser/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/parser/apps.py b/backend/apps/parser/apps.py deleted file mode 100644 index 4365e8f..0000000 --- a/backend/apps/parser/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ParserConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.parser' diff --git a/backend/apps/parser/ast_parser.py b/backend/apps/parser/ast_parser.py deleted file mode 100644 index abe7112..0000000 --- a/backend/apps/parser/ast_parser.py +++ /dev/null @@ -1,165 +0,0 @@ -import ast -from typing import Any - - -def parse_python_file(source_code: str) -> dict: - try: - tree = ast.parse(source_code) - except SyntaxError as e: - return { - "error": True, - "error_message": f"SyntaxError on line {e.lineno}: {e.msg}" - } - - result = { - "error": False, - "module_docstring": ast.get_docstring(tree), - "imports": [], - "functions": [], - "classes": [], - "ordered_items": [] # Preserves source order: [{"type": "import"|"function"|"class", "data": ...}] - } - - # Collect all top-level names for cross-reference detection - top_level_names = set() - - # First pass: collect top-level definitions to know what names exist - for node in tree.body: - if isinstance(node, ast.FunctionDef): - top_level_names.add(node.name) - elif isinstance(node, ast.ClassDef): - top_level_names.add(node.name) - for item in node.body: - if isinstance(item, ast.FunctionDef): - top_level_names.add(item.name) - - # Second pass: ordered traversal of tree.body - for node in tree.body: - if isinstance(node, (ast.Import, ast.ImportFrom)): - import_entry = _extract_import(node) - result["imports"].append(import_entry) - result["ordered_items"].append({ - "type": "import", - "line": node.lineno, - "data": import_entry - }) - - elif isinstance(node, ast.FunctionDef): - func_data = _extract_function(node, top_level_names) - result["functions"].append(func_data) - result["ordered_items"].append({ - "type": "function", - "line": node.lineno, - "data": func_data - }) - - elif isinstance(node, ast.ClassDef): - class_data = _extract_class(node, top_level_names) - result["classes"].append(class_data) - result["ordered_items"].append({ - "type": "class", - "line": node.lineno, - "data": class_data - }) - - return result - - -def _extract_import(node) -> dict[str, Any]: - """Extract import information with line number.""" - if isinstance(node, ast.Import): - names = [alias.name for alias in node.names] - return { - "line": node.lineno, - "type": "import", - "module": names[0] if len(names) == 1 else None, - "names": names, - "display": ', '.join(names) - } - else: # ImportFrom - names = [alias.name for alias in node.names] - module = node.module or '' - return { - "line": node.lineno, - "type": "import-from", - "module": module, - "names": names, - "display": f"from {module} import {', '.join(names)}" - } - - -def _extract_function(node, available_names: set) -> dict[str, Any]: - """Extract function definition with connections.""" - connections = _find_connections(node, available_names) - return { - "name": node.name, - "args": extract_args(node), - "returns": extract_return_type(node), - "docstring": ast.get_docstring(node), - "decorators": [ast.unparse(d) for d in node.decorator_list], - "line": node.lineno, - "is_async": isinstance(node, ast.AsyncFunctionDef), - "connections": connections - } - - -def _extract_class(node, available_names: set) -> dict[str, Any]: - """Extract class definition with methods in order and connections.""" - methods = [] - class_connections = _find_connections(node, available_names) - - # Process class body in order - for item in node.body: - if isinstance(item, ast.FunctionDef): - method_connections = _find_connections(item, available_names) - methods.append({ - "name": item.name, - "args": extract_args(item), - "returns": extract_return_type(item), - "docstring": ast.get_docstring(item), - "is_private": item.name.startswith("_"), - "line": item.lineno, - "connections": method_connections, - "parent_class": node.name - }) - - return { - "name": node.name, - "docstring": ast.get_docstring(node), - "bases": [ast.unparse(b) for b in node.bases], - "methods": methods, - "line": node.lineno, - "connections": class_connections - } - - -def _find_connections(node, available_names: set) -> list[str]: - """Find references to other top-level names within a node's body.""" - connections = set() - for child in ast.walk(node): - if isinstance(child, ast.Call): - # Function calls: foo(...) - if isinstance(child.func, ast.Name): - if child.func.id in available_names: - connections.add(child.func.id) - elif isinstance(child, ast.Name): - # Variable references: x = foo - if child.id in available_names: - connections.add(child.id) - return sorted(connections) - - -def extract_args(func_node) -> list: - args = [] - for arg in func_node.args.args: - args.append({ - "name": arg.arg, - "type": ast.unparse(arg.annotation) if arg.annotation else None - }) - return args - - -def extract_return_type(func_node) -> str | None: - if func_node.returns: - return ast.unparse(func_node.returns) - return None diff --git a/backend/apps/parser/migrations/__init__.py b/backend/apps/parser/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/parser/tasks.py b/backend/apps/parser/tasks.py deleted file mode 100644 index 55d4347..0000000 --- a/backend/apps/parser/tasks.py +++ /dev/null @@ -1,115 +0,0 @@ -import base64 -import io -import logging -import os -import zipfile - -from celery import shared_task - -from apps.parser.validators import should_exclude -from apps.projects.models import Project - -logger = logging.getLogger(__name__) - -PARSER_URL = os.getenv("PARSER_URL", "http://fastapi-parser:8002") -AI_URL = os.getenv("AI_URL", "http://fastapi-ai:8003") -INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "pydocai-internal-key") - - -def _call_fastapi(method: str, url: str, **kwargs): - import requests - headers = kwargs.pop("headers", {}) - headers["X-Internal-Api-Key"] = INTERNAL_API_KEY - resp = requests.request(method, url, headers=headers, timeout=300, **kwargs) - resp.raise_for_status() - return resp.json() - - -@shared_task(bind=True, max_retries=3, default_retry_delay=30) -def parse_folder_task(self, project_id, py_files, zip_base64=None, user_description=None, custom_info=None): - project = None - try: - project = Project.objects.get(id=project_id) - project.status = Project.Status.PROCESSING - project.save() - - if not zip_base64: - project.status = Project.Status.FAILED - project.error_message = "No ZIP data provided" - project.save() - return {"error": "No ZIP data provided"} - - zip_data = base64.b64decode(zip_base64) - zf = zipfile.ZipFile(io.BytesIO(zip_data)) - - parsed_count = 0 - for file_path in py_files: - if should_exclude(file_path): - continue - try: - content = zf.read(file_path).decode("utf-8", errors="ignore") - files = {"file": (file_path.split("/")[-1], content.encode("utf-8"), "text/x-python")} - data = {"project_id": str(project_id), "name": project.name, "file_path": file_path} - _call_fastapi("POST", f"{PARSER_URL}/api/parser/file/", files=files, data=data) - parsed_count += 1 - except Exception as e: - logger.warning(f"Error sending {file_path} to parser: {e}") - - ai_resp = _call_fastapi("POST", f"{AI_URL}/api/ai/generate/", json={"project_id": str(project_id)}) - - project.refresh_from_db() - - if ai_resp.get("status") == "failed": - project.status = Project.Status.FAILED - if not project.error_message: - project.error_message = ai_resp.get("error_message", "AI generation failed") - else: - project.status = Project.Status.DONE - project.save() - - return {"project_id": str(project.id), "files_parsed": parsed_count} - - except Exception as e: - logger.error(f"parse_folder_task error: {e}") - if project: - project.status = Project.Status.FAILED - project.error_message = str(e) - project.save() - return {"error": str(e)} - - -@shared_task(bind=True, max_retries=3, default_retry_delay=30) -def parse_and_generate_docs_task(self, project_id, source_code, file_name, file_size): - project = None - try: - project = Project.objects.get(id=project_id) - project.status = Project.Status.PROCESSING - project.save() - - files = {"file": (file_name, source_code.encode("utf-8"), "text/x-python")} - data = {"project_id": str(project_id), "name": project.name} - _call_fastapi("POST", f"{PARSER_URL}/api/parser/file/", files=files, data=data) - - ai_resp = _call_fastapi("POST", f"{AI_URL}/api/ai/generate/", json={"project_id": str(project_id)}) - - project.refresh_from_db() - - if ai_resp.get("status") == "failed": - project.status = Project.Status.FAILED - if not project.error_message: - project.error_message = ai_resp.get("error_message", "AI generation failed") - else: - project.status = Project.Status.DONE - project.save() - - return {"project_id": str(project.id)} - - except Exception as e: - if "rate_limit" in str(e).lower(): - raise self.retry(exc=e, countdown=60) - logger.error(f"parse_and_generate_docs_task error: {e}") - if project: - project.status = Project.Status.FAILED - project.error_message = str(e) - project.save() - return {"error": str(e)} diff --git a/backend/apps/parser/tests.py b/backend/apps/parser/tests.py deleted file mode 100644 index 9066f76..0000000 --- a/backend/apps/parser/tests.py +++ /dev/null @@ -1,127 +0,0 @@ -from unittest.mock import patch - -from django.contrib.auth import get_user_model -from django.core.files.uploadedfile import SimpleUploadedFile -from rest_framework import status -from rest_framework.test import APITestCase - -from apps.parser.ast_parser import parse_python_file -from apps.parser.validators import should_exclude, validate_python_code -from apps.projects.models import Project - -User = get_user_model() - -class ParserLogicTests(APITestCase): - def test_python_code_validator(self): - """Test syntax validation logic.""" - valid, err = validate_python_code("def foo():\n pass\n") - self.assertTrue(valid) - self.assertIsNone(err) - - valid, err = validate_python_code("def foo() pass") - self.assertFalse(valid) - self.assertIn("SyntaxError", err) - - def test_should_exclude_logic(self): - """Test exclusion of virtual environments and cache directories.""" - self.assertTrue(should_exclude("venv/lib/site-packages/django/models.py")) - self.assertTrue(should_exclude("__pycache__/views.py")) - self.assertFalse(should_exclude("apps/core/models.py")) - - def test_ast_parser_function_extraction(self): - """Test that the AST parser accurately identifies functions and args.""" - code = "def add(x, y):\n return x + y" - parsed = parse_python_file(code) - self.assertFalse(parsed['error']) - self.assertEqual(len(parsed['functions']), 1) - self.assertEqual(parsed['functions'][0]['name'], 'add') - self.assertEqual(parsed['functions'][0]['args'][0]['name'], 'x') - -class ParserAPITests(APITestCase): - def setUp(self): - self.user = User.objects.create_user(email='parser@test.com', name='Parser', password='pwd') - self.client.force_authenticate(user=self.user) - - @patch('apps.parser.tasks.parse_and_generate_docs_task.delay') - def test_single_file_upload(self, mock_task): - """Test uploading a single python file triggers the correct celery task.""" - file_content = b"def my_func(): pass" - test_file = SimpleUploadedFile("test.py", file_content, content_type="text/x-python") - - response = self.client.post('/api/parser/file/', { - 'file': test_file, - 'name': 'Test Script' - }) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) - self.assertIn('project_id', response.data) - mock_task.assert_called_once() - - -class ParserTaskTests(APITestCase): - def setUp(self): - self.user = User.objects.create_user(email='tasktest@test.com', name='TaskTest', password='pwd') - - @patch('apps.parser.tasks._call_fastapi') - def test_parse_and_generate_docs_task_success(self, mock_fastapi): - """Test the celery task completes successfully for a single file.""" - from apps.parser.tasks import parse_and_generate_docs_task - mock_fastapi.return_value = {"status": "done"} - project = Project.objects.create( - user=self.user, name='TestPy', - source_type=Project.SourceType.FILE, - status=Project.Status.PENDING, - ) - result = parse_and_generate_docs_task(project.id, 'print("hello")', 'test.py', 14) - project.refresh_from_db() - self.assertEqual(project.status, Project.Status.DONE) - self.assertEqual(result['project_id'], str(project.id)) - - @patch('apps.parser.tasks._call_fastapi') - def test_parse_task_sets_failed_on_fastapi_error(self, mock_fastapi): - """Test the task sets failed status when FastAPI returns failure.""" - from apps.parser.tasks import parse_and_generate_docs_task - mock_fastapi.return_value = {"status": "failed", "error_message": "AI error"} - project = Project.objects.create( - user=self.user, name='FailPy', - source_type=Project.SourceType.FILE, - status=Project.Status.PENDING, - ) - parse_and_generate_docs_task(project.id, 'bad code', 'bad.py', 8) - project.refresh_from_db() - self.assertEqual(project.status, Project.Status.FAILED) - - @patch('apps.parser.tasks._call_fastapi') - def test_parse_folder_task_creates_files(self, mock_fastapi): - """Test parse_folder_task sends files to FastAPI and updates project.""" - import base64 - import io - import zipfile - - from apps.parser.tasks import parse_folder_task - mock_fastapi.return_value = {"status": "done"} - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, 'w') as zf: - zf.writestr('app/models.py', 'class Foo: pass\n') - zf.writestr('app/views.py', 'def bar(): pass\n') - zip_b64 = base64.b64encode(zip_buffer.getvalue()).decode() - project = Project.objects.create( - user=self.user, name='FolderProj', - source_type=Project.SourceType.FOLDER, - status=Project.Status.PENDING, - ) - result = parse_folder_task(project.id, ['app/models.py', 'app/views.py'], zip_b64) - project.refresh_from_db() - self.assertEqual(project.status, Project.Status.DONE) - self.assertEqual(result['files_parsed'], 2) - - def test_parse_folder_task_no_zip_returns_error(self): - from apps.parser.tasks import parse_folder_task - project = Project.objects.create( - user=self.user, name='NoZip', - source_type=Project.SourceType.FOLDER, - status=Project.Status.PENDING, - ) - result = parse_folder_task(project.id, ['main.py'], zip_base64=None) - project.refresh_from_db() - self.assertEqual(project.status, Project.Status.FAILED) - self.assertIn('error', result) diff --git a/backend/apps/parser/urls.py b/backend/apps/parser/urls.py deleted file mode 100644 index 0c9f5c8..0000000 --- a/backend/apps/parser/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path - -from .views import AnalyseFolderView, AnalyseSingleFileView - -urlpatterns = [ - path('file/', AnalyseSingleFileView.as_view(), name='analyse_file'), - path('folder/', AnalyseFolderView.as_view(), name='analyse_folder'), -] diff --git a/backend/apps/parser/validators.py b/backend/apps/parser/validators.py deleted file mode 100644 index 59aa8fe..0000000 --- a/backend/apps/parser/validators.py +++ /dev/null @@ -1,45 +0,0 @@ -import ast - - -def validate_python_code(source_code: str) -> tuple: - """ - Returns (is_valid, error_message) - Call this before parse_python_file() - """ - if not source_code or not source_code.strip(): - return False, "Code cannot be empty" - - try: - ast.parse(source_code) - return True, None - except SyntaxError as e: - return False, f"SyntaxError on line {e.lineno}: {e.msg}" - - -EXCLUDED_DIRS = { - 'venv', '.venv', 'env', '.env', - 'virtualenv', '.virtualenv', - 'node_modules', 'site-packages', - '__pycache__', '.pytest_cache', - 'build', 'dist', '*.egg-info', - '.git', '.github', '.gitignore', - '.vscode', '.idea', - 'migrations', - 'media', - 'staticfiles', -} - -def should_exclude(file_path: str) -> bool: - """ - Returns True if the file should be skipped. - Checks every part of the path against EXCLUDED_DIRS. - e.g. 'myproject/venv/lib/site.py' → True - 'myproject/utils/helpers.py' → False - """ - parts = file_path.replace('\\', '/').split('/') - for part in parts: - if part in EXCLUDED_DIRS: - return True - if part.endswith('.egg-info') or part.endswith('.dist-info'): - return True - return False diff --git a/backend/apps/parser/views.py b/backend/apps/parser/views.py deleted file mode 100644 index 5ee08f9..0000000 --- a/backend/apps/parser/views.py +++ /dev/null @@ -1,121 +0,0 @@ -import base64 -import io -import json -import zipfile - -from rest_framework import status -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.parser.tasks import parse_and_generate_docs_task, parse_folder_task -from apps.parser.validators import should_exclude -from apps.projects.models import Project - - -class AnalyseFolderView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - zip_file = request.FILES.get('folder') - name = request.data.get('name', 'Untitled Project') - description = request.data.get('description', '') - - # Optional user-provided project details for documentation - user_description = request.data.get('user_description', None) - # Parse custom_info (Mandatory) - custom_info = request.data.get('custom_info', None) - if not custom_info: - return Response({'error': 'additional project details (custom_info) are mandatory for folder uploads'}, status=400) - - if isinstance(custom_info, str): - try: - # Try parsing as JSON first - custom_info = json.loads(custom_info) - except json.JSONDecodeError: - # If it's just plain text, wrap it in a JSON object - custom_info = {"details": custom_info} - - if not zip_file: - return Response({'error': 'No zip file provided'}, status=400) - - if not zip_file.name.endswith('.zip'): - return Response({'error': 'File must be a .zip'}, status=400) - - try: - zip_content = zip_file.read() - zf = zipfile.ZipFile(io.BytesIO(zip_content)) - - py_files = [ - f for f in zf.namelist() - if f.endswith('.py') and not should_exclude(f) - ] - - if not py_files: - return Response({'error': 'No Python files found after filtering'}, status=400) - - project = Project.objects.create( - user=request.user, - name=name, - description=description, - source_type=Project.SourceType.FOLDER, - status=Project.Status.PENDING, - custom_details=custom_info - ) - - zip_base64 = base64.b64encode(zip_content).decode('utf-8') - - parse_folder_task.delay( - project.id, - py_files, - zip_base64, - user_description=user_description, - custom_info=custom_info - ) - - return Response( - {'project_id': str(project.id)}, - status=status.HTTP_202_ACCEPTED - ) - - except zipfile.BadZipFile: - return Response({'error': 'Invalid zip file'}, status=400) - - -class AnalyseSingleFileView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - uploaded_file = request.FILES.get('file') - name = request.data.get('name', 'Untitled Project') - description = request.data.get('description', '') - - if not uploaded_file: - return Response({'error': 'No file uploaded'}, status=400) - - if not uploaded_file.name.endswith('.py'): - return Response({'error': 'Only .py files are allowed'}, status=400) - - try: - source_code = uploaded_file.read().decode('utf-8') - fname = uploaded_file.name - fsize = uploaded_file.size - except UnicodeDecodeError: - return Response({'error': 'File must be UTF-8 encoded'}, status=400) - - project = Project.objects.create( - user=request.user, - name=name, - description=description, - source_type=Project.SourceType.FILE, - file_name=fname, - file_size=fsize, - status=Project.Status.PENDING, - ) - - parse_and_generate_docs_task.delay(project.id, source_code, fname, fsize) - - return Response( - {'project_id': str(project.id)}, - status=status.HTTP_202_ACCEPTED - ) diff --git a/backend/apps/projects/__init__.py b/backend/apps/projects/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/projects/admin.py b/backend/apps/projects/admin.py deleted file mode 100644 index b97a94f..0000000 --- a/backend/apps/projects/admin.py +++ /dev/null @@ -1,2 +0,0 @@ - -# Register your models here. diff --git a/backend/apps/projects/apps.py b/backend/apps/projects/apps.py deleted file mode 100644 index 46a1b48..0000000 --- a/backend/apps/projects/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class ProjectsConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.projects' diff --git a/backend/apps/projects/migrations/0001_initial.py b/backend/apps/projects/migrations/0001_initial.py deleted file mode 100644 index b587d65..0000000 --- a/backend/apps/projects/migrations/0001_initial.py +++ /dev/null @@ -1,63 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-02 10:44 - -import uuid - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Project', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('name', models.CharField(max_length=255)), - ('description', models.TextField(blank=True)), - ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('done', 'Done'), ('failed', 'Failed')], default='pending', max_length=20)), - ('source_type', models.CharField(choices=[('file', 'Single File'), ('folder', 'Folder / ZIP'), ('github', 'GitHub Link')], default='file', max_length=20)), - ('file', models.FileField(blank=True, null=True, upload_to='uploads/%Y/%m/%d/')), - ('file_name', models.CharField(blank=True, max_length=255)), - ('file_size', models.PositiveIntegerField(blank=True, null=True)), - ('zip_file', models.FileField(blank=True, null=True, upload_to='zips/%Y/%m/%d/')), - ('github_url', models.URLField(blank=True, null=True)), - ('github_branch', models.CharField(blank=True, default='main', max_length=100)), - ('parsed_data', models.JSONField(blank=True, null=True)), - ('generated_docs', models.TextField(blank=True, null=True)), - ('error_message', models.TextField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'db_table': 'projects', - 'ordering': ['-created_at'], - }, - ), - migrations.CreateModel( - name='ProjectFile', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('file_path', models.CharField(max_length=500)), - ('file_name', models.CharField(max_length=255)), - ('file_size', models.PositiveIntegerField(blank=True, null=True)), - ('content', models.TextField(blank=True)), - ('parsed_data', models.JSONField(blank=True, null=True)), - ('generated_docs', models.TextField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='projects.project')), - ], - options={ - 'db_table': 'project_files', - 'ordering': ['file_path'], - }, - ), - ] diff --git a/backend/apps/projects/migrations/0002_project_project_info_project_readme_docs.py b/backend/apps/projects/migrations/0002_project_project_info_project_readme_docs.py deleted file mode 100644 index d56295c..0000000 --- a/backend/apps/projects/migrations/0002_project_project_info_project_readme_docs.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-08 12:29 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='project_info', - field=models.JSONField(blank=True, null=True), - ), - migrations.AddField( - model_name='project', - name='readme_docs', - field=models.TextField(blank=True, null=True), - ), - ] diff --git a/backend/apps/projects/migrations/0003_project_api_docs.py b/backend/apps/projects/migrations/0003_project_api_docs.py deleted file mode 100644 index 6bda2c0..0000000 --- a/backend/apps/projects/migrations/0003_project_api_docs.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.5 on 2026-05-08 13:24 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0002_project_project_info_project_readme_docs'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='api_docs', - field=models.TextField(blank=True, null=True), - ), - ] diff --git a/backend/apps/projects/migrations/0004_project_custom_details.py b/backend/apps/projects/migrations/0004_project_custom_details.py deleted file mode 100644 index ea1ae3f..0000000 --- a/backend/apps/projects/migrations/0004_project_custom_details.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 6.0.5 on 2026-05-09 07:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0003_project_api_docs'), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='custom_details', - field=models.JSONField(blank=True, null=True), - ), - ] diff --git a/backend/apps/projects/migrations/0005_remove_file_fields.py b/backend/apps/projects/migrations/0005_remove_file_fields.py deleted file mode 100644 index df61c1c..0000000 --- a/backend/apps/projects/migrations/0005_remove_file_fields.py +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-21 04:50 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0004_project_custom_details'), - ] - - operations = [ - migrations.RemoveField( - model_name='project', - name='file', - ), - migrations.RemoveField( - model_name='project', - name='zip_file', - ), - ] diff --git a/backend/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py b/backend/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py deleted file mode 100644 index 5c59f5c..0000000 --- a/backend/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py +++ /dev/null @@ -1,57 +0,0 @@ -# Generated by Django 5.2.13 on 2026-06-16 13:40 - -import uuid - -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('projects', '0005_remove_file_fields'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='project', - name='framework_info', - field=models.JSONField(blank=True, null=True), - ), - migrations.AddField( - model_name='project', - name='is_published', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='project', - name='public_slug', - field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), - ), - migrations.AddField( - model_name='project', - name='published_description', - field=models.TextField(blank=True), - ), - migrations.AddIndex( - model_name='project', - index=models.Index(fields=['status'], name='projects_status_6303d7_idx'), - ), - migrations.AddIndex( - model_name='project', - index=models.Index(fields=['source_type'], name='projects_source__8aaacb_idx'), - ), - migrations.AddIndex( - model_name='project', - index=models.Index(fields=['created_at'], name='projects_created_40bcd1_idx'), - ), - migrations.AddIndex( - model_name='project', - index=models.Index(fields=['user', 'status'], name='projects_user_id_05c680_idx'), - ), - migrations.AddIndex( - model_name='project', - index=models.Index(fields=['is_published', 'created_at'], name='projects_is_publ_4fac0d_idx'), - ), - ] diff --git a/backend/apps/projects/migrations/__init__.py b/backend/apps/projects/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/projects/models.py b/backend/apps/projects/models.py deleted file mode 100644 index 9c59d07..0000000 --- a/backend/apps/projects/models.py +++ /dev/null @@ -1,99 +0,0 @@ -import uuid - -from django.db import models - -from apps.users.models import User - - -class Project(models.Model): - - class Status(models.TextChoices): - PENDING = 'pending', 'Pending' - PROCESSING = 'processing', 'Processing' - DONE = 'done', 'Done' - FAILED = 'failed', 'Failed' - - class SourceType(models.TextChoices): - FILE = 'file', 'Single File' - FOLDER = 'folder', 'Folder / ZIP' - GITHUB = 'github', 'GitHub Link' - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='projects') - name = models.CharField(max_length=255) - description = models.TextField(blank=True) - status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) - source_type = models.CharField(max_length=20, choices=SourceType.choices, default=SourceType.FILE) - - # single file metadata - file_name = models.CharField(max_length=255, blank=True) - file_size = models.PositiveIntegerField(blank=True, null=True) - - # folder / zip (metadata only, never stored on disk) - - # github - github_url = models.URLField(blank=True, null=True) - github_branch = models.CharField(max_length=100, blank=True, default='main') - - # results - parsed_data = models.JSONField(blank=True, null=True) - generated_docs = models.TextField(blank=True, null=True) - readme_docs = models.TextField(blank=True, null=True) # Generated README - api_docs = models.TextField(blank=True, null=True) # API Documentation - project_info = models.JSONField(blank=True, null=True) # Project structure info - custom_details = models.JSONField(blank=True, null=True) # Extra user-provided context - framework_info = models.JSONField(blank=True, null=True) # Auto-detected framework - error_message = models.TextField(blank=True, null=True) - - # publish / share - is_published = models.BooleanField(default=False) - public_slug = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) - published_description = models.TextField(blank=True) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - db_table = 'projects' - ordering = ['-created_at'] - indexes = [ - models.Index(fields=['status']), - models.Index(fields=['source_type']), - models.Index(fields=['created_at']), - models.Index(fields=['user', 'status']), - models.Index(fields=['is_published', 'created_at']), - ] - - def __str__(self): - return f'{self.name} ({self.source_type}) — {self.user.email}' - - @property - def is_done(self): - return self.status == self.Status.DONE - - @property - def is_failed(self): - return self.status == self.Status.FAILED - - -class ProjectFile(models.Model): - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='files') - - file_path = models.CharField(max_length=500) # e.g. src/utils/helpers.py - file_name = models.CharField(max_length=255) - file_size = models.PositiveIntegerField(blank=True, null=True) - content = models.TextField(blank=True) # raw source code - - parsed_data = models.JSONField(blank=True, null=True) # ast result - generated_docs = models.TextField(blank=True, null=True) # ai result - - created_at = models.DateTimeField(auto_now_add=True) - - class Meta: - db_table = 'project_files' - ordering = ['file_path'] - - def __str__(self): - return f'{self.project.name} / {self.file_path}' diff --git a/backend/apps/projects/serializers.py b/backend/apps/projects/serializers.py deleted file mode 100644 index 3b87001..0000000 --- a/backend/apps/projects/serializers.py +++ /dev/null @@ -1,67 +0,0 @@ -from rest_framework import serializers - -from .models import Project, ProjectFile - - -class ProjectFileSerializer(serializers.ModelSerializer): - class Meta: - model = ProjectFile - fields = '__all__' - -class ProjectSerializer(serializers.ModelSerializer): - files = ProjectFileSerializer(many=True, read_only=True) - - class Meta: - model = Project - fields = [ - 'id', 'user', 'name', 'description', 'status', 'source_type', - 'file_name', 'file_size', 'github_url', - 'github_branch', 'parsed_data', 'generated_docs', 'readme_docs', - 'api_docs', 'project_info', 'custom_details', 'framework_info', 'error_message', - 'is_published', 'public_slug', 'published_description', - 'created_at', 'updated_at', 'files', - ] - read_only_fields = [ - 'user', 'status', 'parsed_data', 'generated_docs', - 'readme_docs', 'api_docs', 'project_info', 'error_message', - 'public_slug', - ] - -class ProjectListSerializer(serializers.ModelSerializer): - user_name = serializers.CharField(source='user.name', read_only=True) - user_email = serializers.EmailField(source='user.email', read_only=True) - file_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Project - fields = [ - 'id', 'name', 'description', 'status', 'source_type', - 'is_published', 'public_slug', 'published_description', - 'created_at', 'updated_at', 'user_name', 'user_email', 'file_count', 'framework_info', - ] - -class PublicProjectSerializer(serializers.ModelSerializer): - user_name = serializers.CharField(source='user.name', read_only=True) - file_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Project - fields = [ - 'id', 'name', 'description', 'published_description', - 'generated_docs', 'readme_docs', 'api_docs', - 'user_name', 'file_count', 'public_slug', 'source_type', - 'github_url', 'github_branch', - 'created_at', 'updated_at', - ] - -class PublicProjectListSerializer(serializers.ModelSerializer): - user_name = serializers.CharField(source='user.name', read_only=True) - file_count = serializers.IntegerField(read_only=True) - - class Meta: - model = Project - fields = [ - 'id', 'name', 'description', 'published_description', - 'user_name', 'file_count', 'public_slug', 'source_type', - 'created_at', 'updated_at', - ] diff --git a/backend/apps/projects/tests.py b/backend/apps/projects/tests.py deleted file mode 100644 index fc6cd9c..0000000 --- a/backend/apps/projects/tests.py +++ /dev/null @@ -1,51 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework import status -from rest_framework.test import APITestCase - -from apps.projects.models import Project - -User = get_user_model() - -class ProjectTests(APITestCase): - def setUp(self): - self.user1 = User.objects.create_user(email='user1@example.com', name='User1', password='pwd') - self.user2 = User.objects.create_user(email='user2@example.com', name='User2', password='pwd') - - self.project1 = Project.objects.create( - user=self.user1, - name='Project 1', - status=Project.Status.DONE - ) - self.project2 = Project.objects.create( - user=self.user2, - name='Project 2', - status=Project.Status.PENDING - ) - - def test_list_projects(self): - """Users should only see their own projects.""" - self.client.force_authenticate(user=self.user1) - response = self.client.get('/api/projects/') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['stats']['total'], 1) - self.assertEqual(response.data['results'][0]['name'], 'Project 1') - - def test_get_project_detail(self): - """User can get details of their own project.""" - self.client.force_authenticate(user=self.user1) - response = self.client.get(f'/api/projects/{self.project1.id}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['name'], 'Project 1') - - def test_cannot_access_others_project(self): - """User cannot access another user's project.""" - self.client.force_authenticate(user=self.user1) - response = self.client.get(f'/api/projects/{self.project2.id}/') - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - - def test_delete_project(self): - """User can delete their own project.""" - self.client.force_authenticate(user=self.user1) - response = self.client.delete(f'/api/projects/{self.project1.id}/') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertFalse(Project.objects.filter(id=self.project1.id).exists()) diff --git a/backend/apps/projects/throttles.py b/backend/apps/projects/throttles.py deleted file mode 100644 index c20a3f3..0000000 --- a/backend/apps/projects/throttles.py +++ /dev/null @@ -1,17 +0,0 @@ -from rest_framework.throttling import SimpleRateThrottle - - -class PublishRateThrottle(SimpleRateThrottle): - scope = 'publish' - - def get_cache_key(self, request, view): - if request.user.is_authenticated: - return f'publish:{request.user.pk}' - return None - - -class PublicRateThrottle(SimpleRateThrottle): - scope = 'public' - - def get_cache_key(self, request, view): - return self.get_ident(request) diff --git a/backend/apps/projects/urls.py b/backend/apps/projects/urls.py deleted file mode 100644 index 001ca64..0000000 --- a/backend/apps/projects/urls.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.urls import path - -from .views import ( - ProjectDetailView, - ProjectListView, - PublicProjectDetailView, - PublicProjectListView, - PublishProjectView, -) - -urlpatterns = [ - path('', ProjectListView.as_view(), name='project_list'), - path('/', ProjectDetailView.as_view(), name='project_detail'), - path('/publish/', PublishProjectView.as_view(), name='project_publish'), -] - -public_urlpatterns = [ - path('', PublicProjectListView.as_view(), name='public_project_list'), - path('/', PublicProjectDetailView.as_view(), name='public_project_detail'), -] diff --git a/backend/apps/projects/views.py b/backend/apps/projects/views.py deleted file mode 100644 index ee8abe3..0000000 --- a/backend/apps/projects/views.py +++ /dev/null @@ -1,159 +0,0 @@ -from django.contrib.auth import get_user_model -from django.core.cache import cache -from django.db import connection -from django.db.models import Count -from rest_framework import generics, permissions, status -from rest_framework.pagination import PageNumberPagination -from rest_framework.response import Response -from rest_framework.views import APIView - -from .models import Project -from .serializers import ProjectListSerializer, ProjectSerializer, PublicProjectListSerializer, PublicProjectSerializer -from .throttles import PublicRateThrottle, PublishRateThrottle - -User = get_user_model() - -class ProjectListView(generics.ListAPIView): - """ - List all projects for the authenticated user. - """ - permission_classes = [permissions.IsAuthenticated] - serializer_class = ProjectListSerializer - search_fields = ['name', 'description', 'status', 'source_type'] - filterset_fields = ['status', 'source_type', 'is_published'] - ordering_fields = ['created_at', 'name', 'status', 'source_type'] - - def get_queryset(self): - return (Project.objects - .filter(user=self.request.user) - .select_related('user') - .annotate(file_count=Count('files')) - .order_by('-created_at')) - - def list(self, request, *args, **kwargs): - queryset = self.filter_queryset(self.get_queryset()) - cache_key = f'project_stats_{request.user.id}' - stats = cache.get(cache_key) - if not stats: - stats = self._compute_stats(queryset) - cache.set(cache_key, stats, 60) - - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - response = self.get_paginated_response(serializer.data) - response.data['stats'] = stats - return response - - serializer = self.get_serializer(queryset, many=True) - return Response({'stats': stats, 'results': serializer.data}) - - def _compute_stats(self, queryset): - base = (Project.objects - .filter(user=self.request.user) - .order_by()) - counts = base.values('status').annotate(count=Count('id')) - status_map = {c['status']: c['count'] for c in counts} - return { - 'total': sum(status_map.values()), - 'done': status_map.get('done', 0), - 'processing': status_map.get('processing', 0), - 'failed': status_map.get('failed', 0), - 'pending': status_map.get('pending', 0), - 'published': base.filter(is_published=True).count(), - 'total_files': base.aggregate(total=Count('files', distinct=True))['total'] or 0, - 'by_source': list( - base.values('source_type') - .annotate(count=Count('id', distinct=True)) - .order_by('-count') - ), - } - -class ProjectDetailView(generics.RetrieveDestroyAPIView): - """ - Get details of a specific project or delete it. - """ - permission_classes = [permissions.IsAuthenticated] - serializer_class = ProjectSerializer - lookup_field = 'id' - - def get_queryset(self): - user = self.request.user - if user.is_staff or getattr(user, 'is_admin', False): - return Project.objects.all() - return Project.objects.filter(user=user) - - def delete(self, request, *args, **kwargs): - project = self.get_object() - with connection.cursor() as cursor: - cursor.execute("DELETE FROM feedback WHERE project_id = %s", [str(project.id)]) - project.files.all().delete() - project.delete() - return Response({"detail": "Project has been deleted successfully"}, status=status.HTTP_200_OK) - - -class PublishProjectView(APIView): - permission_classes = [permissions.IsAuthenticated] - throttle_classes = [PublishRateThrottle] - - def patch(self, request, pk): - try: - project = Project.objects.get(pk=pk, user=request.user) - except Project.DoesNotExist: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) - - is_published = request.data.get("is_published") - if is_published is None: - return Response({"detail": "is_published is required."}, status=status.HTTP_400_BAD_REQUEST) - - project.is_published = is_published - if is_published and request.data.get("published_description"): - project.published_description = request.data["published_description"] - project.save(update_fields=["is_published", "published_description", "updated_at"]) - cache.delete(f'project_stats_{request.user.id}') - return Response(ProjectSerializer(project).data) - - -class NoPagination(PageNumberPagination): - page_size = None - - -class PublicProjectPage(PageNumberPagination): - page_size = 12 - page_size_query_param = 'page_size' - max_page_size = 50 - - -class PublicProjectListView(generics.ListAPIView): - permission_classes = [] - serializer_class = PublicProjectListSerializer - throttle_classes = [PublicRateThrottle] - pagination_class = PublicProjectPage - - def get_queryset(self): - return (Project.objects - .filter(is_published=True, status='done') - .select_related('user') - .annotate(file_count=Count('files')) - .order_by('-updated_at')) - - -class PublicProjectDetailView(APIView): - permission_classes = [] - throttle_classes = [PublicRateThrottle] - - def get(self, request, slug): - cache_key = f'public_project_{slug}' - data = cache.get(cache_key) - if not data: - try: - project = (Project.objects - .filter(public_slug=slug, is_published=True) - .select_related('user') - .prefetch_related('files') - .get()) - except Project.DoesNotExist: - return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND) - data = PublicProjectSerializer(project).data - cache.set(cache_key, data, 300) - return Response(data) diff --git a/backend/apps/universal/__init__.py b/backend/apps/universal/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/universal/apps.py b/backend/apps/universal/apps.py deleted file mode 100644 index 489b915..0000000 --- a/backend/apps/universal/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class UniversalConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.universal' diff --git a/backend/apps/universal/prompts.py b/backend/apps/universal/prompts.py deleted file mode 100644 index a916d64..0000000 --- a/backend/apps/universal/prompts.py +++ /dev/null @@ -1,196 +0,0 @@ -UNIVERSAL_PROMPT = """You are an expert technical documentation generator. Given the source code and project metadata below, produce COMPREHENSIVE, DETAILED documentation. - -First, analyze the code to determine: -1. All languages and frameworks used -2. Whether this is **frontend-only**, **backend-only**, or **full-stack** (CHECK file names for frontend indicators like .jsx, .tsx, components/, pages/, .vue, .svelte, .css, .scss; check for backend indicators like views.py, controllers, API routes, models) -3. Key architectural patterns and design decisions - -Then generate documentation with the sections below. Be THOROUGH and SPECIFIC — use real code details, not placeholders. - ---- - -## SECTION RULES (read carefully) - -**If the project is FRONTEND-ONLY:** -- Output ONLY these sections: Detected, Project Overview, Project Structure (with mermaid), Components/Modules, Getting Started. -- Skip API/Endpoints, Data/State Management, Configuration, Error Handling & Logging, Deployment entirely — do not even mention them. -- The goal is a clean README-style overview, not exhaustive infra docs. - -**If the project is BACKEND or FULL-STACK:** -- Output ALL sections listed below. -- For the API section, include concrete examples: curl commands, example request bodies (JSON), and example response bodies for EVERY endpoint. -- Be exhaustive — document every route, model, and config detail. - ---- - -## Detected -- Language: [detected language(s)] -- Framework: [detected framework(s)] -- Type: Frontend-only / Backend-only / Full-stack -- File count: [approximate number of meaningful source files] - -## Project Overview -- Language, framework, purpose (3-4 paragraphs) -- Architecture summary: monolith, microservices, serverless, etc. -- Key design patterns observed (MVC, REST, event-driven, etc.) -- Major dependencies and what each is used for - -## Project Structure -Here is the actual file tree of the project: -``` -{file_tree} -``` -For each key directory, explain its purpose. If the tree is very large, group related directories and summarize. - -Include a mermaid flowchart diagram showing the project architecture and workflow: -```mermaid -flowchart TD - [Create a detailed diagram showing how components interact, data flow, request lifecycle, etc.] -``` - -## Components / Modules -For each major component/module/app found in the code: -- **Purpose**: What it does -- **Key files**: Important files in this component -- **Inputs/Outputs**: What data it receives and produces -- **Dependencies**: What other components it relies on -- **Error handling**: How errors are handled in this component -- **Testing**: Testing strategy if tests are found - -## API / Endpoints (backend or full-stack only) -For EVERY endpoint found, provide a detailed table: - -| Method | Path | Auth | Description | -|--------|------|------|-------------| -| GET | /api/example/ | Token | Lists resources | - -Then for each endpoint, document: -- **Request Headers**: What headers are required -- **Path Parameters**: Variables in the URL path -- **Query Parameters**: Filtering, pagination, sorting -- **Request Body**: JSON schema of the request payload -- **Response**: JSON schema of the response -- **Status Codes**: What each status code means for this endpoint -- **Example request**: - ```bash - curl -X GET https://api.example.com/api/example/ \ - -H "Authorization: Bearer " - ``` -- **Example response**: - ```json - { - "id": 1, - "name": "example" - } - ``` - -If the code has frontend API calls, list which endpoints the frontend consumes. - -## Data / State Management -- Database models / tables with their fields, types, and relationships -- State management approach (Redux, Context, Vuex, etc.) -- Data flow diagrams (how data moves through the system) -- Caching strategy if present -- Migrations and seed data - -## Configuration -- Build tools and configuration files -- Environment variables and their purpose -- Development, staging, and production configuration differences -- How to configure the project for local development - -## Error Handling & Logging -- Error handling patterns (try/catch, middleware, exception handlers) -- Logging setup (log levels, log files, external services) -- Common errors and how they are handled -- Monitoring and alerting if present - -## Deployment -- Deployment configuration (Docker, docker-compose, CI/CD) -- Infrastructure requirements -- How to build and run in production -- Database migration strategy - -## Getting Started -- **Clone the repository**: - ``` - git clone {github_url} - cd {repo_dir} - ``` -- **Install dependencies**: - Use the correct package manager for this project. Detected requirements files: {req_files}. Provide the exact install commands. -- **Environment setup**: Configuration steps, .env setup -- **Database setup**: Migration commands -- **Run the project**: Exact commands to start the project -- **Run tests**: How to execute tests - -Rules: -- Output ONLY valid markdown. No preamble or wrapper text. -- Use actual code details from the provided source. Do NOT hallucinate. -- Use code blocks with language-specific syntax highlighting. -- If the code contains frontend AND backend code, document BOTH thoroughly. -- For mermaid diagrams, ALWAYS start with 'flowchart TD' on its own line. Use 'A --> B' or 'A -->|label| B' syntax only. -- For API tables, use proper markdown table syntax with | pipes. -- If a section has no content to document, output "Not applicable or not detected in the provided code." instead of making things up. -- Be specific about file paths, function names, class names, and variable names found in the code. -- **For EVERY API endpoint in backend/full-stack projects, include a curl example and a JSON response example.** - -Quality validation (review your own output before finalizing): -1. **No invented directory names**: In `cd` and path commands, use only names that exist in the provided file tree `{file_tree}`. -2. **No typos in commands**: Every `git clone`, `cd`, `npm install`, `pip install`, or similar command must be syntactically correct. Verify directory names against the file tree. -3. **No invented file paths**: Any file path referenced in the docs (e.g., `src/components/Foo.jsx`) must actually appear in the file tree or file list. -4. **Accuracy over length**: A short correct answer is better than a long invented one. If unsure about a detail, say "not detected" rather than guessing. -5. **`cd` directory rule**: The `cd` directory after `git clone` MUST be `{repo_dir}`. Never use any other name. This is the exact directory created by the clone. -6. **Frontend-only projects**: Must NOT contain API, Data/State Management, Configuration, Error Handling & Logging, or Deployment sections. -7. **Backend/full-stack projects**: Every API endpoint MUST have a curl example and example JSON response.""" - -MAX_SOURCE_CHARS = 25_000 - - -def get_prompt(mode, source_code, project_name, file_list=None, max_chars=None, - github_url='', file_tree='', req_files=None): - safe_source = source_code.replace("```", "\u200b`\u200b`\u200b`\u200b") - limit = max_chars if max_chars is not None else MAX_SOURCE_CHARS - if len(safe_source) > limit: - safe_source = safe_source[:limit] + "\n\n... [truncated]" - - prefix = f"""Project: {project_name} -Source code to document: -``` -{safe_source} -``` -""" - - file_list_text = "" - if file_list: - file_list_text = "\n".join(f" - {f}" for f in file_list) - file_list_text = f"\n\nAll files in project:\n{file_list_text}" - - meta_lines = [ - "## Metadata", - f"- Project name: {project_name}", - f"- GitHub URL: {github_url}", - ] - if req_files: - meta_lines.append(f"- Detected requirements files: {', '.join(req_files)}") - meta_lines.append("") - - repo_dir = '' - if github_url: - repo_dir = github_url.rstrip('/').split('/')[-1].replace('.git', '') - if not repo_dir: - repo_dir = project_name - - prompt_kwargs = { - 'project_name': project_name, - 'github_url': github_url, - 'repo_dir': repo_dir, - 'file_tree': file_tree, - 'req_files': ', '.join(req_files) if req_files else 'None detected', - } - - prompt_body = UNIVERSAL_PROMPT - for k, v in prompt_kwargs.items(): - prompt_body = prompt_body.replace(f'{{{k}}}', str(v)) - - return prefix + file_list_text + "\n\n" + "\n".join(meta_lines) + prompt_body diff --git a/backend/apps/universal/tasks.py b/backend/apps/universal/tasks.py deleted file mode 100644 index 680de80..0000000 --- a/backend/apps/universal/tasks.py +++ /dev/null @@ -1,345 +0,0 @@ -import io -import logging -import os -import zipfile - -import requests -from celery import shared_task -from django.conf import settings -from groq import Groq - -from apps.projects.models import Project, ProjectFile -from apps.universal.prompts import MAX_SOURCE_CHARS, get_prompt - -logger = logging.getLogger(__name__) - -FILE_PRIORITY_KEYWORDS = [ - (10, ['urls.py', 'routes.py', 'router', 'main.py', 'app.py', - 'manage.py', 'wsgi.py', 'asgi.py', 'index.js', 'index.ts', - 'index.jsx', 'index.tsx', 'entry', 'server.js', 'server.ts', - 'app.js', 'app.ts', 'app.jsx', 'app.tsx']), - (8, ['views.py', 'controllers.py', 'handlers.py', 'serializers.py', - 'schemas.py', 'resolver']), - (7, ['models.py', 'entities.py', 'database.py', 'schema.prisma', - 'migration']), - (6, ['requirements.txt', 'pyproject.toml', 'package.json', 'Dockerfile', - 'docker-compose.yml', '.env.example', 'settings', 'config', - 'tailwind.config', 'vite.config', 'next.config', 'webpack.config']), - (5, ['services.py', 'repositories.py', 'middleware.py', 'middleware.', - 'decorators.py', 'permissions.py', 'auth.py', 'authentication']), - (4, ['admin.py', 'forms.py', 'tasks.py', 'apps.py', - 'tests.py', 'test_', '_test.', 'spec.', 'cypress', 'jest']), - (3, ['utils.py', 'helpers.py', 'constants.py', 'types.py', - 'exceptions.py', 'filters.py', 'pagination.py', - 'signals.py', 'context_processors.py']), - (2, ['css', '.scss', '.less', '.svg', '.png', '.jpg', '.ico', - 'locale', 'translation', 'messages.']), -] - - -def _file_priority(path): - name = (path or '').lower() - for score, keywords in FILE_PRIORITY_KEYWORDS: - for kw in keywords: - if kw.lower() in name: - return score - return 1 - - -def _build_file_tree(file_paths): - tree = {} - for path in sorted(file_paths): - parts = path.replace('\\', '/').split('/') - node = tree - for part in parts: - node = node.setdefault(part, {}) - return tree - - -def _format_tree(tree, prefix=''): - lines = [] - items = sorted(tree.items(), key=lambda x: (isinstance(x[1], dict) and len(x[1]) == 0, x[0])) - for i, (name, subtree) in enumerate(items): - is_last = i == len(items) - 1 - connector = '└── ' if is_last else '├── ' - if isinstance(subtree, dict) and subtree: - lines.append(f'{prefix}{connector}{name}/') - extension = ' ' if is_last else '│ ' - lines.extend(_format_tree(subtree, prefix + extension)) - else: - lines.append(f'{prefix}{connector}{name}') - return lines - - -def _detect_req_files(file_list): - req_patterns = {p.lower() for p in [ - 'requirements.txt', 'pyproject.toml', 'Pipfile', - 'package.json', 'composer.json', 'Cargo.toml', - 'Gemfile', 'go.mod', 'build.gradle', 'pom.xml', - ]} - found = [] - for f in file_list: - name = f.lower().split('/')[-1] - if name in req_patterns: - found.append(f) - return found - - -def _validate_and_fix_output(output, github_url, project_name, file_tree, file_list): - """Check generated docs for common errors and fix via a corrective prompt.""" - repo_dir = '' - if github_url: - repo_dir = github_url.rstrip('/').split('/')[-1].replace('.git', '') - if not repo_dir: - repo_dir = project_name - - errors = [] - - # Check 1: cd command after git clone uses the wrong directory - import re - clone_blocks = re.findall(r'```(?:bash|sh|shell)?\s*\n(git clone[^\n]*)\n(cd [^\n]*)', output) - for clone_line, cd_line in clone_blocks: - cd_dir = cd_line.replace('cd ', '').strip() - if cd_dir != repo_dir: - errors.append( - f"Wrong cd directory '{cd_dir}' after `{clone_line}`. " - f"Must be '{repo_dir}' (the cloned repo name)." - ) - - # Check 2: referenced paths exist in file tree - path_refs = re.findall(r'`([^`]+)`', output) - file_set = set(f.lower() for f in file_list) - for ref in path_refs: - ref_lower = ref.lower() - if '/' in ref and not ref_lower.startswith('http') and not ref_lower.startswith('api'): - # Skip common false positives - if any(ref_lower.endswith(ext) for ext in ['.png', '.jpg', '.svg', '.ico', '.md']): - continue - if ref_lower in file_set: - continue - parts = ref_lower.split('/') - if len(parts) >= 2: - matched = any(parts[-1] in f or f.endswith('/' + parts[-1]) for f in file_set) - if not matched and len(parts) >= 3: - # Might be a hallucinated path — flag it - pass - - if errors: - logger.warning(f"Output validation found {len(errors)} issue(s), sending corrective prompt") - correction_prompt = ( - "The following documentation was generated but has errors. " - "Fix ONLY the specific issues listed below. Output ONLY the corrected markdown, nothing else.\n\n" - "Errors:\n" + "\n".join(f"- {e}" for e in errors) + - f"\n\nFile tree for reference:\n{file_tree}\n\n" - f"Here is the documentation to fix:\n\n{output}" - ) - try: - fixed = _call_groq(correction_prompt) - return fixed - except Exception as e: - logger.warning(f"Correction attempt failed: {e}, returning original output") - - return output - - -def _call_groq(prompt, model="llama-3.3-70b-versatile"): - keys = [ - os.getenv("GROQ_API_KEY"), - os.getenv("GROQ_API_KEY_2"), - ] - last_error = None - for key in keys: - if not key: - continue - try: - client = Groq(api_key=key) - resp = client.chat.completions.create( - model=model, - messages=[{"role": "user", "content": prompt}], - temperature=0.7, - max_tokens=3000, - ) - return resp.choices[0].message.content.strip() - except Exception as e: - last_error = e - logger.warning(f"Groq key failed: {e}") - raise last_error or Exception("No Groq API keys available") - - -@shared_task(bind=True, max_retries=2, default_retry_delay=30) -def generate_universal_docs_task(self, project_id, mode): - project = None - try: - project = Project.objects.get(id=project_id) - project.status = Project.Status.PROCESSING - project.save() - - files = project.files.all() - skip_dirs = {'node_modules', 'vendor', '.git', '__pycache__', '.next', 'dist', 'build', - '.venv', 'venv', 'env', '.tox', 'eggs', 'bundle', '.gem', - '.gradle', 'target', 'bin', 'obj', '.idea', '.vscode', - '.settings', '.metadata', 'tmp', 'temp', 'logs', - '.npm', '.yarn', '.pytest_cache', '.mypy_cache', - '.serverless', '.terraform', 'Pods', '.swiftpm'} - files = [f for f in files if not any( - seg in (f.file_path or '').lower().split('/') for seg in skip_dirs - )] - source_parts = [] - file_list = [] - for f in files: - file_list.append(f.file_path or f.file_name) - content = (f.content or '').replace('\x00', '') - if content: - source_parts.append((f.file_path or f.file_name, content)) - - source_parts.sort(key=lambda x: _file_priority(x[0]), reverse=True) - - source_code_lines = [] - for path, content in source_parts: - source_code_lines.append(f"--- {path} ---\n{content}") - - source_code = "\n\n".join(source_code_lines) if source_code_lines else "" - - tree = _build_file_tree(file_list) - tree_text = '\n'.join(_format_tree(tree)) - req_files = _detect_req_files(file_list) - - max_chars = MAX_SOURCE_CHARS - for attempt in range(3): - prompt = get_prompt(mode, source_code, project.name, file_list, - max_chars, github_url=project.github_url or '', - file_tree=tree_text, req_files=req_files) - try: - result = _call_groq(prompt).replace('\x00', '') - break - except Exception as e: - err_str = str(e) - is_too_large = '413' in err_str or 'rate_limit_exceeded' in err_str or 'Request too large' in err_str - if is_too_large and attempt < 2: - max_chars = max_chars // 2 - logger.warning(f"Request too large, retrying with MAX_SOURCE_CHARS={max_chars}") - continue - raise - - if result.startswith("REJECT:"): - project.status = Project.Status.FAILED - project.error_message = result - project.save() - return {"error": result} - - # Validate output for common errors and retry if needed - result = _validate_and_fix_output(result, project.github_url or '', - project.name, tree_text, file_list) - - project.generated_docs = result - - project.status = Project.Status.DONE - project.save() - return {"project_id": str(project.id)} - - except Exception as e: - logger.error(f"generate_universal_docs_task error: {e}") - if project: - project.status = Project.Status.FAILED - project.error_message = str(e) - project.save() - return {"error": str(e)} - - -# ── GitHub Import (Universal, all file types) ──────────────── - -def _download_github_zipball(url: str, headers: dict, folder_path: str) -> list: - """Download a GitHub zipball and extract ALL files (no .py filter).""" - resp = requests.get(url, headers=headers, timeout=30, stream=True) - resp.raise_for_status() - zip_bytes = resp.content - - files = [] - with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: - names = zf.namelist() - prefix = '' - if names: - first = names[0] - if '/' in first: - prefix = first.split('/', 1)[0] + '/' - for name in names: - if name.endswith('/'): - continue - rel_path = name[len(prefix):] if prefix else name - if folder_path and folder_path != '/' and not rel_path.startswith(folder_path.lstrip('/')): - continue - try: - content = zf.read(name).decode('utf-8', errors='ignore').replace('\x00', '') - files.append({'file_path': rel_path, 'content': content}) - except Exception: - files.append({'file_path': rel_path, 'content': '[binary file]'}) - return files - - -def _fetch_public_repo_api(full_name: str) -> dict: - api_token = getattr(settings, 'GITHUB_API_TOKEN', None) - headers = {'Accept': 'application/vnd.github+json'} - if api_token and api_token.strip(): - headers['Authorization'] = f'token {api_token}' - resp = requests.get( - f'https://api.github.com/repos/{full_name}', - headers=headers, timeout=10, - ) - resp.raise_for_status() - return resp.json() - - -@shared_task(bind=True, max_retries=2, default_retry_delay=30) -def import_universal_github_task(self, project_id, mode, full_name, folder_path, branch, github_token=None): - """Download ALL files from a GitHub repo (any language), create ProjectFiles, - then delegate to generate_universal_docs_task.""" - project = None - try: - project = Project.objects.get(id=project_id) - project.status = Project.Status.PROCESSING - project.save(update_fields=['status']) - - if github_token: - url = f'https://api.github.com/repos/{full_name}/zipball/{branch}' - headers = { - 'Accept': 'application/vnd.github+json', - 'Authorization': f'token {github_token}', - } - else: - repo_data = _fetch_public_repo_api(full_name) - branch = branch or repo_data.get('default_branch') or 'main' - api_token = getattr(settings, 'GITHUB_API_TOKEN', None) - headers = {'Accept': 'application/vnd.github+json'} - if api_token and api_token.strip(): - headers['Authorization'] = f'token {api_token}' - url = f'https://api.github.com/repos/{full_name}/zipball/{branch}' - else: - url = f'https://github.com/{full_name}/archive/refs/heads/{branch}.zip' - - raw_files = _download_github_zipball(url, headers, folder_path) - - if not raw_files: - project.status = Project.Status.FAILED - project.error_message = "No files found in the specified repository path." - project.save(update_fields=['status', 'error_message']) - return {"error": "No files found"} - - for f in raw_files: - ProjectFile.objects.create( - project=project, - file_name=os.path.basename(f['file_path']), - file_path=f['file_path'], - content=f['content'], - ) - - # Delegate AI generation to the existing task - generate_universal_docs_task.delay(str(project.id), mode) - return {"project_id": str(project.id)} - - except Exception as e: - logger.error(f"import_universal_github_task error: {e}") - if project: - project.status = Project.Status.FAILED - project.error_message = str(e) - project.save(update_fields=['status', 'error_message']) - return {"error": str(e)} diff --git a/backend/apps/universal/tests.py b/backend/apps/universal/tests.py deleted file mode 100644 index 6623b35..0000000 --- a/backend/apps/universal/tests.py +++ /dev/null @@ -1,284 +0,0 @@ -from unittest.mock import patch - -from django.contrib.auth import get_user_model -from django.test import TestCase - -from apps.projects.models import Project, ProjectFile -from apps.universal.prompts import MAX_SOURCE_CHARS, get_prompt -from apps.universal.tasks import ( - _build_file_tree, - _detect_req_files, - _file_priority, - _format_tree, - _validate_and_fix_output, -) - -User = get_user_model() - - -# ── Pure function tests (no DB) ─────────────────────────────── - -class TestFilePriority: - def test_urls_highest_priority(self): - assert _file_priority("myapp/urls.py") == 10 - - def test_views_high_priority(self): - assert _file_priority("myapp/views.py") == 8 - - def test_models_priority(self): - assert _file_priority("myapp/models.py") == 7 - - def test_config_priority(self): - assert _file_priority("settings/base.py") == 6 - assert _file_priority("myapp/Dockerfile") == 6 - assert _file_priority("package.json") == 6 - - def test_services_priority(self): - assert _file_priority("myapp/services.py") == 5 - assert _file_priority("myapp/auth.py") == 5 - - def test_admin_priority(self): - assert _file_priority("myapp/admin.py") == 4 - assert _file_priority("myapp/tasks.py") == 4 - - def test_utils_priority(self): - assert _file_priority("myapp/utils.py") == 3 - assert _file_priority("myapp/constants.py") == 3 - - def test_css_priority(self): - assert _file_priority("styles/app.css") == 2 - assert _file_priority("assets/logo.svg") == 2 - - def test_unknown_lowest_priority(self): - assert _file_priority("README.md") == 1 - assert _file_priority("data/somefile.txt") == 1 - - def test_case_insensitive(self): - assert _file_priority("MYAPP/URLs.py") == 10 - assert _file_priority("MYAPP/MODELS.py") == 7 - - -class TestBuildFileTree: - def test_empty_list(self): - assert _build_file_tree([]) == {} - - def test_single_file(self): - result = _build_file_tree(["main.py"]) - assert result == {"main.py": {}} - - def test_nested_paths(self): - files = ["src/app/main.py", "src/app/utils.py", "README.md"] - tree = _build_file_tree(files) - assert "src" in tree - assert "app" in tree["src"] - assert "main.py" in tree["src"]["app"] - assert "utils.py" in tree["src"]["app"] - assert "README.md" in tree - - def test_backslash_normalized(self): - tree = _build_file_tree(["src\\app\\main.py"]) - assert "src" in tree and "app" in tree["src"] and "main.py" in tree["src"]["app"] - - -class TestFormatTree: - def test_single_file(self): - tree = _build_file_tree(["main.py"]) - lines = _format_tree(tree) - assert lines == ["└── main.py"] - - def test_nested_tree(self): - files = ["src/main.py", "src/utils.py", "README.md"] - tree = _build_file_tree(files) - lines = _format_tree(tree) - result = "\n".join(lines) - assert "README.md" in result - assert "src/" in result - assert "main.py" in result - assert "utils.py" in result - - def test_directories_have_slash(self): - tree = _build_file_tree(["src/main.py"]) - lines = _format_tree(tree) - assert "src/" in lines[0] - - -class TestDetectReqFiles: - def test_detects_requirements(self): - files = ["README.md", "requirements.txt", "src/main.py"] - assert _detect_req_files(files) == ["requirements.txt"] - - def test_detects_pyproject(self): - files = ["pyproject.toml", "src/main.py"] - assert _detect_req_files(files) == ["pyproject.toml"] - - def test_detects_package_json(self): - files = ["package.json", "index.js"] - assert _detect_req_files(files) == ["package.json"] - - def test_multiple_detected(self): - files = ["requirements.txt", "pyproject.toml", "Pipfile", "README.md"] - result = _detect_req_files(files) - assert "requirements.txt" in result - assert "pyproject.toml" in result - assert "Pipfile" in result - - def test_case_insensitive(self): - files = ["REQUIREMENTS.TXT"] - assert _detect_req_files(files) == ["REQUIREMENTS.TXT"] - - def test_no_req_files(self): - files = ["src/main.py", "README.md"] - assert _detect_req_files(files) == [] - - -class TestValidateAndFixOutput: - def test_correct_cd_passes_through(self): - output = "```bash\ngit clone https://github.com/user/myproject.git\ncd myproject\n```" - tree = _build_file_tree(["main.py"]) - tree_text = "\n".join(_format_tree(tree)) - result = _validate_and_fix_output( - output, "https://github.com/user/myproject.git", - "myproject", tree_text, ["main.py"] - ) - assert result == output - - @patch("apps.universal.tasks._call_groq") - def test_wrong_cd_triggers_fix(self, mock_groq): - mock_groq.return_value = "```bash\ngit clone https://github.com/user/myproject.git\ncd correct_dir\n```" - output = "```bash\ngit clone https://github.com/user/myproject.git\ncd wrong_dir\n```" - tree = _build_file_tree(["main.py"]) - tree_text = "\n".join(_format_tree(tree)) - result = _validate_and_fix_output( - output, "https://github.com/user/myproject.git", - "myproject", tree_text, ["main.py"] - ) - mock_groq.assert_called_once() - assert "correct_dir" in result - - -# ── Prompt tests ────────────────────────────────────────────── - -class TestGetPrompt: - def test_includes_project_name(self): - prompt = get_prompt("universal", "print('hello')", "MyProject") - assert "MyProject" in prompt - - def test_includes_source_code(self): - prompt = get_prompt("universal", "def foo(): pass", "Proj") - assert "def foo(): pass" in prompt - - def test_truncates_long_source(self): - long_code = "x = 1\n" * (MAX_SOURCE_CHARS // 4 + 100) - prompt = get_prompt("universal", long_code, "Proj") - assert len(prompt) < len(long_code) + 5000 - assert "[truncated]" in prompt - - def test_escapes_triple_backticks(self): - malicious = "```\nmalicious code\n```" - prompt = get_prompt("universal", malicious, "Proj") - assert "```" not in prompt[prompt.index("Source code"):prompt.index("```", prompt.index("Source code"))] - - def test_includes_repo_dir_from_github_url(self): - prompt = get_prompt("universal", "code", "Proj", github_url="https://github.com/user/my-repo.git") - assert "my-repo" in prompt - - def test_falls_back_to_project_name_for_repo_dir(self): - prompt = get_prompt("universal", "code", "MyProject") - assert "MyProject" in prompt - - def test_includes_file_tree_when_provided(self): - prompt = get_prompt("universal", "code", "Proj", file_tree="└── main.py") - assert "└── main.py" in prompt - - def test_includes_req_files_when_provided(self): - prompt = get_prompt("universal", "code", "Proj", req_files=["requirements.txt"]) - assert "requirements.txt" in prompt - - -# ── Celery task tests (need DB) ─────────────────────────────── - -class GenerateUniversalDocsTaskTest(TestCase): - def setUp(self): - self.user = User.objects.create_user( - email="universal@test.com", name="Universal", password="pwd" - ) - self.project = Project.objects.create( - user=self.user, name="TestProj", - source_type=Project.SourceType.FILE, - status=Project.Status.PENDING, - ) - ProjectFile.objects.create( - project=self.project, - file_name="main.py", - file_path="main.py", - content="def foo(): pass\n", - ) - - @patch("apps.universal.tasks._call_groq") - def test_task_completes_successfully(self, mock_groq): - from apps.universal.tasks import generate_universal_docs_task - mock_groq.return_value = "# Documentation\n\nSome content" - result = generate_universal_docs_task(str(self.project.id), "universal") - self.project.refresh_from_db() - assert self.project.status == Project.Status.DONE - assert self.project.generated_docs == "# Documentation\n\nSome content" - assert result["project_id"] == str(self.project.id) - - @patch("apps.universal.tasks._call_groq") - def test_rejection_sets_failed_status(self, mock_groq): - from apps.universal.tasks import generate_universal_docs_task - mock_groq.return_value = "REJECT: Project is not a valid codebase" - generate_universal_docs_task(str(self.project.id), "universal") - self.project.refresh_from_db() - assert self.project.status == Project.Status.FAILED - assert "REJECT" in self.project.error_message - - @patch("apps.universal.tasks._call_groq") - def test_groq_failure_sets_failed_status(self, mock_groq): - from apps.universal.tasks import generate_universal_docs_task - mock_groq.side_effect = Exception("API timeout") - generate_universal_docs_task(str(self.project.id), "universal") - self.project.refresh_from_db() - assert self.project.status == Project.Status.FAILED - - def test_unknown_project_returns_error(self): - from apps.universal.tasks import generate_universal_docs_task - result = generate_universal_docs_task("00000000-0000-0000-0000-000000000000", "universal") - assert "error" in result - - -class ImportUniversalGithubTaskTest(TestCase): - def setUp(self): - self.user = User.objects.create_user( - email="github@test.com", name="GitHub", password="pwd" - ) - self.project = Project.objects.create( - user=self.user, name="GitProj", - source_type=Project.SourceType.GITHUB, - github_url="https://github.com/user/repo", - status=Project.Status.PENDING, - ) - - @patch("apps.universal.tasks._download_github_zipball") - @patch("apps.universal.tasks._fetch_public_repo_api") - @patch("apps.universal.tasks.generate_universal_docs_task.delay") - def test_import_creates_files_and_delegates(self, mock_delay, mock_fetch, mock_download): - from apps.universal.tasks import import_universal_github_task - mock_download.return_value = [ - {"file_path": "src/main.py", "content": "print('hello')"}, - ] - import_universal_github_task( - str(self.project.id), "universal", "user/repo", "", "main" - ) - assert ProjectFile.objects.filter(project=self.project).count() == 1 - mock_delay.assert_called_once_with(str(self.project.id), "universal") - - @patch("apps.universal.tasks._download_github_zipball") - def test_no_files_sets_failed(self, mock_download): - from apps.universal.tasks import import_universal_github_task - mock_download.return_value = [] - import_universal_github_task( - str(self.project.id), "universal", "user/repo", "", "main" - ) - self.project.refresh_from_db() - assert self.project.status == Project.Status.FAILED diff --git a/backend/apps/universal/urls.py b/backend/apps/universal/urls.py deleted file mode 100644 index 78b324f..0000000 --- a/backend/apps/universal/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path - -from apps.universal.views import UniversalStatusView, UniversalUploadView - -urlpatterns = [ - path('upload/', UniversalUploadView.as_view(), name='universal-upload'), - path('status//', UniversalStatusView.as_view(), name='universal-status'), -] diff --git a/backend/apps/universal/views.py b/backend/apps/universal/views.py deleted file mode 100644 index 9ae46c0..0000000 --- a/backend/apps/universal/views.py +++ /dev/null @@ -1,146 +0,0 @@ -import logging -import os -import zipfile - -from rest_framework import status -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from apps.projects.models import Project, ProjectFile -from apps.universal.tasks import generate_universal_docs_task, import_universal_github_task - -logger = logging.getLogger(__name__) - - -class UniversalUploadView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - mode = request.data.get('mode', 'universal') - name = request.data.get('name', '').strip() - description = request.data.get('description', '').strip() - github_url = request.data.get('github_url', '').strip() - - if not name: - return Response({'detail': 'Project name is required.'}, status=status.HTTP_400_BAD_REQUEST) - - valid_modes = ['universal'] - if mode not in valid_modes: - return Response( - {'detail': f'Invalid mode. Choose from: {", ".join(valid_modes)}'}, - status=status.HTTP_400_BAD_REQUEST, - ) - - project = Project.objects.create( - user=request.user, - name=name, - description=description, - source_type='file', - status=Project.Status.PENDING, - ) - - if github_url: - project.source_type = 'github' - project.save(update_fields=['source_type']) - return self._handle_github(project, mode, github_url, request) - - uploaded_file = request.FILES.get('file') - if uploaded_file and uploaded_file.name.endswith('.zip'): - project.source_type = 'folder' - project.save(update_fields=['source_type']) - self._process_zip(project, uploaded_file) - elif uploaded_file: - self._process_single_file(project, uploaded_file) - else: - source_code = request.data.get('source_code', '') - if source_code: - ProjectFile.objects.create( - project=project, - file_name='source.txt', - file_path='source.txt', - content=source_code, - ) - else: - project.status = Project.Status.FAILED - project.error_message = "No file, GitHub URL, or source code provided." - project.save() - return Response( - {'detail': 'No file, GitHub URL, or source code provided.'}, - status=status.HTTP_400_BAD_REQUEST, - ) - - project.save() - generate_universal_docs_task.delay(str(project.id), mode) - return Response({'project_id': project.id}, status=status.HTTP_202_ACCEPTED) - - def _handle_github(self, project, mode, github_url, request): - from apps.github_integration.views import parse_github_url - - full_name = parse_github_url(github_url) - if not full_name: - project.delete() - return Response({'detail': 'Invalid GitHub URL.'}, status=status.HTTP_400_BAD_REQUEST) - - folder_path = request.data.get('folder_path', '/') - branch = request.data.get('branch', 'main') or 'main' - project.github_url = f'https://github.com/{full_name}' - - # Check if user has connected GitHub (authenticated import) - github_token = request.user.github_token if hasattr(request.user, 'github_token') else None - project.save(update_fields=['github_url']) - - import_universal_github_task.delay( - str(project.id), mode, full_name, folder_path, branch, github_token, - ) - return Response({'project_id': project.id}, status=status.HTTP_202_ACCEPTED) - - def _process_zip(self, project, zip_file): - try: - with zipfile.ZipFile(zip_file) as zf: - for file_path in zf.namelist(): - if file_path.startswith('__MACOSX/') or file_path.startswith('.') or file_path.endswith('/'): - continue - try: - content = zf.read(file_path).decode('utf-8', errors='ignore').replace('\x00', '') - ProjectFile.objects.create( - project=project, - file_name=os.path.basename(file_path), - file_path=file_path, - content=content, - ) - except Exception as e: - logger.warning(f"Error reading {file_path}: {e}") - except Exception as e: - logger.error(f"Error processing zip: {e}") - raise - - def _process_single_file(self, project, uploaded_file): - content = uploaded_file.read().decode('utf-8', errors='ignore').replace('\x00', '') - ProjectFile.objects.create( - project=project, - file_name=uploaded_file.name, - file_path=uploaded_file.name, - content=content, - ) - - -class UniversalStatusView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request, project_id): - try: - project = Project.objects.get(id=project_id, user=request.user) - data = { - 'id': project.id, - 'name': project.name, - 'description': project.description or '', - 'status': project.status, - 'mode': 'universal', - 'error': project.error_message or '', - 'docs': project.generated_docs or '', - 'created_at': project.created_at, - } - return Response(data) - except Project.DoesNotExist: - return Response({'detail': 'Project not found.'}, status=status.HTTP_404_NOT_FOUND) diff --git a/backend/apps/users/__init__.py b/backend/apps/users/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/users/admin.py b/backend/apps/users/admin.py deleted file mode 100644 index 0ca260a..0000000 --- a/backend/apps/users/admin.py +++ /dev/null @@ -1,54 +0,0 @@ -from django.contrib import admin -from django.contrib.auth.admin import UserAdmin as BaseUserAdmin - -from .models import PasswordResetToken, User - - -@admin.register(User) -class UserAdmin(BaseUserAdmin): - list_display = ['email', 'name', 'role', 'is_verified', 'github_connected', 'is_active', 'created_at'] - list_filter = ['role', 'is_verified', 'is_active', 'created_at'] - search_fields = ['email', 'name', 'username'] - ordering = ['-created_at'] - readonly_fields = ['created_at', 'updated_at', 'github_connected'] - actions = ['verify_users', 'deactivate_users'] - - fieldsets = ( - (None, {'fields': ('email', 'password')}), - ('Info', {'fields': ('name', 'username', 'github_connected')}), - ('Role', {'fields': ('role', 'is_verified')}), - ('Access', {'fields': ('is_active', 'is_staff', 'is_superuser')}), - ('Dates', {'fields': ('created_at', 'updated_at')}), - ) - add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ('email', 'name', 'password1', 'password2'), - }), - ) - - @admin.display(boolean=True, description='GitHub') - def github_connected(self, obj): - return bool(obj.github_token) - - @admin.action(description='Mark selected users as verified') - def verify_users(self, request, queryset): - updated = queryset.update(is_verified=True) - self.message_user(request, f'{updated} user(s) verified.') - - @admin.action(description='Deactivate selected users') - def deactivate_users(self, request, queryset): - updated = queryset.update(is_active=False) - self.message_user(request, f'{updated} user(s) deactivated.') - - -@admin.register(PasswordResetToken) -class PasswordResetTokenAdmin(admin.ModelAdmin): - list_display = ['user', 'created_at', 'used', 'is_valid'] - list_filter = ['used'] - search_fields = ['user__email'] - readonly_fields = ['token', 'created_at'] - - @admin.display(boolean=True) - def is_valid(self, obj): - return obj.is_valid() diff --git a/backend/apps/users/apps.py b/backend/apps/users/apps.py deleted file mode 100644 index 2bb189c..0000000 --- a/backend/apps/users/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class UsersConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'apps.users' diff --git a/backend/apps/users/email_utils.py b/backend/apps/users/email_utils.py deleted file mode 100644 index ed0d2b2..0000000 --- a/backend/apps/users/email_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -import logging - -from django.conf import settings -from django.core.mail import send_mail -from django.template.loader import render_to_string - -logger = logging.getLogger(__name__) - - -def send_welcome_email(user): - """Send welcome email after successful registration.""" - subject = 'Welcome to PyDocAI!' - message = render_to_string('emails/welcome.txt', { - 'user': user, - 'frontend_url': settings.FRONTEND_URL, - }) - html_message = render_to_string('emails/welcome.html', { - 'user': user, - 'frontend_url': settings.FRONTEND_URL, - }) - try: - send_mail( - subject=subject, - message=message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - html_message=html_message, - fail_silently=False, - ) - logger.info(f"Welcome email sent to {user.email}") - except Exception as e: - logger.error(f"Failed to send welcome email to {user.email}: {e}") - - -def send_password_reset_email(user, reset_token): - """Send password reset email with token link.""" - reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}&email={user.email}" - subject = 'Reset Your PyDocAI Password' - message = render_to_string('emails/password_reset.txt', { - 'user': user, - 'reset_url': reset_url, - 'timeout_hours': settings.PASSWORD_RESET_TIMEOUT // 3600, - }) - html_message = render_to_string('emails/password_reset.html', { - 'user': user, - 'reset_url': reset_url, - 'timeout_hours': settings.PASSWORD_RESET_TIMEOUT // 3600, - }) - try: - send_mail( - subject=subject, - message=message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - html_message=html_message, - fail_silently=False, - ) - logger.info(f"Password reset email sent to {user.email}") - except Exception as e: - logger.error(f"Failed to send password reset email to {user.email}: {e}") diff --git a/backend/apps/users/github.py b/backend/apps/users/github.py deleted file mode 100644 index 2b7ce3b..0000000 --- a/backend/apps/users/github.py +++ /dev/null @@ -1,46 +0,0 @@ -import requests -from django.conf import settings - - -def exchange_code_for_token(code: str) -> str | None: - resp = requests.post( - 'https://github.com/login/oauth/access_token', - data={ - 'client_id': settings.GITHUB_CLIENT_ID, - 'client_secret': settings.GITHUB_CLIENT_SECRET, - 'code': code, - }, - headers={'Accept': 'application/json'}, - timeout=10, - ) - resp.raise_for_status() - return resp.json().get('access_token') - - -def get_github_user(access_token: str) -> dict: - headers = { - 'Authorization': f'Bearer {access_token}', - 'Accept': 'application/vnd.github+json', - } - profile = requests.get( - 'https://api.github.com/user', - headers=headers, - timeout=10, - ) - profile.raise_for_status() - data = profile.json() - - if not data.get('email'): - emails_resp = requests.get( - 'https://api.github.com/user/emails', - headers=headers, - timeout=10, - ) - emails_resp.raise_for_status() - primary = next( - (e['email'] for e in emails_resp.json() if e['primary'] and e['verified']), - None, - ) - data['email'] = primary - - return data diff --git a/backend/apps/users/management/commands/create_test_user.py b/backend/apps/users/management/commands/create_test_user.py deleted file mode 100644 index a592e34..0000000 --- a/backend/apps/users/management/commands/create_test_user.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.contrib.auth import get_user_model -from django.contrib.auth.hashers import make_password -from django.core.management.base import BaseCommand - - -class Command(BaseCommand): - help = 'Create a test user for API testing' - - def handle(self, *args, **options): - user_model = get_user_model() - - # Delete existing test user if exists - user_model.objects.filter(username='testuser').delete() - - # Create fresh user - user = user_model.objects.create( - username='testuser', - email='test@example.com', - ) - user.password = make_password('testpass123') - user.save() - - self.stdout.write(self.style.SUCCESS('Test user created successfully!')) - self.stdout.write(f'Username: {user.username}') - self.stdout.write(f'Email: {user.email}') - self.stdout.write('Password: testpass123') diff --git a/backend/apps/users/migrations/0001_initial.py b/backend/apps/users/migrations/0001_initial.py deleted file mode 100644 index a28688a..0000000 --- a/backend/apps/users/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-02 07:58 - -import uuid - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='User', - fields=[ - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('email', models.EmailField(max_length=254, unique=True)), - ('username', models.CharField(blank=True, max_length=50, null=True, unique=True)), - ('role', models.CharField(choices=[('admin', 'Admin'), ('user', 'User')], default='user', max_length=20)), - ('github_token', models.CharField(blank=True, max_length=255, null=True)), - ('is_active', models.BooleanField(default=True)), - ('is_staff', models.BooleanField(default=False)), - ('is_verified', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ], - options={ - 'db_table': 'users', - 'ordering': ['-created_at'], - }, - ), - ] diff --git a/backend/apps/users/migrations/0002_user_name.py b/backend/apps/users/migrations/0002_user_name.py deleted file mode 100644 index 2d33143..0000000 --- a/backend/apps/users/migrations/0002_user_name.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-02 08:04 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='name', - field=models.CharField(default=django.utils.timezone.now, max_length=100), - preserve_default=False, - ), - ] diff --git a/backend/apps/users/migrations/0003_passwordresettoken.py b/backend/apps/users/migrations/0003_passwordresettoken.py deleted file mode 100644 index 84cb8c6..0000000 --- a/backend/apps/users/migrations/0003_passwordresettoken.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.13 on 2026-05-23 06:22 - -import uuid - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0002_user_name'), - ] - - operations = [ - migrations.CreateModel( - name='PasswordResetToken', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('used', models.BooleanField(default=False)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reset_tokens', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'db_table': 'password_reset_tokens', - }, - ), - ] diff --git a/backend/apps/users/migrations/__init__.py b/backend/apps/users/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py deleted file mode 100644 index 9001415..0000000 --- a/backend/apps/users/models.py +++ /dev/null @@ -1,83 +0,0 @@ -import uuid - -from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin -from django.db import models -from django.utils import timezone - - -class UserManager(BaseUserManager): - - def create_user(self, email, password=None, **extra): - if not email: - raise ValueError('Email is required') - email = self.normalize_email(email) - user = self.model(email=email, **extra) - user.set_password(password) - user.save(using=self._db) - return user - - def create_superuser(self, email, password=None, **extra): - extra.setdefault('is_staff', True) - extra.setdefault('is_superuser', True) - extra.setdefault('is_verified', True) - return self.create_user(email, password, **extra) - - -class User(AbstractBaseUser, PermissionsMixin): - - class Role(models.TextChoices): - ADMIN = 'admin', 'Admin' - USER = 'user', 'User' - - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - email = models.EmailField(unique=True) - username = models.CharField(max_length=50, unique=True, blank=True, null=True) - role = models.CharField(max_length=20, choices=Role.choices, default=Role.USER) - github_token = models.CharField(max_length=255, blank=True, null=True) - name = models.CharField(max_length=100) - is_active = models.BooleanField(default=True) - is_staff = models.BooleanField(default=False) - is_verified = models.BooleanField(default=False) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - objects = UserManager() - - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = ['name'] - - class Meta: - db_table = 'users' - ordering = ['-created_at'] - - def __str__(self): - return self.email - - @property - def is_admin(self): - return self.role == self.Role.ADMIN - - @property - def has_password(self): - # Users who signed up via OAuth have an unusable password - return self.password and not self.password.startswith('!') - - - -class PasswordResetToken(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reset_tokens') - token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) - created_at = models.DateTimeField(auto_now_add=True) - used = models.BooleanField(default=False) - - class Meta: - db_table = 'password_reset_tokens' - - def is_valid(self): - from django.conf import settings - expiry = self.created_at + timezone.timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT) - return not self.used and timezone.now() < expiry - - def __str__(self): - return f"Reset token for {self.user.email}" diff --git a/backend/apps/users/serializers.py b/backend/apps/users/serializers.py deleted file mode 100644 index c7bcbff..0000000 --- a/backend/apps/users/serializers.py +++ /dev/null @@ -1,105 +0,0 @@ -from django.contrib.auth import authenticate -from rest_framework import serializers - -from .models import User - - -class RegisterSerializer(serializers.ModelSerializer): - password = serializers.CharField(write_only=True, min_length=8) - password2 = serializers.CharField(write_only=True) - - class Meta: - model = User - fields = ['email', 'name', 'username', 'password', 'password2'] - - def validate(self, data): - if data['password'] != data['password2']: - raise serializers.ValidationError({'password': 'Passwords do not match'}) - return data - - def create(self, validated_data): - validated_data.pop('password2') - return User.objects.create_user(**validated_data) - - -class LoginSerializer(serializers.Serializer): - email = serializers.EmailField() - password = serializers.CharField(write_only=True) - - def validate(self, data): - user = authenticate(email=data['email'], password=data['password']) - if not user: - raise serializers.ValidationError('Invalid credentials') - if not user.is_active: - raise serializers.ValidationError('Account is disabled') - data['user'] = user - return data - - -class UserSerializer(serializers.ModelSerializer): - has_password = serializers.BooleanField(read_only=True) - - class Meta: - model = User - fields = [ - 'id', 'email', 'name', 'username', 'role', - 'is_verified', 'has_password', 'created_at', 'updated_at' - ] - read_only_fields = [ - 'id', 'email', 'role', 'is_verified', - 'has_password', 'created_at', 'updated_at' - ] - - -class AdminUserSerializer(UserSerializer): - project_count = serializers.IntegerField(read_only=True) - published_count = serializers.IntegerField(read_only=True) - github_connected = serializers.SerializerMethodField() - - class Meta(UserSerializer.Meta): - fields = UserSerializer.Meta.fields + ['project_count', 'published_count', 'github_connected'] - - def get_github_connected(self, obj): - return bool(obj.github_token) - - -class ChangePasswordSerializer(serializers.Serializer): - old_password = serializers.CharField(write_only=True, required=False, allow_blank=True) - new_password = serializers.CharField(write_only=True, min_length=8) - - def validate(self, data): - user = self.context['request'].user - - if user.has_password: - # normal user — must provide old password - old_password = data.get('old_password') - if not old_password: - raise serializers.ValidationError( - {'old_password': 'Old password is required'} - ) - if not user.check_password(old_password): - raise serializers.ValidationError( - {'old_password': 'Old password is incorrect'} - ) - # github user — no old password needed, just set the new one - - return data - - def save(self): - user = self.context['request'].user - user.set_password(self.validated_data['new_password']) - user.save() - - -class GithubAuthSerializer(serializers.Serializer): - code = serializers.CharField() - - -class PasswordResetRequestSerializer(serializers.Serializer): - email = serializers.EmailField() - - -class PasswordResetConfirmSerializer(serializers.Serializer): - email = serializers.EmailField() - token = serializers.UUIDField() - new_password = serializers.CharField(write_only=True, min_length=8) diff --git a/backend/apps/users/tasks.py b/backend/apps/users/tasks.py deleted file mode 100644 index 1ec2f11..0000000 --- a/backend/apps/users/tasks.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging - -from celery import shared_task - -logger = logging.getLogger(__name__) - - -@shared_task -def send_welcome_email_task(user_id): - from .email_utils import send_welcome_email - from .models import User - try: - user = User.objects.get(id=user_id) - send_welcome_email(user) - except Exception as e: - logger.error(f"send_welcome_email_task failed for user {user_id}: {e}") - - -@shared_task -def send_password_reset_email_task(user_id, token_str): - from .email_utils import send_password_reset_email - from .models import User - try: - user = User.objects.get(id=user_id) - send_password_reset_email(user, token_str) - except Exception as e: - logger.error(f"send_password_reset_email_task failed for user {user_id}: {e}") diff --git a/backend/apps/users/tests.py b/backend/apps/users/tests.py deleted file mode 100644 index f664a55..0000000 --- a/backend/apps/users/tests.py +++ /dev/null @@ -1,66 +0,0 @@ -from unittest.mock import patch - -from django.contrib.auth import get_user_model -from rest_framework import status -from rest_framework.test import APITestCase - -User = get_user_model() - -class UserAuthTests(APITestCase): - def setUp(self): - self.user_data = { - 'email': 'test@example.com', - 'name': 'Test User', - 'username': 'testuser', - 'password': 'strongpassword123', - 'password2': 'strongpassword123' - } - - @patch('apps.users.tasks.send_welcome_email_task.delay') - def test_user_registration(self, mock_email_task): - """Test successful user registration and token generation.""" - response = self.client.post('/api/users/register/', self.user_data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertIn('tokens', response.data) - self.assertIn('access', response.data['tokens']) - self.assertTrue(User.objects.filter(email='test@example.com').exists()) - mock_email_task.assert_called_once() - - def test_user_registration_password_mismatch(self): - """Test registration fails if passwords do not match.""" - data = self.user_data.copy() - data['password2'] = 'differentpassword' - response = self.client.post('/api/users/register/', data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('password', response.data) - - def test_user_login(self): - """Test user login returns JWT tokens.""" - User.objects.create_user(email='login@example.com', name='Login', password='password123') - response = self.client.post('/api/users/login/', { - 'email': 'login@example.com', - 'password': 'password123' - }) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('tokens', response.data) - - def test_get_profile_unauthenticated(self): - """Ensure unauthenticated users cannot access profile.""" - response = self.client.get('/api/users/profile/') - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - def test_get_and_update_profile(self): - """Test getting and updating the authenticated user's profile.""" - user = User.objects.create_user(email='profile@example.com', name='Old Name', password='pwd') - self.client.force_authenticate(user=user) - - # Get profile - response = self.client.get('/api/users/profile/') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['name'], 'Old Name') - - # Update profile - response = self.client.patch('/api/users/profile/', {'name': 'New Name'}) - self.assertEqual(response.status_code, status.HTTP_200_OK) - user.refresh_from_db() - self.assertEqual(user.name, 'New Name') diff --git a/backend/apps/users/urls.py b/backend/apps/users/urls.py deleted file mode 100644 index 05e50e1..0000000 --- a/backend/apps/users/urls.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.urls import path -from rest_framework_simplejwt.views import TokenRefreshView - -from .views import ( - ChangePasswordView, - GithubAuthView, - LoginView, - LogoutView, - PasswordResetConfirmView, - PasswordResetRequestView, - ProfileView, - RegisterView, - UserListView, -) - -urlpatterns = [ - path('register/', RegisterView.as_view(), name='register'), - path('login/', LoginView.as_view(), name='login'), - path('logout/', LogoutView.as_view(), name='logout'), - path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), - path('profile/', ProfileView.as_view(), name='profile'), - path('change-password/', ChangePasswordView.as_view(), name='change_password'), - path('list/', UserListView.as_view(), name='user_list'), - path('auth/github/', GithubAuthView.as_view(), name='github-auth'), - path('password-reset/', PasswordResetRequestView.as_view(), name='password-reset-request'), - path('password-reset/confirm/',PasswordResetConfirmView.as_view(), name='password-reset-confirm'), -] diff --git a/backend/apps/users/views.py b/backend/apps/users/views.py deleted file mode 100644 index 484acc1..0000000 --- a/backend/apps/users/views.py +++ /dev/null @@ -1,237 +0,0 @@ -import requests -from rest_framework import generics, status -from rest_framework.pagination import PageNumberPagination -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView -from rest_framework_simplejwt.exceptions import TokenError -from rest_framework_simplejwt.tokens import RefreshToken - -from .github import exchange_code_for_token, get_github_user -from .models import PasswordResetToken, User -from .serializers import ( - ChangePasswordSerializer, - GithubAuthSerializer, - LoginSerializer, - PasswordResetConfirmSerializer, - PasswordResetRequestSerializer, - RegisterSerializer, - UserSerializer, -) - - -def get_tokens(user): - refresh = RefreshToken.for_user(user) - return { - 'refresh': str(refresh), - 'access': str(refresh.access_token), - } - - -class RegisterView(APIView): - permission_classes = [AllowAny] - - def post(self, request): - serializer = RegisterSerializer(data=request.data) - if serializer.is_valid(): - user = serializer.save() - tokens = get_tokens(user) - from .tasks import send_welcome_email_task - send_welcome_email_task.delay(user.id) - return Response({ - 'user': UserSerializer(user).data, - 'tokens': tokens, - }, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class LoginView(APIView): - permission_classes = [AllowAny] - - def post(self, request): - serializer = LoginSerializer(data=request.data) - if serializer.is_valid(): - user = serializer.validated_data['user'] - tokens = get_tokens(user) - return Response({ - 'user': UserSerializer(user).data, - 'tokens': tokens, - }) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class LogoutView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - try: - token = RefreshToken(request.data['refresh']) - token.blacklist() - return Response({'detail': 'Logged out successfully'}) - except KeyError: - return Response({'detail': 'Refresh token required'}, status=status.HTTP_400_BAD_REQUEST) - except TokenError: - return Response({'detail': 'Invalid or expired token'}, status=status.HTTP_400_BAD_REQUEST) - - -class ProfileView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request): - return Response(UserSerializer(request.user).data) - - def patch(self, request): - serializer = UserSerializer(request.user, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class ChangePasswordView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request): - serializer = ChangePasswordSerializer(data=request.data, context={'request': request}) - if serializer.is_valid(): - serializer.save() - return Response({'detail': 'Password changed successfully'}) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class AdminUserPage(PageNumberPagination): - page_size = 50 - page_size_query_param = 'page_size' - max_page_size = 200 - - -class UserListView(generics.ListAPIView): - permission_classes = [IsAuthenticated] - serializer_class = UserSerializer - pagination_class = AdminUserPage - search_fields = ['email', 'name', 'username'] - filterset_fields = ['role', 'is_verified', 'is_active'] - ordering_fields = ['created_at', 'email', 'name'] - - def get_serializer_class(self): - if self.request.user.is_admin: - from .serializers import AdminUserSerializer - return AdminUserSerializer - return UserSerializer - - def get_queryset(self): - if self.request.user.is_admin: - from django.db.models import Count, Q - return User.objects.annotate( - project_count=Count('projects'), - published_count=Count('projects', filter=Q(projects__is_published=True)), - ).all() - return User.objects.none() - - -class GithubAuthView(APIView): - permission_classes = [AllowAny] - - def post(self, request): - serializer = GithubAuthSerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - code = serializer.validated_data['code'] - - try: - github_token = exchange_code_for_token(code) - except requests.RequestException: - return Response( - {'detail': 'Failed to reach GitHub. Try again.'}, - status=status.HTTP_502_BAD_GATEWAY, - ) - - if not github_token: - return Response( - {'detail': 'Invalid or expired OAuth code.'}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - github_user = get_github_user(github_token) - except requests.RequestException: - return Response( - {'detail': 'Failed to fetch GitHub profile.'}, - status=status.HTTP_502_BAD_GATEWAY, - ) - - email = github_user.get('email') - if not email: - return Response( - {'detail': 'Your GitHub account has no verified public email.'}, - status=status.HTTP_400_BAD_REQUEST, - ) - - user, created = User.objects.get_or_create( - email=email, - defaults={ - 'name': github_user.get('name') or github_user.get('login', ''), - 'username': github_user.get('login'), - 'is_verified': True, - }, - ) - - # Keep the token fresh and ensure is_verified - update_fields = ['github_token'] - user.github_token = github_token - if not user.is_verified: - user.is_verified = True - update_fields.append('is_verified') - user.save(update_fields=update_fields) - - tokens = get_tokens(user) - return Response( - { - 'user': UserSerializer(user).data, - 'tokens': tokens, - 'created': created, - }, - status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, - ) - - -class PasswordResetRequestView(APIView): - permission_classes = [AllowAny] - - def post(self, request): - serializer = PasswordResetRequestSerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - email = serializer.validated_data['email'] - try: - user = User.objects.get(email=email) - token = PasswordResetToken.objects.create(user=user) - from .tasks import send_password_reset_email_task - send_password_reset_email_task.delay(user.id, str(token.token)) - except User.DoesNotExist: - pass # Don't reveal if email exists - # Always return 200 to prevent email enumeration - return Response({'detail': 'If that email exists, a reset link has been sent.'}) - - -class PasswordResetConfirmView(APIView): - permission_classes = [AllowAny] - - def post(self, request): - serializer = PasswordResetConfirmSerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - data = serializer.validated_data - try: - user = User.objects.get(email=data['email']) - token = PasswordResetToken.objects.get(token=data['token'], user=user) - except (User.DoesNotExist, PasswordResetToken.DoesNotExist): - return Response({'detail': 'Invalid or expired token.'}, status=status.HTTP_400_BAD_REQUEST) - if not token.is_valid(): - return Response({'detail': 'Token has expired or already been used.'}, status=status.HTTP_400_BAD_REQUEST) - user.set_password(data['new_password']) - user.save() - token.used = True - token.save() - return Response({'detail': 'Password reset successfully.'}) diff --git a/backend/celerybeat-schedule.db b/backend/celerybeat-schedule.db deleted file mode 100644 index b4b12fab704e38d36f4b9d8a03c23f29848a5e5b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12288 zcmeI%F=_%q6vpwl##K}3*a#lMYMYQ;Ko4M%v<^lGB7_a-CPg4%X`!9Df<>Afz&ix2 zEIoj&So_9}kZhGE*!%}(_VMlPEd09RiHHO`{pi*Ta_Nhz&XO-ga_QsOTHlA#(xkFo zw2%9>_Gg^VX=oklT+JRjKXZ*32q1s}0tg_000IagfB*srAaEvu>>a%%A4bYMvoUM4 zGGAtCK8+>}1Q0*~0R#|0009ILKmY**5cqe2V4R;s!E@5HF7Dnxy1oYQ#qZxvFYa!e z^C(DOHGR{<#mR8sMt;w!ML!L%qnqeTUt0U(hNHn#Y_0#^-~Tgx{;HJIlQa1-{7e7e dpGp5eHDpBq0R#|0009ILKmY**5J2GE0tc5rDqR2o diff --git a/backend/config/__init__.py b/backend/config/__init__.py deleted file mode 100644 index fb989c4..0000000 --- a/backend/config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .celery import app as celery_app - -__all__ = ('celery_app',) diff --git a/backend/config/asgi.py b/backend/config/asgi.py deleted file mode 100644 index 69ab9a8..0000000 --- a/backend/config/asgi.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -from django.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') - -application = get_asgi_application() diff --git a/backend/config/celery.py b/backend/config/celery.py deleted file mode 100644 index 8601098..0000000 --- a/backend/config/celery.py +++ /dev/null @@ -1,9 +0,0 @@ -import os - -from celery import Celery - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') - -app = Celery('config') -app.config_from_object('django.conf:settings', namespace='CELERY') -app.autodiscover_tasks() diff --git a/backend/config/settings/__init__.py b/backend/config/settings/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py deleted file mode 100644 index 4206378..0000000 --- a/backend/config/settings/base.py +++ /dev/null @@ -1,155 +0,0 @@ -from pathlib import Path - -from decouple import config -from dotenv import load_dotenv - -env_path = Path(__file__).resolve().parent.parent.parent / '.env' -load_dotenv(env_path) - -BASE_DIR = Path(__file__).resolve().parent.parent.parent - -SECRET_KEY = config('DJANGO_SECRET_KEY') - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'corsheaders', - 'rest_framework_simplejwt', - 'rest_framework_simplejwt.token_blacklist', - 'apps.users', - 'apps.projects', - 'apps.parser', - 'apps.ai', - 'apps.github_integration', - 'apps.exports', - 'apps.admin_dashboard', - 'apps.feedback', - 'apps.internal', - 'apps.comments', - 'apps.notifications', - 'apps.universal', -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', - 'corsheaders.middleware.CorsMiddleware', - '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', -] - -STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' -ROOT_URLCONF = 'config.urls' -WSGI_APPLICATION = 'config.wsgi.application' -ASGI_APPLICATION = 'config.asgi.application' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR / 'templates'], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -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'}, -] - -AUTH_USER_MODEL = 'users.User' -LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' -USE_I18N = True -USE_TZ = True - -STATIC_URL = '/static/' -STATIC_ROOT = BASE_DIR / 'staticfiles' -MEDIA_URL = '/media/' -MEDIA_ROOT = BASE_DIR / 'media' - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication', - ], - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', - ], - 'DEFAULT_FILTER_BACKENDS': [ - 'django_filters.rest_framework.DjangoFilterBackend', - 'rest_framework.filters.SearchFilter', - 'rest_framework.filters.OrderingFilter', - ], - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 6, - 'DEFAULT_THROTTLE_CLASSES': [ - 'rest_framework.throttling.AnonRateThrottle', - 'rest_framework.throttling.UserRateThrottle', - ], - 'DEFAULT_THROTTLE_RATES': { - 'anon': '30/hour', - 'user': '1000/hour', - 'comment_create': '20/hour', - 'publish': '10/hour', - 'public': '100/hour', - }, -} - -# JWT SETTINGS -from datetime import timedelta - -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), - 'ROTATE_REFRESH_TOKENS': True, - 'BLACKLIST_AFTER_ROTATION': True, - 'AUTH_HEADER_TYPES': ('Bearer',), -} - -CELERY_TIMEZONE = 'UTC' -CELERY_TASK_TRACK_STARTED = True -CELERY_TASK_TIME_LIMIT = 30 * 60 - -AUTH_USER_MODEL = 'users.User' -GROQ_API_KEY = config('GROQ_API_KEY', default=None) -GROQ_API_KEY_2 = config('GROQ_API_KEY_2', default=None) - -GITHUB_CLIENT_ID = config('GITHUB_CLIENT_ID', default=None) -GITHUB_CLIENT_SECRET = config('GITHUB_CLIENT_SECRET', default=None) -GITHUB_API_TOKEN = config('GITHUB_API_TOKEN', default=None) - - -# ── EMAIL SETTINGS ────────────────────────────────────────── -from datetime import timedelta - -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com') -EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) -EMAIL_USE_TLS = True -EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') -EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') -DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@pydocai.com') -FRONTEND_URL = config('FRONTEND_URL', default='http://localhost:5173') -SITE_URL = config('SITE_URL', default=FRONTEND_URL) - -PASSWORD_RESET_TIMEOUT = 3600 # 1 hour diff --git a/backend/config/settings/development.py b/backend/config/settings/development.py deleted file mode 100644 index 5e35f18..0000000 --- a/backend/config/settings/development.py +++ /dev/null @@ -1,37 +0,0 @@ -from config.settings.base import * - -DEBUG = True -ALLOWED_HOSTS = ['localhost', '127.0.0.1'] - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } -} - -CELERY_BROKER_URL = 'redis://localhost:6379/0' -CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' -CELERY_TASK_ALWAYS_EAGER = True -CELERY_TASK_EAGER_PROPAGATES = True - -CORS_ALLOWED_ORIGINS = [ - 'http://localhost:5173', - 'http://127.0.0.1:5173', -] - -CORS_ALLOW_CREDENTIALS = True - -CORS_ALLOW_HEADERS = [ - 'accept', - 'accept-encoding', - 'authorization', - 'content-type', - 'dnt', - 'origin', - 'user-agent', - 'x-csrftoken', - 'x-requested-with', -] - -# EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py deleted file mode 100644 index fd63d38..0000000 --- a/backend/config/settings/production.py +++ /dev/null @@ -1,83 +0,0 @@ -from decouple import config - -from config.settings.base import * - -DEBUG = False -ALLOWED_HOSTS = config('ALLOWED_HOSTS').split(',') - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': config('DB_NAME'), - 'USER': config('DB_USER'), - 'PASSWORD': config('DB_PASSWORD'), - 'HOST': config('DB_HOST'), - 'PORT': config('DB_PORT', default='5432'), - 'CONN_MAX_AGE': 300, - } -} - -CELERY_BROKER_URL = config('CELERY_BROKER_URL') -CELERY_RESULT_BACKEND = config('CELERY_RESULT_BACKEND') -CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS').split(',') - -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = config('EMAIL_HOST') -EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) -EMAIL_USE_TLS = True -EMAIL_HOST_USER = config('EMAIL_HOST_USER') -EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD') - -USE_X_FORWARDED_HOST = True -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -SECURE_SSL_REDIRECT = False -SESSION_COOKIE_SECURE = False -CSRF_COOKIE_SECURE = False -SECURE_HSTS_SECONDS = 0 - -REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [ - 'rest_framework.throttling.AnonRateThrottle', - 'rest_framework.throttling.UserRateThrottle', -] -REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = { - 'anon': '100/day', - 'user': '1000/hour', - 'comment_create': '20/hour', - 'publish': '10/hour', - 'public': '100/hour', -} - -# ── S3 STATIC & MEDIA FILES ────────────────────────────────── -AWS_ACCESS_KEY_ID = config('AWS_ACCESS_KEY_ID', default=None) -AWS_SECRET_ACCESS_KEY = config('AWS_SECRET_ACCESS_KEY', default=None) -AWS_STORAGE_BUCKET_NAME = config('AWS_STORAGE_BUCKET_NAME', default=None) -AWS_S3_REGION_NAME = config('AWS_S3_REGION_NAME', default='us-east-1') -AWS_S3_FILE_OVERWRITE = False -AWS_DEFAULT_ACL = None -AWS_QUERYSTRING_AUTH = False -AWS_S3_VERIFY = True - -if AWS_STORAGE_BUCKET_NAME: - STORAGES = { - "default": { - "BACKEND": "config.storage_backends.MediaStorage", - }, - "staticfiles": { - "BACKEND": "storages.backends.s3.S3StaticStorage", - }, - } - cloudfront_domain = config('AWS_CLOUDFRONT_DOMAIN', default=None) - if cloudfront_domain: - STATIC_URL = f'https://{cloudfront_domain}/static/' - MEDIA_URL = f'https://{cloudfront_domain}/media/' - else: - STATIC_URL = f'https://{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com/static/' - MEDIA_URL = f'https://{AWS_STORAGE_BUCKET_NAME}.s3.{AWS_S3_REGION_NAME}.amazonaws.com/media/' - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": config('CELERY_BROKER_URL'), - "TIMEOUT": 300, - } -} diff --git a/backend/config/storage_backends.py b/backend/config/storage_backends.py deleted file mode 100644 index 7d62ab1..0000000 --- a/backend/config/storage_backends.py +++ /dev/null @@ -1,5 +0,0 @@ -from storages.backends.s3 import S3Storage - - -class MediaStorage(S3Storage): - location = 'media' diff --git a/backend/config/urls.py b/backend/config/urls.py deleted file mode 100644 index 612fecf..0000000 --- a/backend/config/urls.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.contrib import admin -from django.urls import include, path - -from apps.projects.urls import public_urlpatterns as public_project_urls - -urlpatterns = [ - path('admin/', admin.site.urls), - path('api/users/', include('apps.users.urls')), - path('api/projects/', include('apps.projects.urls')), - path('api/public/projects/', include(public_project_urls)), - path('api/parser/', include('apps.parser.urls')), - path('api/ai/', include('apps.ai.urls')), - path('api/github/', include('apps.github_integration.urls')), - path('api/exports/', include('apps.exports.urls')), - path('api/admin-dashboard/', include('apps.admin_dashboard.urls')), - path('api/feedback/', include('apps.feedback.urls')), - path('api/comments/', include('apps.comments.urls')), - path('api/notifications/', include('apps.notifications.urls')), - # Internal microservice communication endpoints - path('api/internal/', include('apps.internal.urls')), - path('api/universal/', include('apps.universal.urls')), -] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py deleted file mode 100644 index 85585bf..0000000 --- a/backend/config/wsgi.py +++ /dev/null @@ -1,7 +0,0 @@ -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') - -application = get_wsgi_application() diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh deleted file mode 100644 index 9e3cfd6..0000000 --- a/backend/entrypoint.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -set -e -uv run python manage.py migrate --noinput -uv run python manage.py collectstatic --noinput -exec uv run gunicorn config.wsgi:application --bind 0.0.0.0:8000 --worker-class gevent --workers 4 diff --git a/backend/manage.py b/backend/manage.py deleted file mode 100644 index cd2a05c..0000000 --- a/backend/manage.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError("Couldn't import Django.") from exc - execute_from_command_line(sys.argv) - -if __name__ == '__main__': - main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml deleted file mode 100644 index 99d0d96..0000000 --- a/backend/pyproject.toml +++ /dev/null @@ -1,73 +0,0 @@ -[project] -name = "backend" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.10" -dependencies = [ - "celery>=5.6.3", - "django>=5.2.13", - "django-cors-headers>=4.9.0", - "django-environ>=0.13.0", - "djangorestframework>=3.17.1", - "djangorestframework-simplejwt>=5.5.1", - "google-genai>=1.74.0", - "groq>=1.2.0", - "markdown>=3.10.2", - "psycopg2-binary>=2.9.12", - "pygithub>=2.9.1", - "python-decouple>=3.8", - "python-dotenv>=1.2.2", - "redis>=7.4.0", - "requests>=2.31.0", - "gunicorn>=23.0.0", - "gevent>=24.10.1", - "weasyprint>=68.1", - "tomli>=2.0.1", - "anthropic>=0.45.0", - "django-anymail>=15.0", - "django-filter>=25.2", - "whitenoise>=6.7.0", - "django-storages>=1.14.0", - "boto3>=1.34.0", - "pytest>=8.0", - "pytest-django>=4.9", - "coverage>=7.6", - "ruff>=0.8", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.0", - "pytest-django>=4.9", - "coverage>=7.6", - "ruff>=0.8", -] - -[tool.ruff] -target-version = "py310" -line-length = 120 - -[tool.ruff.lint] -select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"] -ignore = ["E501", "SIM102"] - -[tool.ruff.lint.per-file-ignores] -"*/migrations/*" = ["E501"] -"apps/universal/prompts.py" = ["E501"] -"config/settings/*" = ["F403", "F405", "E402"] -"apps/github_integration/tasks.py" = ["SIM105", "B904"] -"apps/exports/views.py" = ["B904"] -"apps/parser/tasks.py" = ["B904"] -"apps/parser/ast_parser.py" = ["SIM102"] -"apps/github_integration/views.py" = ["E501"] -"apps/parser/views.py" = ["E501"] - -[tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "config.settings.development" -testpaths = ["apps"] -python_files = ["tests.py", "test_*.py"] - -[tool.coverage.run] -source = ["apps"] -omit = ["*/migrations/*", "*/tests*"] diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt deleted file mode 100644 index 3c9b512..0000000 --- a/backend/requirements/base.txt +++ /dev/null @@ -1,14 +0,0 @@ -django -djangorestframework -django-cors-headers -djangorestframework-simplejwt -celery -redis -python-decouple -Pillow -psycopg2-binary -gunicorn -groq -tomli -anthropic -django-filter diff --git a/backend/requirements/development.txt b/backend/requirements/development.txt deleted file mode 100644 index aa19018..0000000 --- a/backend/requirements/development.txt +++ /dev/null @@ -1,2 +0,0 @@ --r base.txt -django-debug-toolbar diff --git a/backend/requirements/production.txt b/backend/requirements/production.txt deleted file mode 100644 index f8050d8..0000000 --- a/backend/requirements/production.txt +++ /dev/null @@ -1,2 +0,0 @@ --r base.txt -sentry-sdk diff --git a/backend/services/ai/Dockerfile b/backend/services/ai/Dockerfile deleted file mode 100644 index 9f2170b..0000000 --- a/backend/services/ai/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" - -COPY . . - -EXPOSE 8003 - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8003", "--workers", "2"] diff --git a/backend/services/ai/database.py b/backend/services/ai/database.py deleted file mode 100644 index 27b8fa8..0000000 --- a/backend/services/ai/database.py +++ /dev/null @@ -1,28 +0,0 @@ -import os -from sqlalchemy import create_engine, text -from sqlalchemy.orm import sessionmaker, declarative_base - -DATABASE_URL = os.getenv( - "DATABASE_URL", - "postgresql://pydocai_user:pydocai_pass@localhost:5433/pydocai" -) - -engine = create_engine(DATABASE_URL, pool_pre_ping=True) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() - - -def create_tables(): - with engine.connect() as conn: - conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) - conn.commit() - from models import Project, ProjectFile, CodeEmbedding - Base.metadata.create_all(bind=engine) - - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/backend/services/ai/main.py b/backend/services/ai/main.py deleted file mode 100644 index db37a7f..0000000 --- a/backend/services/ai/main.py +++ /dev/null @@ -1,943 +0,0 @@ -import os -import re -import uuid -import json -import tempfile -import shutil -import logging -from datetime import datetime -from typing import Optional - -from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks -from fastapi.security import APIKeyHeader -from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy.orm import Session -from pydantic import BaseModel - -from database import get_db, engine, Base, create_tables -from models import Project, ProjectFile -from rag import embed_and_store_chunks, retrieve_context, store_generated_doc - -create_tables() - -app = FastAPI(title="PyDocAI AI Generator Service", version="0.2.0") - -INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "") -api_key_header = APIKeyHeader(name="X-Internal-Api-Key", auto_error=False) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -def verify_internal_key(key: str = Depends(api_key_header)): - if INTERNAL_API_KEY and key != INTERNAL_API_KEY: - raise HTTPException(401, "Invalid or missing internal API key") - return key - -GROQ_API_KEY = os.getenv("GROQ_API_KEY") -GROQ_API_KEY_2 = os.getenv("GROQ_API_KEY_2") - - -class GenerateRequest(BaseModel): - project_id: str - file_path: Optional[str] = None - use_ai: bool = True - - -class GenerateResponse(BaseModel): - project_id: str - status: str - generated_docs: Optional[str] = None - readme_docs: Optional[str] = None - api_docs: Optional[str] = None - - -@app.get("/health") -def health(): - return { - "status": "ok", - "service": "ai", - "groq_configured": bool(GROQ_API_KEY), - "embedding_model": os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2"), - } - - -def _sanitize_markdown(text: str) -> str: - if not text: - return text - text = re.sub(r'(^|\n)mermaid\s*\n', r'\1```mermaid\n', text) - lines = text.split("\n") - result = [] - i = 0 - while i < len(lines): - line = lines[i] - if line.startswith("```mermaid"): - result.append(line) - i += 1 - while i < len(lines): - if lines[i].strip() == "```": - result.append(lines[i]) - i += 1 - break - elif lines[i].startswith("```mermaid"): - result.append("```") - break - elif lines[i].startswith("##") or lines[i].startswith("# "): - result.append("```") - result.append(lines[i]) - i += 1 - break - else: - result.append(lines[i]) - i += 1 - else: - result.append("```") - else: - result.append(lines[i]) - i += 1 - text = "\n".join(result) - text = re.sub(r'\bcode\s*\n\s*Copy\s*\n\s*python\s*\n', '```python\n', text) - text = re.sub(r'\bcode\s*\n\s*Copy\s*\n\s*(\w+)\s*\n', r'```\1\n', text) - text = re.sub(r'\bcode\s*\n\s*Copy\s*\n', '```\n', text) - text = re.sub(r'\n\s*```\s*\n\s*```\s*\n', '\n```\n', text) - text = re.sub(r'([^\n])\n(#{1,6} )', r'\1\n\n\2', text) - text = re.sub(r'(#{1,6} .+)\n([^\n#])', r'\1\n\n\2', text) - text = re.sub(r'\n{3,}', '\n\n', text) - return text.strip() - - -def _call_groq(prompt: str, max_tokens: int = 2048, key_start: int = 0, model: str = "llama-3.1-8b-instant") -> str: - import time - from groq import Groq - key_pool = [] - if GROQ_API_KEY: - key_pool.append(("key1", GROQ_API_KEY)) - if GROQ_API_KEY_2: - key_pool.append(("key2", GROQ_API_KEY_2)) - if not key_pool: - raise HTTPException(503, "No Groq API keys configured") - keys_to_try = key_pool[key_start:] + key_pool[:key_start] - print(f"[_call_groq] Keys available: {len(key_pool)}, start={key_start}, order={[k[0] for k in keys_to_try]}", flush=True) - for name, key in keys_to_try: - for attempt in range(3): - try: - client = Groq(api_key=key) - response = client.chat.completions.create( - model=model, - messages=[{"role": "user", "content": prompt}], - max_tokens=max_tokens, - ) - result = response.choices[0].message.content.strip() - if result.startswith("```"): - result = result.split("```", 2)[-1].strip() - if result.endswith("```"): - result = result[:-3].strip() - print(f"[_call_groq] {name}: OK ({len(result)} chars)", flush=True) - return result - except Exception as e: - status_code = getattr(e, 'status_code', 0) or getattr(e, 'status', 0) - body = getattr(e, 'body', '') or (str(e.args) if e.args else str(e))[:200] - if status_code in (401, 403): - print(f"[_call_groq] {name}: auth error ({status_code}) {body}, skipping key", flush=True) - break - if status_code == 429: - print(f"[_call_groq] {name}: rate limited (429) {body}, trying next key", flush=True) - break - wait = 2 ** attempt - print(f"[_call_groq] {name}: attempt {attempt+1} FAILED (HTTP {status_code}) {body}, retrying in {wait}s", flush=True) - time.sleep(wait) - continue - raise HTTPException(503, "All Groq API keys failed") - - -def _build_structure_tree(ordered_items: list) -> str: - lines = [] - for item in ordered_items: - typ = item["type"] - data = item["data"] - prefix = "├── " if item != ordered_items[-1] else "└── " - if typ == "import": - lines.append(f"{prefix}[Import] {data.get('display', '')}") - elif typ == "function": - args = ", ".join(a["name"] for a in data.get("args", [])) - lines.append(f"{prefix}[Function] {data['name']}({args}) -> {data.get('returns', 'None')}") - elif typ == "class": - bases = ", ".join(data.get("bases", [])) - base_str = f"({bases})" if bases else "" - lines.append(f"{prefix}[Class] {data['name']}{base_str}") - for i, m in enumerate(data.get("methods", [])): - m_prefix = " ├── " if i < len(data["methods"]) - 1 else " └── " - m_args = ", ".join(a["name"] for a in m.get("args", [])) - lines.append(f"{m_prefix}{m['name']}({m_args})") - return "\n".join(lines) - - -def get_item_docs_prompt(item_type: str, data: dict, file_path: str, is_pattern: bool = False) -> str: - if item_type == "function": - args_table = "| Parameter | Type | Description | Default | Constraints |\n|---|---|---|---|---|\n" - for a in data.get("args", []): - args_table += f"| {a['name']} | {a['type'] or 'Any'} | ... | ... | ... |\n" - connections = data.get("connections", []) - conn_str = ", ".join(connections) if connections else "None" - - if is_pattern: - return f"""This function follows the same pattern as other functions in this project. -Write a SHORT, differential doc focusing only on what makes THIS one unique. - -Function: `{data['name']}` -File: `{file_path}` -Line: {data.get('line', '?')} -Async: {data.get('is_async', False)} -Decorators: {', '.join(data.get('decorators', [])) or 'None'} -Parameters: -{args_table} -Returns: `{data.get('returns', 'None')}` -Calls/References: {conn_str} - -Provide ONLY: -- ### Purpose (1 sentence) -- ### Unique behavior (what differs from the pattern) -- ### Parameters table (just name and type) -- ### Returns (1 line) - -Output in clean markdown with headings. Keep it short.""" - return f"""Document the following Python function in detail using markdown. - -Function: `{data['name']}` -File: `{file_path}` -Line: {data.get('line', '?')} -Async: {data.get('is_async', False)} -Decorators: {', '.join(data.get('decorators', [])) or 'None'} -Parameters: -{args_table} -Returns: `{data.get('returns', 'None')}` -Calls/References: {conn_str} - -Provide: -- ### Purpose (2-3 sentences) -- ### Behavior (step-by-step) -- ### Parameters table (Parameter | Type | Description | Default | Constraints) -- ### Returns (type, description, possible values) -- ### Raises (all exceptions that can be raised) -- ### Relationships (Calls, Called By, Uses) -- ### Example Usage (Input/Output) -- ### Edge Cases -- ### Complexity (Big O) - -Output in clean markdown with headings.""" - elif item_type == "class": - methods_str = "" - for m in data.get("methods", []): - m_args = ", ".join(a["name"] for a in m.get("args", [])) - methods_str += f"- `{m['name']}({m_args}) -> {m.get('returns', 'None')}` (line {m.get('line', '?')})\n" - connections = data.get("connections", []) - conn_str = ", ".join(connections) if connections else "None" - - if is_pattern: - return f"""This class follows the same pattern as other classes in this project. -Write a SHORT, differential doc focusing only on what makes THIS one unique. - -Class: `{data['name']}` -File: `{file_path}` -Line: {data.get('line', '?')} -Bases: {', '.join(data.get('bases', [])) or 'None'} -Methods: -{methods_str} -Uses/References: {conn_str} - -Provide ONLY: -- ### Purpose (1 sentence) -- ### Unique behavior (what differs from the pattern) -- ### Attributes table (just name and type) -- ### Methods (1 line summary per method) - -Output in clean markdown with headings. Keep it short.""" - return f"""Document the following Python class in detail using markdown. - -Class: `{data['name']}` -File: `{file_path}` -Line: {data.get('line', '?')} -Bases: {', '.join(data.get('bases', [])) or 'None'} -Methods: -{methods_str} -Uses/References: {conn_str} - -Provide: -- ### Purpose (2-3 sentences) -- ### Attributes table (Attribute | Type | Description | Default) -- ### Methods (for each: purpose, parameters, returns, example) -- ### Inherits from (bases, inherited methods) -- ### Usage Example -- ### Relationships to other classes - -Output in clean markdown with headings.""" - return "" - - -def generate_file_docs(parsed: dict, file_path: str, framework_info: Optional[dict] = None, db: Session = None, project_id: str = None) -> str: - ordered_items = parsed.get("ordered_items", []) - module_doc = parsed.get("module_docstring") or "No module docstring" - imports = parsed.get("imports", []) - - import_displays = [] - for imp in imports: - if isinstance(imp, dict): - import_displays.append(imp.get("display", str(imp))) - else: - import_displays.append(str(imp)) - - structure_tree = _build_structure_tree(ordered_items) - - fw_header = "" - fw_instructions = "" - if framework_info and framework_info.get("primary_framework"): - fw = framework_info["primary_framework"] - fw_header = f"\nFramework: {fw}\n" - fw_type = framework_info.get("primary_type", "") - if fw_type == "web": - fw_instructions = "\nSpecial instructions: When documenting Python web framework code, include details about routes, middleware, request/response handling, and dependency injection where applicable." - elif fw_type == "task_queue": - fw_instructions = "\nSpecial instructions: When documenting task queue code, include details about task signatures, queues, retries, and result backends where applicable." - elif fw_type == "orm": - fw_instructions = "\nSpecial instructions: When documenting ORM code, include details about model definitions, relationships, sessions, and query patterns where applicable." - elif fw_type == "ai": - fw_instructions = "\nSpecial instructions: When documenting AI/LLM code, include details about model configuration, prompts, streaming, and error handling where applicable." - - - - rag_context = "" - if db and project_id: - try: - rag_context, _ = retrieve_context(project_id, f"Module overview of {file_path}: {module_doc}", top_k=3, db=db) - except Exception: - pass - - rag_block = f"\n\nRelevant code context from project:\n{rag_context}\n\n---\n" if rag_context else "" - - overview_prompt = f"""{rag_block} -Generate the beginning sections for the Python file {file_path}.{fw_header} -Module docstring: {module_doc} -Imports: {', '.join(import_displays)} - -Output ONLY the following sections in markdown (nothing else): - -# {file_path} - -## Overview -3-5 detailed paragraphs on what this module does, its purpose, architecture, and key components. Include a mermaid flowchart showing the module architecture and data flow. - -## Code Structure (Source Order) -Paste the structure tree below EXACTLY as shown: - -{structure_tree} - -## Imports -For each import, provide a DETAILED table row with ALL columns: -| Import | Purpose | Where Used | Notes | -|--------|---------|----------|-------| -| ... | ... | ... | ... | - -## Notes -Any additional observations about the module. -""" - overview_docs = _call_groq(overview_prompt, max_tokens=2048, key_start=0) - - item_docs = [] - for i, item in enumerate(ordered_items): - typ = item["type"] - data = item["data"] - if typ == "import": - continue - elif typ in ("function", "class"): - item_prompt = get_item_docs_prompt(typ, data, file_path) - if db and project_id: - try: - item_rag, is_pattern = retrieve_context( - project_id, f"{typ}: {data.get('name', '')} in {file_path}", top_k=3, db=db - ) - if item_rag: - item_prompt = get_item_docs_prompt(typ, data, file_path, is_pattern=is_pattern) - item_prompt = f"Relevant code context from project:\n{item_rag}\n\n---\n\n" + item_prompt - except Exception: - pass - docs = _call_groq(item_prompt, max_tokens=2048, key_start=(i + 1) % 2) - item_docs.append(docs) - if db and project_id and docs: - try: - store_generated_doc(project_id, typ, data["name"], file_path, docs, db) - except Exception: - pass - - result = overview_docs + "\n\n" - result += "## Detailed Documentation (IN SOURCE ORDER)\n\n" - result += "\n\n---\n\n".join(item_docs) - result += "\n\n## End of Documentation" - - return result.strip() - - -def _mock_docs(parsed: dict, file_path: str, _framework_info: Optional[dict] = None) -> str: - imports = parsed.get("imports", []) - ordered = parsed.get("ordered_items", []) - - import_lines = [] - for imp in imports: - if isinstance(imp, dict): - import_lines.append(f'- `{imp.get("display", str(imp))}` (line {imp.get("line", "?")})') - else: - import_lines.append(f"- `{imp}`") - - doc_sections = [] - for item in ordered: - typ = item["type"] - data = item["data"] - if typ == "import": - pass - elif typ == "function": - args = ", ".join(a["name"] for a in data.get("args", [])) - returns = data.get("returns") or "None" - line = data.get("line", "?") - connections = data.get("connections", []) - conn_str = f" (calls: {', '.join(connections)})" if connections else "" - doc_sections.append( - f'### `{data["name"]}({args}) -> {returns}`\n' - f'- **Line:** {line}{conn_str}\n' - f'- **Purpose:** Mock documentation' - ) - elif typ == "class": - line = data.get("line", "?") - bases = ", ".join(data.get("bases", [])) - base_str = f"({bases})" if bases else "" - connections = data.get("connections", []) - conn_str = f" (uses: {', '.join(connections)})" if connections else "" - doc_sections.append( - f'### `{data["name"]}{base_str}`\n' - f'- **Line:** {line}{conn_str}\n' - f'- **Methods:** {", ".join(m["name"] for m in data.get("methods", []))}' - ) - - return f"""# {file_path} - -## Overview -Mock documentation generated for development purposes. This shows how the documentation will be structured with source order preserved. - -## Imports -{chr(10).join(import_lines) or 'No imports'} - -## Detailed Documentation (IN SOURCE ORDER) - -{chr(10).join(doc_sections) or 'No functions or classes'} - -> ⚠️ This is mock documentation. Configure GROQ_API_KEY to generate real AI-powered docs. -""" - - -def _retrieve_project_context(project_id: str, project_name: str, db: Session) -> str: - try: - rag_text, _ = retrieve_context( - project_id, - f"Project {project_name}: architecture, components, data flow, dependencies", - top_k=5, - db=db, - ) - if rag_text: - return rag_text - return "No additional code context available." - except Exception: - return "No additional code context available." - - -def generate_project_summary(project_path: str, project_name: str = None) -> dict: - summary = { - "name": project_name or os.path.basename(project_path), - "framework": "python", - "architecture": "monolith", - "apps": [], - "dependencies": [], - "file_count": 0, - "project_tree": "", - "package_manager": "pip", - } - - # Detect package manager from project root files - root_contents = set(os.listdir(project_path)) - if "uv.lock" in root_contents or "uv.toml" in root_contents: - summary["package_manager"] = "uv" - elif "poetry.lock" in root_contents: - summary["package_manager"] = "poetry" - elif "Pipfile" in root_contents: - summary["package_manager"] = "pipenv" - elif "pyproject.toml" in root_contents: - summary["package_manager"] = "pip" - else: - summary["package_manager"] = "pip" - - dir_map = {} - for root, dirs, files in os.walk(project_path): - dirs[:] = [d for d in dirs if d not in { - "venv", ".venv", "__pycache__", "node_modules", - "migrations", ".git", "build", "dist", ".egg-info" - }] - rel_dir = os.path.relpath(root, project_path) - if rel_dir == ".": - rel_dir = "" - for f in files: - if f.endswith(".py"): - dir_map.setdefault(rel_dir, []).append(f) - - summary["file_count"] = sum(len(v) for v in dir_map.values()) - - # Build a proper nested tree from directory map - def _build_tree(dir_map): - root = {} - for dir_path, files in dir_map.items(): - if not dir_path: - continue - parts = dir_path.replace("\\", "/").split("/") - node = root - for p in parts: - node = node.setdefault(p, {}) - node["__files__"] = files - return root - - def _render_tree(node, prefix="", is_last=True): - lines = [] - items = list(node.items()) - items.sort(key=lambda x: (x[0] == "__files__", x[0])) - for i, (key, val) in enumerate(items): - if key == "__files__": - files = sorted(val) - for fi, f in enumerate(files): - conn = "└── " if fi == len(files) - 1 else "├── " - lines.append(f"{prefix}{conn}{f}") - else: - conn = "└── " if i == len(items) - 1 else "├── " - lines.append(f"{prefix}{conn}{key}/") - ext = " " if i == len(items) - 1 else "│ " - sub_lines = _render_tree(val, prefix + ext, i == len(items) - 1) - lines.extend(sub_lines) - return lines - - tree = _build_tree(dir_map) - tree_lines = [] - # Only use top-level dirs as roots (skip files at repo root) - top_dirs = sorted(k for k in tree if k != "__files__") - for ti, td in enumerate(top_dirs): - tree_lines.append(f"{td}/") - sub = _render_tree(tree[td], "", ti == len(top_dirs) - 1) - tree_lines.extend(sub) - summary["project_tree"] = "\n".join(tree_lines) - - for root, dirs, files in os.walk(project_path): - rel = os.path.relpath(root, project_path) - if rel == ".": - continue - if "apps.py" in files or "models.py" in files: - summary["apps"].append(rel.replace(os.sep, ".")) - - req_files = ["requirements.txt", "pyproject.toml", "Pipfile"] - for rf in req_files: - rpath = os.path.join(project_path, rf) - if os.path.exists(rpath): - with open(rpath) as f: - summary["dependencies"] = f.read().splitlines() - break - - return summary - - -def _postman_body_example(route: str) -> str: - examples = { - "register": '\n\n{\n "username": "johndoe",\n "email": "john@example.com",\n "password": "********"\n}', - "login": '\n\n{\n "username": "johndoe",\n "password": "********"\n}', - "change-password": '\n\n{\n "old_password": "********",\n "new_password": "********"\n}', - "password-reset": '\n\n{\n "email": "john@example.com"\n}', - "password-reset/confirm": '\n\n{\n "token": "...",\n "new_password": "********"\n}', - "import": '\n\n{\n "repo_url": "https://github.com/user/repo"\n}', - "folder": '\n\n{\n "folder_path": "src/"\n}', - "file": '\n\n{\n "file_path": "src/main.py",\n "content": "# code here"\n}', - "auth/github": '\n\n{\n "code": "github_oauth_code"\n}', - } - for key, body in examples.items(): - if key in route: - return body - return '\n\n{\n \n}' - - -def _build_api_docs(files: list) -> str: - import re as _re - view_routes = {} - url_prefixes = {} # file → prefix from include() - - # First pass: collect include() prefixes - for f in files: - if f.file_path.endswith("urls.py") and f.content: - for m in _re.finditer( - r"(?:path|re_path)\(\s*(['\"])(.+?)\1\s*,\s*include\((['\"])(.+?)\3\)", - f.content, - ): - route, included = m.group(2), m.group(4) - included_path = included.replace(".urls", "").replace(".", "/") - url_prefixes[included_path] = route - - # Second pass: extract view → route mappings - for f in files: - if f.file_path.endswith("urls.py") and f.content: - # Determine prefix from include() parent - file_key = f.file_path.replace("\\", "/") - parts = file_key.split("/") - prefix = "" - for p in range(len(parts)): - candidate = "/".join(parts[p:]).replace("/urls.py", "").replace(".", "/") - if candidate in url_prefixes: - prefix = url_prefixes[candidate] - break - - for m in _re.finditer( - r"(?:path|re_path)\(\s*(['\"])(.+?)\1\s*,\s*([^)]+)", - f.content, - ): - route = m.group(2) - view_expr = m.group(3).strip() - if view_expr.startswith("include") or view_expr.startswith("("): - continue - name_match = _re.search(r'(\w+)\.as_view\(', view_expr) - if name_match: - view_name = name_match.group(1) - else: - name_match = _re.search(r'(\w+)$', view_expr) - if name_match: - view_name = name_match.group(1) - else: - continue - full_route = f"{prefix.strip('/')}/{route.strip('/')}" - view_routes[view_name] = full_route - - app_docs = {} - for f in files: - if not f.parsed_data: - continue - # Only process views.py and admin.py for API docs - if not (f.file_path.endswith("views.py") or f.file_path.endswith("admin.py")): - continue - parts = f.file_path.replace("\\", "/").split("/") - app_name = "other" - for i, p in enumerate(parts): - if p == "apps" and i + 1 < len(parts): - app_name = parts[i + 1] - break - if p == "config": - app_name = "config" - break - if app_name not in app_docs: - app_docs[app_name] = [] - - for item in f.parsed_data.get("ordered_items", []): - if item["type"] not in ("function", "class"): - continue - data = item["data"] - name = data["name"] - docstring = (data.get("docstring") or "")[:150] - route = view_routes.get(name, "") - methods = [] - if item["type"] == "class": - for m in data.get("methods", []): - mname = m["name"] - if mname in ("get", "post", "put", "patch", "delete", "head", "options"): - methods.append(mname.upper()) - else: - methods.append(mname) - desc = docstring.replace("\n", " ") if docstring else "" - # Only include items that have a route OR are view classes with HTTP methods - if not route and not methods: - continue - dedup_methods = list(dict.fromkeys(methods)) # dedup preserving order - http_verbs = [m for m in dedup_methods if m in ("GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS")] - custom_methods = [m for m in dedup_methods if m not in ("GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS")] - app_docs[app_name].append({ - "name": name, "type": item["type"], "route": route, - "http_verbs": http_verbs, "custom_methods": custom_methods, - "description": desc, "file": f.file_path, - }) - - if not app_docs: - return "# API Documentation\n\nNo API endpoints found." - - lines = ["# API Documentation\n"] - for app_name in sorted(app_docs.keys()): - entries = app_docs[app_name] - if not entries: - continue - lines.append(f"## {app_name}\n") - for e in entries: - name = e["name"] - desc = e["description"] if e["description"] else "—" - file_path = e["file"] - - if e["route"]: - clean_route = f"/{e['route'].strip('/')}" - methods_line = "`, `".join(e["http_verbs"]) if e["http_verbs"] else "—" - lines.append(f"### {name}\n") - lines.append(f"`{methods_line}` `{clean_route}`\n") - lines.append(f"{desc}\n") - if e["custom_methods"]: - lines.append(f"**Custom methods:** `{', '.join(e['custom_methods'])}`\n") - methods_lower = set(m.lower() for m in e["http_verbs"]) - body = _postman_body_example(clean_route) if any(v in methods_lower for v in ("post","put","patch")) else "" - if "post" in methods_lower: - lines.append(f"**Postman:**\n```\nPOST http://localhost:8000{clean_route}\nAuthorization: Bearer \nContent-Type: application/json{body}\n```\n") - elif "put" in methods_lower: - lines.append(f"**Postman:**\n```\nPUT http://localhost:8000{clean_route}\nAuthorization: Bearer \nContent-Type: application/json{body}\n```\n") - elif "patch" in methods_lower: - lines.append(f"**Postman:**\n```\nPATCH http://localhost:8000{clean_route}\nAuthorization: Bearer \nContent-Type: application/json{body}\n```\n") - elif "delete" in methods_lower: - lines.append(f"**Postman:**\n```\nDELETE http://localhost:8000{clean_route}\nAuthorization: Bearer \n```\n") - elif "get" in methods_lower: - lines.append(f"**Postman:**\n```\nGET http://localhost:8000{clean_route}\nAuthorization: Bearer \n```\n") - else: - methods_str = ", ".join(e["http_verbs"] + e["custom_methods"]) if (e["http_verbs"] or e["custom_methods"]) else "—" - lines.append(f"### {name}\n") - lines.append(f"**Methods:** `{methods_str}`\n") - lines.append(f"{desc}\n") - - lines.append(f"**File:** `{file_path}`\n") - lines.append("---\n") - return "\n".join(lines) - - -@app.post("/api/ai/generate/", response_model=GenerateResponse) -def generate_docs(req: GenerateRequest, background_tasks: BackgroundTasks, db: Session = Depends(get_db), _auth: str = Depends(verify_internal_key)): - project = db.query(Project).filter(Project.id == uuid.UUID(req.project_id)).first() - if not project: - raise HTTPException(404, "Project not found") - - project.status = "processing" - project.updated_at = datetime.utcnow() - db.commit() - - files = db.query(ProjectFile).filter( - ProjectFile.project_id == project.id - ).all() - - if not files: - raise HTTPException(400, "No parsed files found. Run parser first.") - - try: - fw_info = project.framework_info or {} - - if project.source_type == "file" and len(files) == 1: - f = files[0] - if req.use_ai and GROQ_API_KEY: - try: - embed_and_store_chunks(str(project.id), files, db) - except Exception as e: - logger = logging.getLogger("ai.generate_docs") - logger.warning("Embedding failed (continuing without RAG): %s", e) - docs = generate_file_docs(f.parsed_data or {}, f.file_name, fw_info, db, str(project.id)) - else: - docs = _mock_docs(f.parsed_data or {}, f.file_name, fw_info) - - project.generated_docs = _sanitize_markdown(docs) - f.generated_docs = project.generated_docs - db.commit() - else: - temp_dir = tempfile.mkdtemp() - try: - for f in files: - if f.content: - fp = os.path.join(temp_dir, f.file_path) - os.makedirs(os.path.dirname(fp), exist_ok=True) - with open(fp, "w") as fh: - fh.write(f.content) - - summary = generate_project_summary(temp_dir, project.name) - project.project_info = summary - - if req.use_ai and GROQ_API_KEY: - try: - embed_and_store_chunks(str(project.id), files, db) - except Exception as e: - logger = logging.getLogger("ai.generate_docs") - logger.warning("Embedding failed (continuing without RAG): %s", e) - - parsed_list = [] - for f in files: - if f.parsed_data: - parsed_list.append({"file_path": f.file_path, "parsed": f.parsed_data}) - - fw_name = fw_info.get("primary_framework", summary.get("framework", "python")) - fw_summary = fw_info.get("summary", "") - fw_block = f"\nDetected Frameworks: {fw_summary}\n" if fw_summary else "" - - tree_lines = (summary.get("project_tree") or "").splitlines() - if len(tree_lines) > 30: - tree_lines = tree_lines[:28] + ["... (truncated)"] - tree_trunc = "\n".join(tree_lines) - - deps = summary.get("dependencies", []) - if len(deps) > 20: - deps = deps[:18] + ["... (truncated)"] - clone_url = (project.github_url or "").strip() - clone_hint = "" - repo_dir = project.name - if clone_url: - clone_hint = f"\nClone URL: {clone_url}" - repo_dir = clone_url.rstrip("/").split("/")[-1].replace(".git", "") or project.name - - pm_map = { - "uv": "`uv sync`", - "poetry": "`poetry install`", - "pipenv": "`pipenv install`", - "pip": "`pip install -r requirements.txt`", - } - pm_cmd = pm_map.get(summary.get("package_manager", "pip"), "`pip install -r requirements.txt`") - - project_context = ( - f"Project Name: {project.name}\n" - f"Description: {project.description or 'No description provided'}\n" - f"Framework: {fw_name}\n" - f"Architecture: {summary.get('architecture', 'monolith')}\n" - f"Total Files: {summary['file_count']}{fw_block}\n\n" - f"Project Structure:\n{tree_trunc}\n\n" - f"Dependencies:\n{chr(10).join(deps)}\n\n" - f"Apps/Modules: {', '.join(summary.get('apps', [])) or 'None detected'}\n" - f"Package Manager: {summary.get('package_manager', 'pip')}\n" - f"Relevant Code Context:\n{_retrieve_project_context(str(project.id), project.name, db)}\n" - f"{clone_hint}\n" - ) - - def _call_ai_section(prompt_body: str, key_offset: int, label: str, max_tokens: int = 4096) -> str: - plen = len(prompt_body) - if plen > 12000: - prompt_body = prompt_body[:12000] + "\n... (truncated)" - print(f"[{label}] Prompt truncated from {plen} to 12000 chars", flush=True) - print(f"[{label}] Prompt size: {len(prompt_body)} chars, calling _call_groq", flush=True) - return _call_groq(prompt_body, max_tokens=max_tokens, model="llama-3.3-70b-versatile", key_start=key_offset) - - import time as _time - - # Build API docs programmatically from urls.py + parsed data - project.api_docs = _build_api_docs(files) - - _time.sleep(3) - - # Determine correct cd directory from project structure - has_root_req = any(f.file_path == "requirements.txt" for f in files) - has_root_manage = any(f.file_path == "manage.py" for f in files) - cd_dir = "." if (has_root_req or has_root_manage) else (repo_dir or ".") - - # Build dynamic mermaid flowchart from actual apps - detected_apps = summary.get("apps", []) - app_labels = [a.split(".")[-1].replace("_", " ").title() for a in detected_apps] - mermaid_lines = ["flowchart TD", " A[Client] --> B[Backend]"] - for i, label in enumerate(app_labels): - node = chr(67 + i) if i < 24 else f"N{i}" - mermaid_lines.append(f" B --> {node}[{label}]") - mermaid_block = "\n".join(mermaid_lines) - app_list_str = ", ".join(f"`{a}`" for a in detected_apps) if detected_apps else "the detected modules" - - summary_prompt = f"""{project_context} - -Generate a project summary with two parts. - -Part 1 — Overview: 2-3 paragraphs on the project's purpose, architecture, data flow. Include exactly ONE detailed mermaid flowchart based on the actual apps above (do NOT add a second simplified version): - -```mermaid -{mermaid_block} -``` - -Part 2 — App-by-App Breakdown: For EACH app/module detected in this project, write one paragraph explaining what that app does, its key files, and how it fits the architecture. Include ALL of: {app_list_str}. Do NOT skip any. - -Output ONLY the summary content.""" - summary_result = _call_ai_section(summary_prompt, 1, "SUMMARY", max_tokens=2048) - tree_block = "\n\n---\n\n## Project Structure\n\n```\n" + summary.get("project_tree", "") + "\n```\n" - if summary_result: - project.generated_docs = _sanitize_markdown(summary_result + tree_block) - else: - project.generated_docs = tree_block.strip() - - _time.sleep(3) - - readme_prompt = f"""{project_context} - -Write a README with: - -## Title & Description -What the project is and who it's for (3-4 sentences). - -## Key Features -5-7 bullet features from the app modules. - -## Quick Start -```bash -git clone {clone_url or ''} -cd {cd_dir} -{pm_cmd} -``` -Then run: `python manage.py migrate && python manage.py runserver` (or equivalent for the framework). - -## Architecture & Project Structure -Describe the monolithic layout, the apps under `backend/apps/`, and the data flow (2-3 paragraphs). - -Output ONLY the README content.""" - readme_result = _call_ai_section(readme_prompt, 0, "README", max_tokens=2048) - project.readme_docs = _sanitize_markdown(readme_result) if readme_result else "# " + project.name + "\n\nNo README generated." - else: - fallback = f"""# {project.name} - -## Overview -Mock project documentation for {project.name}. - -## Project Structure -{summary['project_tree']} - -## Dependencies -{' '.join(summary.get('dependencies', [])) or 'Not detected'} - -> ⚠️ Configure GROQ_API_KEY to generate AI-powered documentation.""" - project.readme_docs = _sanitize_markdown(fallback) - project.generated_docs = _sanitize_markdown(fallback) - - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - project.status = "done" - project.updated_at = datetime.utcnow() - db.commit() - - except Exception as e: - import traceback - logger = logging.getLogger("ai.generate_docs") - logger.error("Generation failed: %s\n%s", str(e), traceback.format_exc()) - project.status = "failed" - project.error_message = str(e) - db.commit() - return GenerateResponse( - project_id=str(project.id), - status="failed", - ) - - return GenerateResponse( - project_id=str(project.id), - status="done", - generated_docs=project.generated_docs, - readme_docs=project.readme_docs, - api_docs=project.api_docs, - ) - - -@app.get("/api/ai/status/{project_id}") -def ai_status(project_id: str, db: Session = Depends(get_db), _auth: str = Depends(verify_internal_key)): - project = db.query(Project).filter(Project.id == uuid.UUID(project_id)).first() - if not project: - raise HTTPException(404, "Project not found") - return { - "project_id": str(project.id), - "status": project.status, - "has_docs": bool(project.generated_docs), - } diff --git a/backend/services/ai/models.py b/backend/services/ai/models.py deleted file mode 100644 index 9471395..0000000 --- a/backend/services/ai/models.py +++ /dev/null @@ -1,62 +0,0 @@ -import uuid -from datetime import datetime -from sqlalchemy import Column, String, Text, DateTime, JSON, ForeignKey, Integer -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import relationship -from pgvector.sqlalchemy import Vector -from database import Base - - -class User(Base): - __tablename__ = "users" - __table_args__ = {"extend_existing": True} - - id = Column(UUID(as_uuid=True), primary_key=True) - email = Column(String(254), nullable=False) - - -class Project(Base): - __tablename__ = "projects" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name = Column(String(255), nullable=False) - description = Column(Text, default="") - status = Column(String(20), default="pending") - source_type = Column(String(20), default="file") - github_url = Column(String(500), nullable=True) - parsed_data = Column(JSON, nullable=True) - generated_docs = Column(Text, nullable=True) - readme_docs = Column(Text, nullable=True) - api_docs = Column(Text, nullable=True) - project_info = Column(JSON, nullable=True) - custom_details = Column(JSON, nullable=True) - framework_info = Column(JSON, nullable=True) - error_message = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - -class ProjectFile(Base): - __tablename__ = "project_files" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) - file_path = Column(String(500), nullable=False) - file_name = Column(String(255), nullable=False) - content = Column(Text, default="") - parsed_data = Column(JSON, nullable=True) - generated_docs = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow) - - -class CodeEmbedding(Base): - __tablename__ = "code_embeddings" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False, index=True) - file_path = Column(String(500), nullable=False) - chunk_type = Column(String(20), nullable=False) - chunk_text = Column(Text, nullable=False) - embedding = Column(Vector(384), nullable=False) - chunk_metadata = Column("metadata", JSON, default=dict) - created_at = Column(DateTime, default=datetime.utcnow) diff --git a/backend/services/ai/rag.py b/backend/services/ai/rag.py deleted file mode 100644 index 955bdbf..0000000 --- a/backend/services/ai/rag.py +++ /dev/null @@ -1,214 +0,0 @@ -import os -import logging -from typing import List, Optional -from sqlalchemy.orm import Session - -logger = logging.getLogger("ai.rag") - -_model = None - - -def get_embedding_model(): - global _model - if _model is None: - from sentence_transformers import SentenceTransformer - model_name = os.getenv("EMBEDDING_MODEL", "all-MiniLM-L6-v2") - logger.info("Loading embedding model: %s", model_name) - _model = SentenceTransformer(model_name) - return _model - - -def embed_text(text: str) -> list[float]: - model = get_embedding_model() - return model.encode(text).tolist() - - -def chunk_parsed_data(project_id: str, files: list) -> list[dict]: - chunks = [] - for f in files: - file_path = f.file_path - parsed = f.parsed_data or {} - - if parsed.get("module_docstring") or parsed.get("imports"): - lines = [f"File: {file_path}"] - if parsed.get("module_docstring"): - lines.append(f"Module: {parsed['module_docstring']}") - imports = parsed.get("imports", []) - if imports: - imp_strs = [] - for i in imports: - if isinstance(i, dict): - imp_strs.append(i.get("display", str(i))) - else: - imp_strs.append(str(i)) - lines.append(f"Imports: {' '.join(imp_strs)}") - chunks.append({ - "project_id": project_id, - "file_path": file_path, - "chunk_type": "module", - "chunk_text": "\n".join(lines), - "metadata": {"file_path": file_path, "type": "module"}, - }) - - for item in parsed.get("ordered_items", []): - typ = item["type"] - data = item["data"] - if typ == "import": - continue - if typ == "function": - chunk_text = _function_to_text(data, file_path) - elif typ == "class": - chunk_text = _class_to_text(data, file_path) - else: - continue - chunks.append({ - "project_id": project_id, - "file_path": file_path, - "chunk_type": typ, - "chunk_text": chunk_text, - "metadata": { - "file_path": file_path, - "type": typ, - "name": data.get("name", ""), - "line": data.get("line", 0), - }, - }) - return chunks - - -def _function_to_text(data: dict, file_path: str) -> str: - args = ", ".join(a["name"] for a in data.get("args", [])) - decorators = ", ".join(data.get("decorators", [])) - connections = ", ".join(data.get("connections", [])) - return ( - f"File: {file_path}\n" - f"Function: {data['name']}({args})\n" - f"Returns: {data.get('returns', 'None')}\n" - f"Async: {data.get('is_async', False)}\n" - f"Decorators: {decorators}\n" - f"Lines: {data.get('line', '?')}-{data.get('end_line', '?')}\n" - f"Calls: {connections}\n" - f"Docstring: {data.get('docstring', '')}" - ) - - -def _class_to_text(data: dict, file_path: str) -> str: - methods = [] - for m in data.get("methods", []): - margs = ", ".join(a["name"] for a in m.get("args", [])) - methods.append(f" {m['name']}({margs}) -> {m.get('returns', 'None')}") - bases = ", ".join(data.get("bases", [])) - connections = ", ".join(data.get("connections", [])) - return ( - f"File: {file_path}\n" - f"Class: {data['name']}({bases})\n" - f"Lines: {data.get('line', '?')}-{data.get('end_line', '?')}\n" - f"Methods:\n" + "\n".join(methods) + "\n" - f"Uses: {connections}\n" - f"Docstring: {data.get('docstring', '')}" - ) - - -def embed_and_store_chunks(project_id, files, db: Session): - from models import CodeEmbedding - - chunks = chunk_parsed_data(project_id, files) - if not chunks: - return - - db.query(CodeEmbedding).filter( - CodeEmbedding.project_id == project_id - ).delete() - db.flush() - - model = get_embedding_model() - texts = [c["chunk_text"] for c in chunks] - embeddings = model.encode(texts).tolist() - - for chunk, embedding in zip(chunks, embeddings): - ce = CodeEmbedding( - project_id=chunk["project_id"], - file_path=chunk["file_path"], - chunk_type=chunk["chunk_type"], - chunk_text=chunk["chunk_text"], - embedding=embedding, - metadata=chunk["metadata"], - ) - db.add(ce) - db.commit() - logger.info("Stored %d embeddings for project %s", len(chunks), project_id) - - -def _detect_pattern(results) -> tuple[bool, str]: - if len(results) < 3: - return False, "" - types = {} - for r in results: - t = r.chunk_type - if t not in types: - types[t] = [] - types[t].append(r) - for typ, items in types.items(): - if len(items) >= 3 and typ in ("function", "class"): - names = [r.chunk_metadata.get("name", "?") if r.chunk_metadata else "?" for r in items] - display = ", ".join(names[:5]) - if len(names) > 5: - display += "..." - return True, f"Pattern: {len(items)} similar {typ}s ({display}). Write concise differential docs focusing on what makes each unique." - return False, "" - - -def retrieve_context(project_id, query: str, top_k: int = 5, db: Session = None) -> tuple[str, bool]: - from models import CodeEmbedding - - query_emb = embed_text(query) - - results = ( - db.query(CodeEmbedding) - .filter(CodeEmbedding.project_id == project_id) - .order_by(CodeEmbedding.embedding.cosine_distance(query_emb)) - .limit(top_k) - .all() - ) - - if not results: - return "", False - - is_pattern, pattern_desc = _detect_pattern(results) - - context_parts = [] - for r in results: - meta = r.chunk_metadata or {} - name = meta.get("name", "unknown") - prev_doc = meta.get("prev_doc", "") - header = f"Related code in {r.file_path} ({r.chunk_type}: {name})" - chunk = f"{header}\n{r.chunk_text}" - if prev_doc: - chunk += f"\nPreviously generated doc:\n{prev_doc}" - context_parts.append(chunk) - - context_text = "\n\n".join(context_parts) - if is_pattern and pattern_desc: - context_text = pattern_desc + "\n\n" + context_text - - return context_text, is_pattern - - -def store_generated_doc(project_id, chunk_type: str, item_name: str, file_path: str, generated_text: str, db: Session): - from models import CodeEmbedding - embeddings = ( - db.query(CodeEmbedding) - .filter( - CodeEmbedding.project_id == project_id, - CodeEmbedding.file_path == file_path, - CodeEmbedding.chunk_type == chunk_type, - ) - .all() - ) - for emb in embeddings: - meta = emb.chunk_metadata or {} - if meta.get("name") == item_name: - meta["prev_doc"] = generated_text[:500] - emb.chunk_metadata = meta - db.commit() - return diff --git a/backend/services/ai/requirements.txt b/backend/services/ai/requirements.txt deleted file mode 100644 index 89c8332..0000000 --- a/backend/services/ai/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -fastapi>=0.115.0 -uvicorn[standard]>=0.34.0 -sqlalchemy>=2.0.36 -psycopg2-binary>=2.9.12 -groq>=1.2.0 -redis>=7.4.0 -httpx>=0.28.0 -pydantic>=2.10.0 -python-dotenv>=1.2.2 -sentence-transformers>=3.0.0 -pgvector>=0.3.0 diff --git a/backend/services/ai/test_main.py b/backend/services/ai/test_main.py deleted file mode 100644 index d60a7bb..0000000 --- a/backend/services/ai/test_main.py +++ /dev/null @@ -1,109 +0,0 @@ -from uuid import uuid4 -from unittest.mock import patch, MagicMock - -import pytest -from fastapi.testclient import TestClient - -from main import app - -client = TestClient(app) - - -@pytest.fixture(autouse=True) -def mock_env(monkeypatch): - monkeypatch.setenv("INTERNAL_API_KEY", "") - monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:") - - -@pytest.fixture -def mock_db(): - session = MagicMock() - - def gen(): - yield session - - with patch("main.get_db", return_value=gen()): - yield session - - -class TestHealth: - def test_health_endpoint(self): - resp = client.get("/health") - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "ok" - assert data["service"] == "ai" - - -class TestGenerateDocs: - def test_404_for_missing_project(self, mock_db): - mock_db.query.return_value.filter.return_value.first.return_value = None - resp = client.post( - "/api/ai/generate/", - json={"project_id": str(uuid4())}, - ) - assert resp.status_code == 404 - - @patch("main.GROQ_API_KEY", None) - def test_no_groq_key_uses_mock(self, mock_db): - mock_project = MagicMock() - mock_project.id = uuid4() - mock_project.source_type = "file" - mock_db.query.return_value.filter.return_value.first.return_value = mock_project - mock_db.query.return_value.filter.return_value.all.return_value = [ - MagicMock(file_name="test.py", parsed_data={ - "functions": [{"name": "foo", "args": []}], - "classes": [], "imports": [], "error": False, - }, content="def foo(): pass") - ] - - resp = client.post( - "/api/ai/generate/", - json={"project_id": str(mock_project.id)}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "done" - assert "Mock documentation" in (data.get("generated_docs") or "") - - -class TestAIStatus: - def test_returns_project_status(self, mock_db): - mock_project = MagicMock() - mock_project.id = uuid4() - mock_project.status = "done" - mock_project.generated_docs = "# Docs" - mock_db.query.return_value.filter.return_value.first.return_value = mock_project - - resp = client.get(f"/api/ai/status/{mock_project.id}") - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "done" - assert data["has_docs"] is True - - def test_404_for_unknown(self, mock_db): - mock_db.query.return_value.filter.return_value.first.return_value = None - resp = client.get("/api/ai/status/00000000-0000-0000-0000-000000000000") - assert resp.status_code == 404 - - -class TestSanitizeMarkdown: - @patch("main.GROQ_API_KEY", None) - def test_generated_docs_is_sanitized(self, mock_db): - mock_project = MagicMock() - mock_project.id = uuid4() - mock_project.source_type = "file" - mock_db.query.return_value.filter.return_value.first.return_value = mock_project - mock_db.query.return_value.filter.return_value.all.return_value = [ - MagicMock(file_name="test.py", parsed_data={ - "functions": [], "classes": [], "imports": [], "error": False, - }, content="# code") - ] - - resp = client.post( - "/api/ai/generate/", - json={"project_id": str(mock_project.id)}, - ) - assert resp.status_code == 200 - docs = resp.json().get("generated_docs", "") - assert "\n\n\n" not in docs diff --git a/backend/services/parser/Dockerfile b/backend/services/parser/Dockerfile deleted file mode 100644 index e741afc..0000000 --- a/backend/services/parser/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -EXPOSE 8002 - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002", "--workers", "2"] diff --git a/backend/services/parser/ast_parser.py b/backend/services/parser/ast_parser.py deleted file mode 100644 index e16b3de..0000000 --- a/backend/services/parser/ast_parser.py +++ /dev/null @@ -1,153 +0,0 @@ -import ast -from typing import Any - - -def parse_python_file(source_code: str) -> dict: - try: - tree = ast.parse(source_code) - except SyntaxError as e: - return { - "error": True, - "error_message": f"SyntaxError on line {e.lineno}: {e.msg}" - } - - result = { - "error": False, - "module_docstring": ast.get_docstring(tree), - "imports": [], - "functions": [], - "classes": [], - "ordered_items": [] - } - - top_level_names = set() - - for node in tree.body: - if isinstance(node, ast.FunctionDef): - top_level_names.add(node.name) - elif isinstance(node, ast.ClassDef): - top_level_names.add(node.name) - for item in node.body: - if isinstance(item, ast.FunctionDef): - top_level_names.add(item.name) - - for node in tree.body: - if isinstance(node, (ast.Import, ast.ImportFrom)): - import_entry = _extract_import(node) - result["imports"].append(import_entry) - result["ordered_items"].append({ - "type": "import", - "line": node.lineno, - "data": import_entry - }) - elif isinstance(node, ast.FunctionDef): - func_data = _extract_function(node, top_level_names) - result["functions"].append(func_data) - result["ordered_items"].append({ - "type": "function", - "line": node.lineno, - "data": func_data - }) - elif isinstance(node, ast.ClassDef): - class_data = _extract_class(node, top_level_names) - result["classes"].append(class_data) - result["ordered_items"].append({ - "type": "class", - "line": node.lineno, - "data": class_data - }) - - return result - - -def _extract_import(node) -> dict[str, Any]: - if isinstance(node, ast.Import): - names = [alias.name for alias in node.names] - return { - "line": node.lineno, - "type": "import", - "module": names[0] if len(names) == 1 else None, - "names": names, - "display": ', '.join(names) - } - else: - names = [alias.name for alias in node.names] - module = node.module or '' - return { - "line": node.lineno, - "type": "import-from", - "module": module, - "names": names, - "display": f"from {module} import {', '.join(names)}" - } - - -def _extract_function(node, available_names: set) -> dict[str, Any]: - connections = _find_connections(node, available_names) - return { - "name": node.name, - "args": extract_args(node), - "returns": extract_return_type(node), - "docstring": ast.get_docstring(node), - "decorators": [ast.unparse(d) for d in node.decorator_list], - "line": node.lineno, - "is_async": isinstance(node, ast.AsyncFunctionDef), - "connections": connections - } - - -def _extract_class(node, available_names: set) -> dict[str, Any]: - methods = [] - class_connections = _find_connections(node, available_names) - - for item in node.body: - if isinstance(item, ast.FunctionDef): - method_connections = _find_connections(item, available_names) - methods.append({ - "name": item.name, - "args": extract_args(item), - "returns": extract_return_type(item), - "docstring": ast.get_docstring(item), - "is_private": item.name.startswith("_"), - "line": item.lineno, - "connections": method_connections, - "parent_class": node.name - }) - - return { - "name": node.name, - "docstring": ast.get_docstring(node), - "bases": [ast.unparse(b) for b in node.bases], - "methods": methods, - "line": node.lineno, - "connections": class_connections - } - - -def _find_connections(node, available_names: set) -> list[str]: - connections = set() - for child in ast.walk(node): - if isinstance(child, ast.Call): - if isinstance(child.func, ast.Name): - if child.func.id in available_names: - connections.add(child.func.id) - elif isinstance(child, ast.Name): - if child.id in available_names: - connections.add(child.id) - return sorted(connections) - - -def extract_args(func_node) -> list: - args = [] - for arg in func_node.args.args: - args.append({ - "name": arg.arg, - "type": ast.unparse(arg.annotation) if arg.annotation else None - }) - return args - - -def extract_return_type(func_node) -> str | None: - if func_node.returns: - return ast.unparse(func_node.returns) - return None diff --git a/backend/services/parser/database.py b/backend/services/parser/database.py deleted file mode 100644 index b24f4bd..0000000 --- a/backend/services/parser/database.py +++ /dev/null @@ -1,28 +0,0 @@ -import os -from sqlalchemy import create_engine, text -from sqlalchemy.orm import sessionmaker, declarative_base - -DATABASE_URL = os.getenv( - "DATABASE_URL", - "postgresql://pydocai_user:pydocai_pass@localhost:5433/pydocai" -) - -engine = create_engine(DATABASE_URL, pool_pre_ping=True) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() - - -def create_tables(): - with engine.connect() as conn: - conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) - conn.commit() - from models import Project, ProjectFile - Base.metadata.create_all(bind=engine) - - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/backend/services/parser/framework_detector.py b/backend/services/parser/framework_detector.py deleted file mode 100644 index 0a0cbf9..0000000 --- a/backend/services/parser/framework_detector.py +++ /dev/null @@ -1,195 +0,0 @@ -from typing import Any - -TYPE_PRIORITY = { - "web": 10, - "api": 8, - "server": 6, - "pdf": 5, - "cli": 4, - "ai": 4, - "task_queue": 3, - "orm": 2, - "migration": 1, - "testing": 1, - "validation": 1, - "middleware": 1, - "http": 0, -} - -FRAMEWORK_PATTERNS = [ - { - "name": "Django", - "imports": ["django", "django.db", "django.urls", "django.shortcuts"], - "files": ["manage.py", "wsgi.py", "asgi.py", "settings.py"], - "dirs": ["migrations", "templates"], - "type": "web", - }, - { - "name": "FastAPI", - "imports": ["fastapi", "fastapi.routing"], - "config": ["FastAPI("], - "type": "web", - }, - { - "name": "Flask", - "imports": ["flask", "flask.blueprints"], - "config": ["Flask("], - "type": "web", - }, - { - "name": "Django REST Framework", - "imports": ["rest_framework"], - "type": "api", - }, - { - "name": "Celery", - "imports": ["celery", "celery.task"], - "files": ["celery.py"], - "type": "task_queue", - }, - { - "name": "SQLAlchemy", - "imports": ["sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext"], - "type": "orm", - }, - { - "name": "Alembic", - "imports": ["alembic", "alembic.config"], - "files": ["alembic.ini"], - "dirs": ["alembic"], - "type": "migration", - }, - { - "name": "Pydantic", - "imports": ["pydantic"], - "type": "validation", - }, - { - "name": "Pytest", - "imports": ["pytest"], - "files": ["pytest.ini", "conftest.py"], - "type": "testing", - }, - { - "name": "Groq", - "imports": ["groq"], - "type": "ai", - }, - { - "name": "PydanticAI", - "imports": ["pydantic_ai"], - "type": "ai", - }, - { - "name": "LangChain", - "imports": ["langchain", "langchain_core", "langchain_groq"], - "type": "ai", - }, - { - "name": "WeasyPrint", - "imports": ["weasyprint"], - "type": "pdf", - }, - { - "name": "Gunicorn", - "imports": ["gunicorn"], - "files": ["gunicorn.conf.py"], - "type": "server", - }, - { - "name": "httpx", - "imports": ["httpx"], - "type": "http", - }, - { - "name": "requests", - "imports": ["requests"], - "type": "http", - }, - { - "name": "Click", - "imports": ["click"], - "type": "cli", - }, - { - "name": "Typer", - "imports": ["typer"], - "type": "cli", - }, - { - "name": "CORS", - "imports": ["corsheaders"], - "type": "middleware", - }, -] - - -def detect_framework( - imports: list[str], file_paths: list[str], source_codes: list[str] -) -> dict[str, Any]: - detected = {} - file_set = set(file_paths) - all_source = "\n".join(source_codes) - - for fw in FRAMEWORK_PATTERNS: - score = 0 - reasons = [] - - for imp in fw.get("imports", []): - if any(imp in i for i in imports): - score += 2 - reasons.append(f"import:{imp}") - - for pat in fw.get("config", []): - if pat.lower() in all_source.lower(): - score += 3 - reasons.append(f"config:{pat}") - - for fn in fw.get("files", []): - if fn in file_set: - score += 3 - reasons.append(f"file:{fn}") - - for dn in fw.get("dirs", []): - if any(f"/{dn}/" in f or f.startswith(f"{dn}/") for f in file_paths): - score += 2 - reasons.append(f"dir:{dn}") - - if score > 0: - detected[fw["name"]] = { - "score": score, - "type": fw.get("type", "unknown"), - "reasons": reasons, - } - - primary = ( - max( - detected.items(), - key=lambda x: (x[1]["score"] * 2 + TYPE_PRIORITY.get(x[1]["type"], 0)), - ) - if detected - else None - ) - - return { - "frameworks": detected, - "primary_framework": primary[0] if primary else None, - "primary_type": primary[1]["type"] if primary else None, - "summary": _build_summary(detected), - } - - -def _build_summary(detected: dict) -> str: - if not detected: - return "No known frameworks detected" - parts = [] - weighted = sorted( - detected.items(), - key=lambda x: ( - x[1]["score"] * 2 + TYPE_PRIORITY.get(x[1]["type"], 0) - ), - reverse=True, - ) - for name, info in weighted: - parts.append(f"{name}:{info['type']}") - return ", ".join(parts) diff --git a/backend/services/parser/main.py b/backend/services/parser/main.py deleted file mode 100644 index fb77a9a..0000000 --- a/backend/services/parser/main.py +++ /dev/null @@ -1,197 +0,0 @@ -import io -import json -import os -import base64 -import zipfile -import uuid -from datetime import datetime - -from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends -from fastapi.security import APIKeyHeader -from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy.orm import Session - -from database import get_db, engine, Base, create_tables -from models import Project, ProjectFile -from ast_parser import parse_python_file -from validators import validate_python_code, should_exclude -from framework_detector import detect_framework - -create_tables() - -app = FastAPI(title="PyDocAI Parser Service", version="0.2.0") - -INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "") -api_key_header = APIKeyHeader(name="X-Internal-Api-Key", auto_error=False) - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -def verify_internal_key(key: str = Depends(api_key_header)): - if INTERNAL_API_KEY and key != INTERNAL_API_KEY: - raise HTTPException(401, "Invalid or missing internal API key") - return key - - -@app.get("/health") -def health(): - return {"status": "ok", "service": "parser"} - - -@app.post("/api/parser/file/") -async def analyze_file( - _auth: str = Depends(verify_internal_key), - file: UploadFile = File(...), - project_id: str = Form(...), - name: str = Form("Untitled Project"), - description: str = Form(""), - file_path: str = Form(None), - db: Session = Depends(get_db), -): - if not file.filename.endswith(".py"): - raise HTTPException(400, "Only .py files are allowed") - - try: - source_code = (await file.read()).decode("utf-8") - except UnicodeDecodeError: - raise HTTPException(400, "File must be UTF-8 encoded") - - is_valid, err = validate_python_code(source_code) - if not is_valid: - raise HTTPException(400, err) - - parsed = parse_python_file(source_code) - - project = db.query(Project).filter(Project.id == uuid.UUID(project_id)).first() - if not project: - raise HTTPException(404, "Project not found") - - imports = [imp.get("display", str(imp)) if isinstance(imp, dict) else str(imp) for imp in parsed.get("imports", [])] - fw_info = detect_framework(imports, [file_path or file.filename], [source_code]) - project.framework_info = fw_info - - project.parsed_data = parsed - project.status = "processing" - project.updated_at = datetime.utcnow() - db.commit() - - final_path = file_path or file.filename - db.add(ProjectFile( - project_id=project.id, - file_path=final_path, - file_name=file.filename.split("/")[-1], - file_size=len(source_code), - content=source_code, - parsed_data=parsed, - generated_docs="", - )) - db.commit() - - return { - "project_id": project_id, - "parsed": parsed, - "file_count": 1, - "framework": fw_info, - } - - -@app.post("/api/parser/folder/") -async def analyze_folder( - folder: UploadFile = File(...), - _auth: str = Depends(verify_internal_key), - project_id: str = Form(...), - name: str = Form("Untitled Project"), - description: str = Form(""), - custom_info: str = Form(None), - db: Session = Depends(get_db), -): - if not folder.filename.endswith(".zip"): - raise HTTPException(400, "File must be a .zip") - - try: - zip_content = await folder.read() - zf = zipfile.ZipFile(io.BytesIO(zip_content)) - except zipfile.BadZipFile: - raise HTTPException(400, "Invalid zip file") - - py_files = [ - f for f in zf.namelist() - if f.endswith(".py") and not should_exclude(f) - ] - - if not py_files: - raise HTTPException(400, "No Python files found after filtering") - - project = db.query(Project).filter(Project.id == uuid.UUID(project_id)).first() - if not project: - raise HTTPException(404, "Project not found") - - if custom_info: - try: - project.custom_details = json.loads(custom_info) - except json.JSONDecodeError: - project.custom_details = {"details": custom_info} - - all_imports: list[str] = [] - all_paths: list[str] = [] - all_sources: list[str] = [] - - results = [] - for file_path in py_files: - try: - content = zf.read(file_path).decode("utf-8", errors="ignore") - is_valid, _ = validate_python_code(content) - if not is_valid: - continue - - parsed = parse_python_file(content) - db.add(ProjectFile( - project_id=project.id, - file_path=file_path, - file_name=file_path.split("/")[-1], - file_size=len(content), - content=content, - parsed_data=parsed, - generated_docs="", - )) - - imports = [imp.get("display", str(imp)) if isinstance(imp, dict) else str(imp) for imp in parsed.get("imports", [])] - all_imports.extend(imports) - all_paths.append(file_path) - all_sources.append(content) - results.append({"file_path": file_path, "parsed": parsed}) - except Exception: - continue - - fw_info = detect_framework(all_imports, all_paths, all_sources) - project.framework_info = fw_info - project.parsed_data = results - project.status = "processing" - project.updated_at = datetime.utcnow() - db.commit() - - return { - "project_id": project_id, - "files_parsed": len(results), - "framework": fw_info, - } - - -@app.get("/api/parser/status/{project_id}") -def parser_status(project_id: str, db: Session = Depends(get_db), _auth: str = Depends(verify_internal_key)): - project = db.query(Project).filter(Project.id == uuid.UUID(project_id)).first() - if not project: - raise HTTPException(404, "Project not found") - return { - "project_id": str(project.id), - "status": project.status, - "files_count": db.query(ProjectFile).filter( - ProjectFile.project_id == project.id - ).count(), - } diff --git a/backend/services/parser/models.py b/backend/services/parser/models.py deleted file mode 100644 index d56f794..0000000 --- a/backend/services/parser/models.py +++ /dev/null @@ -1,57 +0,0 @@ -import uuid -from datetime import datetime -from sqlalchemy import Column, String, Text, DateTime, JSON, ForeignKey, Boolean, Integer -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import relationship -from database import Base - - -class User(Base): - __tablename__ = "users" - __table_args__ = {"extend_existing": True} - - id = Column(UUID(as_uuid=True), primary_key=True) - email = Column(String(254), nullable=False) - - -class Project(Base): - __tablename__ = "projects" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) - name = Column(String(255), nullable=False) - description = Column(Text, default="") - status = Column(String(20), default="pending") - source_type = Column(String(20), default="file") - file_name = Column(String(255), default="", server_default="") - file_size = Column(Integer, nullable=True) - github_url = Column(String(500), nullable=True) - github_branch = Column(String(100), default="main") - parsed_data = Column(JSON, nullable=True) - generated_docs = Column(Text, nullable=True) - readme_docs = Column(Text, nullable=True) - api_docs = Column(Text, nullable=True) - project_info = Column(JSON, nullable=True) - custom_details = Column(JSON, nullable=True) - framework_info = Column(JSON, nullable=True) - error_message = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - files = relationship("ProjectFile", back_populates="project") - - -class ProjectFile(Base): - __tablename__ = "project_files" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False) - file_path = Column(String(500), nullable=False) - file_name = Column(String(255), nullable=False) - file_size = Column(Integer, nullable=True) - content = Column(Text, default="") - parsed_data = Column(JSON, nullable=True) - generated_docs = Column(Text, nullable=True) - created_at = Column(DateTime, default=datetime.utcnow) - - project = relationship("Project", back_populates="files") diff --git a/backend/services/parser/requirements.txt b/backend/services/parser/requirements.txt deleted file mode 100644 index 5476fe9..0000000 --- a/backend/services/parser/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -fastapi>=0.115.0 -uvicorn[standard]>=0.34.0 -python-multipart>=0.0.18 -sqlalchemy>=2.0.36 -psycopg2-binary>=2.9.12 -redis>=7.4.0 -httpx>=0.28.0 -pydantic>=2.10.0 -python-dotenv>=1.2.2 diff --git a/backend/services/parser/test_main.py b/backend/services/parser/test_main.py deleted file mode 100644 index 97adcaf..0000000 --- a/backend/services/parser/test_main.py +++ /dev/null @@ -1,133 +0,0 @@ -import io -import zipfile -from uuid import uuid4 -from unittest.mock import patch, MagicMock - -import pytest -from fastapi.testclient import TestClient - -from main import app - -client = TestClient(app) - - -@pytest.fixture(autouse=True) -def mock_env(monkeypatch): - monkeypatch.setenv("INTERNAL_API_KEY", "") - monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:") - - -@pytest.fixture -def mock_db(): - session = MagicMock() - gen = mock_get_db(session) - with patch("main.get_db", return_value=gen): - yield session - - -def mock_get_db(session): - yield session - - -class TestHealth: - def test_health_endpoint(self): - resp = client.get("/health") - assert resp.status_code == 200 - assert resp.json()["status"] == "ok" - - -class TestAnalyzeFile: - def test_rejects_non_py_files(self): - resp = client.post( - "/api/parser/file/", - data={"project_id": str(uuid4()), "name": "Test"}, - files={"file": ("test.txt", b"not python")}, - ) - assert resp.status_code == 400 - - def test_rejects_invalid_python(self): - resp = client.post( - "/api/parser/file/", - data={"project_id": str(uuid4()), "name": "Test"}, - files={"file": ("bad.py", b"def foo() pass")}, - ) - assert resp.status_code == 400 - - @patch("main.validate_python_code") - @patch("main.parse_python_file") - @patch("main.detect_framework") - def test_parses_valid_file(self, mock_detect, mock_parse, mock_validate, mock_db): - mock_validate.return_value = (True, None) - mock_parse.return_value = { - "functions": [{"name": "foo", "args": []}], - "classes": [], "imports": [], "error": False, - } - mock_detect.return_value = {"primary_framework": "python"} - mock_project = MagicMock() - mock_project.id = uuid4() - mock_db.query.return_value.filter.return_value.first.return_value = mock_project - - pid = str(uuid4()) - resp = client.post( - "/api/parser/file/", - data={"project_id": pid, "name": "Test"}, - files={"file": ("test.py", b"def foo(): pass")}, - ) - assert resp.status_code == 200 - assert resp.json()["project_id"] == pid - assert resp.json()["file_count"] == 1 - - -class TestAnalyzeFolder: - def test_rejects_non_zip(self): - resp = client.post( - "/api/parser/folder/", - data={"project_id": str(uuid4()), "name": "Test"}, - files={"folder": ("test.txt", b"content")}, - ) - assert resp.status_code == 400 - - @patch("main.should_exclude") - @patch("main.validate_python_code") - @patch("main.parse_python_file") - def test_parses_zip(self, mock_parse, mock_validate, mock_exclude, mock_db): - mock_exclude.return_value = False - mock_validate.return_value = (True, None) - mock_parse.return_value = { - "functions": [], "classes": [], "imports": [], "error": False, - } - mock_project = MagicMock() - mock_project.id = uuid4() - mock_db.query.return_value.filter.return_value.first.return_value = mock_project - - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "w") as zf: - zf.writestr("app/main.py", "def bar(): pass\n") - zip_bytes = zip_buffer.getvalue() - - resp = client.post( - "/api/parser/folder/", - data={"project_id": str(uuid4()), "name": "Test"}, - files={"folder": ("proj.zip", zip_bytes, "application/zip")}, - ) - assert resp.status_code == 200 - assert resp.json()["files_parsed"] == 1 - - -class TestParserStatus: - def test_returns_status(self, mock_db): - mock_project = MagicMock() - mock_project.id = uuid4() - mock_project.status = "done" - mock_db.query.return_value.filter.return_value.first.return_value = mock_project - mock_db.query.return_value.filter.return_value.count.return_value = 3 - - resp = client.get(f"/api/parser/status/{mock_project.id}") - assert resp.status_code == 200 - assert resp.json()["status"] == "done" - assert resp.json()["files_count"] == 3 - - def test_404_for_unknown(self, mock_db): - mock_db.query.return_value.filter.return_value.first.return_value = None - resp = client.get("/api/parser/status/00000000-0000-0000-0000-000000000000") - assert resp.status_code == 404 diff --git a/backend/services/parser/validators.py b/backend/services/parser/validators.py deleted file mode 100644 index b3db568..0000000 --- a/backend/services/parser/validators.py +++ /dev/null @@ -1,36 +0,0 @@ -import ast - - -def validate_python_code(source_code: str) -> tuple: - if not source_code or not source_code.strip(): - return False, "Code cannot be empty" - - try: - ast.parse(source_code) - return True, None - except SyntaxError as e: - return False, f"SyntaxError on line {e.lineno}: {e.msg}" - - -EXCLUDED_DIRS = { - 'venv', '.venv', 'env', '.env', - 'virtualenv', '.virtualenv', - 'node_modules', 'site-packages', - '__pycache__', '.pytest_cache', - 'build', 'dist', '*.egg-info', - '.git', '.github', '.gitignore', - '.vscode', '.idea', - 'migrations', - 'media', - 'staticfiles', -} - - -def should_exclude(file_path: str) -> bool: - parts = file_path.replace('\\', '/').split('/') - for part in parts: - if part in EXCLUDED_DIRS: - return True - if part.endswith('.egg-info') or part.endswith('.dist-info'): - return True - return False diff --git a/backend/templates/emails/feedback_confirmation.html b/backend/templates/emails/feedback_confirmation.html deleted file mode 100644 index 5ba5ec1..0000000 --- a/backend/templates/emails/feedback_confirmation.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - -
-

Feedback Received

-

Hi {{ user.name }},

-

Thank you for sharing your feedback with us! Here's a copy of what you submitted:

-

Category

-

{{ feedback.get_category_display }}

-

Message

-

{{ feedback.message }}

-

Our team will review your feedback and get back to you if needed. You can track responses in your profile.

-

- View My Feedback -

- -
- - diff --git a/backend/templates/emails/feedback_confirmation.txt b/backend/templates/emails/feedback_confirmation.txt deleted file mode 100644 index de520aa..0000000 --- a/backend/templates/emails/feedback_confirmation.txt +++ /dev/null @@ -1,13 +0,0 @@ -Hi {{ user.name }}, - -Thank you for sharing your feedback with us! Here's a copy of what you submitted: - -Category: {{ feedback.get_category_display }} -Message: {{ feedback.message }} - -Our team will review your feedback and get back to you if needed. You can track responses in your profile. - -View your feedback: {{ frontend_url }}/my-feedback - -Thanks, -The PyDocAI Team diff --git a/backend/templates/emails/feedback_reply.html b/backend/templates/emails/feedback_reply.html deleted file mode 100644 index 4beecc6..0000000 --- a/backend/templates/emails/feedback_reply.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - -
-

New Reply on Your Feedback

-

Hi {{ user.name }},

-

You have received a reply from our team regarding your feedback:

-

Your Original Message

-

{{ feedback.message }}

-

Reply from {{ reply.user.name }}

-
{{ reply.message }}
-

- View Conversation -

- -
- - diff --git a/backend/templates/emails/feedback_reply.txt b/backend/templates/emails/feedback_reply.txt deleted file mode 100644 index 6835c9a..0000000 --- a/backend/templates/emails/feedback_reply.txt +++ /dev/null @@ -1,14 +0,0 @@ -Hi {{ user.name }}, - -You have received a reply from our team regarding your feedback: - -Your Original Message: -{{ feedback.message }} - -Reply from {{ reply.user.name }}: -{{ reply.message }} - -View the full conversation: {{ frontend_url }}/my-feedback - -Thanks, -The PyDocAI Team diff --git a/backend/templates/emails/password_reset.html b/backend/templates/emails/password_reset.html deleted file mode 100644 index 0a861e7..0000000 --- a/backend/templates/emails/password_reset.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - -
-

Reset Your Password

-

Hi {{ user.name }},

-

You requested a password reset. Click the button below to set a new password. This link expires in {{ timeout_hours }} hour(s).

-

- Reset My Password -

-
If you didn't request this, ignore this email. Your password won't change.
- -
- - \ No newline at end of file diff --git a/backend/templates/emails/password_reset.txt b/backend/templates/emails/password_reset.txt deleted file mode 100644 index 38c2c71..0000000 --- a/backend/templates/emails/password_reset.txt +++ /dev/null @@ -1,12 +0,0 @@ -Hi {{ user.name }}, - -You requested a password reset for your PyDocAI account. - -Click the link below to reset your password (expires in {{ timeout_hours }} hour(s)): - -{{ reset_url }} - -If you did not request this, you can safely ignore this email. - -Thanks, -The PyDocAI Team \ No newline at end of file diff --git a/backend/templates/emails/welcome.html b/backend/templates/emails/welcome.html deleted file mode 100644 index d3491b6..0000000 --- a/backend/templates/emails/welcome.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - -
-

Welcome to PyDocAI

-

Hi {{ user.name }},

-

Your account has been created successfully. You can now log in and start generating beautiful documentation for your Python projects automatically.

-

- Get Started -

- -
- - \ No newline at end of file diff --git a/backend/templates/emails/welcome.txt b/backend/templates/emails/welcome.txt deleted file mode 100644 index 203ae12..0000000 --- a/backend/templates/emails/welcome.txt +++ /dev/null @@ -1,10 +0,0 @@ -Hi {{ user.name }}, - -Welcome to PyDocAI! Your account has been created successfully. - -You can now log in and start generating documentation for your Python projects. - -Get Started: {{ frontend_url }}/dashboard - -Thanks, -The PyDocAI Team \ No newline at end of file diff --git a/backend/uv.lock b/backend/uv.lock deleted file mode 100644 index 1b29cdd..0000000 --- a/backend/uv.lock +++ /dev/null @@ -1,2314 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", -] - -[[package]] -name = "amqp" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anthropic" -version = "0.100.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "docstring-parser" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9c/2d/24caf0ff727cba2ed863925017c8f93463a2ea6224a0efe5626e672bc3d2/anthropic-0.100.0.tar.gz", hash = "sha256:650dee9e023afb16395939ee4104bbc21f966b380210119fb91122c12099c79a", size = 758255, upload-time = "2026-05-06T15:07:13.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/c775c59ab9445ecabb57ef3d5c24027de060139189a9e312ef9ef889a665/anthropic-0.100.0-py3-none-any.whl", hash = "sha256:1c15769efa15d8fd5c1ebf900e25c57e3ee540f8554a29aa56e4edefffe2951d", size = 753596, upload-time = "2026-05-06T15:07:12.106Z" }, -] - -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "asgiref" -version = "3.11.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, -] - -[[package]] -name = "backend" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "anthropic" }, - { name = "boto3" }, - { name = "celery" }, - { name = "coverage" }, - { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "django-anymail" }, - { name = "django-cors-headers" }, - { name = "django-environ" }, - { name = "django-filter" }, - { name = "django-storages" }, - { name = "djangorestframework" }, - { name = "djangorestframework-simplejwt" }, - { name = "gevent" }, - { name = "google-genai" }, - { name = "groq" }, - { name = "gunicorn" }, - { name = "markdown" }, - { name = "psycopg2-binary" }, - { name = "pygithub" }, - { name = "pytest" }, - { name = "pytest-django" }, - { name = "python-decouple" }, - { name = "python-dotenv" }, - { name = "redis" }, - { name = "requests" }, - { name = "ruff" }, - { name = "tomli" }, - { name = "weasyprint" }, - { name = "whitenoise" }, -] - -[package.optional-dependencies] -dev = [ - { name = "coverage" }, - { name = "pytest" }, - { name = "pytest-django" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anthropic", specifier = ">=0.45.0" }, - { name = "boto3", specifier = ">=1.34.0" }, - { name = "celery", specifier = ">=5.6.3" }, - { name = "coverage", specifier = ">=7.6" }, - { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, - { name = "django", specifier = ">=5.2.13" }, - { name = "django-anymail", specifier = ">=15.0" }, - { name = "django-cors-headers", specifier = ">=4.9.0" }, - { name = "django-environ", specifier = ">=0.13.0" }, - { name = "django-filter", specifier = ">=25.2" }, - { name = "django-storages", specifier = ">=1.14.0" }, - { name = "djangorestframework", specifier = ">=3.17.1" }, - { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, - { name = "gevent", specifier = ">=24.10.1" }, - { name = "google-genai", specifier = ">=1.74.0" }, - { name = "groq", specifier = ">=1.2.0" }, - { name = "gunicorn", specifier = ">=23.0.0" }, - { name = "markdown", specifier = ">=3.10.2" }, - { name = "psycopg2-binary", specifier = ">=2.9.12" }, - { name = "pygithub", specifier = ">=2.9.1" }, - { name = "pytest", specifier = ">=8.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-django", specifier = ">=4.9" }, - { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" }, - { name = "python-decouple", specifier = ">=3.8" }, - { name = "python-dotenv", specifier = ">=1.2.2" }, - { name = "redis", specifier = ">=7.4.0" }, - { name = "requests", specifier = ">=2.31.0" }, - { name = "ruff", specifier = ">=0.8" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, - { name = "tomli", specifier = ">=2.0.1" }, - { name = "weasyprint", specifier = ">=68.1" }, - { name = "whitenoise", specifier = ">=6.7.0" }, -] -provides-extras = ["dev"] - -[[package]] -name = "billiard" -version = "4.2.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, -] - -[[package]] -name = "boto3" -version = "1.43.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/37/2ae45d06423182b4561c03bc33494fafa21a0d1e847f0554f590e3cbbc62/boto3-1.43.18.tar.gz", hash = "sha256:33138883e984eb1937d1553da699182c8ad2099138091e885b65c9accbccea16", size = 113154, upload-time = "2026-05-29T19:33:30.046Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/75/fcb2c10d516496536c50e397248de42a673d8ea8137caf5b578a72b11293/boto3-1.43.18-py3-none-any.whl", hash = "sha256:7b62ce5c0a51428d692aa4f2adc9dc2a4a4c2989bf65a0a12834eeffa99b0b84", size = 140538, upload-time = "2026-05-29T19:33:27.131Z" }, -] - -[[package]] -name = "botocore" -version = "1.43.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/6d/436d69ec484ffc43635b38d7fb7d717d38824671f10e12e77924019ca929/botocore-1.43.18.tar.gz", hash = "sha256:dc8c105351b49688c667065cd5a45fc5b9db982657cefc9e3fbfb9417a55c7df", size = 15424886, upload-time = "2026-05-29T19:33:16.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/5a/35c92c0af1514581031fe66c398b622176b3c928a6d5cf8133c7207e3bd7/botocore-1.43.18-py3-none-any.whl", hash = "sha256:e2610fce16df9f89deab5f3c163430a814e6804034eb95bef8957c8db60b7dbc", size = 15106258, upload-time = "2026-05-29T19:33:11.18Z" }, -] - -[[package]] -name = "brotli" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" }, - { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" }, - { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" }, - { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" }, - { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" }, - { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" }, - { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461, upload-time = "2025-11-05T18:38:10.729Z" }, - { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035, upload-time = "2025-11-05T18:38:11.827Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, - { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, - { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, - { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, - { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, - { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, - { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, - { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, - { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, - { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, - { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, - { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, - { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, - { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, - { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, - { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, - { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, - { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, - { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, - { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, - { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, - { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, - { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, - { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, - { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, - { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, - { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, -] - -[[package]] -name = "brotlicffi" -version = "1.2.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, - { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, - { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, - { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, - { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, -] - -[[package]] -name = "celery" -version = "5.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "billiard" }, - { name = "click" }, - { name = "click-didyoumean" }, - { name = "click-plugins" }, - { name = "click-repl" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "kombu" }, - { name = "python-dateutil" }, - { name = "tzlocal" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, -] - -[[package]] -name = "certifi" -version = "2026.4.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - -[[package]] -name = "click" -version = "8.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, -] - -[[package]] -name = "click-didyoumean" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, -] - -[[package]] -name = "click-plugins" -version = "1.1.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, -] - -[[package]] -name = "click-repl" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "prompt-toolkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, -] - -[[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 = "coverage" -version = "7.14.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, - { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, - { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, - { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, - { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, - { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, - { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, - { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, - { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, - { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, - { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, - { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, - { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, - { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, - { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, - { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, - { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, - { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, - { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, - { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, - { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, - { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, - { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, - { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, - { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, - { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, - { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, - { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, - { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, - { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, - { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, - { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, - { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, - { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, - { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, - { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, - { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, - { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, - { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, - { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, - { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, - { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, - { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, - { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, - { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, - { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, - { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, - { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, - { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, - { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, - { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, - { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, - { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, - { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, - { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, -] - -[[package]] -name = "cryptography" -version = "47.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, - { url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, - { url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, - { url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, - { url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, -] - -[[package]] -name = "cssselect2" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tinycss2" }, - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, -] - -[[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.13" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", -] -dependencies = [ - { name = "asgiref", marker = "python_full_version < '3.12'" }, - { name = "sqlparse", marker = "python_full_version < '3.12'" }, - { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" }, -] - -[[package]] -name = "django" -version = "6.0.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", -] -dependencies = [ - { name = "asgiref", marker = "python_full_version >= '3.12'" }, - { name = "sqlparse", marker = "python_full_version >= '3.12'" }, - { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/b9/4155091ad1788b38563bd77a7258c0834e8c12a7f56f6975deaf54f8b61d/django-6.0.4.tar.gz", hash = "sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac", size = 10907407, upload-time = "2026-04-07T13:55:44.961Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" }, -] - -[[package]] -name = "django-anymail" -version = "15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "idna" }, - { name = "requests" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/43/f0aadb31f2c58afcd9f001f4291998cbd6d289898167e79d908506fc6faf/django_anymail-15.0.tar.gz", hash = "sha256:23d8ab6589afe8cc1ae7665c26879814ad192f4c3ed837a2a1868b0a056869e0", size = 106985, upload-time = "2026-04-18T20:44:19.237Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/d1/daae99ec3b30886010a499975880ec20c32c622bee6b92c226b715e42f0c/django_anymail-15.0-py3-none-any.whl", hash = "sha256:64d33dd1084bfc8e4e12245f56629be40aa0b0498fc7fc7544d87b9b2048be1e", size = 147229, upload-time = "2026-04-18T20:44:17.323Z" }, -] - -[[package]] -name = "django-cors-headers" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asgiref" }, - { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, -] - -[[package]] -name = "django-environ" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/3c/60983e6ec9b24a8d8588eecebfd21123cba980bce0a905807a27692f0860/django_environ-0.13.0.tar.gz", hash = "sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f", size = 63529, upload-time = "2026-02-18T01:08:08.791Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/00/3767393ece946084e1c6830a33ffb8e39d68642e27ad5ac7d4c8bd5de866/django_environ-0.13.0-py3-none-any.whl", hash = "sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e", size = 20682, upload-time = "2026-02-18T01:08:07.359Z" }, -] - -[[package]] -name = "django-filter" -version = "25.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2c/e4/465d2699cd388c0005fb8d6ae6709f239917c6d8790ac35719676fffdcf3/django_filter-25.2.tar.gz", hash = "sha256:760e984a931f4468d096f5541787efb8998c61217b73006163bf2f9523fe8f23", size = 143818, upload-time = "2025-10-05T09:51:31.521Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/40/6a02495c5658beb1f31eb09952d8aa12ef3c2a66342331ce3a35f7132439/django_filter-25.2-py3-none-any.whl", hash = "sha256:9c0f8609057309bba611062fe1b720b4a873652541192d232dd28970383633e3", size = 94145, upload-time = "2025-10-05T09:51:29.728Z" }, -] - -[[package]] -name = "django-storages" -version = "1.14.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" }, -] - -[[package]] -name = "djangorestframework" -version = "3.17.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" }, -] - -[[package]] -name = "djangorestframework-simplejwt" -version = "5.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "django", version = "6.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "djangorestframework" }, - { name = "pyjwt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, -] - -[[package]] -name = "docstring-parser" -version = "0.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "fonttools" -version = "4.62.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, - { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, - { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, - { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, - { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, - { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, - { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, - { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, - { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, - { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, - { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, - { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, - { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, - { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, - { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, - { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, - { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, - { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, - { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, - { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, - { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, -] - -[package.optional-dependencies] -woff = [ - { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, - { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, - { name = "zopfli" }, -] - -[[package]] -name = "gevent" -version = "26.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, - { name = "zope-event" }, - { name = "zope-interface" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/cb/98aa3a299e2fc4a2372b5d124863e02965b64579ffc29fe54d0641e65b2f/gevent-26.5.0.tar.gz", hash = "sha256:1655eb04c1e20d71b2aa4a3c7528162dd58ff6cc46a037af1f01f534c80fefba", size = 6712354, upload-time = "2026-05-20T21:22:45.132Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/b7/01a5880e01702f39fb09e3616c624054a0dc9a82561a865f3b1eff4bfc80/gevent-26.5.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:2ba673dcbf7747513b58fa64ca7e9d6a828bc5c604d1552d23db89006d7911df", size = 2181491, upload-time = "2026-05-20T20:35:19.326Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fe/035ec5fa58a886740a744380118f03a90ac2da3f6c9cba248f28074ce40a/gevent-26.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:271b1474d81bb33036631adb16a35e5a1ee9dc414b05c999d6b01dc839a89975", size = 2212161, upload-time = "2026-05-20T20:43:25.678Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ea/ea87c08931c9e4c6c40bb05a2cb19c2d6f93fe6e0052f9152ea5ade6d037/gevent-26.5.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cd3dc60581687e2618286108f8e2f820d8446be4b34131065011c066e911d39c", size = 1768295, upload-time = "2026-05-20T21:17:29.438Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1d0e7287ae55700a8d25153ac736896bd9bcc3f85a12d374ef398db4b33c/gevent-26.5.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:dc7fa28b2d627f8e87595f39043b6dec71e8e7fb97e685e5506c47cf3ff8cb2e", size = 1862627, upload-time = "2026-05-20T21:15:59.365Z" }, - { url = "https://files.pythonhosted.org/packages/3a/4c/7f5ed67e52dfdef4ff91ae1a6fb28186d52e2496962edc8f17bdea9ab2c0/gevent-26.5.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:68c5fc21cef80268cdff88a4ae6c025fabb019b071f6f8ee4d20a7bccbddb873", size = 1804690, upload-time = "2026-05-20T21:30:51.713Z" }, - { url = "https://files.pythonhosted.org/packages/4c/75/0f5da6ca045f8a052203e1810058029f4b682507a789b413cac7d28bae28/gevent-26.5.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d325502eb0695708ef8c899f605573ed6847f3961f8159627dba267fbf3ce457", size = 2119054, upload-time = "2026-05-20T20:35:22.678Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f1/fcff7f7fad2bb33f3742db6b2145825a2191c0cd31d75789b0741fd28faf/gevent-26.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a11daf3a588b932c8bf965fb18444c69aff48badec88435e988cf8d67137075a", size = 1778784, upload-time = "2026-05-20T21:16:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/98/57/151314f00bdc6ba77333febb3e9dc97fdf94d79426559b4fa8332f0c2b6e/gevent-26.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1101b5ef82a3fb178550cfd80f32293dc8dd2f3d0828292223ebba29d6f76e33", size = 2145373, upload-time = "2026-05-20T20:43:27.255Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b5/7a02f711db62cbed1c1a00e1f9ff50eef95ccc78d4c04a0f93636655d1b7/gevent-26.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:5233109ad4f3af16393ba9888f238919a05ce15ce68d6831ac8a0da8dfb750ae", size = 1696576, upload-time = "2026-05-20T20:15:49.62Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/5022adc310697ef25c6fb22eb9bf0ebcad3427b51776e882709de9a8b6d7/gevent-26.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:3be804565168ffacebeb21af9f1cd689831a89f0f12fc0c3f423c730c3c9eb31", size = 1552095, upload-time = "2026-05-20T20:16:54.81Z" }, - { url = "https://files.pythonhosted.org/packages/37/0b/1a530b2db55c97cc0cf44116201f538f3033c04c1d2aca143979b412f4be/gevent-26.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:e80ad2a8a1e8bdaa5605e3bf4929e0cebf9ea7b8237c83362f7257698bb14280", size = 2929714, upload-time = "2026-05-20T20:13:24.656Z" }, - { url = "https://files.pythonhosted.org/packages/b9/df/32fe851ed5f68493f354e09b19bdebae0de1185be4db0b2988e71e737fd3/gevent-26.5.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fe42c037253580a3386fce275f8a2a845e540f5a729916934a732f13d42e72cc", size = 1784838, upload-time = "2026-05-20T21:17:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9a/21332674f9a10e8cdf13b41b52e9d663647a1c6e1dc3c62b07c0aeefd360/gevent-26.5.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:9f463c7d6f69d13b6fe8e3b832a6175a6e95328a940f38495d25496d1ae8ad88", size = 1880440, upload-time = "2026-05-20T21:16:00.881Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b1/5f8a4196113cf7f3fdd987b483f7e6b10c28ea3930c4727e31ba8cce51b6/gevent-26.5.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:96d5e96b1b14a4c1023dcfcc114533217f13febc3b6169254f23fc18d19fee29", size = 1831592, upload-time = "2026-05-20T21:30:53.832Z" }, - { url = "https://files.pythonhosted.org/packages/4e/69/1559b1f6b5107a9118fccd300240879bd581b6d87b03d568d0d155ea702c/gevent-26.5.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bccff69c462e3650a0fd1d4e9cfc8b6effe15f3e9b1cad20a7bb5ce14b057efd", size = 2114915, upload-time = "2026-05-20T20:35:25.041Z" }, - { url = "https://files.pythonhosted.org/packages/e4/32/602c499d54472f64e5cdf6013aeab5ce6aa6fed005387e8b4f2d22f5dc8d/gevent-26.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f519139354d5ca7625df9ddb1b2ffada885c14abc5b4dbae3682e967ddf79669", size = 1796906, upload-time = "2026-05-20T21:16:39.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3c/2fe77ee6e3d381b3c50c0b7d6c4c08c08b8ff5e8c0d9dd51a3b426d61eec/gevent-26.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0bf57df54f1c66273bf3601c2a1e41b12138fe848933718369663bc54f177ca2", size = 2140806, upload-time = "2026-05-20T20:43:28.895Z" }, - { url = "https://files.pythonhosted.org/packages/22/d5/4620797bbd9c88f4541188efc138b0d615f9834db540da36a2249ee929c5/gevent-26.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e49ce0de007dfd7412edbc2b5d41cce33b049bb1b7086f50be5a09e601bde603", size = 1699995, upload-time = "2026-05-20T20:15:39.311Z" }, - { url = "https://files.pythonhosted.org/packages/cb/83/ac3477dfc0f9fd80c88110102c73cefc35dcded2b248544f45a8fa5412df/gevent-26.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:5c5ff29495a2eed2a244de8150f21893d6c1b15d8b4b5719ab4bbfa06db1e28f", size = 1547433, upload-time = "2026-05-20T20:15:51.656Z" }, - { url = "https://files.pythonhosted.org/packages/7d/47/5b992ab9c8037633cfd0fe698a97a878f59d8eb53c381e91e9a1a76fd215/gevent-26.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:9b4d3f34c913d1a6bec6d030365a517f3b527a9773b12e58cf56c3339bbe96e6", size = 2952523, upload-time = "2026-05-20T20:13:04.698Z" }, - { url = "https://files.pythonhosted.org/packages/74/11/c7dfc773eb43331a682efed610b49df6e976331f1b0e1c592a0c35d29872/gevent-26.5.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1d8da4e799431feeb4c9e441ac7431f0baabb9106976790d884289d08ac08359", size = 1787044, upload-time = "2026-05-20T21:17:32.845Z" }, - { url = "https://files.pythonhosted.org/packages/ae/28/9812933dac93560f46910a9e834805fe76f822c408bd1c20cdf299d7c311/gevent-26.5.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:51becdb4c30a8f45c1c028ad7a97bf5a1ed141f74b159a31aa9cc6aa1e6263a6", size = 1882342, upload-time = "2026-05-20T21:16:02.645Z" }, - { url = "https://files.pythonhosted.org/packages/96/4b/514f248f69b2230b69b0bb17f4158b0b05dd4b2cb469a60ab206e9fe7496/gevent-26.5.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:c42bbcd3d453b08ad8915fd3feaf3d44a3562cdf1c7b208f9837149711e16d9d", size = 1834136, upload-time = "2026-05-20T21:30:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/53/67/f5f30716efca99b6200ae89a9303a7e94dae085b7de6f6d0033c52a37f4b/gevent-26.5.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bd3445e4fbeeb46690ed8efe94b8d1d46b14aa04af8866ae7a8da5997828d1c6", size = 2115349, upload-time = "2026-05-20T20:35:28.132Z" }, - { url = "https://files.pythonhosted.org/packages/09/d8/60e8809bde7986e6c4e6d106080b3603fa09b3bb0255fed1a4d8282e3ca2/gevent-26.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b573d5b2826edc705f31f07da6889ad483a6a0d64944ebd8d32205f7c5bf46fb", size = 1799443, upload-time = "2026-05-20T21:16:41.928Z" }, - { url = "https://files.pythonhosted.org/packages/f8/41/b388b2b1f0a026ea30687e51ddf81dbb783dfb55fac0a16708d2821d99e5/gevent-26.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d53b1b28f2082a151bded2850b53f6baed02f742d2a1584029e8bd42d457fb4", size = 2141117, upload-time = "2026-05-20T20:43:30.694Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f3/ac9a4b0de487e390c5d53a908a9347c0df0102de2bbf3e8603087769191d/gevent-26.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:23569ce0c254eb821fc3dcfe250843dde8b3180b09bae9e222e41aa3fa4885b7", size = 1699862, upload-time = "2026-05-20T20:15:33.642Z" }, - { url = "https://files.pythonhosted.org/packages/2a/cf/1ef1fc9b390563c0f97702f94a557d1649b7bbb5724f9b86c2122747e92f/gevent-26.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:40cdcdb2e404b6c82b82a4576bdb33958f23fc2deb0d933e9e022b362001e647", size = 1545341, upload-time = "2026-05-20T20:16:26.229Z" }, - { url = "https://files.pythonhosted.org/packages/17/55/7d98d3888e7bb9ad4656420dec69232ecbbea48792aff9295d0ad7cf8435/gevent-26.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:75a0050e4b87f08ddee7e56f59e6014cd7fcdc3153046c09a847940515d12c85", size = 2968223, upload-time = "2026-05-20T20:13:17.223Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b4/e8e116fcbcb9dc0bf3acc50037f86e1204c217c8ed5defde68be11b3aab6/gevent-26.5.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:fd1a0b83a04e19378d9466ae0ee2b5937cf1d7fbfdcb916b2aea82179a208574", size = 1793926, upload-time = "2026-05-20T21:17:34.321Z" }, - { url = "https://files.pythonhosted.org/packages/28/07/7b267e9754b661defb93542e97731a4df21f8a40dc0f6c853faa717cf124/gevent-26.5.0-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:4c964c15076e76391d523ec24202f579a2535f7e301a40efb1656ae046d3eb69", size = 1887632, upload-time = "2026-05-20T21:16:04.158Z" }, - { url = "https://files.pythonhosted.org/packages/5c/50/b47d29e99449bd13b557ffa451401dc13d397a9923f562ef90a4e8514502/gevent-26.5.0-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:45d5438d1c84da5df7e832434627624709543630977332bb4e2d05ecca362cc9", size = 1838688, upload-time = "2026-05-20T21:30:57.979Z" }, - { url = "https://files.pythonhosted.org/packages/8b/eb/5b54ccff11bc7d7bebd40a24571ccc115d5cdae4f6c32ab457b43b436e42/gevent-26.5.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:354f35924113abc954819216c2a6ee16751958c615681e0490946e31b437bd2f", size = 2120351, upload-time = "2026-05-20T20:35:32.699Z" }, - { url = "https://files.pythonhosted.org/packages/9c/70/30fd325c30e04b1e5174c61945e17421d53ddb2450366cc52cef234f8c4b/gevent-26.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a47cd2d32f6404212d374ad8014a3491d7477dcf0cc09c5a2308ad6d325fd663", size = 1806684, upload-time = "2026-05-20T21:16:43.87Z" }, - { url = "https://files.pythonhosted.org/packages/cd/e8/fbf911ac3f9524ecfaed174d100fde671904ab8db92ceaf07faaebd13386/gevent-26.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:032157cebdedb84f2f52cdd980f2f5f2623eed6a8f083aadf44b44c47f628642", size = 2146606, upload-time = "2026-05-20T20:43:32.216Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4d/284fcbbfde66fd978c2980c1fbe0eabd586af6e4b728649e9cf459e8b38f/gevent-26.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:9c414935ba5fc88359110968851d3616f119082c937390d00a1c0f4f59be814f", size = 1722497, upload-time = "2026-05-20T20:16:44.274Z" }, - { url = "https://files.pythonhosted.org/packages/15/d2/9f66eb53434704402be0ba733bf3320bf589671a4b76fac52a7d6077e972/gevent-26.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:2a0f5993a04b95a35b3a118b1a58ba272833f9b547b774001dea29f90620882f", size = 1574249, upload-time = "2026-05-20T20:15:50.873Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d5/b4c50adb761878e3c96642b9f79bf44cee3120f3df55cd40876f51d89866/gevent-26.5.0-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2e117df896a2660c9ebd4e2b5afc02dfd6e2ddf9b495e787e67c72d105432b09", size = 2971993, upload-time = "2026-05-20T20:12:50.845Z" }, - { url = "https://files.pythonhosted.org/packages/03/83/71c2a945e80198422d1d93dbe67355f249fb456b451bf9201199d3ef6a1a/gevent-26.5.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:af5ffe9c11ffb8a39b6bef2e8b722aa2043ae4980977915c6aa8c68b4bc26e46", size = 1796658, upload-time = "2026-05-20T21:17:35.968Z" }, - { url = "https://files.pythonhosted.org/packages/42/96/548ca77aed5cb9a44e855a6c23ebceeb3554a0ea9ca0c01c311878899a3e/gevent-26.5.0-cp315-cp315-manylinux_2_28_ppc64le.whl", hash = "sha256:7da34aef7e87c43dd3662e5785e79ed505c01399a7cb42876d2d8925969fd75f", size = 1891473, upload-time = "2026-05-20T21:16:05.657Z" }, - { url = "https://files.pythonhosted.org/packages/f6/4f/f48bd47d5287afb0fbcc56165f3ed47583f1803bad401653fe27e71ade2d/gevent-26.5.0-cp315-cp315-manylinux_2_28_s390x.whl", hash = "sha256:1c6293a7046bcc6f3d8972a74b19cd7a4cfd02d3881edf0fcf827aa514bd247b", size = 1841429, upload-time = "2026-05-20T21:30:59.907Z" }, - { url = "https://files.pythonhosted.org/packages/a0/72/1925215fc720d2561fa3ec8d4af5f098f8d0cbfa76a45fafed6e5ade7718/gevent-26.5.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:d3bde0f140a275b2fa88e4b6516bda85551930e10bc2fd95e18c1b7d11cb780c", size = 2123895, upload-time = "2026-05-20T20:35:34.964Z" }, - { url = "https://files.pythonhosted.org/packages/83/59/0f584f6b1170c9a6abd9b70ccf5e9cc5ead34eabafabc0e21876ef0fe6f7/gevent-26.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e29fb4b17d9958ec8cb7f6339a111b29bc23f2c2efbef86189d1248bb4862d17", size = 1809047, upload-time = "2026-05-20T21:16:45.977Z" }, - { url = "https://files.pythonhosted.org/packages/82/88/61e854bfd98ac22eac78a97fc6db10de0f9ace46514072b435c217168729/gevent-26.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b2239df2f7570efa03736678f3f053bb1bdd22a8a16cd28a2feb7d32ea5f533f", size = 2150764, upload-time = "2026-05-20T20:43:33.781Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f5/af048b97433d7f9a7df7f5510b2c46918b7d073dcfb3bf6d0ef0e5a83dcc/gevent-26.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:aae214952fd38d27a42dc416bb70193962ec932384b63445d29bbb5817a1c042", size = 1722600, upload-time = "2026-05-20T20:19:56.81Z" }, - { url = "https://files.pythonhosted.org/packages/11/95/fb74a2299c6a2d78d9de12deaaac640ab5d2ef96a8e0f97a3ff84b9ca84b/gevent-26.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:f7067564f139e33bf26a31ee3b13d168d76eb99a44b85ced626652b158baa80c", size = 1574406, upload-time = "2026-05-20T20:17:12.125Z" }, -] - -[[package]] -name = "google-auth" -version = "2.50.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/18/238d7021d151bdab868f23433817b027dd759135202f4dfce0670d1230ca/google_auth-2.50.0.tar.gz", hash = "sha256:f35eafb191195328e8ce10a7883970877e7aeb49c2bfaa54aa0e394316d353d0", size = 336523, upload-time = "2026-04-30T21:19:29.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/cf/4880c2137c14280b2f59975cdf12cc442bc0ae1f9ea473a26eaa0c146786/google_auth-2.50.0-py3-none-any.whl", hash = "sha256:04382175e28b94f49694977f0a792688b59a668def1499e9d8de996dc9ce5b15", size = 246495, upload-time = "2026-04-30T21:19:27.664Z" }, -] - -[package.optional-dependencies] -requests = [ - { name = "requests" }, -] - -[[package]] -name = "google-genai" -version = "1.74.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "google-auth", extra = ["requests"] }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sniffio" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/c8/4a8f1de0a3268d526a345b8c74456b3e1e6ffd200982626326cf7ca83e5b/google_genai-1.74.0.tar.gz", hash = "sha256:c4c473cebdeb6e5adbb0639326de66a3a85a2209e0d32de7d66bf05c698abae8", size = 536772, upload-time = "2026-04-29T22:16:35.881Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/2b/539c328b66f7bfef2df869371a1789361228e5a7694ba02a642608367b46/google_genai-1.74.0-py3-none-any.whl", hash = "sha256:87d0b311c67d4b2a0ca741e9fc6891330c29defae81d46d8db41079aa1a3d80a", size = 790433, upload-time = "2026-04-29T22:16:33.979Z" }, -] - -[[package]] -name = "greenlet" -version = "3.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/21/117c8710abb7f146d804a124c07eb5964a60b90d02b72452885aecc18efa/greenlet-3.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7eacb17a9d41538a2bc4912eba5ef13823c83cb69e4d141d0813debe7163187f", size = 283510, upload-time = "2026-05-20T13:12:26.475Z" }, - { url = "https://files.pythonhosted.org/packages/b9/f7/6762a56fa5f6c2295c449c6524e10ce481e381c994cc44d9d03aef0700fb/greenlet-3.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5cc9606aa5f4e0bde0d3bd502b44f743864c3ffa5cfa1011b1e30f5aa02366f", size = 599696, upload-time = "2026-05-20T14:00:02.906Z" }, - { url = "https://files.pythonhosted.org/packages/0f/05/85a511e68ee109aff0aa00b4b497806091dd2d82ce209e49c6e801bd5d92/greenlet-3.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3d35f87c7253b715d13d679e0783d845910144f282cb939fe1ba4ac8616269c", size = 612618, upload-time = "2026-05-20T14:05:39.202Z" }, - { url = "https://files.pythonhosted.org/packages/2e/19/60df45065b2981ff894fdd51e7c99a3a4b107412822b083d88d5d528f663/greenlet-3.5.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:00929c98ec525fd9bf075875d8c5f6a983a90906cdf78a66e6de2d8e466c2a19", size = 619237, upload-time = "2026-05-20T14:09:06.421Z" }, - { url = "https://files.pythonhosted.org/packages/89/b8/8b83d18ae07c46c019617f35afd7b47aab7f9b4fbb12fc637d681e10bdd8/greenlet-3.5.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:540dae7b956209af4d70a3be35927b4055f617763771e5e84a5255bea934d2f5", size = 612947, upload-time = "2026-05-20T13:14:23.469Z" }, - { url = "https://files.pythonhosted.org/packages/26/9a/4ba4c2bc9d9df5f41bb8943fb7bb11e440352e6b9c2e36716b6e85f8b82d/greenlet-3.5.1-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:001775efe7b8e758861294c7a27c28af87f3f3f1c20468a2bc618c45b346c061", size = 415653, upload-time = "2026-05-20T14:01:36.999Z" }, - { url = "https://files.pythonhosted.org/packages/5d/14/ad1f9fc9b82384c010212464a3702bd911f95dab2f1180bc6fbcfb1f958c/greenlet-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed8cdb691169715a9a492844a83246f090182247d1a5031dc78a403f68ba1e97", size = 1571425, upload-time = "2026-05-20T14:02:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/46/1c/43b8203cf10f4292c9e3d270e9e5f5ade79115a0a0ca5ea6f1be5f8915a7/greenlet-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d59e840387076a51016777a9328b3f2c427c6f9208a6e958bad251be50a648d", size = 1638688, upload-time = "2026-05-20T13:14:30.026Z" }, - { url = "https://files.pythonhosted.org/packages/ac/6e/0344b1e99f58f71715456e46492101fd2daa408957b8186ade0a4b515da7/greenlet-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:b9152fca4a6466e114aaec745ae61cba739903a109754a9d4e1262f01e9259b1", size = 237763, upload-time = "2026-05-20T13:11:35.659Z" }, - { url = "https://files.pythonhosted.org/packages/42/3c/ff890b466eaba2b0f5e6bdfff025f8c75f41b8ffdc3dbc3d24ad261e764a/greenlet-3.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:73f78f9b9f0a5c06e5c946ba1e8e36f5114923b6be109ee618c54f079c3ea14f", size = 284764, upload-time = "2026-05-20T13:09:10.204Z" }, - { url = "https://files.pythonhosted.org/packages/81/0e/5e5457be3d256918f6a4756f073548a3f0190836e2cc94aa6d0d617a940b/greenlet-3.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0cbed8bb44e23c5b199f888f4e4ce096b45ad9f25ff74a7ad0213875e936bb2", size = 603479, upload-time = "2026-05-20T14:00:04.757Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e1/f89a21d58d308298e6f275f13a1b472ed96c680b601a371b08be6a725989/greenlet-3.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a203a8bd0acb0701653d3bbb26e404854a68674139ed5cbb778830f42b09bb33", size = 615495, upload-time = "2026-05-20T14:05:40.87Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/8fd452fd81adb9ec79c8275c1375702ab0fd6bee4952da12eaa09b9508d8/greenlet-3.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ebeb75c81211f5c702576cf81f315e77e23cfdb2c7c6fcb9dd143e6de35c360", size = 623515, upload-time = "2026-05-20T14:09:07.853Z" }, - { url = "https://files.pythonhosted.org/packages/75/de/af6cef182862d2ccd6975440d21c9058a77c3f9b469abf94e322dfd2e0e3/greenlet-3.5.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a271fcd66c74615cda6a964fda3f304267a12e50a084472218a39bb0376f563", size = 614754, upload-time = "2026-05-20T13:14:24.947Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bc/c318aa9f3ffc77320fddcee3d892be957b42e2ff947198d9450b004f3a38/greenlet-3.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:017a544f0385d441e88714160d089d6900ef46c9eff9d99b6715a5ef2d127747", size = 418439, upload-time = "2026-05-20T14:01:38.446Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c6/50e520283a9f19388a7326b05f9e8637e566003475eacaadad04f558c68d/greenlet-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ded7b068c7c31c1a8657d4fd42d886b3e051ae29f88b80c5ff9d502257b0f071", size = 1574097, upload-time = "2026-05-20T14:02:24.003Z" }, - { url = "https://files.pythonhosted.org/packages/21/1c/13abd1f4860d987fa5e1170a01930d6e6cd40d328de487a3c9fdaff0ffd0/greenlet-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d0932b81d72f552ded9d810d00021b64d89f2195a91ce115b893f943b7a4ab3c", size = 1641058, upload-time = "2026-05-20T13:14:31.83Z" }, - { url = "https://files.pythonhosted.org/packages/f5/56/5f332b7705545eac2dc01b4e9254d24a793f2656d55d5cc6b94ee59d22ae/greenlet-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:88e300d136eac057b2397aa1cfd7328b4c87c7eb66a09c7bc6a1292234db474e", size = 238089, upload-time = "2026-05-20T13:14:03.229Z" }, - { url = "https://files.pythonhosted.org/packages/d9/a9/a3c2fa886c5b94863fb0e61b3bc14610b7aa94cf4f17f8741b11708305fc/greenlet-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:cc6ab7e555c8a112ad3a76e368e86e12a2754bcae1652a5602e133ec7b635523", size = 234989, upload-time = "2026-05-20T13:08:27.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, - { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, - { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, - { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, - { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, - { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, - { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, - { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, - { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, - { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, - { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, - { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, - { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, - { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, - { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, - { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, - { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, - { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, - { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, - { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, - { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, - { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, - { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, - { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, - { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, - { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, - { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, - { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, - { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, -] - -[[package]] -name = "groq" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/51/4728c13611849ff6cf8536740ae78ba3ee5e665d67b572a47c9ead0f9788/groq-1.2.0.tar.gz", hash = "sha256:85459e27c9c17f22404349c785cd08680362cfe85e07cc060be46c4832f108c3", size = 155609, upload-time = "2026-04-18T10:43:50.68Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/82/748639c95c60ad8846c65b167ca611c815d06d5f67a9e73b23486dce4fdf/groq-1.2.0-py3-none-any.whl", hash = "sha256:1002060a743b27c8f86765e1bc9749c98498e961d9fe2e4902bf7804a71c3c84", size = 142334, upload-time = "2026-04-18T10:43:49.125Z" }, -] - -[[package]] -name = "gunicorn" -version = "26.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, -] - -[[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 = "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 = "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.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, -] - -[[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 = "jiter" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/2e/a9959997739c403378d0a4a3a1c4ed80b60aeace216c4d37b303a9fc60a4/jiter-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:02f36a5c700f105ac04a6556fe664a59037a2c200db3b7e88784fac2ddf02531", size = 316927, upload-time = "2026-04-10T14:25:40.753Z" }, - { url = "https://files.pythonhosted.org/packages/27/72/b6de8a531e0adbadd839bec301165feb1fccf00e9ff55073ba2dd20f0043/jiter-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41eab6c09ceffb6f0fe25e214b3068146edb1eda3649ca2aee2a061029c7ba2e", size = 321181, upload-time = "2026-04-10T14:25:42.621Z" }, - { url = "https://files.pythonhosted.org/packages/db/d8/2040b9efa13c917f855c40890ae4119fe02c25b7c7677d5b4fa820a851fc/jiter-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf4d4c109641f9cfaf4a7b6aebd51654e405cd00fa9ebbf87163b8b97b325aa", size = 347387, upload-time = "2026-04-10T14:25:44.212Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/655c0ad5ce6a8e90f9068c175b8a236877d753e460762b3183c136db1c5b/jiter-0.14.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80c7b41a628e6be2213ad0ece763c5f88aa5ee003fa394d58acaaee1f4b8342", size = 373083, upload-time = "2026-04-10T14:25:45.55Z" }, - { url = "https://files.pythonhosted.org/packages/f1/66/549c40fa068f08710b7570869c306a051eb67a29758bd64f4114f730554c/jiter-0.14.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb3dbf7cc0d4dbe73cce307ebe7eefa7f73a7d3d854dd119ea0c243f03e40927", size = 463639, upload-time = "2026-04-10T14:25:47.452Z" }, - { url = "https://files.pythonhosted.org/packages/25/2f/97a32a05fed14ed58a18e181fdfb619e05163f3726b54ee6080ec0539c09/jiter-0.14.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7054adcdeb06b46efd17b5734f75817a44a2d06d3748e36c3a023a1bb52af9ec", size = 380735, upload-time = "2026-04-10T14:25:49.305Z" }, - { url = "https://files.pythonhosted.org/packages/2a/3b/4347e1d6c2a973d653bbb7a2d671a2d2426e54b52ba735b8ff0d0a29b75c/jiter-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d597cd1bf6790376f3fffc7c708766e57301d99a19314824ea0ccc9c3c70e1e2", size = 358632, upload-time = "2026-04-10T14:25:50.931Z" }, - { url = "https://files.pythonhosted.org/packages/ef/24/ca452fbf2ea33548ed30ce68a39a50442d3f7c9bf0704a7af958a930c057/jiter-0.14.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df63a14878da754427926281626fd3ee249424a186e25a274e78176d42945264", size = 359969, upload-time = "2026-04-10T14:25:52.381Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a3/94470a0d199287caabeb4da2bb2ae5f6d17f3cf05dfc975d7cb064d58e0f/jiter-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ea73187627bcc5810e085df715e8a99da8bdfd96a7eb36b4b4df700ba6d4c9c", size = 397529, upload-time = "2026-04-10T14:25:53.801Z" }, - { url = "https://files.pythonhosted.org/packages/cf/71/6768edc09d7c45c39f093feb3de105fa718a3e982b5208b8a2ed6382b44b/jiter-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9f541eaf7bb8382367a1a23d6fc3d6aad57f8dd8c18c3c17f838bee20f217220", size = 522342, upload-time = "2026-04-10T14:25:55.396Z" }, - { url = "https://files.pythonhosted.org/packages/3d/6b/5c2e17559a0f4e96e934479f7137df46c939e983fa05244e674815befb73/jiter-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:107465250de4fce00fdb47166bcd51df8e634e049541174fe3c71848e44f52ce", size = 556784, upload-time = "2026-04-10T14:25:56.927Z" }, - { url = "https://files.pythonhosted.org/packages/b1/83/c25f3556a60fc74d11199100f1b6cc0c006b815c8494dea8ca16fe398732/jiter-0.14.0-cp310-cp310-win32.whl", hash = "sha256:ffb2a08a406465bb076b7cc1df41d833106d3cf7905076cc73f0cb90078c7d10", size = 208439, upload-time = "2026-04-10T14:25:58.796Z" }, - { url = "https://files.pythonhosted.org/packages/2e/99/781a1b413f0989b7f2ea203b094b331685f1a35e52e0a45e5d000ecaab27/jiter-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb8b682d10cb0cce7ff4c1af7244af7022c9b01ae16d46c357bdd0df13afb25d", size = 204558, upload-time = "2026-04-10T14:26:00.208Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, - { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, - { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, - { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, - { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, - { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, - { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, - { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, - { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, - { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, - { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, - { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, - { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, - { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, - { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, - { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, - { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, - { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, - { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, - { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, - { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, - { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, - { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, - { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, - { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, - { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, - { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, - { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, - { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, - { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, - { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, - { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, - { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, - { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, -] - -[[package]] -name = "jmespath" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, -] - -[[package]] -name = "kombu" -version = "5.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "amqp" }, - { name = "packaging" }, - { name = "tzdata" }, - { name = "vine" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, -] - -[[package]] -name = "markdown" -version = "3.10.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, - { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, - { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, - { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, -] - -[[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 = "prompt-toolkit" -version = "3.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, -] - -[[package]] -name = "psycopg2-binary" -version = "2.9.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/80/49bacf9e51617d8309f6f0123e29edc793f6f5f6700c7d1f1b20782fbb37/psycopg2_binary-2.9.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b818ceff717f98851a64bffd4c5eb5b3059ae280276dcecc52ac658dcf006a4", size = 3712314, upload-time = "2026-04-20T23:33:31.363Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f2/98eeac7d60c43df9338287834edf9b3e69be68a2db78a57b1b81d705e735/psycopg2_binary-2.9.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d2fa0d7caca8635c56e373055094eeda3208d901d55dd0ff5abc1d4e47f82b56", size = 3822389, upload-time = "2026-04-20T23:33:34.178Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7c/30575e75f14d5351a56a1971bb43fe7f8bf7edf1b654fb1bec65c42a8812/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:864c261b3690e1207d14bbfe0a61e27567981b80c47a778561e49f676f7ce433", size = 4578448, upload-time = "2026-04-20T23:33:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/4df366d89f28c527dc39d0b6c98a5ca74e30d37ac097b73f3352147568ae/psycopg2_binary-2.9.12-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c5ee5213445dd45312459029b8c4c0a695461eb517b753d2582315bd07995f5e", size = 4273705, upload-time = "2026-04-20T23:33:39.291Z" }, - { url = "https://files.pythonhosted.org/packages/4e/8a/c566803818eb03161ba869b6ba612bf7ad56816d98b9e5121e0a22ad6b0b/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f9cae1f848779b5b01f417e762c40d026ea93eb0648249a604728cda991dde3", size = 5893784, upload-time = "2026-04-20T23:33:41.658Z" }, - { url = "https://files.pythonhosted.org/packages/63/fe/0dfa5797e0b229e0567bc378695224caf14d547f73b05be0c80549089772/psycopg2_binary-2.9.12-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63a3ebbd543d3d1eda088ac99164e8c5bac15293ee91f20281fd17d050aee1c4", size = 4109306, upload-time = "2026-04-20T23:33:43.953Z" }, - { url = "https://files.pythonhosted.org/packages/3c/89/28063adf17a4ba501eedd9890feab0c649ee4d8bd0a97df0ff1e9584feab/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d6fcbba8c9fed08a73b8ac61ea79e4821e45b1e92bb466230c5e746bbf3d5256", size = 3654400, upload-time = "2026-04-20T23:33:46.115Z" }, - { url = "https://files.pythonhosted.org/packages/84/94/5a01de0aa4ead0b8d8d1aa4ec18cec0bd36d03fa714eaa5bb8a0b1b50020/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:36512911ebb2b60a0c3e44d0bb5048c1980aced91235d133b7874f3d1d93487c", size = 3299215, upload-time = "2026-04-20T23:33:48.202Z" }, - { url = "https://files.pythonhosted.org/packages/7a/85/723bb085a61c6ac2dc0a0043f375f2fe7365363e27b073bad56ca5bda979/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:8ffdb59fe88f99589e34354a130217aa1fd2d615612402d6edc8b3dbc7a44463", size = 3047724, upload-time = "2026-04-20T23:33:50.74Z" }, - { url = "https://files.pythonhosted.org/packages/b4/67/4d8b1e0d2fc4166677380eac0edf9cdff91013aca2546e8ef7bc04b56158/psycopg2_binary-2.9.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a46fe069b65255df410f856d842bc235f90e22ffdf532dda625fd4213d3fd9b1", size = 3349183, upload-time = "2026-04-20T23:33:59.635Z" }, - { url = "https://files.pythonhosted.org/packages/73/99/21af7a5498637ea4dc91a17c281a53bc1d632fbafe00f6689fbfb32a9fed/psycopg2_binary-2.9.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab29414b25dcb698bf26bf213e3348abdcd07bbd5de032a5bec15bd75b298b03", size = 2757036, upload-time = "2026-04-20T23:34:01.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/19/d4ce60954f3bb9d8e3bc5e5c4d1f2487de2d3851bf2391d54954c9df12a6/psycopg2_binary-2.9.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5c8ce6c61bd1b1f6b9c24ee32211599f6166af2c55abb19456090a21fd16554b", size = 3712338, upload-time = "2026-04-20T23:34:03.961Z" }, - { url = "https://files.pythonhosted.org/packages/53/71/c85409ee0d78890f0660eff262e815e7dd2bb741a17611d82e9e8cd9dc5e/psycopg2_binary-2.9.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4a9eaa6e7f4ff91bec10aa3fb296878e75187bced5cc4bafe17dc40915e1326", size = 3822407, upload-time = "2026-04-20T23:34:05.977Z" }, - { url = "https://files.pythonhosted.org/packages/3c/ed/60486c2c7f0d4d1ede2bfb1ed27e2498477ce646bc7f6b2759906303117e/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c6528cefc8e50fcc6f4a107e27a672058b36cc5736d665476aeb413ba88dbb06", size = 4578425, upload-time = "2026-04-20T23:34:08.246Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b9/656cb03fad9f4f49f2145c334b1126ee75189929ca4e6187d485a2d59951/psycopg2_binary-2.9.12-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e184b1fb6072bf05388aa41c697e1b2d01b3473f107e7ec44f186a32cfd0b8", size = 4273709, upload-time = "2026-04-20T23:34:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/99/66/08cf0da0e25cc6fb142c89be45fc8418792858f0c4cbff5e24530ff02cd6/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4766ab678563054d3f1d064a4db19cc4b5f9e3a8d9018592a8285cf200c248f3", size = 5893779, upload-time = "2026-04-20T23:34:13.905Z" }, - { url = "https://files.pythonhosted.org/packages/17/d7/eecd9ce8e146d3721115d82d3836efdbb712187e4590325df549989d18f4/psycopg2_binary-2.9.12-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5a0253224780c978746cb9be55a946bcdaf40fe3519c0f622924cdabdafe2c39", size = 4109308, upload-time = "2026-04-20T23:34:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/b1dc289b362cc8d45697b57eefbd673186f49a4ea0906928988e3affcc98/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0dc9228d47c46bda253d2ecd6bb93b56a9f2d7ad33b684a1fa3622bf74ffe30c", size = 3654405, upload-time = "2026-04-20T23:34:19.303Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/4c4aea6473214dbdbd0fbba11aa4691e76dc01722c55724c5951719865ff/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f921f3cd87035ef7df233383011d7a53ea1d346224752c1385f1edfd790ceb6a", size = 3299187, upload-time = "2026-04-20T23:34:21.206Z" }, - { url = "https://files.pythonhosted.org/packages/ba/5d/b03b99986446a4f57b170ed9a2579fb7ff9783ca0fa5226b19db99737fee/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d999bd982a723113c1a45b55a7a6a90d64d0ed2278020ed625c490ff7bef96c", size = 3047716, upload-time = "2026-04-20T23:34:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/14/86/382ee4afbd1d97500c9d2862b20c2fdeddf4b7335e984df3fb4309f64108/psycopg2_binary-2.9.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29d4d134bd0ab46ffb04e94aa3c5fa3ef582e9026609165e2f758ff76fc3a3be", size = 3349237, upload-time = "2026-04-20T23:34:25.211Z" }, - { url = "https://files.pythonhosted.org/packages/a8/16/9a57c75ba1eda7165c017342f526810d5f5a12647dde749c99ae9a7141d7/psycopg2_binary-2.9.12-cp311-cp311-win_amd64.whl", hash = "sha256:cb4a1dacdd48077150dc762a9e5ddbf32c256d66cb46f80839391aa458774936", size = 2757036, upload-time = "2026-04-20T23:34:27.77Z" }, - { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, - { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, - { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, - { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, - { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, - { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, - { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, - { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, - { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, - { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, - { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, - { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, - { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, - { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, - { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, - { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, - { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, - { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, - { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, - { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, - { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, - { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, - { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, -] - -[[package]] -name = "pyasn1" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pydantic" -version = "2.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/98/b50eb9a411e87483b5c65dba4fa430a06bac4234d3403a40e5a9905ebcd0/pydantic_core-2.46.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da3786b8018e60349680720158cc19161cc3b4bdd815beb0a321cd5ce1ad5b1", size = 2108971, upload-time = "2026-04-20T14:43:51.945Z" }, - { url = "https://files.pythonhosted.org/packages/08/4b/f364b9d161718ff2217160a4b5d41ce38de60aed91c3689ebffa1c939d23/pydantic_core-2.46.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cc0988cb29d21bf4a9d5cf2ef970b5c0e38d8d8e107a493278c05dc6c1dda69f", size = 1949588, upload-time = "2026-04-20T14:44:10.386Z" }, - { url = "https://files.pythonhosted.org/packages/8f/8b/30bd03ee83b2f5e29f5ba8e647ab3c456bf56f2ec72fdbcc0215484a0854/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f9067c3bfadd04c55484b89c0d267981b2f3512850f6f66e1e74204a4e4ce3", size = 1975986, upload-time = "2026-04-20T14:43:57.106Z" }, - { url = "https://files.pythonhosted.org/packages/3c/54/13ccf954d84ec275d5d023d5786e4aa48840bc9f161f2838dc98e1153518/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a642ac886ecf6402d9882d10c405dcf4b902abeb2972cd5fb4a48c83cd59279a", size = 2055830, upload-time = "2026-04-20T14:44:15.499Z" }, - { url = "https://files.pythonhosted.org/packages/be/0e/65f38125e660fdbd72aa858e7dfae893645cfa0e7b13d333e174a367cd23/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79f561438481f28681584b89e2effb22855e2179880314bcddbf5968e935e807", size = 2222340, upload-time = "2026-04-20T14:41:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/d1/88/f3ab7739efe0e7e80777dbb84c59eb98518e3f57ea433206194c2e425272/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57a973eae4665352a47cf1a99b4ee864620f2fe663a217d7a8da68a1f3a5bfda", size = 2280727, upload-time = "2026-04-20T14:41:30.461Z" }, - { url = "https://files.pythonhosted.org/packages/2a/6d/c228219080817bec4982f9531cadb18da6aaa770fdeb114f49c237ac2c9f/pydantic_core-2.46.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83d002b97072a53ea150d63e0a3adfae5670cef5aa8a6e490240e482d3b22e57", size = 2092158, upload-time = "2026-04-20T14:44:07.305Z" }, - { url = "https://files.pythonhosted.org/packages/0f/b1/525a16711e7c6d61635fac3b0bd54600b5c5d9f60c6fc5aaab26b64a2297/pydantic_core-2.46.3-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b40ddd51e7c44b28cfaef746c9d3c506d658885e0a46f9eeef2ee815cbf8e045", size = 2116626, upload-time = "2026-04-20T14:42:34.118Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7c/17d30673351439a6951bf54f564cf2443ab00ae264ec9df00e2efd710eb5/pydantic_core-2.46.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac5ec7fb9b87f04ee839af2d53bcadea57ded7d229719f56c0ed895bff987943", size = 2160691, upload-time = "2026-04-20T14:41:14.023Z" }, - { url = "https://files.pythonhosted.org/packages/86/66/af8adbcbc0886ead7f1a116606a534d75a307e71e6e08226000d51b880d2/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a3b11c812f61b3129c4905781a2601dfdfdea5fe1e6c1cfb696b55d14e9c054f", size = 2182543, upload-time = "2026-04-20T14:40:48.886Z" }, - { url = "https://files.pythonhosted.org/packages/b0/37/6de71e0f54c54a4190010f57deb749e1ddf75c568ada3b1320b70067f121/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1108da631e602e5b3c38d6d04fe5bb3bfa54349e6918e3ca6cf570b2e2b2f9d4", size = 2324513, upload-time = "2026-04-20T14:42:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/51/b1/9fc74ce94f603d5ef59ff258ca9c2c8fb902fb548d340a96f77f4d1c3b7f/pydantic_core-2.46.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:de885175515bcfa98ae618c1df7a072f13d179f81376c8007112af20567fd08a", size = 2361853, upload-time = "2026-04-20T14:43:24.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/d0/4c652fc592db35f100279ee751d5a145aca1b9a7984b9684ba7c1b5b0535/pydantic_core-2.46.3-cp310-cp310-win32.whl", hash = "sha256:d11058e3201527d41bc6b545c79187c9e4bf85e15a236a6007f0e991518882b7", size = 1980465, upload-time = "2026-04-20T14:44:46.239Z" }, - { url = "https://files.pythonhosted.org/packages/27/b8/a920453c38afbe1f355e1ea0b0d94a0a3e0b0879d32d793108755fa171d5/pydantic_core-2.46.3-cp310-cp310-win_amd64.whl", hash = "sha256:3612edf65c8ea67ac13616c4d23af12faef1ae435a8a93e5934c2a0cbbdd1fd6", size = 2073884, upload-time = "2026-04-20T14:43:01.201Z" }, - { url = "https://files.pythonhosted.org/packages/22/a2/1ba90a83e85a3f94c796b184f3efde9c72f2830dcda493eea8d59ba78e6d/pydantic_core-2.46.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ab124d49d0459b2373ecf54118a45c28a1e6d4192a533fbc915e70f556feb8e5", size = 2106740, upload-time = "2026-04-20T14:41:20.932Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f6/99ae893c89a0b9d3daec9f95487aa676709aa83f67643b3f0abaf4ab628a/pydantic_core-2.46.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cca67d52a5c7a16aed2b3999e719c4bcf644074eac304a5d3d62dd70ae7d4b2c", size = 1948293, upload-time = "2026-04-20T14:43:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/3e/b8/2e8e636dc9e3f16c2e16bf0849e24be82c5ee82c603c65fc0326666328fc/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c024e08c0ba23e6fd68c771a521e9d6a792f2ebb0fa734296b36394dc30390e", size = 1973222, upload-time = "2026-04-20T14:41:57.841Z" }, - { url = "https://files.pythonhosted.org/packages/34/36/0e730beec4d83c5306f417afbd82ff237d9a21e83c5edf675f31ed84c1fe/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6645ce7eec4928e29a1e3b3d5c946621d105d3e79f0c9cddf07c2a9770949287", size = 2053852, upload-time = "2026-04-20T14:40:43.077Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f0/3071131f47e39136a17814576e0fada9168569f7f8c0e6ac4d1ede6a4958/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a712c7118e6c5ea96562f7b488435172abb94a3c53c22c9efc1412264a45cbbe", size = 2221134, upload-time = "2026-04-20T14:43:03.349Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a9/a2dc023eec5aa4b02a467874bad32e2446957d2adcab14e107eab502e978/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a868ef3ff206343579021c40faf3b1edc64b1cc508ff243a28b0a514ccb050", size = 2279785, upload-time = "2026-04-20T14:41:19.285Z" }, - { url = "https://files.pythonhosted.org/packages/0a/44/93f489d16fb63fbd41c670441536541f6e8cfa1e5a69f40bc9c5d30d8c90/pydantic_core-2.46.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc7e8c32db809aa0f6ea1d6869ebc8518a65d5150fdfad8bcae6a49ae32a22e2", size = 2089404, upload-time = "2026-04-20T14:43:10.108Z" }, - { url = "https://files.pythonhosted.org/packages/2a/78/8692e3aa72b2d004f7a5d937f1dfdc8552ba26caf0bec75f342c40f00dec/pydantic_core-2.46.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3481bd1341dc85779ee506bc8e1196a277ace359d89d28588a9468c3ecbe63fa", size = 2114898, upload-time = "2026-04-20T14:44:51.475Z" }, - { url = "https://files.pythonhosted.org/packages/6a/62/e83133f2e7832532060175cebf1f13748f4c7e7e7165cdd1f611f174494b/pydantic_core-2.46.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8690eba565c6d68ffd3a8655525cbdd5246510b44a637ee2c6c03a7ebfe64d3c", size = 2157856, upload-time = "2026-04-20T14:43:46.64Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ec/6a500e3ad7718ee50583fae79c8651f5d37e3abce1fa9ae177ae65842c53/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4de88889d7e88d50d40ee5b39d5dac0bcaef9ba91f7e536ac064e6b2834ecccf", size = 2180168, upload-time = "2026-04-20T14:42:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/d8/53/8267811054b1aa7fc1dc7ded93812372ef79a839f5e23558136a6afbfde1/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:e480080975c1ef7f780b8f99ed72337e7cc5efea2e518a20a692e8e7b278eb8b", size = 2322885, upload-time = "2026-04-20T14:41:05.253Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c1/1c0acdb3aa0856ddc4ecc55214578f896f2de16f400cf51627eb3c26c1c4/pydantic_core-2.46.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:de3a5c376f8cd94da9a1b8fd3dd1c16c7a7b216ed31dc8ce9fd7a22bf13b836e", size = 2360328, upload-time = "2026-04-20T14:41:43.991Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/ef39cd0f4a926814f360e71c1adeab48ad214d9727e4deb48eedfb5bce1a/pydantic_core-2.46.3-cp311-cp311-win32.whl", hash = "sha256:fc331a5314ffddd5385b9ee9d0d2fee0b13c27e0e02dad71b1ae5d6561f51eeb", size = 1979464, upload-time = "2026-04-20T14:43:12.215Z" }, - { url = "https://files.pythonhosted.org/packages/18/9c/f41951b0d858e343f1cf09398b2a7b3014013799744f2c4a8ad6a3eec4f2/pydantic_core-2.46.3-cp311-cp311-win_amd64.whl", hash = "sha256:b5b9c6cf08a8a5e502698f5e153056d12c34b8fb30317e0c5fd06f45162a6346", size = 2070837, upload-time = "2026-04-20T14:41:47.707Z" }, - { url = "https://files.pythonhosted.org/packages/9f/1e/264a17cd582f6ed50950d4d03dd5fefd84e570e238afe1cb3e25cf238769/pydantic_core-2.46.3-cp311-cp311-win_arm64.whl", hash = "sha256:5dfd51cf457482f04ec49491811a2b8fd5b843b64b11eecd2d7a1ee596ea78a6", size = 2053647, upload-time = "2026-04-20T14:42:27.535Z" }, - { url = "https://files.pythonhosted.org/packages/4b/cb/5b47425556ecc1f3fe18ed2a0083188aa46e1dd812b06e406475b3a5d536/pydantic_core-2.46.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b11b59b3eee90a80a36701ddb4576d9ae31f93f05cb9e277ceaa09e6bf074a67", size = 2101946, upload-time = "2026-04-20T14:40:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/a1/4f/2fb62c2267cae99b815bbf4a7b9283812c88ca3153ef29f7707200f1d4e5/pydantic_core-2.46.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:af8653713055ea18a3abc1537fe2ebc42f5b0bbb768d1eb79fd74eb47c0ac089", size = 1951612, upload-time = "2026-04-20T14:42:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/50/6e/b7348fd30d6556d132cddd5bd79f37f96f2601fe0608afac4f5fb01ec0b3/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75a519dab6d63c514f3a81053e5266c549679e4aa88f6ec57f2b7b854aceb1b0", size = 1977027, upload-time = "2026-04-20T14:42:02.001Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/31d60ee2b45540d3fb0b29302a393dbc01cd771c473f5b5147bcd353e593/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6cd87cb1575b1ad05ba98894c5b5c96411ef678fa2f6ed2576607095b8d9789", size = 2063008, upload-time = "2026-04-20T14:44:17.952Z" }, - { url = "https://files.pythonhosted.org/packages/8a/db/3a9d1957181b59258f44a2300ab0f0be9d1e12d662a4f57bb31250455c52/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f80a55484b8d843c8ada81ebf70a682f3f00a3d40e378c06cf17ecb44d280d7d", size = 2233082, upload-time = "2026-04-20T14:40:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e1/3277c38792aeb5cfb18c2f0c5785a221d9ff4e149abbe1184d53d5f72273/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3861f1731b90c50a3266316b9044f5c9b405eecb8e299b0a7120596334e4fe9c", size = 2304615, upload-time = "2026-04-20T14:42:12.584Z" }, - { url = "https://files.pythonhosted.org/packages/5e/d5/e3d9717c9eba10855325650afd2a9cba8e607321697f18953af9d562da2f/pydantic_core-2.46.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb528e295ed31570ac3dcc9bfdd6e0150bc11ce6168ac87a8082055cf1a67395", size = 2094380, upload-time = "2026-04-20T14:43:05.522Z" }, - { url = "https://files.pythonhosted.org/packages/a1/20/abac35dedcbfd66c6f0b03e4e3564511771d6c9b7ede10a362d03e110d9b/pydantic_core-2.46.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:367508faa4973b992b271ba1494acaab36eb7e8739d1e47be5035fb1ea225396", size = 2135429, upload-time = "2026-04-20T14:41:55.549Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a5/41bfd1df69afad71b5cf0535055bccc73022715ad362edbc124bc1e021d7/pydantic_core-2.46.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ad3c826fe523e4becf4fe39baa44286cff85ef137c729a2c5e269afbfd0905d", size = 2174582, upload-time = "2026-04-20T14:41:45.96Z" }, - { url = "https://files.pythonhosted.org/packages/79/65/38d86ea056b29b2b10734eb23329b7a7672ca604df4f2b6e9c02d4ee22fe/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ec638c5d194ef8af27db69f16c954a09797c0dc25015ad6123eb2c73a4d271ca", size = 2187533, upload-time = "2026-04-20T14:40:55.367Z" }, - { url = "https://files.pythonhosted.org/packages/b6/55/a1129141678a2026badc539ad1dee0a71d06f54c2f06a4bd68c030ac781b/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:28ed528c45446062ee66edb1d33df5d88828ae167de76e773a3c7f64bd14e976", size = 2332985, upload-time = "2026-04-20T14:44:13.05Z" }, - { url = "https://files.pythonhosted.org/packages/d7/60/cb26f4077719f709e54819f4e8e1d43f4091f94e285eb6bd21e1190a7b7c/pydantic_core-2.46.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aed19d0c783886d5bd86d80ae5030006b45e28464218747dcf83dabfdd092c7b", size = 2373670, upload-time = "2026-04-20T14:41:53.421Z" }, - { url = "https://files.pythonhosted.org/packages/6b/7e/c3f21882bdf1d8d086876f81b5e296206c69c6082551d776895de7801fa0/pydantic_core-2.46.3-cp312-cp312-win32.whl", hash = "sha256:06d5d8820cbbdb4147578c1fe7ffcd5b83f34508cb9f9ab76e807be7db6ff0a4", size = 1966722, upload-time = "2026-04-20T14:44:30.588Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/6b5e757b859013ebfbd7adba02f23b428f37c86dcbf78b5bb0b4ffd36e99/pydantic_core-2.46.3-cp312-cp312-win_amd64.whl", hash = "sha256:c3212fda0ee959c1dd04c60b601ec31097aaa893573a3a1abd0a47bcac2968c1", size = 2072970, upload-time = "2026-04-20T14:42:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f8/a989b21cc75e9a32d24192ef700eea606521221a89faa40c919ce884f2b1/pydantic_core-2.46.3-cp312-cp312-win_arm64.whl", hash = "sha256:f1f8338dd7a7f31761f1f1a3c47503a9a3b34eea3c8b01fa6ee96408affb5e72", size = 2035963, upload-time = "2026-04-20T14:44:20.4Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, - { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, - { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, - { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, - { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, - { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, - { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, - { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/66/7f/03dbad45cd3aa9083fbc93c210ae8b005af67e4136a14186950a747c6874/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:9715525891ed524a0a1eb6d053c74d4d4ad5017677fb00af0b7c2644a31bae46", size = 2105683, upload-time = "2026-04-20T14:42:19.779Z" }, - { url = "https://files.pythonhosted.org/packages/26/22/4dc186ac8ea6b257e9855031f51b62a9637beac4d68ac06bee02f046f836/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:9d2f400712a99a013aff420ef1eb9be077f8189a36c1e3ef87660b4e1088a874", size = 1940052, upload-time = "2026-04-20T14:43:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/d376391a5aff1f2e8188960d7873543608130a870961c2b6b5236627c116/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd2aab0e2e9dc2daf36bd2686c982535d5e7b1d930a1344a7bb6e82baab42a76", size = 1988172, upload-time = "2026-04-20T14:41:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/0e/6b/523b9f85c23788755d6ab949329de692a2e3a584bc6beb67fef5e035aa9d/pydantic_core-2.46.3-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e9d76736da5f362fabfeea6a69b13b7f2be405c6d6966f06b2f6bfff7e64531", size = 2128596, upload-time = "2026-04-20T14:40:41.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/42/f426db557e8ab2791bc7562052299944a118655496fbff99914e564c0a94/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b12dd51f1187c2eb489af8e20f880362db98e954b54ab792fa5d92e8bcc6b803", size = 2091877, upload-time = "2026-04-20T14:43:27.091Z" }, - { url = "https://files.pythonhosted.org/packages/5c/4f/86a832a9d14df58e663bfdf4627dc00d3317c2bd583c4fb23390b0f04b8e/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f00a0961b125f1a47af7bcc17f00782e12f4cd056f83416006b30111d941dfa3", size = 1932428, upload-time = "2026-04-20T14:40:45.781Z" }, - { url = "https://files.pythonhosted.org/packages/11/1a/fe857968954d93fb78e0d4b6df5c988c74c4aaa67181c60be7cfe327c0ca/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57697d7c056aca4bbb680200f96563e841a6386ac1129370a0102592f4dddff5", size = 1997550, upload-time = "2026-04-20T14:44:02.425Z" }, - { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, - { url = "https://files.pythonhosted.org/packages/1f/da/99d40830684f81dec901cac521b5b91c095394cc1084b9433393cde1c2df/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:13afdd885f3d71280cf286b13b310ee0f7ccfefd1dbbb661514a474b726e2f25", size = 2107973, upload-time = "2026-04-20T14:42:06.175Z" }, - { url = "https://files.pythonhosted.org/packages/99/a5/87024121818d75bbb2a98ddbaf638e40e7a18b5e0f5492c9ca4b1b316107/pydantic_core-2.46.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f91c0aff3e3ee0928edd1232c57f643a7a003e6edf1860bc3afcdc749cb513f3", size = 1947191, upload-time = "2026-04-20T14:43:14.319Z" }, - { url = "https://files.pythonhosted.org/packages/60/62/0c1acfe10945b83a6a59d19fbaa92f48825381509e5701b855c08f13db76/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6529d1d128321a58d30afcc97b49e98836542f68dd41b33c2e972bb9e5290536", size = 2123791, upload-time = "2026-04-20T14:43:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/75/3e/3b2393b4c8f44285561dc30b00cf307a56a2eff7c483a824db3b8221ca51/pydantic_core-2.46.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:975c267cff4f7e7272eacbe50f6cc03ca9a3da4c4fbd66fffd89c94c1e311aa1", size = 2153197, upload-time = "2026-04-20T14:44:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/ba/75/5af02fb35505051eee727c061f2881c555ab4f8ddb2d42da715a42c9731b/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:2b8e4f2bbdf71415c544b4b1138b8060db7b6611bc927e8064c769f64bed651c", size = 2181073, upload-time = "2026-04-20T14:43:20.729Z" }, - { url = "https://files.pythonhosted.org/packages/10/92/7e0e1bd9ca3c68305db037560ca2876f89b2647deb2f8b6319005de37505/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e61ea8e9fff9606d09178f577ff8ccdd7206ff73d6552bcec18e1033c4254b85", size = 2315886, upload-time = "2026-04-20T14:44:04.826Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d8/101655f27eaf3e44558ead736b2795d12500598beed4683f279396fa186e/pydantic_core-2.46.3-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b504bda01bafc69b6d3c7a0c7f039dcf60f47fab70e06fe23f57b5c75bdc82b8", size = 2360528, upload-time = "2026-04-20T14:40:47.431Z" }, - { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, -] - -[[package]] -name = "pydyf" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" }, -] - -[[package]] -name = "pygithub" -version = "2.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyjwt", extra = ["crypto"] }, - { name = "pynacl" }, - { name = "requests" }, - { name = "typing-extensions" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ab/c3/8465a311197e16cf5ab68789fe689535e90f6b61ab524cc32a39e67237ae/pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c", size = 2594989, upload-time = "2026-04-14T07:26:13.622Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/aa/81a5506f089a26338bff17535e4339b3b22049ebd1bcdeff756c4d7a7559/pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9", size = 449710, upload-time = "2026-04-14T07:26:12.382Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyjwt" -version = "2.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "pynacl" -version = "1.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, - { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, - { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, - { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, - { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, - { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, - { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, - { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, - { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, - { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, - { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, - { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, - { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, - { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, -] - -[[package]] -name = "pyphen" -version = "0.17.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, -] - -[[package]] -name = "pytest-django" -version = "4.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" }, -] - -[[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-decouple" -version = "3.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e1/97/373dcd5844ec0ea5893e13c39a2c67e7537987ad8de3842fe078db4582fa/python-decouple-3.8.tar.gz", hash = "sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f", size = 9612, upload-time = "2023-03-01T19:38:38.143Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/d4/9193206c4563ec771faf2ccf54815ca7918529fe81f6adb22ee6d0e06622/python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66", size = 9947, upload-time = "2023-03-01T19:38:36.015Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - -[[package]] -name = "redis" -version = "7.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, -] - -[[package]] -name = "requests" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, -] - -[[package]] -name = "ruff" -version = "0.15.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, - { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, - { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, - { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, - { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, - { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, - { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, - { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, -] - -[[package]] -name = "s3transfer" -version = "0.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" }, -] - -[[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.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, -] - -[[package]] -name = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - -[[package]] -name = "tinycss2" -version = "1.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, -] - -[[package]] -name = "tinyhtml5" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "webencodings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" }, -] - -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - -[[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 = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "tzdata" -version = "2026.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, -] - -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - -[[package]] -name = "vine" -version = "5.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, -] - -[[package]] -name = "weasyprint" -version = "68.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi" }, - { name = "cssselect2" }, - { name = "fonttools", extra = ["woff"] }, - { name = "pillow" }, - { name = "pydyf" }, - { name = "pyphen" }, - { name = "tinycss2" }, - { name = "tinyhtml5" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/db/3e/65c0f176e6fb5c2b0a1ac13185b366f727d9723541babfa7fa4309998169/weasyprint-68.1.tar.gz", hash = "sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e", size = 1542379, upload-time = "2026-02-06T15:04:11.203Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/dd/14eb73cea481ad8162d3b18a4850d4a84d6e804a22840cca207648532265/weasyprint-68.1-py3-none-any.whl", hash = "sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be", size = 319789, upload-time = "2026-02-06T15:04:09.189Z" }, -] - -[[package]] -name = "webencodings" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, -] - -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, - { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, - { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, - { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] - -[[package]] -name = "whitenoise" -version = "6.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, -] - -[[package]] -name = "zope-event" -version = "6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, -] - -[[package]] -name = "zope-interface" -version = "8.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/43/9cd98bee951d23848de690ba2809f87e3b22c67c370987acc960da15ad37/zope_interface-8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c8aa2bf8f3911ef37b87deb1bbe225a310e6eb6522a16d77f5d8330c4f6fbe", size = 210951, upload-time = "2026-05-26T06:49:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/17/0f/8f1a29966bcf863e3a2121edcafb81c55715de7886bcc9544749cc79e7da/zope_interface-8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:efe234a0fafb4b6b1602e9be9245b97c2bf06d67c07af5a4bc3c0438978b555c", size = 211309, upload-time = "2026-05-26T06:49:02.732Z" }, - { url = "https://files.pythonhosted.org/packages/9f/9f/37e564eaaf85e3abc1ada40a79fa43f2ab45bdb67431b0ec0fe29e4763e2/zope_interface-8.5-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dabeb6fe1228d411994f300811edc6866fff0cdcbc9cef98a78f05ea0da42e37", size = 254881, upload-time = "2026-05-26T06:49:04.303Z" }, - { url = "https://files.pythonhosted.org/packages/06/61/e6501d8ea7a2cac3217e03f404e1f98c1df7191d83cfe86b1895fbba5dac/zope_interface-8.5-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:147a9442dcc2b7339ecdb1be2b3cdb098e90462e39425054053ebfb50d99125a", size = 259811, upload-time = "2026-05-26T06:49:06.373Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/bfa25ef480b02af6e9452c478483fec75e87c9e2b60c407fd0b1f6054b9c/zope_interface-8.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a17e681224267880707c9ec9e730ad9a1ad2d65c371256843efba6cf48711b58", size = 260358, upload-time = "2026-05-26T06:49:08.317Z" }, - { url = "https://files.pythonhosted.org/packages/64/51/2b518072fea76242da64451d501c69b7b5ccdef9b57fead584ccf1c180d5/zope_interface-8.5-cp310-cp310-win_amd64.whl", hash = "sha256:d178968a1a611df30549a717d1624cb38ca810347339e3e37b7baa6f6781a170", size = 214822, upload-time = "2026-05-26T06:49:10.441Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f1/83ad110fb847413affe71609bb50e59e1aa082e1236030122227c7c283d3/zope_interface-8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:afc66ccaef2a3c0bef6ca02aad40d29a39276389dad16a8eac36f9f385e4d057", size = 211426, upload-time = "2026-05-26T06:49:12.595Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a7/6b6e0c31ac240cb9fc015ae9ed45ca54be886c18fcf7bfa2377a4d7a8785/zope_interface-8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28044972187245d7a309e4699319bfdbd2ffcbf7176d1d4ddf5adffb2dea80f", size = 211850, upload-time = "2026-05-26T06:49:14.474Z" }, - { url = "https://files.pythonhosted.org/packages/37/36/7599ecabcf80ce4fef2e1ef3c5ac0d4696b61f03f724cc44022f4d226af9/zope_interface-8.5-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03bbecc7982af713d7499d4084bc03916413d17ffd45f89009348cc0c1d9e376", size = 260711, upload-time = "2026-05-26T06:49:16.568Z" }, - { url = "https://files.pythonhosted.org/packages/03/3e/1774b0ee46ccbb5498ee3c33ece40315b6ef58bc71957be94bd345340bc1/zope_interface-8.5-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf917009a4a7457c7290225a019f4a0aa706d96accd2cfdba2418d3bc1fcde2f", size = 265277, upload-time = "2026-05-26T06:49:18.656Z" }, - { url = "https://files.pythonhosted.org/packages/b6/09/e533b2ffabaae4e5d5730d6768a591cf335defe8e37bec2ad905d09be656/zope_interface-8.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31cff25b2aaedb5267e6e77b1e9be6b0ec4f622032de8a069202b8ffacda7dc2", size = 266369, upload-time = "2026-05-26T06:49:20.174Z" }, - { url = "https://files.pythonhosted.org/packages/49/4a/3ebe6a4c122b2d5340db45cbe7e490663d3228b172710ec71060cd5d541e/zope_interface-8.5-cp311-cp311-win_amd64.whl", hash = "sha256:17a3114bbdddb5e75e5784cdf318944636190cbbc72d357ef9fb1a8b0351f955", size = 215161, upload-time = "2026-05-26T06:49:21.799Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/056ad97af5b16db1975ee98ec7ab03d2ce3f3355efad904ced1dbce0e39f/zope_interface-8.5-cp311-cp311-win_arm64.whl", hash = "sha256:aab6bb5bee10f38ea688b95ba054396b67f613552d2c8378be7fcb2d2fba7646", size = 213481, upload-time = "2026-05-26T06:49:25.085Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/b84123a948f3162a34623e188922827cd845244fdd043ed20f8d02228caa/zope_interface-8.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e6ee90c2e6de7c37058d5fa41f123c8b13a312db8d1e0fb5840d7f4bcdff9c9", size = 212165, upload-time = "2026-05-26T06:49:26.566Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/cbceec44f1b27208a76c1a688c131302685852406a23df5aab68324109cc/zope_interface-8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1adc90d3576b3b4c4de4953e6002c37bef28b78d7fa54c1bbfd0c50f022fe7c", size = 212341, upload-time = "2026-05-26T06:49:28.182Z" }, - { url = "https://files.pythonhosted.org/packages/e1/c3/005032195ff3b210c139b7c560ed5c534e844b0907d8e44d2b3d8919305e/zope_interface-8.5-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e6347b8d8d12c5eca6502450a92be30079b7acfade2c4f693efa0deb8871b06e", size = 265296, upload-time = "2026-05-26T06:49:29.741Z" }, - { url = "https://files.pythonhosted.org/packages/c5/66/1036543d6a66bc04c19df3cf650f3ad938a002ab0a443c24e23e8de5e8b9/zope_interface-8.5-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e970dabea777a24b0b0bbf9dae3ab75ce8b2d8e948edf4875627034b21f3560", size = 270689, upload-time = "2026-05-26T06:49:31.767Z" }, - { url = "https://files.pythonhosted.org/packages/30/4c/8b56259558cace4414e753ca6740396a1f59d4a95ddb55b4658600408670/zope_interface-8.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0b48ccadaa9839e09ff81e969703cecb3f402c813bfe8b958652e699bea69f5", size = 270280, upload-time = "2026-05-26T06:49:33.489Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ea/649908c83aa8fdb7faf2ddca4d3cf6fb8f2157121267dc56e8f72681e26c/zope_interface-8.5-cp312-cp312-win_amd64.whl", hash = "sha256:e0e311f1277468c08fd59a2b41f71b43d25dff639789d364747acd1705c0df6e", size = 215019, upload-time = "2026-05-26T06:49:35.607Z" }, - { url = "https://files.pythonhosted.org/packages/9f/97/da13037b4c563e4df32eedbc819f8c00b754af494f68211e3dffd48d52da/zope_interface-8.5-cp312-cp312-win_arm64.whl", hash = "sha256:652b73107a04159ec6c020db6c1543d4f1e8f4d069bd2aac88a947820923517b", size = 213569, upload-time = "2026-05-26T06:49:37.317Z" }, - { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, - { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, - { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, - { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, - { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, - { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, - { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, - { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, - { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, - { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, - { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, - { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, - { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, - { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, -] - -[[package]] -name = "zopfli" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/4d/a8cc1768b2eda3c0c7470bf8059dcb94ef96d45dd91fc6edd29430d44072/zopfli-0.4.1.tar.gz", hash = "sha256:07a5cdc5d1aaa6c288c5d9f5a5383042ba743641abf8e2fd898dcad622d8a38e", size = 179001, upload-time = "2026-02-13T14:17:27.156Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/2f/1a7082e9163ae3703b27d571720bf3c954a02a9cf1fdce47c51e70639256/zopfli-0.4.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:4238d4d746d1095e29c9125490985e0c12ffd3654f54a24af551e2391e936d54", size = 291570, upload-time = "2026-02-13T14:17:12.556Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/4a1a88edf9fa0ce102703f38ab4dfb285b7cd2dde5389184264ec759e06e/zopfli-0.4.1-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fdfb7ce9f5de37a5b2f75dd2642fd7717956ef2a72e0387302a36d382440db07", size = 829437, upload-time = "2026-02-13T14:17:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/e3/77/d231012ddcaac9d2e184bd7808e106a8a0048855912e2e1c902b3f383413/zopfli-0.4.1-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7bcee1b189d64ec33d1e05cfa1b6a1268c29329c382f6ca1bd6245b04925c57", size = 818542, upload-time = "2026-02-13T14:17:16.353Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4e/9b23690c4ca14fbeae2a8f7f6b2006611bf4cd7d5bcb2d9e6c718bd4b0e9/zopfli-0.4.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:27823dc1161a4031d1c25925fd45d9868ec0cbc7692341830a7dcfa25063662c", size = 1778034, upload-time = "2026-02-13T14:17:17.509Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1b/51f7c28d4cde639cac4f5d47ff615548c1d9809f43cbacdd66eba5cd679d/zopfli-0.4.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c22b6161f47f5bd34637dbaee6735abd287cd64e0d1ce28ef1871bf625f4b", size = 1863957, upload-time = "2026-02-13T14:17:19.259Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4d/1ef17017d38eabe7ae28f18ef0f16d48966cc23a5657e4555fff61704539/zopfli-0.4.1-cp310-abi3-win32.whl", hash = "sha256:a899eca405662a23ae75054affa3517a060362eae1185d3d791c86a50153c4dd", size = 82314, upload-time = "2026-02-13T14:17:20.795Z" }, - { url = "https://files.pythonhosted.org/packages/0f/94/806bc84b389c7d70051d7c9a0179cff52de8b9f8dc2fc25bcf0bca302986/zopfli-0.4.1-cp310-abi3-win_amd64.whl", hash = "sha256:84a31ba9edc921b1d3a4449929394a993888f32d70de3a3617800c428a947b9b", size = 102186, upload-time = "2026-02-13T14:17:21.622Z" }, - { url = "https://files.pythonhosted.org/packages/15/53/0afc94574553bad50d7add81f54eed1a864e13f91c3a342c99775a947ff9/zopfli-0.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:02086247dd12fda929f9bfe8b3962b6bcdbfc8c82e99255aebcf367867cf0760", size = 147127, upload-time = "2026-02-13T14:17:22.995Z" }, - { url = "https://files.pythonhosted.org/packages/45/2b/0d9e4bdfd3d646a36b8516a01dec4ccd2967554603801e7c2d6c72fede3d/zopfli-0.4.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a93c2ecafff372de6c0aa2212eff18a75f6c71a100372fee7b4b129cc0b6f9a7", size = 127349, upload-time = "2026-02-13T14:17:24.107Z" }, - { url = "https://files.pythonhosted.org/packages/23/f0/ad6e26aa06943ce9f1be4ae6738513a7b69d8ea1f3b13e46009a249a3f73/zopfli-0.4.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7", size = 124371, upload-time = "2026-02-13T14:17:24.976Z" }, - { url = "https://files.pythonhosted.org/packages/7b/36/3c15d564db6dfdd740919b205bdb69be75113e9919c422cde658e6d013c0/zopfli-0.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707", size = 102199, upload-time = "2026-02-13T14:17:26.246Z" }, -] diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 5f8b8d5..acae541 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -6,7 +6,7 @@ http { resolver 127.0.0.11 ipv6=off valid=10s; server { - listen 8000; + listen 80; server_name _; client_max_body_size 100M; @@ -17,7 +17,7 @@ http { proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Internal-Api-Key "pydocai-internal-key"; - set $django_core "django-core:8000"; + set $django_core "core:8000"; set $fastapi_parser "fastapi-parser:8002"; set $fastapi_ai "fastapi-ai:8003"; @@ -33,9 +33,22 @@ http { location /api/parser/ { proxy_pass http://$django_core; } location /api/comments/ { proxy_pass http://$django_core; } location /api/notifications/ { proxy_pass http://$django_core; } - location /api/ai/ { proxy_pass http://$fastapi_ai; } - location /admin/ { proxy_pass http://$django_core; } location /api/universal/ { proxy_pass http://$django_core; } location /api/internal/ { proxy_pass http://$django_core; } + location /admin/ { proxy_pass http://$django_core; } + + location /api/ai/ { proxy_pass http://$fastapi_ai; } + + location /api/schema/ { proxy_pass http://$django_core; } + location /api/docs/ { proxy_pass http://$django_core; } + location /api/redoc/ { proxy_pass http://$django_core; } + + location /parser/docs/ { proxy_pass http://$fastapi_parser/docs/; } + location /parser/redoc/ { proxy_pass http://$fastapi_parser/redoc/; } + location /parser/openapi.json { proxy_pass http://$fastapi_parser/openapi.json; } + + location /ai/docs/ { proxy_pass http://$fastapi_ai/docs/; } + location /ai/redoc/ { proxy_pass http://$fastapi_ai/redoc/; } + location /ai/openapi.json { proxy_pass http://$fastapi_ai/openapi.json; } } } diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index 2fbc884..7e0f28c 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -38,7 +38,7 @@ services: fastapi-parser: build: - context: ./backend/services/parser + context: ./services/parser dockerfile: Dockerfile restart: always env_file: @@ -56,7 +56,7 @@ services: fastapi-ai: build: - context: ./backend/services/ai + context: ./services/ai dockerfile: Dockerfile restart: always env_file: diff --git a/docker-compose.yml b/docker-compose.yml index 7828c41..e6e204e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,10 @@ +name: pydocai services: db: image: postgres:16-alpine - restart: always + restart: unless-stopped + container_name: pydocai-db volumes: - postgres_data:/var/lib/postgresql/data/ environment: @@ -13,104 +15,154 @@ services: - "5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U pydocai_user"] - interval: 5s + interval: 10s timeout: 5s retries: 5 + start_period: 10s + networks: + - pydocai-net + logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" redis: image: redis:7-alpine - restart: always + restart: unless-stopped + container_name: pydocai-redis ports: - "6379:6379" healthcheck: test: ["CMD", "redis-cli", "ping"] - interval: 5s + interval: 10s timeout: 3s retries: 5 + networks: + - pydocai-net + logging: *default-logging - backend: - build: - context: ./backend - dockerfile: Dockerfile - restart: always - command: ["bash", "entrypoint.sh"] - volumes: - - ./backend:/app - - static_volume:/app/staticfiles - ports: - - "8000:8000" - env_file: - - ./backend/.env - environment: - DJANGO_SETTINGS_MODULE: config.settings.production - UV_LINK_MODE: copy - UV_PROJECT_ENVIRONMENT: /opt/venv - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy + core: + build: + context: ./services/core + dockerfile: docker/Dockerfile + restart: unless-stopped + container_name: pydocai-core + command: ["/bin/sh", "docker/entrypoint.sh"] + volumes: + - static_volume:/app/staticfiles + ports: + - "8000:8000" + env_file: + - ./services/core/env/.env + environment: + DJANGO_SETTINGS_MODULE: config.settings.development + depends_on: + redis: + condition: service_healthy + networks: + - pydocai-net + logging: *default-logging + security_opt: + - no-new-privileges:true + stop_grace_period: 30s + healthcheck: + test: ["CMD", "python", "-c", "import http.client; c = http.client.HTTPConnection('localhost', 8000, timeout=5); c.request('GET', '/api/health/'); r = c.getresponse(); assert r.status == 200"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M celery: build: - context: ./backend - dockerfile: Dockerfile - restart: always + context: ./services/core + dockerfile: docker/Dockerfile + restart: unless-stopped + container_name: pydocai-celery command: uv run celery -A config.celery worker --loglevel=info --concurrency=4 --max-tasks-per-child=10 --max-memory-per-child=500000 - volumes: - - ./backend:/app env_file: - - ./backend/.env + - ./services/core/env/.env environment: - DJANGO_SETTINGS_MODULE: config.settings.production - UV_PROJECT_ENVIRONMENT: /opt/venv + DJANGO_SETTINGS_MODULE: config.settings.development depends_on: - db: - condition: service_healthy redis: condition: service_healthy + networks: + - pydocai-net + logging: *default-logging + security_opt: + - no-new-privileges:true + stop_grace_period: 60s + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M celery-beat: build: - context: ./backend - dockerfile: Dockerfile - restart: always + context: ./services/core + dockerfile: docker/Dockerfile + restart: unless-stopped + container_name: pydocai-celery-beat command: uv run celery -A config.celery beat --loglevel=info - volumes: - - ./backend:/app env_file: - - ./backend/.env + - ./services/core/env/.env environment: - DJANGO_SETTINGS_MODULE: config.settings.production - UV_PROJECT_ENVIRONMENT: /opt/venv + DJANGO_SETTINGS_MODULE: config.settings.development depends_on: - db: - condition: service_healthy redis: condition: service_healthy + networks: + - pydocai-net + logging: *default-logging + security_opt: + - no-new-privileges:true + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M pgbouncer: image: edoburu/pgbouncer:latest - restart: always + restart: unless-stopped + container_name: pydocai-pgbouncer environment: - - DATABASE_URL=postgres://pydocai_user:pydocai_pass@db:5432/pydocai - - POOL_MODE=transaction - - MAX_DB_CONNECTIONS=20 + DATABASE_URL: postgres://pydocai_user:pydocai_pass@db:5432/pydocai + POOL_MODE: transaction + MAX_DB_CONNECTIONS: 20 ports: - "6432:5432" depends_on: db: condition: service_healthy - redis: - condition: service_healthy + networks: + - pydocai-net + logging: *default-logging + deploy: + resources: + limits: + memory: 128M + reservations: + memory: 64M fastapi-parser: build: - context: ./backend/services/parser - dockerfile: Dockerfile - restart: always + context: ./services/parser + dockerfile: docker/Dockerfile + restart: unless-stopped + container_name: pydocai-parser env_file: - - ./backend/.env + - ./services/core/env/.env environment: DATABASE_URL: postgresql://pydocai_user:pydocai_pass@db:5432/pydocai INTERNAL_API_KEY: pydocai-internal-key @@ -119,14 +171,26 @@ services: condition: service_healthy ports: - "8002:8002" + networks: + - pydocai-net + logging: *default-logging + security_opt: + - no-new-privileges:true + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M fastapi-ai: build: - context: ./backend/services/ai - dockerfile: Dockerfile - restart: always + context: ./services/ai + dockerfile: docker/Dockerfile + restart: unless-stopped + container_name: pydocai-ai env_file: - - ./backend/.env + - ./services/core/env/.env environment: DATABASE_URL: postgresql://pydocai_user:pydocai_pass@db:5432/pydocai INTERNAL_API_KEY: pydocai-internal-key @@ -138,8 +202,49 @@ services: condition: service_healthy ports: - "8003:8003" + networks: + - pydocai-net + logging: *default-logging + security_opt: + - no-new-privileges:true + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 1G + + nginx: + image: nginx:alpine + restart: unless-stopped + container_name: pydocai-nginx + ports: + - "8080:80" + volumes: + - ./deploy/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - core + - fastapi-parser + - fastapi-ai + networks: + - pydocai-net + logging: *default-logging + deploy: + resources: + limits: + memory: 128M + reservations: + memory: 64M volumes: postgres_data: + name: pydocai_postgres_data static_volume: - model_cache: \ No newline at end of file + name: pydocai_static_volume + model_cache: + name: pydocai_model_cache + +networks: + pydocai-net: + name: pydocai-net + driver: bridge