diff --git a/API_DOCS.md b/API_DOCS.md new file mode 100644 index 0000000..63e4a26 --- /dev/null +++ b/API_DOCS.md @@ -0,0 +1,218 @@ +# PyDocAI API Documentation + +PyDocAI exposes three API surfaces: the **Django REST API** (core orchestration), the **Parser Service** (AST analysis), and the **AI Service** (documentation generation). All services are proxied through Nginx on port `8080` during local development. + +## Interactive Docs + +When running via Docker Compose, Swagger UI and ReDoc are available at: + +| Service | Swagger UI | ReDoc | +|---------|-----------|-------| +| **Django Core API** | `http://localhost:8080/api/docs/` | `http://localhost:8080/api/redoc/` | +| **Parser Service** (FastAPI) | `http://localhost:8080/parser/docs/` | — | +| **AI Service** (FastAPI) | `http://localhost:8080/ai/docs/` | — | + +## Authentication + +Most endpoints require JWT authentication. Obtain a token pair via: + +### Register +```bash +POST /api/users/register/ +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "securepass123", + "name": "User Name" +} +``` + +### Login +```bash +POST /api/users/login/ +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "securepass123" +} +``` + +Response: +```json +{ + "access": "eyJhbGciOiJIUzI1NiIs...", + "refresh": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +Include the access token in subsequent requests: +``` +Authorization: Bearer +``` + +### GitHub OAuth +```bash +POST /api/users/github/login/ +Content-Type: application/json + +{ + "code": "" +} +``` + +## Endpoints + +### Authentication & Users + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/users/register/` | Register a new user | +| POST | `/api/users/login/` | Login, returns JWT pair | +| POST | `/api/users/token/refresh/` | Refresh access token | +| GET/PUT | `/api/users/profile/` | Get or update authenticated user's profile | +| POST | `/api/users/password/reset/` | Request password reset email | +| POST | `/api/users/password/reset/confirm/` | Confirm password reset with token | +| POST | `/api/users/github/login/` | GitHub OAuth login | + +### Projects + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/projects/` | List authenticated user's projects | +| POST | `/api/projects/` | Create a new project | +| GET | `/api/projects/{id}/` | Get project detail | +| PUT | `/api/projects/{id}/` | Update project | +| DELETE | `/api/projects/{id}/` | Delete project | +| POST | `/api/projects/{id}/publish/` | Toggle publish status | + +### Code Parsing (Python AST) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/parser/analyze-file/` | Analyze a single `.py` file | +| POST | `/api/parser/analyze-folder/` | Analyze a `.zip` archive of Python files | + +These endpoints upload the file(s) to the Django API, which forwards them to the **FastAPI Parser** service internally. The parser extracts: +- **Schema tables** — Django model fields, types, constraints, relationships +- **Endpoint mappings** — URL routes, HTTP methods, path parameters, serializer fields + +### AI Documentation Generation + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/ai/generate/` | Start AI documentation generation for a project | +| GET | `/api/ai/status/{task_id}/` | Poll generation status | + +The generation flow: +1. POST to `/api/ai/generate/` with a `project_id` — returns a `task_id` +2. The Django API dispatches the task to the **FastAPI AI** service +3. The AI service uses **Groq** (primary), **Gemini**, or **Claude** (fallbacks) to generate documentation +4. Poll `/api/ai/status/{task_id}/` until `status` is `completed` +5. Retrieve the generated docs from the project detail endpoint + +### Universal Code Analysis + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/universal/analyze/` | Analyze code in any supported language | +| GET | `/api/universal/status/{id}/` | Poll analysis status | + +Works with Python, JavaScript, TypeScript, Java, Go, Rust, and more. Unlike AST mode, universal mode sends code directly to the AI for analysis without pre-parsing. + +### GitHub Integration + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/github/repos/` | List authenticated user's GitHub repositories | +| POST | `/api/github/fetch/` | Fetch repository contents for analysis | + +### Exports + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/exports/markdown/{id}/` | Export project documentation as Markdown | + +### Comments (Public Docs) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/comments/` | List comments on a published doc | +| POST | `/api/comments/` | Create a comment | +| DELETE | `/api/comments/{id}/` | Delete own comment | + +### Feedback + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/feedback/` | List user's feedback submissions | +| POST | `/api/feedback/` | Submit feedback | + +### Notifications + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/notifications/` | List authenticated user's notifications | + +### Public Projects + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/public/projects/` | List all published projects (no auth required) | +| GET | `/api/public/projects/{slug}/` | Get a published project by slug (no auth required) | + +### Admin Dashboard + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/admin-dashboard/stats/` | Admin dashboard statistics (admin only) | + +## FastAPI Microservice Endpoints + +These endpoints are not intended for direct external use — they are called internally by the Django API. + +### Parser Service (`/parser/`) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/parser/analyze-file/` | Parse a single Python file via AST | +| POST | `/parser/analyze-folder/` | Parse a ZIP of Python files via AST | +| GET | `/parser/status/{task_id}/` | Get parsing task status | +| GET | `/parser/health/` | Health check | + +### AI Service (`/ai/`) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/ai/generate-docs/` | Generate documentation via AI | +| GET | `/ai/status/{task_id}/` | Get generation task status | +| GET | `/ai/health/` | Health check | + +## Error Responses + +All endpoints return consistent error shapes: + +```json +{ + "detail": "Human-readable error message" +} +``` + +Or for validation errors: + +```json +{ + "field_name": ["This field is required."] +} +``` + +Common HTTP status codes: +- `200` — Success +- `201` — Created +- `202` — Accepted (async task dispatched) +- `400` — Bad request / validation error +- `401` — Unauthenticated +- `403` — Forbidden +- `404` — Not found +- `429` — Rate limited diff --git a/README.md b/README.md index 8737df6..f9db8ea 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## Features -- **🤖 AI-Powered Documentation** — Parses your code and generates human-readable docs using Groq AI (with Gemini/Claude fallbacks) +- **🤖 AI-Powered Documentation** — Parses your code and generates human-readable docs using Groq AI - **🌐 Universal Language Support** — Works with Python, JavaScript, TypeScript, Java, Go, Rust, and more via AI-driven analysis - **🐍 Python AST Mode** — Deep Python/Django code analysis with AST parsing for schema tables, endpoint mapping, and model relationships - **📁 Multiple Input Methods** — Upload single `.py` files, `.zip` archives, paste raw code, or connect a GitHub repository @@ -39,7 +39,7 @@ | **Frontend** | React 19, Vite, TypeScript, Tailwind CSS, React Router | | **Backend** | Django 5, Django REST Framework, Celery, Redis | | **Database** | PostgreSQL | -| **AI** | Groq API (LLaMA), Gemini/Claude fallbacks | +| **AI** | Groq API (LLaMA) | | **Deployment** | Vercel (frontend), AWS EC2 + RDS + ElastiCache (backend) | ## Quick Start @@ -52,6 +52,9 @@ docker-compose up --build -d - Frontend: http://localhost:5173 - Backend API: http://localhost:8000 +- API Docs (Swagger): http://localhost:8080/api/docs/ +- Parser API Docs: http://localhost:8080/parser/docs/ +- AI API Docs: http://localhost:8080/ai/docs/ ### Without Docker @@ -90,69 +93,119 @@ npm run dev (.py/.js/.ts/...) analyzes code API / Architecture ``` +## Architecture + +```mermaid +graph LR + F["React Frontend
:5173"]:::frontend + N["Nginx
:8080"]:::gateway + D["Django Core
:8000"]:::api + R[("Redis")]:::data + C["Celery
Worker"]:::worker + P["FastAPI
Parser :8002"]:::fastapi + A["FastAPI
AI :8003"]:::fastapi + PG[("PostgreSQL")]:::data + G["Groq API"]:::ext + GH["GitHub"]:::ext + + F -->|1. upload code| N + N -->|/api/parser/| D + D -->|2. create project| PG + D -->|3. dispatch task| R + R --> C + C -->|4. AST parse| P + C -->|5. generate docs| A + P -->|parsed data| D + A -->|docs| D + A -.->|embeddings| PG + D -->|6. store result| PG + D -->|7. return docs| F + F -->|8. publish| N + N -->|publish| D + D -->|update visibility| PG + D -.->|OAuth| GH + A -.->|AI| G + + classDef frontend fill:#0f172a,stroke:#38bdf8,color:#f8fafc; + classDef gateway fill:#1e1b4b,stroke:#a78bfa,color:#eef2ff; + classDef api fill:#064e3b,stroke:#34d399,color:#ecfdf5; + classDef data fill:#7f1d1d,stroke:#f87171,color:#fef2f2; + classDef worker fill:#1e3a5f,stroke:#60a5fa,color:#bfdbfe; + classDef fastapi fill:#831843,stroke:#f472b6,color:#fdf2f8; + classDef ext fill:#1c1917,stroke:#a8a29e,color:#fafaf9; +``` + + ## Project Structure ``` PyDocAi/ -├── backend/ # Django REST API -│ ├── apps/ -│ │ ├── users/ # Auth & user management -│ │ ├── projects/ # Project CRUD -│ │ ├── parser/ # Python AST parsing -│ │ ├── ai/ # AI documentation generation -│ │ ├── universal/ # Universal code analysis -│ │ ├── github_integration/# GitHub OAuth & repo fetching -│ │ ├── exports/ # Markdown export -│ │ ├── comments/ # Public doc comments -│ │ ├── feedback/ # User feedback & ratings -│ │ ├── admin_dashboard/ # Admin panel -│ │ ├── notifications/ # User notifications -│ │ └── internal/ # Internal utilities -│ ├── services/ -│ │ ├── parser/ # FastAPI AST parsing service -│ │ └── ai/ # FastAPI AI generation service -│ ├── config/ # Django settings -│ └── requirements/ -├── frondend/ # React + Vite frontend +├── deploy/ +│ └── nginx.conf # Reverse proxy config +├── services/ +│ ├── core/ # Django monolith (API hub) +│ │ ├── apps/ # 13 Django apps +│ │ │ ├── users/ # Auth (JWT, GitHub OAuth, password reset) +│ │ │ ├── projects/ # Project CRUD, publish, sharing +│ │ │ ├── parser/ # Python AST parsing orchestration +│ │ │ ├── ai/ # AI doc generation orchestration +│ │ │ ├── universal/ # Universal code analysis +│ │ │ ├── github_integration/ # GitHub repo fetching +│ │ │ ├── exports/ # Markdown export +│ │ │ ├── comments/ # Public doc comments +│ │ │ ├── feedback/ # User feedback & admin replies +│ │ │ ├── admin_dashboard/ # Admin stats & management +│ │ │ ├── notifications/ # Email notifications +│ │ │ ├── common/ # Shared utilities, health check +│ │ │ └── internal/ # Inter-service communication +│ │ ├── config/ # Django settings (base/dev/prod) +│ │ ├── docker/ # Dockerfile + entrypoint.sh +│ │ ├── env/ # .env + .env.example +│ │ ├── seed/ # seed_admin.py +│ │ ├── templates/emails/ # HTML email templates +│ │ ├── requirements/ # Pip requirements +│ │ └── manage.py +│ ├── parser/ # FastAPI microservice (AST parsing) +│ │ ├── api/routes/ # file, folder, status, health +│ │ ├── ast_parser.py # Core AST logic +│ │ ├── framework_detector.py +│ │ ├── docker/Dockerfile +│ │ └── main.py +│ └── ai/ # FastAPI microservice (AI generation) +│ ├── api/routes/ # generate, status, health +│ ├── services/ # groq, docs_builder, markdown, prompts +│ ├── rag.py # RAG-based code embedding +│ ├── docker/Dockerfile +│ └── main.py +├── frondend/ # React 19 + Vite + Tailwind │ └── src/ -│ ├── pages/ # Route pages (19 pages) -│ ├── components/ # Reusable UI components -│ ├── hooks/ # Custom React hooks -│ ├── context/ # Auth context -│ └── api/ # API client +│ ├── pages/ # 19 route pages +│ │ ├── Home, Login, Register, ForgotPassword, ResetPassword +│ │ ├── Dashboard, Input, InputPython, InputUniversal +│ │ ├── Output, Profile, GitHubCallback +│ │ ├── Published, PublicDoc +│ │ ├── FeedbackPage, MyFeedback +│ │ ├── AdminUsers, AdminProjects, AdminFeedback +│ ├── components/ # 14 reusable UI components +│ ├── hooks/ # useAuth +│ ├── context/ # AuthContext +│ └── api/ # API client (index.js) ├── docker-compose.yml -├── nginx/ +├── docker-compose.prod.yml └── README.md ``` -## API Overview - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/auth/register/` | POST | Register new user | -| `/api/auth/login/` | POST | Login (JWT) | -| `/api/auth/profile/` | GET/PUT | Get/update profile | -| `/api/auth/password/reset/` | POST | Request password reset | -| `/api/auth/github/login/` | POST | GitHub OAuth login | -| `/api/projects/` | GET/POST | List / create projects | -| `/api/projects/{id}/` | GET/PUT/DELETE | Project detail / update / delete | -| `/api/projects/{id}/publish/` | POST | Toggle publish status | -| `/api/parser/analyze-file/` | POST | Analyze single `.py` file (AST) | -| `/api/parser/analyze-folder/` | POST | Analyze ZIP folder (AST) | -| `/api/ai/generate/` | POST | Start AI documentation generation | -| `/api/ai/status/{task_id}/` | GET | Poll generation status | -| `/api/universal/analyze/` | POST | Analyze any code (universal mode) | -| `/api/universal/status/{id}/` | GET | Poll universal analysis status | -| `/api/github/repos/` | GET | List user's GitHub repos | -| `/api/github/fetch/` | POST | Fetch repo contents | -| `/api/exports/markdown/{id}/` | GET | Export as Markdown | -| `/api/comments/` | GET/POST | List / create comments | -| `/api/comments/{id}/` | DELETE | Delete comment | -| `/api/feedback/` | GET/POST | List / submit feedback | -| `/api/notifications/` | GET | List notifications | -| `/api/public/projects/` | GET | List published projects | -| `/api/public/projects/{slug}/` | GET | Get published project detail | -| `/api/admin-dashboard/stats/` | GET | Admin dashboard stats | +## API Documentation + +Full API reference with endpoint details, authentication, request/response examples, and error handling is available in [API_DOCS.md](API_DOCS.md). + +Interactive Swagger UI (when running via Docker): + +| Service | URL | +|---------|-----| +| Django Core API | `http://localhost:8080/api/docs/` | +| Parser Service | `http://localhost:8080/parser/docs/` | +| AI Service | `http://localhost:8080/ai/docs/` | ## Environment Variables 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/celerybeat-schedule.db b/backend/celerybeat-schedule.db deleted file mode 100644 index b4b12fa..0000000 Binary files a/backend/celerybeat-schedule.db and /dev/null differ 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/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/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/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/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/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/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 diff --git a/frondend/index.html b/frondend/index.html index cb98522..5b0c039 100644 --- a/frondend/index.html +++ b/frondend/index.html @@ -3,6 +3,19 @@ + + + + + + + + + + + + + diff --git a/frondend/public/favicon-128x128.png b/frondend/public/favicon-128x128.png new file mode 100644 index 0000000..3268c6e Binary files /dev/null and b/frondend/public/favicon-128x128.png differ diff --git a/frondend/public/favicon-144x144.png b/frondend/public/favicon-144x144.png new file mode 100644 index 0000000..6480d3d Binary files /dev/null and b/frondend/public/favicon-144x144.png differ diff --git a/frondend/public/favicon-152x152.png b/frondend/public/favicon-152x152.png new file mode 100644 index 0000000..fb860b4 Binary files /dev/null and b/frondend/public/favicon-152x152.png differ diff --git a/frondend/public/favicon-167x167.png b/frondend/public/favicon-167x167.png new file mode 100644 index 0000000..fb4c66b Binary files /dev/null and b/frondend/public/favicon-167x167.png differ diff --git a/frondend/public/favicon-16x16.png b/frondend/public/favicon-16x16.png new file mode 100644 index 0000000..05a6791 Binary files /dev/null and b/frondend/public/favicon-16x16.png differ diff --git a/frondend/public/favicon-180x180.png b/frondend/public/favicon-180x180.png new file mode 100644 index 0000000..fe897f7 Binary files /dev/null and b/frondend/public/favicon-180x180.png differ diff --git a/frondend/public/favicon-192x192.png b/frondend/public/favicon-192x192.png new file mode 100644 index 0000000..bde2143 Binary files /dev/null and b/frondend/public/favicon-192x192.png differ diff --git a/frondend/public/favicon-32x32.png b/frondend/public/favicon-32x32.png new file mode 100644 index 0000000..ba1a470 Binary files /dev/null and b/frondend/public/favicon-32x32.png differ diff --git a/frondend/public/favicon-48x48.png b/frondend/public/favicon-48x48.png new file mode 100644 index 0000000..65d0f64 Binary files /dev/null and b/frondend/public/favicon-48x48.png differ diff --git a/frondend/public/favicon-512x512.png b/frondend/public/favicon-512x512.png new file mode 100644 index 0000000..3e04f59 Binary files /dev/null and b/frondend/public/favicon-512x512.png differ diff --git a/frondend/public/favicon-64x64.png b/frondend/public/favicon-64x64.png new file mode 100644 index 0000000..00a3592 Binary files /dev/null and b/frondend/public/favicon-64x64.png differ diff --git a/frondend/public/favicon-96x96.png b/frondend/public/favicon-96x96.png new file mode 100644 index 0000000..577b7b8 Binary files /dev/null and b/frondend/public/favicon-96x96.png differ diff --git a/frondend/public/favicon.ico b/frondend/public/favicon.ico new file mode 100644 index 0000000..1119d6c Binary files /dev/null and b/frondend/public/favicon.ico differ diff --git a/frondend/public/favicon.svg b/frondend/public/favicon.svg index 6893eb1..abbbb00 100644 --- a/frondend/public/favicon.svg +++ b/frondend/public/favicon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/frondend/public/favicone.png b/frondend/public/favicone.png new file mode 100644 index 0000000..4bc8673 Binary files /dev/null and b/frondend/public/favicone.png differ diff --git a/frondend/public/site.webmanifest b/frondend/public/site.webmanifest new file mode 100644 index 0000000..1a1c79b --- /dev/null +++ b/frondend/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "PyDocAI", + "short_name": "PyDocAI", + "description": "AI-Powered Python Documentation Generator", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#4c8df3", + "icons": [ + { + "src": "/favicon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/backend/apps/__init__.py b/services/ai/__init__.py similarity index 100% rename from backend/apps/__init__.py rename to services/ai/__init__.py diff --git a/backend/apps/admin_dashboard/__init__.py b/services/ai/api/__init__.py similarity index 100% rename from backend/apps/admin_dashboard/__init__.py rename to services/ai/api/__init__.py 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/backend/apps/ai/__init__.py b/services/ai/api/routes/__init__.py similarity index 100% rename from backend/apps/ai/__init__.py rename to services/ai/api/routes/__init__.py 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/backend/apps/ai/migrations/__init__.py b/services/ai/common/__init__.py similarity index 100% rename from backend/apps/ai/migrations/__init__.py rename to services/ai/common/__init__.py 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/backend/apps/comments/__init__.py b/services/ai/config/__init__.py similarity index 100% rename from backend/apps/comments/__init__.py rename to services/ai/config/__init__.py 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/backend/services/ai/database.py b/services/ai/database.py similarity index 83% rename from backend/services/ai/database.py rename to services/ai/database.py index 27b8fa8..a105d21 100644 --- a/backend/services/ai/database.py +++ b/services/ai/database.py @@ -7,7 +7,13 @@ "postgresql://pydocai_user:pydocai_pass@localhost:5433/pydocai" ) -engine = create_engine(DATABASE_URL, pool_pre_ping=True) +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() 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/backend/services/ai/rag.py b/services/ai/rag.py similarity index 94% rename from backend/services/ai/rag.py rename to services/ai/rag.py index 955bdbf..d08581b 100644 --- a/backend/services/ai/rag.py +++ b/services/ai/rag.py @@ -8,6 +8,17 @@ _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: @@ -26,8 +37,9 @@ def embed_text(text: str) -> list[float]: 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 {} + 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}"] diff --git a/backend/services/ai/requirements.txt b/services/ai/requirements.txt similarity index 100% rename from backend/services/ai/requirements.txt rename to services/ai/requirements.txt diff --git a/backend/apps/comments/migrations/__init__.py b/services/ai/schemas/__init__.py similarity index 100% rename from backend/apps/comments/migrations/__init__.py rename to services/ai/schemas/__init__.py 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/backend/apps/exports/__init__.py b/services/ai/services/__init__.py similarity index 100% rename from backend/apps/exports/__init__.py rename to services/ai/services/__init__.py 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/backend/apps/exports/migrations/__init__.py b/services/ai/tests/__init__.py similarity index 100% rename from backend/apps/exports/migrations/__init__.py rename to services/ai/tests/__init__.py 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 diff --git a/backend/.gitignore b/services/core/.gitignore similarity index 100% rename from backend/.gitignore rename to services/core/.gitignore diff --git a/backend/.python-version b/services/core/.python-version similarity index 100% rename from backend/.python-version rename to services/core/.python-version diff --git a/backend/README.md b/services/core/README.md similarity index 100% rename from backend/README.md rename to services/core/README.md diff --git a/backend/apps/feedback/__init__.py b/services/core/apps/__init__.py similarity index 100% rename from backend/apps/feedback/__init__.py rename to services/core/apps/__init__.py diff --git a/backend/apps/feedback/migrations/__init__.py b/services/core/apps/admin_dashboard/__init__.py similarity index 100% rename from backend/apps/feedback/migrations/__init__.py rename to services/core/apps/admin_dashboard/__init__.py diff --git a/backend/apps/admin_dashboard/apps.py b/services/core/apps/admin_dashboard/apps.py similarity index 100% rename from backend/apps/admin_dashboard/apps.py rename to services/core/apps/admin_dashboard/apps.py 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/backend/apps/admin_dashboard/urls.py b/services/core/apps/admin_dashboard/urls.py similarity index 100% rename from backend/apps/admin_dashboard/urls.py rename to services/core/apps/admin_dashboard/urls.py 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/backend/apps/github_integration/__init__.py b/services/core/apps/ai/__init__.py similarity index 100% rename from backend/apps/github_integration/__init__.py rename to services/core/apps/ai/__init__.py diff --git a/backend/apps/ai/apps.py b/services/core/apps/ai/apps.py similarity index 100% rename from backend/apps/ai/apps.py rename to services/core/apps/ai/apps.py diff --git a/backend/apps/github_integration/migrations/__init__.py b/services/core/apps/ai/migrations/__init__.py similarity index 100% rename from backend/apps/github_integration/migrations/__init__.py rename to services/core/apps/ai/migrations/__init__.py 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/backend/apps/ai/urls.py b/services/core/apps/ai/urls.py similarity index 100% rename from backend/apps/ai/urls.py rename to services/core/apps/ai/urls.py 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/backend/apps/ai/views.py b/services/core/apps/ai/views/status.py similarity index 90% rename from backend/apps/ai/views.py rename to services/core/apps/ai/views/status.py index 5204e92..fc01938 100644 --- a/backend/apps/ai/views.py +++ b/services/core/apps/ai/views/status.py @@ -5,10 +5,6 @@ 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): @@ -33,7 +29,6 @@ def get(self, request): 'fallback_order': ['groq_primary', 'groq_fallback'], } - # Check primary Groq key if groq_key: try: from groq import Groq @@ -44,7 +39,6 @@ def get(self, request): 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 diff --git a/backend/apps/internal/__init__.py b/services/core/apps/comments/__init__.py similarity index 100% rename from backend/apps/internal/__init__.py rename to services/core/apps/comments/__init__.py diff --git a/backend/apps/comments/apps.py b/services/core/apps/comments/apps.py similarity index 100% rename from backend/apps/comments/apps.py rename to services/core/apps/comments/apps.py diff --git a/backend/apps/comments/migrations/0001_initial.py b/services/core/apps/comments/migrations/0001_initial.py similarity index 100% rename from backend/apps/comments/migrations/0001_initial.py rename to services/core/apps/comments/migrations/0001_initial.py diff --git a/backend/apps/notifications/__init__.py b/services/core/apps/comments/migrations/__init__.py similarity index 100% rename from backend/apps/notifications/__init__.py rename to services/core/apps/comments/migrations/__init__.py diff --git a/backend/apps/comments/models.py b/services/core/apps/comments/models.py similarity index 100% rename from backend/apps/comments/models.py rename to services/core/apps/comments/models.py diff --git a/backend/apps/comments/serializers.py b/services/core/apps/comments/serializers/__init__.py similarity index 98% rename from backend/apps/comments/serializers.py rename to services/core/apps/comments/serializers/__init__.py index e4145ad..1b83636 100644 --- a/backend/apps/comments/serializers.py +++ b/services/core/apps/comments/serializers/__init__.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Comment +from ..models import Comment class CommentSerializer(serializers.ModelSerializer): 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/backend/apps/comments/throttles.py b/services/core/apps/comments/throttles.py similarity index 100% rename from backend/apps/comments/throttles.py rename to services/core/apps/comments/throttles.py diff --git a/backend/apps/comments/urls.py b/services/core/apps/comments/urls.py similarity index 100% rename from backend/apps/comments/urls.py rename to services/core/apps/comments/urls.py 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/backend/apps/comments/views.py b/services/core/apps/comments/views/comments.py similarity index 70% rename from backend/apps/comments/views.py rename to services/core/apps/comments/views/comments.py index 29edb3b..29efaa0 100644 --- a/backend/apps/comments/views.py +++ b/services/core/apps/comments/views/comments.py @@ -1,14 +1,14 @@ from django.db import connection -from rest_framework import generics, permissions, status +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 +from ..models import Comment +from ..serializers import CommentCreateSerializer, CommentSerializer +from ..throttles import CommentRateThrottle class CommentListView(APIView): @@ -45,19 +45,28 @@ def get(self, request, project_id): }) -class CommentCreateView(generics.CreateAPIView): +class CommentCreateView(APIView): 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) + 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] @@ -68,9 +77,6 @@ def delete(self, request, pk): 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] - ) + comment.content = '[deleted]' + comment.save(update_fields=['content']) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/apps/notifications/migrations/__init__.py b/services/core/apps/common/__init__.py similarity index 100% rename from backend/apps/notifications/migrations/__init__.py rename to services/core/apps/common/__init__.py 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/backend/apps/parser/__init__.py b/services/core/apps/common/tests/__init__.py similarity index 100% rename from backend/apps/parser/__init__.py rename to services/core/apps/common/tests/__init__.py 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/backend/apps/parser/migrations/__init__.py b/services/core/apps/exports/__init__.py similarity index 100% rename from backend/apps/parser/migrations/__init__.py rename to services/core/apps/exports/__init__.py diff --git a/backend/apps/exports/apps.py b/services/core/apps/exports/apps.py similarity index 100% rename from backend/apps/exports/apps.py rename to services/core/apps/exports/apps.py diff --git a/backend/apps/exports/generators.py b/services/core/apps/exports/generators.py similarity index 95% rename from backend/apps/exports/generators.py rename to services/core/apps/exports/generators.py index 8f24bc3..e8c4901 100644 --- a/backend/apps/exports/generators.py +++ b/services/core/apps/exports/generators.py @@ -5,7 +5,7 @@ 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')): + if project.readme_docs or project.project_info: combined = "" # Add README if available diff --git a/backend/apps/projects/__init__.py b/services/core/apps/exports/migrations/__init__.py similarity index 100% rename from backend/apps/projects/__init__.py rename to services/core/apps/exports/migrations/__init__.py 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/backend/apps/exports/urls.py b/services/core/apps/exports/urls.py similarity index 100% rename from backend/apps/exports/urls.py rename to services/core/apps/exports/urls.py 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/backend/apps/exports/views.py b/services/core/apps/exports/views/folder.py similarity index 72% rename from backend/apps/exports/views.py rename to services/core/apps/exports/views/folder.py index 8db5253..b104e56 100644 --- a/backend/apps/exports/views.py +++ b/services/core/apps/exports/views/folder.py @@ -1,34 +1,14 @@ +import uuid + 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"'} - ) +from apps.projects.models import Project 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) 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/backend/apps/projects/migrations/__init__.py b/services/core/apps/feedback/__init__.py similarity index 100% rename from backend/apps/projects/migrations/__init__.py rename to services/core/apps/feedback/__init__.py diff --git a/backend/apps/feedback/admin.py b/services/core/apps/feedback/admin.py similarity index 100% rename from backend/apps/feedback/admin.py rename to services/core/apps/feedback/admin.py diff --git a/backend/apps/feedback/apps.py b/services/core/apps/feedback/apps.py similarity index 100% rename from backend/apps/feedback/apps.py rename to services/core/apps/feedback/apps.py diff --git a/backend/apps/feedback/email_utils.py b/services/core/apps/feedback/email_utils.py similarity index 100% rename from backend/apps/feedback/email_utils.py rename to services/core/apps/feedback/email_utils.py diff --git a/backend/apps/feedback/migrations/0001_initial.py b/services/core/apps/feedback/migrations/0001_initial.py similarity index 100% rename from backend/apps/feedback/migrations/0001_initial.py rename to services/core/apps/feedback/migrations/0001_initial.py diff --git a/backend/apps/feedback/migrations/0002_feedback_admin_response.py b/services/core/apps/feedback/migrations/0002_feedback_admin_response.py similarity index 100% rename from backend/apps/feedback/migrations/0002_feedback_admin_response.py rename to services/core/apps/feedback/migrations/0002_feedback_admin_response.py diff --git a/backend/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 similarity index 100% rename from backend/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py rename to services/core/apps/feedback/migrations/0003_remove_feedback_admin_response_and_more.py diff --git a/backend/apps/feedback/migrations/0004_remove_feedback_is_public.py b/services/core/apps/feedback/migrations/0004_remove_feedback_is_public.py similarity index 100% rename from backend/apps/feedback/migrations/0004_remove_feedback_is_public.py rename to services/core/apps/feedback/migrations/0004_remove_feedback_is_public.py diff --git a/backend/apps/universal/__init__.py b/services/core/apps/feedback/migrations/__init__.py similarity index 100% rename from backend/apps/universal/__init__.py rename to services/core/apps/feedback/migrations/__init__.py diff --git a/backend/apps/feedback/models.py b/services/core/apps/feedback/models.py similarity index 100% rename from backend/apps/feedback/models.py rename to services/core/apps/feedback/models.py diff --git a/backend/apps/feedback/serializers.py b/services/core/apps/feedback/serializers/__init__.py similarity index 96% rename from backend/apps/feedback/serializers.py rename to services/core/apps/feedback/serializers/__init__.py index 747baec..a6f86de 100644 --- a/backend/apps/feedback/serializers.py +++ b/services/core/apps/feedback/serializers/__init__.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Feedback, FeedbackReply +from ..models import Feedback, FeedbackReply class FeedbackReplySerializer(serializers.ModelSerializer): @@ -28,4 +28,3 @@ class Meta: '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/services/core/apps/feedback/tasks.py similarity index 100% rename from backend/apps/feedback/tasks.py rename to services/core/apps/feedback/tasks.py 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/backend/apps/feedback/urls.py b/services/core/apps/feedback/urls.py similarity index 100% rename from backend/apps/feedback/urls.py rename to services/core/apps/feedback/urls.py 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/backend/apps/users/__init__.py b/services/core/apps/github_integration/__init__.py similarity index 100% rename from backend/apps/users/__init__.py rename to services/core/apps/github_integration/__init__.py diff --git a/backend/apps/github_integration/apps.py b/services/core/apps/github_integration/apps.py similarity index 100% rename from backend/apps/github_integration/apps.py rename to services/core/apps/github_integration/apps.py 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/backend/apps/users/migrations/__init__.py b/services/core/apps/github_integration/migrations/__init__.py similarity index 100% rename from backend/apps/users/migrations/__init__.py rename to services/core/apps/github_integration/migrations/__init__.py diff --git a/backend/apps/github_integration/serializers.py b/services/core/apps/github_integration/serializers/__init__.py similarity index 100% rename from backend/apps/github_integration/serializers.py rename to services/core/apps/github_integration/serializers/__init__.py diff --git a/backend/apps/github_integration/tasks.py b/services/core/apps/github_integration/tasks.py similarity index 100% rename from backend/apps/github_integration/tasks.py rename to services/core/apps/github_integration/tasks.py 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/backend/apps/github_integration/urls.py b/services/core/apps/github_integration/urls.py similarity index 100% rename from backend/apps/github_integration/urls.py rename to services/core/apps/github_integration/urls.py 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/backend/apps/github_integration/views.py b/services/core/apps/github_integration/views/public.py similarity index 53% rename from backend/apps/github_integration/views.py rename to services/core/apps/github_integration/views/public.py index e9f5cfe..e190d98 100644 --- a/backend/apps/github_integration/views.py +++ b/services/core/apps/github_integration/views/public.py @@ -1,5 +1,3 @@ -import re - from github import GithubException from rest_framework import status from rest_framework.permissions import IsAuthenticated @@ -8,109 +6,15 @@ 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) +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): - """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') @@ -138,7 +42,6 @@ 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) @@ -167,7 +70,6 @@ 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', '/') 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/backend/config/settings/__init__.py b/services/core/apps/internal/__init__.py similarity index 100% rename from backend/config/settings/__init__.py rename to services/core/apps/internal/__init__.py 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/backend/apps/notifications/apps.py b/services/core/apps/notifications/apps.py similarity index 100% rename from backend/apps/notifications/apps.py rename to services/core/apps/notifications/apps.py diff --git a/backend/apps/notifications/migrations/0001_initial.py b/services/core/apps/notifications/migrations/0001_initial.py similarity index 100% rename from backend/apps/notifications/migrations/0001_initial.py rename to services/core/apps/notifications/migrations/0001_initial.py 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/backend/apps/notifications/models.py b/services/core/apps/notifications/models.py similarity index 100% rename from backend/apps/notifications/models.py rename to services/core/apps/notifications/models.py diff --git a/backend/apps/notifications/serializers.py b/services/core/apps/notifications/serializers/__init__.py similarity index 92% rename from backend/apps/notifications/serializers.py rename to services/core/apps/notifications/serializers/__init__.py index 7eb36e5..9cf9cb1 100644 --- a/backend/apps/notifications/serializers.py +++ b/services/core/apps/notifications/serializers/__init__.py @@ -2,7 +2,7 @@ from apps.comments.serializers import CommentSerializer -from .models import Notification +from ..models import Notification class NotificationSerializer(serializers.ModelSerializer): diff --git a/backend/apps/notifications/tasks.py b/services/core/apps/notifications/tasks.py similarity index 100% rename from backend/apps/notifications/tasks.py rename to services/core/apps/notifications/tasks.py 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/backend/apps/notifications/urls.py b/services/core/apps/notifications/urls.py similarity index 100% rename from backend/apps/notifications/urls.py rename to services/core/apps/notifications/urls.py 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/backend/apps/notifications/views.py b/services/core/apps/notifications/views/notifications.py similarity index 95% rename from backend/apps/notifications/views.py rename to services/core/apps/notifications/views/notifications.py index 6f1d1b1..3dc5df9 100644 --- a/backend/apps/notifications/views.py +++ b/services/core/apps/notifications/views/notifications.py @@ -2,8 +2,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .models import Notification -from .serializers import NotificationSerializer +from ..models import Notification +from ..serializers import NotificationSerializer class NotificationListView(APIView): 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/backend/apps/parser/apps.py b/services/core/apps/parser/apps.py similarity index 100% rename from backend/apps/parser/apps.py rename to services/core/apps/parser/apps.py diff --git a/backend/apps/parser/ast_parser.py b/services/core/apps/parser/ast_parser.py similarity index 94% rename from backend/apps/parser/ast_parser.py rename to services/core/apps/parser/ast_parser.py index abe7112..6811f2f 100644 --- a/backend/apps/parser/ast_parser.py +++ b/services/core/apps/parser/ast_parser.py @@ -25,12 +25,12 @@ def parse_python_file(source_code: str) -> dict: # First pass: collect top-level definitions to know what names exist for node in tree.body: - if isinstance(node, ast.FunctionDef): + 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): + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): top_level_names.add(item.name) # Second pass: ordered traversal of tree.body @@ -44,7 +44,7 @@ def parse_python_file(source_code: str) -> dict: "data": import_entry }) - elif isinstance(node, ast.FunctionDef): + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): func_data = _extract_function(node, top_level_names) result["functions"].append(func_data) result["ordered_items"].append({ @@ -110,7 +110,7 @@ def _extract_class(node, available_names: set) -> dict[str, Any]: # Process class body in order for item in node.body: - if isinstance(item, ast.FunctionDef): + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): method_connections = _find_connections(item, available_names) methods.append({ "name": item.name, 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/backend/apps/parser/tasks.py b/services/core/apps/parser/tasks.py similarity index 98% rename from backend/apps/parser/tasks.py rename to services/core/apps/parser/tasks.py index 55d4347..ef42f94 100644 --- a/backend/apps/parser/tasks.py +++ b/services/core/apps/parser/tasks.py @@ -6,6 +6,7 @@ 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 @@ -13,7 +14,6 @@ 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): 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/backend/apps/parser/urls.py b/services/core/apps/parser/urls.py similarity index 100% rename from backend/apps/parser/urls.py rename to services/core/apps/parser/urls.py diff --git a/backend/apps/parser/validators.py b/services/core/apps/parser/validators.py similarity index 100% rename from backend/apps/parser/validators.py rename to services/core/apps/parser/validators.py 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/backend/apps/parser/views.py b/services/core/apps/parser/views/folder.py similarity index 61% rename from backend/apps/parser/views.py rename to services/core/apps/parser/views/folder.py index 5ee08f9..b4d35cc 100644 --- a/backend/apps/parser/views.py +++ b/services/core/apps/parser/views/folder.py @@ -8,7 +8,7 @@ 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.tasks import parse_folder_task from apps.parser.validators import should_exclude from apps.projects.models import Project @@ -21,19 +21,15 @@ def post(self, request): 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: @@ -80,42 +76,3 @@ def post(self, request): 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/services/core/apps/projects/__init__.py b/services/core/apps/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/projects/admin.py b/services/core/apps/projects/admin.py similarity index 100% rename from backend/apps/projects/admin.py rename to services/core/apps/projects/admin.py diff --git a/backend/apps/projects/apps.py b/services/core/apps/projects/apps.py similarity index 100% rename from backend/apps/projects/apps.py rename to services/core/apps/projects/apps.py diff --git a/backend/apps/projects/migrations/0001_initial.py b/services/core/apps/projects/migrations/0001_initial.py similarity index 100% rename from backend/apps/projects/migrations/0001_initial.py rename to services/core/apps/projects/migrations/0001_initial.py diff --git a/backend/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 similarity index 100% rename from backend/apps/projects/migrations/0002_project_project_info_project_readme_docs.py rename to services/core/apps/projects/migrations/0002_project_project_info_project_readme_docs.py diff --git a/backend/apps/projects/migrations/0003_project_api_docs.py b/services/core/apps/projects/migrations/0003_project_api_docs.py similarity index 100% rename from backend/apps/projects/migrations/0003_project_api_docs.py rename to services/core/apps/projects/migrations/0003_project_api_docs.py diff --git a/backend/apps/projects/migrations/0004_project_custom_details.py b/services/core/apps/projects/migrations/0004_project_custom_details.py similarity index 100% rename from backend/apps/projects/migrations/0004_project_custom_details.py rename to services/core/apps/projects/migrations/0004_project_custom_details.py diff --git a/backend/apps/projects/migrations/0005_remove_file_fields.py b/services/core/apps/projects/migrations/0005_remove_file_fields.py similarity index 100% rename from backend/apps/projects/migrations/0005_remove_file_fields.py rename to services/core/apps/projects/migrations/0005_remove_file_fields.py diff --git a/backend/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 similarity index 100% rename from backend/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py rename to services/core/apps/projects/migrations/0006_project_framework_info_project_is_published_and_more.py 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/backend/apps/projects/models.py b/services/core/apps/projects/models/project.py similarity index 57% rename from backend/apps/projects/models.py rename to services/core/apps/projects/models/project.py index 9c59d07..4a13c9c 100644 --- a/backend/apps/projects/models.py +++ b/services/core/apps/projects/models/project.py @@ -2,8 +2,6 @@ from django.db import models -from apps.users.models import User - class Project(models.Model): @@ -19,33 +17,27 @@ class SourceType(models.TextChoices): 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') + 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) - # 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 + 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) - # 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) @@ -65,7 +57,7 @@ class Meta: ] def __str__(self): - return f'{self.name} ({self.source_type}) — {self.user.email}' + return f'{self.name} ({self.source_type}) \u2014 {self.user.email}' @property def is_done(self): @@ -74,26 +66,3 @@ def is_done(self): @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/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/backend/apps/projects/serializers.py b/services/core/apps/projects/serializers/project.py similarity index 58% rename from backend/apps/projects/serializers.py rename to services/core/apps/projects/serializers/project.py index 3b87001..b423d32 100644 --- a/backend/apps/projects/serializers.py +++ b/services/core/apps/projects/serializers/project.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from .models import Project, ProjectFile +from ..models import Project, ProjectFile class ProjectFileSerializer(serializers.ModelSerializer): @@ -8,6 +8,7 @@ class Meta: model = ProjectFile fields = '__all__' + class ProjectSerializer(serializers.ModelSerializer): files = ProjectFileSerializer(many=True, read_only=True) @@ -27,10 +28,14 @@ class Meta: '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) + file_count = serializers.SerializerMethodField() + + def get_file_count(self, obj): + return getattr(obj, 'file_count', obj.files.count()) class Meta: model = Project @@ -39,29 +44,3 @@ class Meta: '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/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/backend/apps/projects/throttles.py b/services/core/apps/projects/throttles.py similarity index 100% rename from backend/apps/projects/throttles.py rename to services/core/apps/projects/throttles.py diff --git a/backend/apps/projects/urls.py b/services/core/apps/projects/urls.py similarity index 100% rename from backend/apps/projects/urls.py rename to services/core/apps/projects/urls.py 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/backend/apps/universal/apps.py b/services/core/apps/universal/apps.py similarity index 100% rename from backend/apps/universal/apps.py rename to services/core/apps/universal/apps.py diff --git a/backend/apps/universal/prompts.py b/services/core/apps/universal/prompts.py similarity index 100% rename from backend/apps/universal/prompts.py rename to services/core/apps/universal/prompts.py diff --git a/backend/apps/universal/tasks.py b/services/core/apps/universal/tasks.py similarity index 87% rename from backend/apps/universal/tasks.py rename to services/core/apps/universal/tasks.py index 680de80..2e985e8 100644 --- a/backend/apps/universal/tasks.py +++ b/services/core/apps/universal/tasks.py @@ -1,9 +1,6 @@ -import io import logging import os -import zipfile -import requests from celery import shared_task from django.conf import settings from groq import Groq @@ -248,45 +245,7 @@ def generate_universal_docs_task(self, project_id, mode): # ── 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() +from apps.common.github import download_zipball, fetch_public_repo_api @shared_task(bind=True, max_retries=2, default_retry_delay=30) @@ -306,7 +265,7 @@ def import_universal_github_task(self, project_id, mode, full_name, folder_path, 'Authorization': f'token {github_token}', } else: - repo_data = _fetch_public_repo_api(full_name) + 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'} @@ -316,7 +275,7 @@ def import_universal_github_task(self, project_id, mode, full_name, folder_path, else: url = f'https://github.com/{full_name}/archive/refs/heads/{branch}.zip' - raw_files = _download_github_zipball(url, headers, folder_path) + raw_files = download_zipball(url, headers, folder_path) if not raw_files: project.status = Project.Status.FAILED 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/backend/apps/universal/urls.py b/services/core/apps/universal/urls.py similarity index 100% rename from backend/apps/universal/urls.py rename to services/core/apps/universal/urls.py 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/backend/apps/universal/views.py b/services/core/apps/universal/views/upload.py similarity index 85% rename from backend/apps/universal/views.py rename to services/core/apps/universal/views/upload.py index 9ae46c0..6bbecd3 100644 --- a/backend/apps/universal/views.py +++ b/services/core/apps/universal/views/upload.py @@ -86,7 +86,6 @@ def _handle_github(self, project, mode, github_url, request): 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']) @@ -123,24 +122,3 @@ def _process_single_file(self, project, uploaded_file): 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/services/core/apps/users/__init__.py b/services/core/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/users/admin.py b/services/core/apps/users/admin.py similarity index 100% rename from backend/apps/users/admin.py rename to services/core/apps/users/admin.py diff --git a/backend/apps/users/apps.py b/services/core/apps/users/apps.py similarity index 100% rename from backend/apps/users/apps.py rename to services/core/apps/users/apps.py diff --git a/backend/apps/users/email_utils.py b/services/core/apps/users/email_utils.py similarity index 100% rename from backend/apps/users/email_utils.py rename to services/core/apps/users/email_utils.py diff --git a/backend/apps/users/github.py b/services/core/apps/users/github.py similarity index 100% rename from backend/apps/users/github.py rename to services/core/apps/users/github.py diff --git a/backend/apps/users/management/commands/create_test_user.py b/services/core/apps/users/management/commands/create_test_user.py similarity index 100% rename from backend/apps/users/management/commands/create_test_user.py rename to services/core/apps/users/management/commands/create_test_user.py diff --git a/backend/apps/users/migrations/0001_initial.py b/services/core/apps/users/migrations/0001_initial.py similarity index 100% rename from backend/apps/users/migrations/0001_initial.py rename to services/core/apps/users/migrations/0001_initial.py diff --git a/backend/apps/users/migrations/0002_user_name.py b/services/core/apps/users/migrations/0002_user_name.py similarity index 100% rename from backend/apps/users/migrations/0002_user_name.py rename to services/core/apps/users/migrations/0002_user_name.py diff --git a/backend/apps/users/migrations/0003_passwordresettoken.py b/services/core/apps/users/migrations/0003_passwordresettoken.py similarity index 100% rename from backend/apps/users/migrations/0003_passwordresettoken.py rename to services/core/apps/users/migrations/0003_passwordresettoken.py 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/backend/apps/users/models.py b/services/core/apps/users/models/user.py similarity index 72% rename from backend/apps/users/models.py rename to services/core/apps/users/models/user.py index 9001415..93819ea 100644 --- a/backend/apps/users/models.py +++ b/services/core/apps/users/models/user.py @@ -2,7 +2,6 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin from django.db import models -from django.utils import timezone class UserManager(BaseUserManager): @@ -60,24 +59,4 @@ def is_admin(self): @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/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/backend/apps/users/serializers.py b/services/core/apps/users/serializers/profile.py similarity index 52% rename from backend/apps/users/serializers.py rename to services/core/apps/users/serializers/profile.py index c7bcbff..550e830 100644 --- a/backend/apps/users/serializers.py +++ b/services/core/apps/users/serializers/profile.py @@ -1,39 +1,6 @@ -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 +from ..models import User class UserSerializer(serializers.ModelSerializer): @@ -71,7 +38,6 @@ 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( @@ -81,7 +47,6 @@ def validate(self, data): raise serializers.ValidationError( {'old_password': 'Old password is incorrect'} ) - # github user — no old password needed, just set the new one return data @@ -89,17 +54,3 @@ 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/services/core/apps/users/tasks.py similarity index 100% rename from backend/apps/users/tasks.py rename to services/core/apps/users/tasks.py 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/backend/apps/users/urls.py b/services/core/apps/users/urls.py similarity index 100% rename from backend/apps/users/urls.py rename to services/core/apps/users/urls.py 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/backend/apps/users/views.py b/services/core/apps/users/views/auth.py similarity index 50% rename from backend/apps/users/views.py rename to services/core/apps/users/views/auth.py index 484acc1..9173789 100644 --- a/backend/apps/users/views.py +++ b/services/core/apps/users/views/auth.py @@ -1,20 +1,16 @@ import requests -from rest_framework import generics, status -from rest_framework.pagination import PageNumberPagination +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 PasswordResetToken, User -from .serializers import ( - ChangePasswordSerializer, +from ..github import exchange_code_for_token, get_github_user +from ..models import User +from ..serializers import ( GithubAuthSerializer, LoginSerializer, - PasswordResetConfirmSerializer, - PasswordResetRequestSerializer, RegisterSerializer, UserSerializer, ) @@ -36,7 +32,7 @@ def post(self, request): if serializer.is_valid(): user = serializer.save() tokens = get_tokens(user) - from .tasks import send_welcome_email_task + from ..tasks import send_welcome_email_task send_welcome_email_task.delay(user.id) return Response({ 'user': UserSerializer(user).data, @@ -74,61 +70,6 @@ def post(self, request): 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] @@ -177,7 +118,6 @@ def post(self, request): }, ) - # Keep the token fresh and ensure is_verified update_fields = ['github_token'] user.github_token = github_token if not user.is_verified: @@ -194,44 +134,3 @@ def post(self, request): }, 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/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/backend/config/__init__.py b/services/core/config/__init__.py similarity index 100% rename from backend/config/__init__.py rename to services/core/config/__init__.py diff --git a/backend/config/asgi.py b/services/core/config/asgi.py similarity index 91% rename from backend/config/asgi.py rename to services/core/config/asgi.py index 69ab9a8..0d8fb96 100644 --- a/backend/config/asgi.py +++ b/services/core/config/asgi.py @@ -2,6 +2,6 @@ from django.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') application = get_asgi_application() diff --git a/backend/config/celery.py b/services/core/config/celery.py similarity index 93% rename from backend/config/celery.py rename to services/core/config/celery.py index 8601098..3623618 100644 --- a/backend/config/celery.py +++ b/services/core/config/celery.py @@ -2,7 +2,7 @@ from celery import Celery -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') app = Celery('config') app.config_from_object('django.conf:settings', namespace='CELERY') 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/backend/config/settings/base.py b/services/core/config/settings/base.py similarity index 72% rename from backend/config/settings/base.py rename to services/core/config/settings/base.py index 4206378..ed3af89 100644 --- a/backend/config/settings/base.py +++ b/services/core/config/settings/base.py @@ -3,7 +3,7 @@ from decouple import config from dotenv import load_dotenv -env_path = Path(__file__).resolve().parent.parent.parent / '.env' +env_path = Path(__file__).resolve().parent.parent.parent / 'env/.env' load_dotenv(env_path) BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -33,6 +33,8 @@ 'apps.comments', 'apps.notifications', 'apps.universal', + 'apps.common', + 'drf_spectacular', ] MIDDLEWARE = [ @@ -113,6 +115,14 @@ '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 @@ -130,7 +140,6 @@ 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) @@ -140,7 +149,6 @@ # ── EMAIL SETTINGS ────────────────────────────────────────── -from datetime import timedelta EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com') @@ -153,3 +161,50 @@ 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/backend/config/settings/development.py b/services/core/config/settings/development.py similarity index 76% rename from backend/config/settings/development.py rename to services/core/config/settings/development.py index 5e35f18..b573db6 100644 --- a/backend/config/settings/development.py +++ b/services/core/config/settings/development.py @@ -1,3 +1,5 @@ +from decouple import config + from config.settings.base import * DEBUG = True @@ -10,8 +12,8 @@ } } -CELERY_BROKER_URL = 'redis://localhost:6379/0' -CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' +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 diff --git a/backend/config/settings/production.py b/services/core/config/settings/production.py similarity index 77% rename from backend/config/settings/production.py rename to services/core/config/settings/production.py index fd63d38..7696cbf 100644 --- a/backend/config/settings/production.py +++ b/services/core/config/settings/production.py @@ -14,12 +14,14 @@ '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') @@ -28,12 +30,20 @@ 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') -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', @@ -81,3 +91,7 @@ "TIMEOUT": 300, } } + +# ── JSON LOGGING IN PRODUCTION ────────────────────────────── +LOGGING['handlers']['console']['formatter'] = 'json' +LOGGING['root']['level'] = 'WARNING' diff --git a/backend/config/storage_backends.py b/services/core/config/storage_backends.py similarity index 100% rename from backend/config/storage_backends.py rename to services/core/config/storage_backends.py diff --git a/backend/config/urls.py b/services/core/config/urls.py similarity index 68% rename from backend/config/urls.py rename to services/core/config/urls.py index 612fecf..5c643ec 100644 --- a/backend/config/urls.py +++ b/services/core/config/urls.py @@ -1,9 +1,12 @@ 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')), @@ -19,4 +22,8 @@ # 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/backend/config/wsgi.py b/services/core/config/wsgi.py similarity index 91% rename from backend/config/wsgi.py rename to services/core/config/wsgi.py index 85585bf..e2f82e6 100644 --- a/backend/config/wsgi.py +++ b/services/core/config/wsgi.py @@ -2,6 +2,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') application = get_wsgi_application() diff --git a/backend/Procfile b/services/core/deploy/Procfile similarity index 100% rename from backend/Procfile rename to services/core/deploy/Procfile diff --git a/backend/.dockerignore b/services/core/docker/.dockerignore similarity index 100% rename from backend/.dockerignore rename to services/core/docker/.dockerignore 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/backend/.env.example b/services/core/env/.env.example similarity index 94% rename from backend/.env.example rename to services/core/env/.env.example index 007b7b1..976a2c7 100644 --- a/backend/.env.example +++ b/services/core/env/.env.example @@ -20,6 +20,7 @@ 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= diff --git a/backend/manage.py b/services/core/manage.py similarity index 100% rename from backend/manage.py rename to services/core/manage.py diff --git a/backend/pyproject.toml b/services/core/pyproject.toml similarity index 95% rename from backend/pyproject.toml rename to services/core/pyproject.toml index 99d0d96..df92585 100644 --- a/backend/pyproject.toml +++ b/services/core/pyproject.toml @@ -18,15 +18,17 @@ dependencies = [ "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", + "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", diff --git a/backend/requirements/base.txt b/services/core/requirements/base.txt similarity index 100% rename from backend/requirements/base.txt rename to services/core/requirements/base.txt diff --git a/backend/requirements/development.txt b/services/core/requirements/development.txt similarity index 100% rename from backend/requirements/development.txt rename to services/core/requirements/development.txt diff --git a/backend/requirements/production.txt b/services/core/requirements/production.txt similarity index 100% rename from backend/requirements/production.txt rename to services/core/requirements/production.txt 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/backend/templates/emails/feedback_confirmation.html b/services/core/templates/emails/feedback_confirmation.html similarity index 100% rename from backend/templates/emails/feedback_confirmation.html rename to services/core/templates/emails/feedback_confirmation.html diff --git a/backend/templates/emails/feedback_confirmation.txt b/services/core/templates/emails/feedback_confirmation.txt similarity index 100% rename from backend/templates/emails/feedback_confirmation.txt rename to services/core/templates/emails/feedback_confirmation.txt diff --git a/backend/templates/emails/feedback_reply.html b/services/core/templates/emails/feedback_reply.html similarity index 100% rename from backend/templates/emails/feedback_reply.html rename to services/core/templates/emails/feedback_reply.html diff --git a/backend/templates/emails/feedback_reply.txt b/services/core/templates/emails/feedback_reply.txt similarity index 100% rename from backend/templates/emails/feedback_reply.txt rename to services/core/templates/emails/feedback_reply.txt 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/backend/templates/emails/password_reset.html b/services/core/templates/emails/password_reset.html similarity index 100% rename from backend/templates/emails/password_reset.html rename to services/core/templates/emails/password_reset.html diff --git a/backend/templates/emails/password_reset.txt b/services/core/templates/emails/password_reset.txt similarity index 100% rename from backend/templates/emails/password_reset.txt rename to services/core/templates/emails/password_reset.txt diff --git a/backend/templates/emails/welcome.html b/services/core/templates/emails/welcome.html similarity index 100% rename from backend/templates/emails/welcome.html rename to services/core/templates/emails/welcome.html diff --git a/backend/templates/emails/welcome.txt b/services/core/templates/emails/welcome.txt similarity index 100% rename from backend/templates/emails/welcome.txt rename to services/core/templates/emails/welcome.txt diff --git a/backend/uv.lock b/services/core/uv.lock similarity index 55% rename from backend/uv.lock rename to services/core/uv.lock index 1b29cdd..ea584f3 100644 --- a/backend/uv.lock +++ b/services/core/uv.lock @@ -2,11 +2,9 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", + "python_full_version >= '3.13'", "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version < '3.12'", ] [[package]] @@ -32,7 +30,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.100.0" +version = "0.111.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -44,23 +42,23 @@ dependencies = [ { 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" } +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/5d/a0/c775c59ab9445ecabb57ef3d5c24027de060139189a9e312ef9ef889a665/anthropic-0.100.0-py3-none-any.whl", hash = "sha256:1c15769efa15d8fd5c1ebf900e25c57e3ee540f8554a29aa56e4edefffe2951d", size = 753596, upload-time = "2026-05-06T15:07:12.106Z" }, + { 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.13.0" +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/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +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/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, + { 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]] @@ -93,8 +91,8 @@ dependencies = [ { 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", 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" }, @@ -113,6 +111,7 @@ dependencies = [ { name = "pytest-django" }, { name = "python-decouple" }, { name = "python-dotenv" }, + { name = "python-json-logger" }, { name = "redis" }, { name = "requests" }, { name = "ruff" }, @@ -144,7 +143,7 @@ requires-dist = [ { 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 = "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" }, @@ -157,6 +156,7 @@ requires-dist = [ { 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" }, @@ -178,30 +178,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.18" +version = "1.43.36" 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" } +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/a7/75/fcb2c10d516496536c50e397248de42a673d8ea8137caf5b578a72b11293/boto3-1.43.18-py3-none-any.whl", hash = "sha256:7b62ce5c0a51428d692aa4f2adc9dc2a4a4c2989bf65a0a12834eeffa99b0b84", size = 140538, upload-time = "2026-05-29T19:33:27.131Z" }, + { 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.18" +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/01/6d/436d69ec484ffc43635b38d7fb7d717d38824671f10e12e77924019ca929/botocore-1.43.18.tar.gz", hash = "sha256:dc8c105351b49688c667065cd5a45fc5b9db982657cefc9e3fbfb9417a55c7df", size = 15424886, upload-time = "2026-05-29T19:33:16.251Z" } +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/21/5a/35c92c0af1514581031fe66c398b622176b3c928a6d5cf8133c7207e3bd7/botocore-1.43.18-py3-none-any.whl", hash = "sha256:e2610fce16df9f89deab5f3c163430a814e6804034eb95bef8957c8db60b7dbc", size = 15106258, upload-time = "2026-05-29T19:33:11.18Z" }, + { 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]] @@ -310,11 +310,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.6.17" 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" } +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/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { 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]] @@ -506,14 +506,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.4.1" 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" } +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/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { 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]] @@ -564,175 +564,157 @@ wheels = [ [[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" }, +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 = "47.0.0" +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/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" }, +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]] @@ -759,29 +741,27 @@ wheels = [ [[package]] name = "django" -version = "5.2.13" +version = "5.2.15" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "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/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" } +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/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" }, + { 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.4" +version = "6.0.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", + "python_full_version >= '3.13'", "python_full_version == '3.12.*'", ] dependencies = [ @@ -789,9 +769,9 @@ dependencies = [ { 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" } +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/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" }, + { 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]] @@ -799,8 +779,8 @@ 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 = "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'" }, @@ -817,8 +797,8 @@ 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'" }, + { 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 = [ @@ -827,11 +807,11 @@ wheels = [ [[package]] name = "django-environ" -version = "0.13.0" +version = "0.14.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" } +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/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" }, + { 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]] @@ -839,8 +819,8 @@ 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'" }, + { 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 = [ @@ -852,8 +832,8 @@ 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'" }, + { 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 = [ @@ -865,8 +845,8 @@ 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'" }, + { 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 = [ @@ -878,8 +858,8 @@ 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 = "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" }, ] @@ -902,7 +882,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { 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 = [ @@ -911,59 +891,59 @@ wheels = [ [[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" }, +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] @@ -975,7 +955,7 @@ woff = [ [[package]] name = "gevent" -version = "26.5.0" +version = "25.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, @@ -983,67 +963,59 @@ dependencies = [ { 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" }, +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.50.0" +version = "2.55.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" } +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/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" }, + { 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] @@ -1053,7 +1025,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.74.0" +version = "2.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1067,100 +1039,100 @@ dependencies = [ { 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" } +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/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" }, + { 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.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" }, +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.2.0" +version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1170,9 +1142,9 @@ dependencies = [ { 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" } +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/0c/82/748639c95c60ad8846c65b167ca611c815d06d5f67a9e73b23486dce4fdf/groq-1.2.0-py3-none-any.whl", hash = "sha256:1002060a743b27c8f86765e1bc9749c98498e961d9fe2e4902bf7804a71c3c84", size = 142334, upload-time = "2026-04-18T10:43:49.125Z" }, + { 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]] @@ -1226,11 +1198,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.18" 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" } +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/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { 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]] @@ -1244,105 +1216,105 @@ wheels = [ [[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" }, +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]] @@ -1601,7 +1573,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.3" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1609,125 +1581,125 @@ dependencies = [ { 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" } +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/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, + { 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.3" +version = "2.46.4" 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" }, +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]] @@ -1766,14 +1738,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.12.1" +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/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +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/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, + { 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] @@ -1827,7 +1799,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1838,9 +1810,9 @@ dependencies = [ { 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" } +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/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { 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]] @@ -1885,21 +1857,30 @@ 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 = "7.4.0" +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/7b/7f/3759b1d0d72b7c92f0d70ffd9dc962b7b7b5ee74e135f9d7d8ab06b8a318/redis-7.4.0.tar.gz", hash = "sha256:64a6ea7bf567ad43c964d2c30d82853f8df927c5c9017766c55a1d1ed95d18ad", size = 4943913, upload-time = "2026-03-24T09:14:37.53Z" } +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/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" }, + { 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.33.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1907,46 +1888,46 @@ dependencies = [ { 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" } +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/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { 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.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" }, +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.18.0" +version = "0.19.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" } +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/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" }, + { 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]] @@ -2095,23 +2076,23 @@ wheels = [ [[package]] name = "tzlocal" -version = "5.3.1" +version = "5.4.3" 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" } +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/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, + { 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.6.3" +version = "2.7.0" 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" } +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/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { 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]] @@ -2125,16 +2106,16 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.6.0" +version = "0.8.1" 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" } +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/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, + { 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 = "68.1" +version = "69.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, @@ -2146,9 +2127,9 @@ dependencies = [ { 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" } +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/dd/dd/14eb73cea481ad8162d3b18a4850d4a84d6e804a22840cca207648532265/weasyprint-68.1-py3-none-any.whl", hash = "sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be", size = 319789, upload-time = "2026-02-06T15:04:09.189Z" }, + { 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]] @@ -2296,19 +2277,20 @@ wheels = [ [[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" }, +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" }, ] 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/backend/services/parser/ast_parser.py b/services/parser/ast_parser.py similarity index 100% rename from backend/services/parser/ast_parser.py rename to services/parser/ast_parser.py 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/backend/services/parser/framework_detector.py b/services/parser/framework_detector.py similarity index 100% rename from backend/services/parser/framework_detector.py rename to services/parser/framework_detector.py 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/backend/services/parser/requirements.txt b/services/parser/requirements.txt similarity index 68% rename from backend/services/parser/requirements.txt rename to services/parser/requirements.txt index 5476fe9..7529897 100644 --- a/backend/services/parser/requirements.txt +++ b/services/parser/requirements.txt @@ -1,9 +1,6 @@ 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/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/backend/services/parser/test_main.py b/services/parser/tests/test_routes.py similarity index 56% rename from backend/services/parser/test_main.py rename to services/parser/tests/test_routes.py index 97adcaf..7699121 100644 --- a/backend/services/parser/test_main.py +++ b/services/parser/tests/test_routes.py @@ -14,19 +14,7 @@ @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 + monkeypatch.setenv("DJANGO_INTERNAL_URL", "http://localhost:8000/api/internal") class TestHealth: @@ -53,19 +41,13 @@ def test_rejects_invalid_python(self): ) 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 + @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( @@ -87,18 +69,15 @@ def test_rejects_non_zip(self): ) 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): + @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_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 + 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: @@ -115,19 +94,21 @@ def test_parses_zip(self, mock_parse, mock_validate, mock_exclude, mock_db): 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}") + @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 - def test_404_for_unknown(self, mock_db): - mock_db.query.return_value.filter.return_value.first.return_value = None + @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/backend/services/parser/validators.py b/services/parser/validators.py similarity index 100% rename from backend/services/parser/validators.py rename to services/parser/validators.py