mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
11 Commits
refacto/bl
...
hack2025/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cb7aeb7ec | ||
|
|
23860065e1 | ||
|
|
f459c56121 | ||
|
|
4a81e1526e | ||
|
|
abcd61cf2f | ||
|
|
c1a591fb4f | ||
|
|
83d8478b5d | ||
|
|
6bd136c76e | ||
|
|
e929fcc682 | ||
|
|
fa819bc1ff | ||
|
|
43e529da2a |
41
.github/workflows/docker-hub.yml
vendored
41
.github/workflows/docker-hub.yml
vendored
@@ -5,13 +5,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'main'
|
- 'do-not-merge/hackathon-2025'
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
- 'ci/trivy-fails'
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOCKER_USER: 1001:127
|
DOCKER_USER: 1001:127
|
||||||
@@ -31,7 +25,6 @@ jobs:
|
|||||||
images: lasuite/impress-backend
|
images: lasuite/impress-backend
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
@@ -43,10 +36,10 @@ jobs:
|
|||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
push: true
|
||||||
context: .
|
context: .
|
||||||
target: backend-production
|
target: backend-production
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -64,7 +57,6 @@ jobs:
|
|||||||
images: lasuite/impress-frontend
|
images: lasuite/impress-frontend
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
@@ -76,13 +68,13 @@ jobs:
|
|||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
push: true
|
||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/Dockerfile
|
file: ./src/frontend/Dockerfile
|
||||||
target: frontend-production
|
target: frontend-production
|
||||||
build-args: |
|
build-args: |
|
||||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
PUBLISH_AS_MIT=false
|
PUBLISH_AS_MIT=false
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -100,7 +92,6 @@ jobs:
|
|||||||
images: lasuite/impress-y-provider
|
images: lasuite/impress-y-provider
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||||
-
|
-
|
||||||
name: Run trivy scan
|
name: Run trivy scan
|
||||||
@@ -112,11 +103,34 @@ jobs:
|
|||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
|
push: true
|
||||||
context: .
|
context: .
|
||||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||||
target: y-provider
|
target: y-provider
|
||||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
build-and-push-mcp-server:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: lasuite/impress-mcp-server
|
||||||
|
- name: Login to DockerHub
|
||||||
|
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
context: ./src/mcp_server
|
||||||
|
file: ./src/mcp_server/Dockerfile
|
||||||
|
build-args: |
|
||||||
|
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -125,7 +139,6 @@ jobs:
|
|||||||
- build-and-push-frontend
|
- build-and-push-frontend
|
||||||
- build-and-push-backend
|
- build-and-push-backend
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||||
id: notify
|
id: notify
|
||||||
|
|||||||
11
bin/Tiltfile
11
bin/Tiltfile
@@ -39,10 +39,19 @@ docker_build(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
docker_build(
|
||||||
|
'localhost:5001/impress-mcp-server:latest',
|
||||||
|
context='../src/mcp_server',
|
||||||
|
dockerfile='../src/mcp_server/Dockerfile',
|
||||||
|
)
|
||||||
|
|
||||||
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
|
k8s_resource('impress-docs-backend-migrate', resource_deps=['postgres-postgresql'])
|
||||||
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
|
k8s_resource('impress-docs-backend-createsuperuser', resource_deps=['impress-docs-backend-migrate'])
|
||||||
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
|
k8s_resource('impress-docs-backend', resource_deps=['impress-docs-backend-migrate'])
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .'))
|
|
||||||
|
# helmfile in docker mount the current working directory and the helmfile.yaml
|
||||||
|
# requires the keycloak config in another directory
|
||||||
|
k8s_yaml(local('cd .. && helmfile -n impress -e ${DEV_ENV:-dev} template --file ./src/helm/helmfile.yaml'))
|
||||||
|
|
||||||
migration = '''
|
migration = '''
|
||||||
set -eu
|
set -eu
|
||||||
|
|||||||
@@ -25,13 +25,15 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
import requests
|
import requests
|
||||||
import rest_framework as drf
|
import rest_framework as drf
|
||||||
from botocore.exceptions import ClientError
|
from botocore.exceptions import ClientError
|
||||||
|
from knox.auth import TokenAuthentication
|
||||||
from lasuite.malware_detection import malware_detection
|
from lasuite.malware_detection import malware_detection
|
||||||
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
from rest_framework import filters, status, viewsets
|
from rest_framework import filters, status, viewsets
|
||||||
from rest_framework import response as drf_response
|
from rest_framework import response as drf_response
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from rest_framework.throttling import UserRateThrottle
|
from rest_framework.throttling import UserRateThrottle
|
||||||
|
|
||||||
from core import authentication, enums, models
|
from core import authentication, enums, models, utils as core_utils
|
||||||
from core.services.ai_services import AIService
|
from core.services.ai_services import AIService
|
||||||
from core.services.collaboration_services import CollaborationService
|
from core.services.collaboration_services import CollaborationService
|
||||||
from core.utils import extract_attachments, filter_descendants
|
from core.utils import extract_attachments, filter_descendants
|
||||||
@@ -430,9 +432,7 @@ class DocumentViewSet(
|
|||||||
ordering = ["-updated_at"]
|
ordering = ["-updated_at"]
|
||||||
ordering_fields = ["created_at", "updated_at", "title"]
|
ordering_fields = ["created_at", "updated_at", "title"]
|
||||||
pagination_class = Pagination
|
pagination_class = Pagination
|
||||||
permission_classes = [
|
permission_classes = [permissions.DocumentAccessPermission]
|
||||||
permissions.DocumentAccessPermission,
|
|
||||||
]
|
|
||||||
queryset = models.Document.objects.all()
|
queryset = models.Document.objects.all()
|
||||||
serializer_class = serializers.DocumentSerializer
|
serializer_class = serializers.DocumentSerializer
|
||||||
ai_translate_serializer_class = serializers.AITranslateSerializer
|
ai_translate_serializer_class = serializers.AITranslateSerializer
|
||||||
@@ -669,10 +669,14 @@ class DocumentViewSet(
|
|||||||
return self.get_response_for_queryset(queryset)
|
return self.get_response_for_queryset(queryset)
|
||||||
|
|
||||||
@drf.decorators.action(
|
@drf.decorators.action(
|
||||||
authentication_classes=[authentication.ServerToServerAuthentication],
|
authentication_classes=[
|
||||||
|
authentication.ServerToServerAuthentication,
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
TokenAuthentication,
|
||||||
|
],
|
||||||
detail=False,
|
detail=False,
|
||||||
methods=["post"],
|
methods=["post"],
|
||||||
permission_classes=[],
|
permission_classes=[permissions.IsAuthenticated],
|
||||||
url_path="create-for-owner",
|
url_path="create-for-owner",
|
||||||
)
|
)
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@@ -1349,6 +1353,25 @@ class DocumentViewSet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
|
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@drf.decorators.action(detail=True, methods=["get"], url_path="content")
|
||||||
|
def content(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Get the content of a document
|
||||||
|
"""
|
||||||
|
|
||||||
|
document = self.get_object()
|
||||||
|
|
||||||
|
# content_type = response.headers.get("Content-Type", "")
|
||||||
|
|
||||||
|
base64_yjs_content = document.content
|
||||||
|
content = core_utils.base64_yjs_to_markdown(base64_yjs_content)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"content": content,
|
||||||
|
}
|
||||||
|
|
||||||
|
return drf.response.Response(body, status=drf.status.HTTP_200_OK)
|
||||||
|
|
||||||
@drf.decorators.action(
|
@drf.decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ from rest_framework.authentication import BaseAuthentication
|
|||||||
from rest_framework.exceptions import AuthenticationFailed
|
from rest_framework.exceptions import AuthenticationFailed
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticatedServer:
|
||||||
|
"""
|
||||||
|
Simple class to represent an authenticated server to be used along the
|
||||||
|
IsAuthenticated permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
is_authenticated = True
|
||||||
|
|
||||||
|
|
||||||
class ServerToServerAuthentication(BaseAuthentication):
|
class ServerToServerAuthentication(BaseAuthentication):
|
||||||
"""
|
"""
|
||||||
Custom authentication class for server-to-server requests.
|
Custom authentication class for server-to-server requests.
|
||||||
@@ -39,13 +48,16 @@ class ServerToServerAuthentication(BaseAuthentication):
|
|||||||
# Validate token format and existence
|
# Validate token format and existence
|
||||||
auth_parts = auth_header.split(" ")
|
auth_parts = auth_header.split(" ")
|
||||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||||
raise AuthenticationFailed("Invalid authorization header.")
|
# Do not raise here to leave the door open for other authentication methods
|
||||||
|
return None
|
||||||
|
|
||||||
token = auth_parts[1]
|
token = auth_parts[1]
|
||||||
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
|
if token not in settings.SERVER_TO_SERVER_API_TOKENS:
|
||||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
# Do not raise here to leave the door open for other authentication methods
|
||||||
|
return None
|
||||||
|
|
||||||
# Authentication is successful, but no user is authenticated
|
# Authentication is successful
|
||||||
|
return AuthenticatedServer(), token
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
"""Return the WWW-Authenticate header value."""
|
"""Return the WWW-Authenticate header value."""
|
||||||
|
|||||||
@@ -839,6 +839,7 @@ class Document(MP_Node, BaseModel):
|
|||||||
"children_list": can_get,
|
"children_list": can_get,
|
||||||
"children_create": can_update and user.is_authenticated,
|
"children_create": can_update and user.is_authenticated,
|
||||||
"collaboration_auth": can_get,
|
"collaboration_auth": can_get,
|
||||||
|
"content": can_get,
|
||||||
"cors_proxy": can_get,
|
"cors_proxy": can_get,
|
||||||
"descendants": can_get,
|
"descendants": can_get,
|
||||||
"destroy": is_owner,
|
"destroy": is_owner,
|
||||||
|
|||||||
131
src/backend/core/tests/test_user_token_api.py
Normal file
131
src/backend/core/tests/test_user_token_api.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Test user_token API endpoints in the impress core app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from knox.models import get_token_model
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core import factories, models
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
AuthToken = get_token_model()
|
||||||
|
|
||||||
|
def test_api_user_token_list_anonymous(client):
|
||||||
|
"""Anonymous users should not be allowed to list user tokens."""
|
||||||
|
response = client.get("/api/v1.0/user-tokens/")
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_user_token_list_authenticated(client):
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to list their own tokens.
|
||||||
|
Tokens are identified by digest, and include created/expiry.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
# Knox creates a token instance and a character string token key.
|
||||||
|
# The create method returns a tuple: (instance, token_key_string)
|
||||||
|
token_instance_1, _ = AuthToken.objects.create(user=user)
|
||||||
|
AuthToken.objects.create(user=user) # Another token for the same user
|
||||||
|
AuthToken.objects.create(user=factories.UserFactory()) # Token for a different user
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.get("/api/v1.0/user-tokens/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content) == 2
|
||||||
|
|
||||||
|
# Check that the response contains the digests of the tokens created for the user
|
||||||
|
response_token_digests = {item["digest"] for item in content}
|
||||||
|
assert token_instance_1.digest in response_token_digests
|
||||||
|
|
||||||
|
# Ensure the token_key is not listed
|
||||||
|
for item in content:
|
||||||
|
assert "token_key" not in item
|
||||||
|
assert "digest" in item
|
||||||
|
assert "created" in item
|
||||||
|
assert "expiry" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_user_token_create_anonymous(client):
|
||||||
|
"""Anonymous users should not be allowed to create user tokens."""
|
||||||
|
# The create endpoint does not take any parameters as per TokenCreateSerializer
|
||||||
|
# (user is implicit, other fields are read_only)
|
||||||
|
response = client.post("/api/v1.0/user-tokens/", data={})
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": "Authentication credentials were not provided."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_user_token_create_authenticated(client):
|
||||||
|
"""
|
||||||
|
Authenticated users should be able to create a new token.
|
||||||
|
The token key should be returned in the response upon creation.
|
||||||
|
"""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
# The create endpoint does not take any parameters as per TokenCreateSerializer
|
||||||
|
response = client.post("/api/v1.0/user-tokens/", data={})
|
||||||
|
assert response.status_code == 201
|
||||||
|
content = response.json()
|
||||||
|
|
||||||
|
# Based on TokenCreateSerializer, these fields should be in the response
|
||||||
|
assert "token_key" in content
|
||||||
|
assert "digest" in content
|
||||||
|
assert "created" in content
|
||||||
|
assert "expiry" in content
|
||||||
|
assert len(content["token_key"]) > 0 # Knox token key should be non-empty
|
||||||
|
|
||||||
|
# Verify the token was actually created in the database for the user
|
||||||
|
assert AuthToken.objects.filter(user=user, digest=content["digest"]).exists()
|
||||||
|
|
||||||
|
def test_api_user_token_destroy_anonymous(client):
|
||||||
|
"""Anonymous users should not be allowed to delete user tokens."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
token_instance, _ = AuthToken.objects.create(user=user)
|
||||||
|
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert AuthToken.objects.filter(digest=token_instance.digest).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_user_token_destroy_authenticated_own_token(client):
|
||||||
|
"""Authenticated users should be able to delete their own tokens."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
token_instance, _ = AuthToken.objects.create(user=user)
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete(f"/api/v1.0/user-tokens/{token_instance.digest}/")
|
||||||
|
assert response.status_code == 204
|
||||||
|
assert not AuthToken.objects.filter(digest=token_instance.digest).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_user_token_destroy_authenticated_other_user_token(client):
|
||||||
|
"""Authenticated users should not be able to delete other users' tokens."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
other_user = factories.UserFactory()
|
||||||
|
other_user_token_instance, _ = AuthToken.objects.create(user=other_user)
|
||||||
|
|
||||||
|
client.force_login(user) # Log in as 'user'
|
||||||
|
|
||||||
|
response = client.delete(f"/api/v1.0/user-tokens/{other_user_token_instance.digest}/")
|
||||||
|
# The default behavior for a non-found or non-permissioned item in DestroyModelMixin
|
||||||
|
# when the queryset is filtered (as in get_queryset) is often a 404.
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert AuthToken.objects.filter(digest=other_user_token_instance.digest).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_user_token_destroy_non_existent_token(client):
|
||||||
|
"""Attempting to delete a non-existent token should result in a 404."""
|
||||||
|
user = factories.UserFactory()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.delete("/api/v1.0/user-tokens/nonexistentdigest/")
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -4,15 +4,22 @@ from django.conf import settings
|
|||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
|
|
||||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||||
|
from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from core.api import viewsets
|
from core.api import viewsets
|
||||||
|
from core.user_token import viewsets as user_token_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register("templates", viewsets.TemplateViewSet, basename="templates")
|
router.register("templates", viewsets.TemplateViewSet, basename="templates")
|
||||||
router.register("documents", viewsets.DocumentViewSet, basename="documents")
|
router.register("documents", viewsets.DocumentViewSet, basename="documents")
|
||||||
router.register("users", viewsets.UserViewSet, basename="users")
|
router.register("users", viewsets.UserViewSet, basename="users")
|
||||||
|
router.register(
|
||||||
|
"user-tokens",
|
||||||
|
user_token_viewsets.UserTokenViewset,
|
||||||
|
basename="user_tokens",
|
||||||
|
)
|
||||||
|
|
||||||
# - Routes nested under a document
|
# - Routes nested under a document
|
||||||
document_related_router = DefaultRouter()
|
document_related_router = DefaultRouter()
|
||||||
@@ -44,6 +51,7 @@ urlpatterns = [
|
|||||||
[
|
[
|
||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
|
*resource_server_urls,
|
||||||
re_path(
|
re_path(
|
||||||
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
|
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
|
||||||
include(document_related_router.urls),
|
include(document_related_router.urls),
|
||||||
|
|||||||
0
src/backend/core/user_token/__init__.py
Normal file
0
src/backend/core/user_token/__init__.py
Normal file
27
src/backend/core/user_token/serializers.py
Normal file
27
src/backend/core/user_token/serializers.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from knox.models import get_token_model
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class TokenReadSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize token for list purpose."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = get_token_model()
|
||||||
|
fields = ["digest", "created", "expiry"]
|
||||||
|
read_only_fields = ["digest", "created", "expiry"]
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCreateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serialize token for creation purpose."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = get_token_model()
|
||||||
|
fields = ["user", "digest", "token_key", "created", "expiry"]
|
||||||
|
read_only_fields = ["digest", "token_key", "created", "expiry"]
|
||||||
|
extra_kwargs = {"user": {"write_only": True}}
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""The default knox token create manager returns a tuple."""
|
||||||
|
instance, token = super().create(validated_data)
|
||||||
|
instance.token_key = token # warning do not save this
|
||||||
|
return instance
|
||||||
50
src/backend/core/user_token/viewsets.py
Normal file
50
src/backend/core/user_token/viewsets.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""API endpoints for user token management"""
|
||||||
|
|
||||||
|
from knox.models import get_token_model
|
||||||
|
from rest_framework import permissions, viewsets, mixins
|
||||||
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenViewset(
|
||||||
|
mixins.CreateModelMixin,
|
||||||
|
mixins.ListModelMixin,
|
||||||
|
mixins.DestroyModelMixin,
|
||||||
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
|
"""API ViewSet for user invitations to document.
|
||||||
|
|
||||||
|
This view access is restricted to the session ie from frontend.
|
||||||
|
|
||||||
|
GET /api/v1.0/user-token/
|
||||||
|
Return list of existing tokens.
|
||||||
|
|
||||||
|
POST /api/v1.0/user-token/
|
||||||
|
Return newly created token.
|
||||||
|
|
||||||
|
DELETE /api/v1.0/user-token/<token_id>/
|
||||||
|
Delete targeted token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
authentication_classes = [SessionAuthentication]
|
||||||
|
pagination_class = None
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
queryset = get_token_model().objects.all()
|
||||||
|
serializer_class = serializers.TokenReadSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return the queryset restricted to the logged-in user."""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
queryset = queryset.filter(user_id=self.request.user.pk)
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return serializers.TokenCreateSerializer
|
||||||
|
return super().get_serializer_class()
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""Enforce request data to use current user."""
|
||||||
|
request.data["user"] = self.request.user.pk
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
@@ -66,6 +66,116 @@ def base64_yjs_to_text(base64_string):
|
|||||||
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
|
soup = BeautifulSoup(blocknote_structure, "lxml-xml")
|
||||||
return soup.get_text(separator=" ", strip=True)
|
return soup.get_text(separator=" ", strip=True)
|
||||||
|
|
||||||
|
def base64_yjs_to_markdown(base64_string: str) -> str:
|
||||||
|
xml_content = base64_yjs_to_xml(base64_string)
|
||||||
|
soup = BeautifulSoup(xml_content, "lxml-xml")
|
||||||
|
|
||||||
|
md_lines: list[str] = []
|
||||||
|
|
||||||
|
def walk(node) -> None:
|
||||||
|
if not getattr(node, "name", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Treat the synthetic “[document]” tag exactly like a wrapper
|
||||||
|
if node.name in {"[document]", "blockGroup", "blockContainer"}:
|
||||||
|
for child in node.find_all(recursive=False):
|
||||||
|
walk(child)
|
||||||
|
if node.name == "blockContainer":
|
||||||
|
md_lines.append("") # paragraph break
|
||||||
|
return
|
||||||
|
|
||||||
|
# ----------- content nodes -------------
|
||||||
|
if node.name == "heading":
|
||||||
|
level = int(node.get("level", 1))
|
||||||
|
md_lines.extend([("#" * level) + " " + process_inline_formatting(node), ""])
|
||||||
|
|
||||||
|
elif node.name == "paragraph":
|
||||||
|
md_lines.extend([process_inline_formatting(node), ""])
|
||||||
|
|
||||||
|
elif node.name == "bulletListItem":
|
||||||
|
md_lines.append("- " + process_inline_formatting(node))
|
||||||
|
|
||||||
|
elif node.name == "numberedListItem":
|
||||||
|
idx = node.get("index", "1")
|
||||||
|
md_lines.append(f"{idx}. " + process_inline_formatting(node))
|
||||||
|
|
||||||
|
elif node.name == "checkListItem":
|
||||||
|
checked = "x" if node.get("checked") == "true" else " "
|
||||||
|
md_lines.append(f"- [{checked}] " + process_inline_formatting(node))
|
||||||
|
|
||||||
|
elif node.name == "codeBlock":
|
||||||
|
lang = node.get("language", "")
|
||||||
|
code = node.get_text("", strip=False)
|
||||||
|
md_lines.extend([f"```{lang}", code, "```", ""])
|
||||||
|
|
||||||
|
elif node.name in {"quote", "blockquote"}:
|
||||||
|
quote = process_inline_formatting(node)
|
||||||
|
for line in quote.splitlines() or [""]:
|
||||||
|
md_lines.append("> " + line)
|
||||||
|
md_lines.append("")
|
||||||
|
|
||||||
|
elif node.name == "divider":
|
||||||
|
md_lines.extend(["---", ""])
|
||||||
|
|
||||||
|
elif node.name == "callout":
|
||||||
|
emoji = node.get("emoji", "💡")
|
||||||
|
md_lines.extend([f"> {emoji} {process_inline_formatting(node)}", ""])
|
||||||
|
|
||||||
|
elif node.name == "img":
|
||||||
|
src = node.get("src", "")
|
||||||
|
alt = node.get("alt", "")
|
||||||
|
md_lines.extend([f"", ""])
|
||||||
|
|
||||||
|
# unknown tags are ignored
|
||||||
|
|
||||||
|
# kick-off: start at the synthetic root
|
||||||
|
walk(soup)
|
||||||
|
|
||||||
|
# collapse accidental multiple blank lines
|
||||||
|
cleaned: list[str] = []
|
||||||
|
for line in md_lines:
|
||||||
|
if line == "" and (not cleaned or cleaned[-1] == ""):
|
||||||
|
continue
|
||||||
|
cleaned.append(line)
|
||||||
|
|
||||||
|
return "\n".join(cleaned).rstrip() + "\n"
|
||||||
|
|
||||||
|
def process_inline_formatting(element):
|
||||||
|
"""
|
||||||
|
Process inline formatting elements like bold, italic, underline, etc.
|
||||||
|
and convert them to markdown syntax.
|
||||||
|
"""
|
||||||
|
result = ""
|
||||||
|
|
||||||
|
# If it's just a text node, return the text
|
||||||
|
if isinstance(element, str):
|
||||||
|
return element
|
||||||
|
|
||||||
|
# Process children elements
|
||||||
|
for child in element.contents:
|
||||||
|
if isinstance(child, str):
|
||||||
|
result += child
|
||||||
|
elif hasattr(child, 'name'):
|
||||||
|
if child.name == "bold":
|
||||||
|
result += "**" + process_inline_formatting(child) + "**"
|
||||||
|
elif child.name == "italic":
|
||||||
|
result += "*" + process_inline_formatting(child) + "*"
|
||||||
|
elif child.name == "underline":
|
||||||
|
result += "__" + process_inline_formatting(child) + "__"
|
||||||
|
elif child.name == "strike":
|
||||||
|
result += "~~" + process_inline_formatting(child) + "~~"
|
||||||
|
elif child.name == "code":
|
||||||
|
result += "`" + process_inline_formatting(child) + "`"
|
||||||
|
elif child.name == "link":
|
||||||
|
href = child.get("href", "")
|
||||||
|
text = process_inline_formatting(child)
|
||||||
|
result += f"[{text}]({href})"
|
||||||
|
else:
|
||||||
|
# For other elements, just process their contents
|
||||||
|
result += process_inline_formatting(child)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def extract_attachments(content):
|
def extract_attachments(content):
|
||||||
"""Helper method to extract media paths from a document's content."""
|
"""Helper method to extract media paths from a document's content."""
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ For the full list of settings and their values, see
|
|||||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
|
import datetime
|
||||||
import os
|
import os
|
||||||
import tomllib
|
import tomllib
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
@@ -303,6 +306,7 @@ class Base(Configuration):
|
|||||||
"django_filters",
|
"django_filters",
|
||||||
"dockerflow.django",
|
"dockerflow.django",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
|
"knox",
|
||||||
"parler",
|
"parler",
|
||||||
"treebeard",
|
"treebeard",
|
||||||
"easy_thumbnails",
|
"easy_thumbnails",
|
||||||
@@ -327,8 +331,9 @@ class Base(Configuration):
|
|||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
|
"knox.auth.TokenAuthentication",
|
||||||
|
"lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
|
||||||
),
|
),
|
||||||
"DEFAULT_PARSER_CLASSES": [
|
"DEFAULT_PARSER_CLASSES": [
|
||||||
"rest_framework.parsers.JSONParser",
|
"rest_framework.parsers.JSONParser",
|
||||||
@@ -593,6 +598,72 @@ class Base(Configuration):
|
|||||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# OIDC - Docs as a resource server
|
||||||
|
OIDC_OP_URL = values.Value(
|
||||||
|
default=None, environ_name="OIDC_OP_URL", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
||||||
|
environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_VERIFY_SSL = values.BooleanValue(
|
||||||
|
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_TIMEOUT = values.IntegerValue(
|
||||||
|
default=3, environ_name="OIDC_TIMEOUT", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
||||||
|
|
||||||
|
OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.ResourceServerBackend"
|
||||||
|
OIDC_RS_AUDIENCE_CLAIM = values.Value( # The claim used to identify the audience
|
||||||
|
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_RS_PRIVATE_KEY_STR = values.Value(
|
||||||
|
default=None,
|
||||||
|
environ_name="OIDC_RS_PRIVATE_KEY_STR",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
||||||
|
default="RSA",
|
||||||
|
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
||||||
|
default="RSA-OAEP",
|
||||||
|
environ_name="OIDC_RS_ENCRYPTION_ALGO",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
||||||
|
default="A256GCM",
|
||||||
|
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_RS_CLIENT_ID = values.Value(
|
||||||
|
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_RS_CLIENT_SECRET = values.Value(
|
||||||
|
None,
|
||||||
|
environ_name="OIDC_RS_CLIENT_SECRET",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_RS_SIGNING_ALGO = values.Value(
|
||||||
|
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_RS_SCOPES = values.ListValue(
|
||||||
|
[], environ_name="OIDC_RS_SCOPES", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# User token (knox)
|
||||||
|
REST_KNOX = {
|
||||||
|
"SECURE_HASH_ALGORITHM": "hashlib.sha512",
|
||||||
|
"AUTH_TOKEN_CHARACTER_LENGTH": 64,
|
||||||
|
"TOKEN_TTL": datetime.timedelta(hours=24 * 7),
|
||||||
|
"TOKEN_LIMIT_PER_USER": None,
|
||||||
|
"AUTO_REFRESH": False,
|
||||||
|
"AUTO_REFRESH_MAX_TTL": None,
|
||||||
|
"MIN_REFRESH_INTERVAL": 60,
|
||||||
|
"AUTH_HEADER_PREFIX": "Token",
|
||||||
|
}
|
||||||
|
|
||||||
# AI service
|
# AI service
|
||||||
AI_FEATURE_ENABLED = values.BooleanValue(
|
AI_FEATURE_ENABLED = values.BooleanValue(
|
||||||
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
|
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ dependencies = [
|
|||||||
"django-lasuite[all]==0.0.9",
|
"django-lasuite[all]==0.0.9",
|
||||||
"django-parler==2.3",
|
"django-parler==2.3",
|
||||||
"django-redis==5.4.0",
|
"django-redis==5.4.0",
|
||||||
|
"django-rest-knox==5.0.2",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django==5.1.9",
|
"django==5.1.9",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Button } from '@openfun/cunningham-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ import { Title } from './Title';
|
|||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
|
|
||||||
@@ -63,6 +66,13 @@ export const Header = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
|
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
|
||||||
<ButtonLogin />
|
<ButtonLogin />
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push(`/user-tokens`)}
|
||||||
|
aria-label={t('API Tokens', 'API Tokens')}
|
||||||
|
color="primary-text"
|
||||||
|
>
|
||||||
|
{t('API Tokens', 'API Tokens')}
|
||||||
|
</Button>
|
||||||
<LanguagePicker />
|
<LanguagePicker />
|
||||||
<LaGaufre />
|
<LaGaufre />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './useListUserTokens';
|
||||||
|
export * from './useCreateUserToken';
|
||||||
|
export * from './useDeleteUserToken';
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||||
|
|
||||||
|
import { NewUserToken } from '../types';
|
||||||
|
|
||||||
|
export const createUserToken = async (): Promise<NewUserToken> => {
|
||||||
|
const response = await fetchAPI(`user-tokens/`, {
|
||||||
|
method: 'POST',
|
||||||
|
// The backend test indicates no data is sent for creation, so body is an empty object
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new APIError(
|
||||||
|
'Failed to create user token',
|
||||||
|
await errorCauses(response),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<NewUserToken>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCreateUserToken() {
|
||||||
|
return useMutation<NewUserToken, APIError>({
|
||||||
|
mutationFn: createUserToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||||
|
|
||||||
|
export const deleteUserToken = async (digest: string): Promise<void> => {
|
||||||
|
const response = await fetchAPI(`user-tokens/${digest}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 204) {
|
||||||
|
// 204 is a valid response for delete
|
||||||
|
throw new APIError(
|
||||||
|
'Failed to delete user token',
|
||||||
|
await errorCauses(response),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// For 204, there's no content, and for other successful deletions, we don't expect content.
|
||||||
|
// So, we don't try to parse JSON.
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteUserTokenParams = {
|
||||||
|
digest: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useDeleteUserToken() {
|
||||||
|
return useMutation<void, APIError, DeleteUserTokenParams>({
|
||||||
|
mutationFn: ({ digest }) => deleteUserToken(digest),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||||
|
|
||||||
|
import { UserToken } from '../types';
|
||||||
|
|
||||||
|
export const listUserTokens = async (): Promise<UserToken[]> => {
|
||||||
|
const response = await fetchAPI(`user-tokens/`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new APIError(
|
||||||
|
'Failed to list user tokens',
|
||||||
|
await errorCauses(response),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<UserToken[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useListUserTokens() {
|
||||||
|
return useQuery<UserToken[], APIError>({
|
||||||
|
queryKey: ['userTokens'],
|
||||||
|
queryFn: listUserTokens,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import {
|
||||||
|
Button as CunninghamButton,
|
||||||
|
DataGrid,
|
||||||
|
Modal,
|
||||||
|
ModalSize,
|
||||||
|
} from '@openfun/cunningham-react';
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Box, Card } from '@/components';
|
||||||
|
|
||||||
|
import { createUserToken, deleteUserToken, listUserTokens } from '../api/index';
|
||||||
|
import { NewUserToken, UserToken } from '../types';
|
||||||
|
|
||||||
|
const formatTimeAgo = (dateString: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds} seconds ago`;
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) {
|
||||||
|
return `${minutes} minutes ago`;
|
||||||
|
}
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) {
|
||||||
|
return `${hours} hours ago`;
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days} days ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add id to UserToken type for DataGrid compatibility
|
||||||
|
interface UserTokenWithId extends UserToken {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define proper type for DataGrid columns
|
||||||
|
interface ColumnDef {
|
||||||
|
field: string;
|
||||||
|
headerName: string;
|
||||||
|
width?: number;
|
||||||
|
renderCell: (params: { row: UserTokenWithId }) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserTokenManager: React.FC = () => {
|
||||||
|
const [tokens, setTokens] = useState<UserTokenWithId[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [newToken, setNewToken] = useState<NewUserToken | null>(null);
|
||||||
|
const [modalState, setModalState] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: React.ReactNode;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
confirmText?: string;
|
||||||
|
isConfirmation: boolean;
|
||||||
|
type?: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
size: ModalSize;
|
||||||
|
}>({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
isConfirmation: false,
|
||||||
|
size: ModalSize.MEDIUM, // Default size using ModalSize enum
|
||||||
|
});
|
||||||
|
|
||||||
|
const showNotification = (
|
||||||
|
message: string,
|
||||||
|
type: 'success' | 'error' = 'success',
|
||||||
|
size: ModalSize = ModalSize.SMALL,
|
||||||
|
) => {
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
title: type === 'success' ? 'Success' : 'Error',
|
||||||
|
message,
|
||||||
|
isConfirmation: false,
|
||||||
|
type: type,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showConfirmation = (
|
||||||
|
title: string,
|
||||||
|
message: React.ReactNode,
|
||||||
|
onConfirm: () => void,
|
||||||
|
confirmText: string = 'Confirm',
|
||||||
|
size: ModalSize = ModalSize.MEDIUM,
|
||||||
|
) => {
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
confirmText,
|
||||||
|
isConfirmation: true,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTokens = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const fetchedTokens = await listUserTokens();
|
||||||
|
// Add id to each token
|
||||||
|
setTokens(fetchedTokens.map((token) => ({ ...token, id: token.digest })));
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
'Failed to fetch tokens. Please ensure you are logged in and have permissions.',
|
||||||
|
);
|
||||||
|
showNotification('Failed to fetch tokens', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchTokens();
|
||||||
|
}, [fetchTokens]);
|
||||||
|
|
||||||
|
const handleCreateToken = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setNewToken(null);
|
||||||
|
try {
|
||||||
|
const generatedToken = await createUserToken();
|
||||||
|
setNewToken(generatedToken);
|
||||||
|
showNotification(
|
||||||
|
'Token created successfully! Store the token key safely, it will not be shown again.',
|
||||||
|
'success',
|
||||||
|
ModalSize.LARGE,
|
||||||
|
);
|
||||||
|
void fetchTokens();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to create token.');
|
||||||
|
showNotification('Failed to create token', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteToken = (digest: string) => {
|
||||||
|
showConfirmation(
|
||||||
|
'Confirm Deletion',
|
||||||
|
'Are you sure you want to delete this token?',
|
||||||
|
() => {
|
||||||
|
void (async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await deleteUserToken(digest);
|
||||||
|
showNotification('Token deleted successfully!');
|
||||||
|
setNewToken(null);
|
||||||
|
await fetchTokens();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to delete token.');
|
||||||
|
showNotification('Failed to delete token', 'error');
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef[] = [
|
||||||
|
{
|
||||||
|
field: 'digest',
|
||||||
|
headerName: 'Name',
|
||||||
|
renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.digest}</>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'created',
|
||||||
|
headerName: 'Updated at',
|
||||||
|
renderCell: ({ row }: { row: UserTokenWithId }) => (
|
||||||
|
<>{formatTimeAgo(row.created)}</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'expires',
|
||||||
|
headerName: 'Expires at',
|
||||||
|
renderCell: ({ row }: { row: UserTokenWithId }) => <>{row.expiry}</>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'actions',
|
||||||
|
headerName: '',
|
||||||
|
width: 50,
|
||||||
|
renderCell: ({ row }: { row: UserTokenWithId }) => (
|
||||||
|
<CunninghamButton
|
||||||
|
onClick={() => {
|
||||||
|
handleDeleteToken(row.digest);
|
||||||
|
}}
|
||||||
|
color="danger"
|
||||||
|
size="small"
|
||||||
|
icon={<span className="material-icons">delete</span>}
|
||||||
|
aria-label="Delete token"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</CunninghamButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
$direction="column"
|
||||||
|
$width="100%"
|
||||||
|
$padding={{
|
||||||
|
top: 'base',
|
||||||
|
horizontal: 'md',
|
||||||
|
bottom: 'md',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box $direction="row" $justify="space-between" $align="center">
|
||||||
|
<h2 style={{ marginBottom: 'var(--c--theme--spacing--medium, 16px)' }}>
|
||||||
|
User token management
|
||||||
|
</h2>
|
||||||
|
<CunninghamButton
|
||||||
|
onClick={() => void handleCreateToken()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Generating...' : 'Generate New Token'}
|
||||||
|
</CunninghamButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{newToken && (
|
||||||
|
<Box
|
||||||
|
$background="var(--c--theme--colors--success-100)"
|
||||||
|
$padding="md"
|
||||||
|
$radius="10px"
|
||||||
|
$margin={{ bottom: 'var(--c--theme--spacing--medium, 16px)' }}
|
||||||
|
$direction="column"
|
||||||
|
>
|
||||||
|
<span style={{ marginLeft: 16 }}>
|
||||||
|
<strong>New Token:</strong> <code>{newToken.token_key}</code>
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 16 }}>
|
||||||
|
<strong>Digest:</strong> <code>{newToken.digest}</code>
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: 16 }}>
|
||||||
|
<strong>Expires:</strong>{' '}
|
||||||
|
<code>{new Date(newToken.expiry).toLocaleString()}</code>
|
||||||
|
</span>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && !tokens.length && (
|
||||||
|
<Box $margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}>
|
||||||
|
Loading...
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Box
|
||||||
|
$color="var(--c--theme--colors--danger-500, red)"
|
||||||
|
$margin={{ bottom: 'var(--c--theme--spacing--small, 8px)' }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DataGrid<UserTokenWithId>
|
||||||
|
rows={tokens}
|
||||||
|
columns={columns}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyCta={<div>No tokens found.</div>}
|
||||||
|
/>
|
||||||
|
{modalState.isOpen && (
|
||||||
|
<Modal
|
||||||
|
isOpen={modalState.isOpen}
|
||||||
|
onClose={() => setModalState((prev) => ({ ...prev, isOpen: false }))}
|
||||||
|
title={modalState.title}
|
||||||
|
size={modalState.size} // Use ModalSize enum directly
|
||||||
|
actions={
|
||||||
|
modalState.isConfirmation ? (
|
||||||
|
<Box $width="100%" $direction="row" $justify="space-between">
|
||||||
|
<CunninghamButton
|
||||||
|
onClick={() =>
|
||||||
|
setModalState((prev) => ({ ...prev, isOpen: false }))
|
||||||
|
}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</CunninghamButton>
|
||||||
|
<CunninghamButton
|
||||||
|
onClick={() => {
|
||||||
|
if (modalState.onConfirm) {
|
||||||
|
modalState.onConfirm();
|
||||||
|
}
|
||||||
|
setModalState((prev) => ({ ...prev, isOpen: false }));
|
||||||
|
}}
|
||||||
|
color="danger"
|
||||||
|
>
|
||||||
|
{modalState.confirmText || 'Confirm'}
|
||||||
|
</CunninghamButton>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<CunninghamButton
|
||||||
|
onClick={() =>
|
||||||
|
setModalState((prev) => ({ ...prev, isOpen: false }))
|
||||||
|
}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</CunninghamButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{modalState.message}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { UserTokenManager } from './components/UserTokenManager';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface UserToken {
|
||||||
|
digest: string;
|
||||||
|
created: string; // Assuming ISO date string
|
||||||
|
expiry: string; // Assuming ISO date string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewUserToken extends UserToken {
|
||||||
|
token_key: string;
|
||||||
|
}
|
||||||
13
src/frontend/apps/impress/src/pages/user-tokens/index.tsx
Normal file
13
src/frontend/apps/impress/src/pages/user-tokens/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { UserTokenManager } from '@/features/user-tokens';
|
||||||
|
import { MainLayout } from '@/layouts';
|
||||||
|
import { NextPageWithLayout } from '@/types/next';
|
||||||
|
|
||||||
|
const UserTokensPage: NextPageWithLayout = () => {
|
||||||
|
return <UserTokenManager />;
|
||||||
|
};
|
||||||
|
|
||||||
|
UserTokensPage.getLayout = function getLayout(page: React.ReactElement) {
|
||||||
|
return <MainLayout backgroundColor="grey">{page}</MainLayout>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserTokensPage;
|
||||||
@@ -29,19 +29,25 @@ backend:
|
|||||||
DJANGO_EMAIL_PORT: 1025
|
DJANGO_EMAIL_PORT: 1025
|
||||||
DJANGO_EMAIL_USE_SSL: False
|
DJANGO_EMAIL_USE_SSL: False
|
||||||
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
|
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
|
||||||
LOGGING_LEVEL_LOGGERS_ROOT: INFO
|
LOGGING_LEVEL_LOGGERS_ROOT: DEBUG
|
||||||
LOGGING_LEVEL_LOGGERS_APP: INFO
|
LOGGING_LEVEL_LOGGERS_APP: DEBUG
|
||||||
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
|
OIDC_USERINFO_SHORTNAME_FIELD: "given_name"
|
||||||
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
|
OIDC_USERINFO_FULLNAME_FIELDS: "given_name,usual_name"
|
||||||
|
OIDC_OP_URL: https://keycloak.127.0.0.1.nip.io/realms/people
|
||||||
OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
OIDC_OP_JWKS_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
|
||||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
OIDC_OP_AUTHORIZATION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
|
||||||
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
OIDC_OP_TOKEN_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
|
||||||
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
OIDC_OP_USER_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
|
||||||
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
OIDC_OP_LOGOUT_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT: https://docs-keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token/introspect
|
||||||
OIDC_RP_CLIENT_ID: impress
|
OIDC_RP_CLIENT_ID: impress
|
||||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
OIDC_RP_SIGN_ALGO: RS256
|
OIDC_RP_SIGN_ALGO: RS256
|
||||||
OIDC_RP_SCOPES: "openid email"
|
OIDC_RP_SCOPES: "openid email"
|
||||||
|
OIDC_RS_CLIENT_ID: impress
|
||||||
|
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
OIDC_RS_SIGNING_ALGO: RS256
|
||||||
|
OIDC_RS_SCOPES: "openid,profile,email"
|
||||||
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
||||||
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
|
||||||
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
|
||||||
@@ -171,3 +177,20 @@ ingressMedia:
|
|||||||
serviceMedia:
|
serviceMedia:
|
||||||
host: minio.impress.svc.cluster.local
|
host: minio.impress.svc.cluster.local
|
||||||
port: 9000
|
port: 9000
|
||||||
|
|
||||||
|
|
||||||
|
mcpServer:
|
||||||
|
replicas: 1
|
||||||
|
|
||||||
|
image:
|
||||||
|
repository: localhost:5001/impress-mcp-server
|
||||||
|
pullPolicy: Always
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
envVars:
|
||||||
|
DOCS_API_URL: https://impress.127.0.0.1.nip.io/
|
||||||
|
SERVER_TRANSPORT: STREAMABLE_HTTP
|
||||||
|
|
||||||
|
ingressMcpServer:
|
||||||
|
enabled: true
|
||||||
|
host: impress.127.0.0.1.nip.io
|
||||||
|
|||||||
@@ -178,6 +178,15 @@ Requires top level scope
|
|||||||
{{ include "impress.fullname" . }}-celery-worker
|
{{ include "impress.fullname" . }}-celery-worker
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Full name for the MCP server
|
||||||
|
|
||||||
|
Requires top level scope
|
||||||
|
*/}}
|
||||||
|
{{- define "impress.mcpServer.fullname" -}}
|
||||||
|
{{ include "impress.fullname" . }}-mcp-server
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Usage : {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" .Values.path.to.the.image1) }}
|
Usage : {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" .Values.path.to.the.image1) }}
|
||||||
*/}}
|
*/}}
|
||||||
|
|||||||
89
src/helm/impress/templates/ingress_mcp_server.yaml
Normal file
89
src/helm/impress/templates/ingress_mcp_server.yaml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{{- if .Values.ingressMcpServer.enabled -}}
|
||||||
|
{{- $fullName := include "impress.fullname" . -}}
|
||||||
|
{{- if and .Values.ingressMcpServer.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||||
|
{{- if not (hasKey .Values.ingressMcpServer.annotations "kubernetes.io/ingress.class") }}
|
||||||
|
{{- $_ := set .Values.ingressMcpServer.annotations "kubernetes.io/ingress.class" .Values.ingressMcpServer.className}}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||||
|
apiVersion: networking.k8s.io/v1beta1
|
||||||
|
{{- else -}}
|
||||||
|
apiVersion: extensions/v1beta1
|
||||||
|
{{- end }}
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}-mcp-server
|
||||||
|
namespace: {{ .Release.Namespace | quote }}
|
||||||
|
labels:
|
||||||
|
{{- include "impress.labels" . | nindent 4 }}
|
||||||
|
{{- with .Values.ingressMcpServer.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if and .Values.ingressMcpServer.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||||
|
ingressClassName: {{ .Values.ingressMcpServer.className }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.ingressMcpServer.tls.enabled }}
|
||||||
|
tls:
|
||||||
|
{{- if .Values.ingressMcpServer.host }}
|
||||||
|
- secretName: {{ .Values.ingressMcpServer.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||||
|
hosts:
|
||||||
|
- {{ .Values.ingressMcpServer.host | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.ingressMcpServer.tls.additional }}
|
||||||
|
- hosts:
|
||||||
|
{{- range .hosts }}
|
||||||
|
- {{ . | quote }}
|
||||||
|
{{- end }}
|
||||||
|
secretName: {{ .secretName }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- if .Values.ingressMcpServer.host }}
|
||||||
|
- host: {{ .Values.ingressMcpServer.host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: {{ .Values.ingressMcpServer.path | quote }}
|
||||||
|
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||||
|
pathType: Prefix
|
||||||
|
{{- end }}
|
||||||
|
backend:
|
||||||
|
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||||
|
service:
|
||||||
|
name: {{ include "impress.mcpServer.fullname" . }}
|
||||||
|
port:
|
||||||
|
number: {{ .Values.mcpServer.service.port }}
|
||||||
|
{{- else }}
|
||||||
|
serviceName: {{ include "impress.mcpServer.fullname" . }}
|
||||||
|
servicePort: {{ .Values.mcpServer.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.ingressMcpServer.customBackends }}
|
||||||
|
{{- toYaml . | nindent 10 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.ingressMcpServer.hosts }}
|
||||||
|
- host: {{ . | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: {{ $.Values.ingressMcpServer.path | quote }}
|
||||||
|
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||||
|
pathType: Prefix
|
||||||
|
{{- end }}
|
||||||
|
backend:
|
||||||
|
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||||
|
service:
|
||||||
|
name: {{ include "impress.mcpServer.fullname" $ }}
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.mcpServer.service.port }}
|
||||||
|
{{- else }}
|
||||||
|
serviceName: {{ include "impress.mcpServer.fullname" $ }}
|
||||||
|
servicePort: {{ $.Values.mcpServer.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with $.Values.ingressMcpServer.customBackends }}
|
||||||
|
{{- toYaml . | nindent 10 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
153
src/helm/impress/templates/mcp_server_deployment.yaml
Normal file
153
src/helm/impress/templates/mcp_server_deployment.yaml
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
{{- $envVars := include "impress.common.env" (list . .Values.mcpServer) -}}
|
||||||
|
{{- $fullName := include "impress.mcpServer.fullname" . -}}
|
||||||
|
{{- $component := "mcp-server" -}}
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
namespace: {{ .Release.Namespace | quote }}
|
||||||
|
annotations:
|
||||||
|
{{- with .Values.mcpServer.dpAnnotations }}
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "impress.common.labels" (list . $component) | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
replicas: {{ .Values.backend.replicas }}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
{{- with .Values.backend.podAnnotations }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
labels:
|
||||||
|
{{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }}
|
||||||
|
spec:
|
||||||
|
{{- if $.Values.image.credentials }}
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }}
|
||||||
|
{{- end}}
|
||||||
|
shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }}
|
||||||
|
containers:
|
||||||
|
{{- with .Values.mcpServer.sidecars }}
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
- name: {{ .Chart.Name }}
|
||||||
|
image: "{{ (.Values.mcpServer.image | default dict).repository }}:{{ (.Values.mcpServer.image | default dict).tag }}"
|
||||||
|
imagePullPolicy: {{ (.Values.mcpServer.image | default dict).pullPolicy }}
|
||||||
|
{{- with .Values.mcpServer.command }}
|
||||||
|
command:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.mcpServer.args }}
|
||||||
|
args:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
env:
|
||||||
|
{{- if $envVars}}
|
||||||
|
{{- $envVars | indent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.mcpServer.securityContext }}
|
||||||
|
securityContext:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: {{ .Values.mcpServer.service.targetPort }}
|
||||||
|
protocol: TCP
|
||||||
|
{{- if .Values.mcpServer.probes.liveness }}
|
||||||
|
livenessProbe:
|
||||||
|
{{- include "impress.probes.abstract" (merge .Values.mcpServer.probes.liveness (dict "targetPort" .Values.mcpServer.service.targetPort )) | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.mcpServer.probes.readiness }}
|
||||||
|
readinessProbe:
|
||||||
|
{{- include "impress.probes.abstract" (merge .Values.mcpServer.probes.readiness (dict "targetPort" .Values.mcpServer.service.targetPort )) | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.mcpServer.probes.startup }}
|
||||||
|
startupProbe:
|
||||||
|
{{- include "impress.probes.abstract" (merge .Values.mcpServer.probes.startup (dict "targetPort" .Values.mcpServer.service.targetPort )) | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.mcpServer.resources }}
|
||||||
|
resources:
|
||||||
|
{{- toYaml . | nindent 12 }}
|
||||||
|
{{- end }}
|
||||||
|
volumeMounts:
|
||||||
|
{{- range $index, $value := .Values.mountFiles }}
|
||||||
|
- name: "files-{{ $index }}"
|
||||||
|
mountPath: {{ $value.path }}
|
||||||
|
subPath: content
|
||||||
|
{{- end }}
|
||||||
|
{{- range $name, $volume := .Values.mcpServer.persistence }}
|
||||||
|
- name: "{{ $name }}"
|
||||||
|
mountPath: "{{ $volume.mountPath }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.mcpServer.extraVolumeMounts }}
|
||||||
|
- name: {{ .name }}
|
||||||
|
mountPath: {{ .mountPath }}
|
||||||
|
subPath: {{ .subPath | default "" }}
|
||||||
|
readOnly: {{ .readOnly }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.mcpServer.nodeSelector }}
|
||||||
|
nodeSelector:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.mcpServer.affinity }}
|
||||||
|
affinity:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
{{- with .Values.mcpServer.tolerations }}
|
||||||
|
tolerations:
|
||||||
|
{{- toYaml . | nindent 8 }}
|
||||||
|
{{- end }}
|
||||||
|
volumes:
|
||||||
|
{{- range $index, $value := .Values.mountFiles }}
|
||||||
|
- name: "files-{{ $index }}"
|
||||||
|
configMap:
|
||||||
|
name: "{{ include "impress.fullname" $ }}-files-{{ $index }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- range $name, $volume := .Values.mcpServer.persistence }}
|
||||||
|
- name: "{{ $name }}"
|
||||||
|
{{- if eq $volume.type "emptyDir" }}
|
||||||
|
emptyDir: {}
|
||||||
|
{{- else }}
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: "{{ $fullName }}-{{ $name }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- range .Values.mcpServer.extraVolumes }}
|
||||||
|
- name: {{ .name }}
|
||||||
|
{{- if .existingClaim }}
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ .existingClaim }}
|
||||||
|
{{- else if .hostPath }}
|
||||||
|
hostPath:
|
||||||
|
{{ toYaml .hostPath | nindent 12 }}
|
||||||
|
{{- else if .csi }}
|
||||||
|
csi:
|
||||||
|
{{- toYaml .csi | nindent 12 }}
|
||||||
|
{{- else if .configMap }}
|
||||||
|
configMap:
|
||||||
|
{{- toYaml .configMap | nindent 12 }}
|
||||||
|
{{- else if .emptyDir }}
|
||||||
|
emptyDir:
|
||||||
|
{{- toYaml .emptyDir | nindent 12 }}
|
||||||
|
{{- else }}
|
||||||
|
emptyDir: {}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
|
{{ if .Values.mcpServer.pdb.enabled }}
|
||||||
|
apiVersion: policy/v1
|
||||||
|
kind: PodDisruptionBudget
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
namespace: {{ .Release.Namespace | quote }}
|
||||||
|
spec:
|
||||||
|
maxUnavailable: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
{{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }}
|
||||||
|
{{ end }}
|
||||||
21
src/helm/impress/templates/mcp_server_svc.yaml
Normal file
21
src/helm/impress/templates/mcp_server_svc.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{{- $envVars := include "impress.common.env" (list . .Values.mcpServer) -}}
|
||||||
|
{{- $fullName := include "impress.mcpServer.fullname" . -}}
|
||||||
|
{{- $component := "mcp-server" -}}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ $fullName }}
|
||||||
|
namespace: {{ .Release.Namespace | quote }}
|
||||||
|
labels:
|
||||||
|
{{- include "impress.common.labels" (list . $component) | nindent 4 }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml $.Values.mcpServer.service.annotations | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.mcpServer.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.mcpServer.service.port }}
|
||||||
|
targetPort: {{ .Values.mcpServer.service.targetPort }}
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
{{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }}
|
||||||
@@ -50,6 +50,31 @@ ingress:
|
|||||||
## @param ingress.customBackends Add custom backends to ingress
|
## @param ingress.customBackends Add custom backends to ingress
|
||||||
customBackends: []
|
customBackends: []
|
||||||
|
|
||||||
|
## @param ingressMcpServer.enabled whether to enable the Ingress or not
|
||||||
|
## @param ingressMcpServer.className IngressClass to use for the Ingress
|
||||||
|
## @param ingressMcpServer.host Host for the Ingress
|
||||||
|
## @param ingressMcpServer.path Path to use for the Ingress
|
||||||
|
ingressMcpServer:
|
||||||
|
enabled: false
|
||||||
|
className: null
|
||||||
|
host: impress.example.com
|
||||||
|
path: /mcp/docs/
|
||||||
|
## @param ingressMcpServer.hosts Additional host to configure for the Ingress
|
||||||
|
hosts: []
|
||||||
|
# - chart-example.local
|
||||||
|
## @param ingressMcpServer.tls.enabled Weather to enable TLS for the Ingress
|
||||||
|
## @param ingressMcpServer.tls.secretName Secret name for TLS config
|
||||||
|
## @skip ingressMcpServer.tls.additional
|
||||||
|
## @extra ingressMcpServer.tls.additional[].secretName Secret name for additional TLS config
|
||||||
|
## @extra ingressMcpServer.tls.additional[].hosts[] Hosts for additional TLS config
|
||||||
|
tls:
|
||||||
|
enabled: true
|
||||||
|
secretName: null
|
||||||
|
additional: []
|
||||||
|
|
||||||
|
## @param ingressMcpServer.customBackends Add custom backends to ingress
|
||||||
|
customBackends: []
|
||||||
|
|
||||||
## @param ingressCollaborationWS.enabled whether to enable the Ingress or not
|
## @param ingressCollaborationWS.enabled whether to enable the Ingress or not
|
||||||
## @param ingressCollaborationWS.className IngressClass to use for the Ingress
|
## @param ingressCollaborationWS.className IngressClass to use for the Ingress
|
||||||
## @param ingressCollaborationWS.host Host for the Ingress
|
## @param ingressCollaborationWS.host Host for the Ingress
|
||||||
@@ -348,6 +373,93 @@ backend:
|
|||||||
timeoutSeconds: 5
|
timeoutSeconds: 5
|
||||||
|
|
||||||
|
|
||||||
|
## @section mcpServer
|
||||||
|
|
||||||
|
mcpServer:
|
||||||
|
## @param mcpServer.image.repository Repository to use to pull impress's MCP server container image
|
||||||
|
## @param mcpServer.image.tag impress's MCP server container tag
|
||||||
|
## @param mcpServer.image.pullPolicy MCP server container image pull policy
|
||||||
|
image:
|
||||||
|
repository: lasuite/impress-mcp-server
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
tag: "latest"
|
||||||
|
|
||||||
|
## @param mcpServer.command Override the MCP server container command
|
||||||
|
command: []
|
||||||
|
|
||||||
|
## @param mcpServer.args Override the backend container args
|
||||||
|
args: []
|
||||||
|
|
||||||
|
## @param mcpServer.envVars Configure MCP server container environment variables
|
||||||
|
envVars: []
|
||||||
|
|
||||||
|
## @param mcpServer.replicas Amount of backend replicas
|
||||||
|
replicas: 3
|
||||||
|
|
||||||
|
## @param mcpServer.podAnnotations Annotations to add to the MCP server Pod
|
||||||
|
podAnnotations: {}
|
||||||
|
|
||||||
|
## @param mcpServer.dpAnnotations Annotations to add to the MCP server Deployment
|
||||||
|
dpAnnotations: {}
|
||||||
|
|
||||||
|
## @param mcpServer.sidecars Add sidecars containers to MCP server deployment
|
||||||
|
sidecars: []
|
||||||
|
|
||||||
|
## @param mcpServer.securityContext Configure MCP server Pod security context
|
||||||
|
securityContext: null
|
||||||
|
|
||||||
|
## @param mcpServer.service.type frontend Service type
|
||||||
|
## @param mcpServer.service.port frontend Service listening port
|
||||||
|
## @param mcpServer.service.targetPort frontend container listening port
|
||||||
|
## @param mcpServer.service.annotations Annotations to add to the frontend Service
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 80
|
||||||
|
targetPort: 4200
|
||||||
|
annotations: {}
|
||||||
|
|
||||||
|
## @param mcpServer.probes Configure probe for frontend
|
||||||
|
## @extra mcpServer.probes.liveness.path Configure path for frontend HTTP liveness probe
|
||||||
|
## @extra mcpServer.probes.liveness.targetPort Configure port for frontend HTTP liveness probe
|
||||||
|
## @extra mcpServer.probes.liveness.initialDelaySeconds Configure initial delay for frontend liveness probe
|
||||||
|
## @extra mcpServer.probes.liveness.initialDelaySeconds Configure timeout for frontend liveness probe
|
||||||
|
## @extra mcpServer.probes.startup.path Configure path for frontend HTTP startup probe
|
||||||
|
## @extra mcpServer.probes.startup.targetPort Configure port for frontend HTTP startup probe
|
||||||
|
## @extra mcpServer.probes.startup.initialDelaySeconds Configure initial delay for frontend startup probe
|
||||||
|
## @extra mcpServer.probes.startup.initialDelaySeconds Configure timeout for frontend startup probe
|
||||||
|
## @extra mcpServer.probes.readiness.path Configure path for frontend HTTP readiness probe
|
||||||
|
## @extra mcpServer.probes.readiness.targetPort Configure port for frontend HTTP readiness probe
|
||||||
|
## @extra mcpServer.probes.readiness.initialDelaySeconds Configure initial delay for frontend readiness probe
|
||||||
|
## @extra mcpServer.probes.readiness.initialDelaySeconds Configure timeout for frontend readiness probe
|
||||||
|
probes: {}
|
||||||
|
|
||||||
|
## @param mcpServer.resources Resource requirements for the frontend container
|
||||||
|
resources: {}
|
||||||
|
|
||||||
|
## @param mcpServer.persistence Additional volumes to create and mount on the backend. Used for debugging purposes
|
||||||
|
## @extra mcpServer.persistence.volume-name.size Size of the additional volume
|
||||||
|
## @extra mcpServer.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
|
||||||
|
## @extra mcpServer.persistence.volume-name.mountPath Path where the volume should be mounted to
|
||||||
|
persistence: {}
|
||||||
|
|
||||||
|
## @param mcpServer.extraVolumeMounts Additional volumes to mount on the backend.
|
||||||
|
extraVolumeMounts: [ ]
|
||||||
|
|
||||||
|
## @param mcpServer.extraVolumes Additional volumes to mount on the backend.
|
||||||
|
extraVolumes: []
|
||||||
|
|
||||||
|
## @param mcpServer.nodeSelector Node selector for the backend Pod
|
||||||
|
nodeSelector: {}
|
||||||
|
|
||||||
|
## @param mcpServer.affinity Affinity for the backend Pod
|
||||||
|
affinity: {}
|
||||||
|
|
||||||
|
## @param mcpServer.tolerations Tolerations for the backend Pod
|
||||||
|
tolerations: []
|
||||||
|
|
||||||
|
## @param mcpServer.pdb.enabled Enable pdb on backend
|
||||||
|
pdb:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
## @section frontend
|
## @section frontend
|
||||||
|
|
||||||
|
|||||||
4
src/mcp_server/.dockerignore
Normal file
4
src/mcp_server/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.venv
|
||||||
|
.env
|
||||||
|
docker-compose.yaml
|
||||||
|
Dockerfile
|
||||||
10
src/mcp_server/.env.example
Normal file
10
src/mcp_server/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
SERVER_TRANSPORT=SSE
|
||||||
|
|
||||||
|
# Example Docker: DOCS_API_URL=http://app-dev:8000/ or http://host.docker.internal:8071/
|
||||||
|
# Example localhost: DOCS_API_URL=http://localhost:8071/
|
||||||
|
# Example Hackathon: DOCS_API_URL=https://docs-ia.beta.numerique.gouv.fr/
|
||||||
|
DOCS_API_URL=<API_URL_HERE>
|
||||||
|
|
||||||
|
# Token generated from the frontend application
|
||||||
|
# see http://localhost:3000/user-tokens/
|
||||||
|
DOCS_API_TOKEN=<TOKEN_HERE>
|
||||||
1
src/mcp_server/.python-version
Normal file
1
src/mcp_server/.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
35
src/mcp_server/Dockerfile
Normal file
35
src/mcp_server/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# Install uv.
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
|
# Change the working directory to the `app` directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --locked --no-install-project
|
||||||
|
|
||||||
|
# Copy the application into the image
|
||||||
|
COPY docs_mcp_server/ /app/docs_mcp_server/
|
||||||
|
|
||||||
|
# Sync the project
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --locked
|
||||||
|
|
||||||
|
# Attach ports
|
||||||
|
EXPOSE 4200
|
||||||
|
ENV SERVER_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Un-privileged user running the application
|
||||||
|
ARG DOCKER_USER
|
||||||
|
USER ${DOCKER_USER}
|
||||||
|
|
||||||
|
# Run the MCP server.
|
||||||
|
CMD ["uv", "--no-cache", "run", "python", "-m" ,"docs_mcp_server.mcp_server"]
|
||||||
84
src/mcp_server/Makefile
Normal file
84
src/mcp_server/Makefile
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
|
||||||
|
#
|
||||||
|
# This Makefile is only meant to be used for DEVELOPMENT purpose as we are
|
||||||
|
# changing the user id that will run in the container.
|
||||||
|
#
|
||||||
|
# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER...
|
||||||
|
#
|
||||||
|
# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\
|
||||||
|
#
|
||||||
|
# Note to developers:
|
||||||
|
#
|
||||||
|
# While editing this file, please respect the following statements:
|
||||||
|
#
|
||||||
|
# 1. Every variable should be defined in the ad hoc VARIABLES section with a
|
||||||
|
# relevant subsection
|
||||||
|
# 2. Every new rule should be defined in the ad hoc RULES section with a
|
||||||
|
# relevant subsection depending on the targeted service
|
||||||
|
# 3. Rules should be sorted alphabetically within their section
|
||||||
|
# 4. When a rule has multiple dependencies, you should:
|
||||||
|
# - duplicate the rule name to add the help string (if required)
|
||||||
|
# - write one dependency per line to increase readability and diffs
|
||||||
|
# 5. .PHONY rule statement should be written after the corresponding rule
|
||||||
|
# ==============================================================================
|
||||||
|
# VARIABLES
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
BOLD := \033[1m
|
||||||
|
RESET := \033[0m
|
||||||
|
GREEN := \033[1;32m
|
||||||
|
|
||||||
|
# Use uv for package management
|
||||||
|
UV = uv
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# RULES
|
||||||
|
|
||||||
|
default: help
|
||||||
|
|
||||||
|
help: ## Display this help message
|
||||||
|
@echo "$(BOLD)Docs MCP server Makefile"
|
||||||
|
@echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:"
|
||||||
|
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
|
||||||
|
.PHONY: help
|
||||||
|
|
||||||
|
install: ## Install the project
|
||||||
|
@$(UV) sync
|
||||||
|
.PHONY: install
|
||||||
|
|
||||||
|
install-dev: ## Install the project with dev dependencies
|
||||||
|
@$(UV) sync --extra dev
|
||||||
|
.PHONY: install-dev
|
||||||
|
|
||||||
|
clean: ## Clean the project folder
|
||||||
|
@rm -rf build/
|
||||||
|
@rm -rf dist/
|
||||||
|
@rm -rf *.egg-info
|
||||||
|
@find . -type d -name __pycache__ -exec rm -rf {} +
|
||||||
|
@find . -type f -name "*.pyc" -delete
|
||||||
|
.PHONY: clean
|
||||||
|
|
||||||
|
format: ## Run the formatter
|
||||||
|
@$(UV) run ruff format
|
||||||
|
.PHONY: format
|
||||||
|
|
||||||
|
lint: format ## Run the linter
|
||||||
|
@$(UV) run ruff check --fix .
|
||||||
|
.PHONY: lint
|
||||||
|
|
||||||
|
test: ## Run the tests
|
||||||
|
@cd tests && PYTHON_PATH=.:$(PYTHON_PATH) $(UV) run python -m pytest . -vvv
|
||||||
|
.PHONY: test
|
||||||
|
|
||||||
|
runserver: ## Run the project server
|
||||||
|
@$(UV) run python -m docs_mcp_server.mcp_server
|
||||||
|
.PHONY: runserver
|
||||||
|
|
||||||
|
runserver-docker: ## Run the project server in a docker container
|
||||||
|
@touch .env
|
||||||
|
@docker compose up --watch
|
||||||
|
.PHONY: runserver-docker
|
||||||
|
|
||||||
|
run_llm: ## Run the LLM server
|
||||||
|
@mcphost -m ollama:qwen2.5:3b --config "$(CWD)/mcphost.json"
|
||||||
154
src/mcp_server/README.md
Normal file
154
src/mcp_server/README.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# mcp_server
|
||||||
|
|
||||||
|
`mcp_server` is a backend server application designed to manage and process MCP requests.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Create a new Docs with a title and markdown content from a request to an agentic LLM.
|
||||||
|
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configuration options can be set via environment variables or a configuration file (`.env`).
|
||||||
|
|
||||||
|
Common options include:
|
||||||
|
|
||||||
|
- `SERVER_TRANSPORT`: `STDIO`, `SSE` or `STREAMABLE_HTTP` (default: `STDIO` locally or `STREAMABLE_HTTP` in the docker image)
|
||||||
|
- `SERVER_PATH`: The base path of the server tools and resources (default: `/mcp/docs/`)
|
||||||
|
|
||||||
|
You will need to set the following options to allow the MCP server to query Docs API:
|
||||||
|
|
||||||
|
- `DOCS_API_URL`: The Docs base URL without the "/api/v1.0/" (default: `http://localhost:8071`)
|
||||||
|
- `DOCS_API_TOKEN`: The API user token you generate from the Docs frontend if you want
|
||||||
|
to use token based authentication (default: `None`). If not provided, the server will
|
||||||
|
use the authentication forwarder (pass the incoming authentication header to the Docs API call).
|
||||||
|
|
||||||
|
You may customize the following options for local development, while it's not recommended and may break the Docker image:
|
||||||
|
|
||||||
|
- `SERVER_HOST`: (default: `localhost` locally and `0.0.0.0` in the docker Image)
|
||||||
|
- `SERVER_PORT`: (default: `4200`)
|
||||||
|
|
||||||
|
Example when using the server from a Docker instance
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SERVER_TRANSPORT=SSE
|
||||||
|
|
||||||
|
DOCS_API_URL=http://host.docker.internal:8071/
|
||||||
|
DOCS_API_TOKEN=<some_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the MCP server
|
||||||
|
|
||||||
|
### Local
|
||||||
|
You may work on the MCP server project using local configuration with `uv`:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd src/mcp_server
|
||||||
|
|
||||||
|
make install
|
||||||
|
make runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
If you don't have local installation of Python or `uv` you can work using the Docker image:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd src/mcp_server
|
||||||
|
|
||||||
|
make runserver-docker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Create a local configuration file `.env`
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SERVER_TRANSPORT=SSE
|
||||||
|
|
||||||
|
DOCS_API_URL=http://host.docker.internal:8071/
|
||||||
|
DOCS_API_TOKEN=your-token-here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the server
|
||||||
|
|
||||||
|
```shell
|
||||||
|
make runserver-docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Cursor IDE
|
||||||
|
|
||||||
|
In Cursor settings, in the MCP section, you can add a new MCP server with the following configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"docs": {
|
||||||
|
"url": "http://127.0.0.1:4200/mcp/docs/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### In VSCode IDE
|
||||||
|
|
||||||
|
In VSCode settings, you can add a new MCP server with the following configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// .vscode/settings.json
|
||||||
|
{
|
||||||
|
"chat.mcp.discovery.enabled": true,
|
||||||
|
"chat.mcp.enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// .vscode/mcp.json
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"docs": {
|
||||||
|
"url": "http://localhost:4200/mcp/docs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Locally with `mcphost` and `ollama`
|
||||||
|
|
||||||
|
1. Install [mcphost](https://github.com/mark3labs/mcphost)
|
||||||
|
2. Install [ollama](https://ollama.ai)
|
||||||
|
3. Start ollama: `ollama serve`
|
||||||
|
4. Pull an agentic model like Qwen2.5 `ollama pull qwen2.5:3b`
|
||||||
|
5. Create an MCP configuration file (e.g. `mcphost.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"docs": {
|
||||||
|
"url": "http://127.0.0.1:4200/mcp/docs/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Start mcphost
|
||||||
|
|
||||||
|
```shell
|
||||||
|
mcphost -m ollama:qwen2.5:3b --config "$PWD/mcphost.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## About the authentication forwarder
|
||||||
|
|
||||||
|
The authentication forwarder is a simple proxy that forwards the authentication header from
|
||||||
|
the incoming request to the Docs API call. This allows to use "resource server" authentication.
|
||||||
|
|
||||||
|
For instance:
|
||||||
|
|
||||||
|
- Docs authentication is based on OIDC with Keycloak.
|
||||||
|
- The AI chat is using the same Keycloak instance for authentication.
|
||||||
|
- You can store the access token in the chat session and use it when calling the MCP server.
|
||||||
|
- The MCP server will forward the access token to the Docs API call
|
||||||
|
(actually, it forwards the whole authentication header).
|
||||||
|
- Docs will introspect the access token and authenticate the user.
|
||||||
|
- Conclusion: the user will be able to create a new Doc with the same access token
|
||||||
|
used in the chat session.
|
||||||
28
src/mcp_server/docker-compose.yaml
Normal file
28
src/mcp_server/docker-compose.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
docs_mcp-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
DOCKER_USER: ${DOCKER_USER:-1000}
|
||||||
|
user: ${DOCKER_USER:-1000}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
ports:
|
||||||
|
- "4200:4200"
|
||||||
|
|
||||||
|
develop:
|
||||||
|
# Create a `watch` configuration to update the app
|
||||||
|
watch:
|
||||||
|
# Sync the working directory with the `/app` directory in the container
|
||||||
|
- action: sync+restart
|
||||||
|
path: ./docs_mcp_server
|
||||||
|
target: /app/docs_mcp_server/
|
||||||
|
# Exclude the project virtual environment
|
||||||
|
ignore:
|
||||||
|
- .venv/
|
||||||
|
|
||||||
|
# Rebuild the image on changes to the `pyproject.toml`
|
||||||
|
- action: rebuild
|
||||||
|
path: ./pyproject.toml
|
||||||
3
src/mcp_server/docs_mcp_server/__init__.py
Normal file
3
src/mcp_server/docs_mcp_server/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""MCP Server package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
1
src/mcp_server/docs_mcp_server/auth/__init__.py
Normal file
1
src/mcp_server/docs_mcp_server/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Authentication module for the MCP server."""
|
||||||
18
src/mcp_server/docs_mcp_server/auth/forwarder.py
Normal file
18
src/mcp_server/docs_mcp_server/auth/forwarder.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Authentication against Docs API via user token."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastmcp.server.dependencies import get_http_request
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderForwarderAuthentication(httpx.Auth):
|
||||||
|
"""Authentication class for request made to Docs, work as boilerplate."""
|
||||||
|
|
||||||
|
def auth_flow(self, request):
|
||||||
|
"""Get Authorization header from request and pass it to the client."""
|
||||||
|
_incoming_request = get_http_request()
|
||||||
|
|
||||||
|
# Get authorization header
|
||||||
|
auth_header = _incoming_request.headers.get("authorization", "")
|
||||||
|
|
||||||
|
request.headers["Authorization"] = auth_header
|
||||||
|
yield request
|
||||||
16
src/mcp_server/docs_mcp_server/auth/token.py
Normal file
16
src/mcp_server/docs_mcp_server/auth/token.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Authentication against Docs API via user token."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class UserTokenAuthentication(httpx.Auth):
|
||||||
|
"""Authentication class for request made to Docs, using the user token."""
|
||||||
|
|
||||||
|
def __init__(self, token):
|
||||||
|
"""Initialize the authentication class with the user token."""
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def auth_flow(self, request):
|
||||||
|
"""Add the Authorization header to the request with the user token."""
|
||||||
|
request.headers["Authorization"] = f"Token {self.token}"
|
||||||
|
yield request
|
||||||
11
src/mcp_server/docs_mcp_server/constants.py
Normal file
11
src/mcp_server/docs_mcp_server/constants.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Project constants."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class TransportLayerEnum(enum.Enum):
|
||||||
|
"""Enum for the MCP server transport layer types."""
|
||||||
|
|
||||||
|
STDIO = "stdio"
|
||||||
|
SSE = "sse"
|
||||||
|
STREAMABLE_HTTP = "streamable-http"
|
||||||
109
src/mcp_server/docs_mcp_server/mcp_server.py
Normal file
109
src/mcp_server/docs_mcp_server/mcp_server.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""The core of the MCP server for the Docs API."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from . import settings, utils
|
||||||
|
from .auth.forwarder import HeaderForwarderAuthentication
|
||||||
|
from .auth.token import UserTokenAuthentication
|
||||||
|
|
||||||
|
logging_config = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"format": "[%(asctime)s] %(levelname)s - %(name)s - %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"default": {
|
||||||
|
"formatter": "default",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"root": {"handlers": ["default"], "level": "DEBUG"},
|
||||||
|
"loggers": {
|
||||||
|
"uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False},
|
||||||
|
"uvicorn.error": {"handlers": ["default"], "level": "INFO", "propagate": False},
|
||||||
|
"uvicorn.access": {"handlers": ["default"], "level": "INFO", "propagate": False},
|
||||||
|
"FastMCP": {"handlers": ["default"], "level": "INFO", "propagate": False},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
logging.config.dictConfig(logging_config)
|
||||||
|
logger = logging.getLogger("docs_mcp_server")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsProvider:
|
||||||
|
"""Provides tools for the MCP server to interact with the Docs API."""
|
||||||
|
|
||||||
|
def __init__(self, mcp_instance):
|
||||||
|
"""Register all the available tools here."""
|
||||||
|
mcp_instance.add_tool(self.create_document_tool)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_client(self):
|
||||||
|
"""Create and return an HTTP client for the Docs API."""
|
||||||
|
if settings.DOCS_API_TOKEN:
|
||||||
|
auth_backend = UserTokenAuthentication(token=settings.DOCS_API_TOKEN)
|
||||||
|
else:
|
||||||
|
auth_backend = HeaderForwarderAuthentication()
|
||||||
|
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
base_url=settings.DOCS_API_URL,
|
||||||
|
auth=auth_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_document_tool(self, document_title: str, document_content: str) -> None:
|
||||||
|
"""
|
||||||
|
Create a new document with the provided title and content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
document_title: The title of the document (required)
|
||||||
|
document_content: The content of the document (required)
|
||||||
|
|
||||||
|
"""
|
||||||
|
_api_client = self.api_client
|
||||||
|
|
||||||
|
# Get current user information
|
||||||
|
user_response = await _api_client.get("/api/v1.0/users/me/")
|
||||||
|
user_response.raise_for_status()
|
||||||
|
user_data = user_response.json()
|
||||||
|
|
||||||
|
# Prepare document data
|
||||||
|
data = {
|
||||||
|
"title": document_title,
|
||||||
|
"content": document_content,
|
||||||
|
"sub": user_data["id"],
|
||||||
|
"email": user_data["email"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create the document
|
||||||
|
create_response = await _api_client.post(
|
||||||
|
"/api/v1.0/documents/create-for-owner/",
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
create_response.raise_for_status()
|
||||||
|
|
||||||
|
await _api_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# Create a server instance from the OpenAPI spec
|
||||||
|
mcp_server = FastMCP(name="Docs MCP Server")
|
||||||
|
ToolsProvider(mcp_server)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(utils.check_mcp(mcp_server))
|
||||||
|
logger.info("Starting Docs MCP Server...")
|
||||||
|
mcp_server.run(
|
||||||
|
transport=settings.SERVER_TRANSPORT.value,
|
||||||
|
host=settings.SERVER_HOST,
|
||||||
|
port=settings.SERVER_PORT,
|
||||||
|
path=settings.SERVER_PATH,
|
||||||
|
)
|
||||||
21
src/mcp_server/docs_mcp_server/settings.py
Normal file
21
src/mcp_server/docs_mcp_server/settings.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Settings for the MCP server."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from .constants import TransportLayerEnum
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
SERVER_TRANSPORT = TransportLayerEnum[os.getenv("SERVER_TRANSPORT", "STDIO")]
|
||||||
|
SERVER_HOST = str(os.getenv("SERVER_HOST", "localhost"))
|
||||||
|
SERVER_PORT = int(os.getenv("SERVER_PORT", "4200"))
|
||||||
|
SERVER_PATH = str(os.getenv("SERVER_PATH", "/mcp/docs"))
|
||||||
|
|
||||||
|
|
||||||
|
# Docs related settings
|
||||||
|
DOCS_API_URL = str(os.getenv("DOCS_API_URL", "http://localhost:8071"))
|
||||||
|
DOCS_API_TOKEN = str(os.getenv("DOCS_API_TOKEN", "")) or None
|
||||||
18
src/mcp_server/docs_mcp_server/utils.py
Normal file
18
src/mcp_server/docs_mcp_server/utils.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Helpers for the project, not named `tools` to avoid confusion."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
logger = logging.getLogger("docs_mcp_server")
|
||||||
|
|
||||||
|
|
||||||
|
async def check_mcp(mcp: FastMCP):
|
||||||
|
"""List the MCP server components (tools, resources and templates)."""
|
||||||
|
tools = await mcp.get_tools()
|
||||||
|
resources = await mcp.get_resources()
|
||||||
|
templates = await mcp.get_resource_templates()
|
||||||
|
|
||||||
|
logger.info("%d Tool(s): %s", len(tools), ", ".join([t.name for t in tools.values()]))
|
||||||
|
logger.info("%d Resource(s): %s", len(resources), ", ".join([r.name for r in resources.values()]))
|
||||||
|
logger.info("%d Resource Template(s): %s", len(templates), ", ".join([t.name for t in templates.values()]))
|
||||||
98
src/mcp_server/pyproject.toml
Normal file
98
src/mcp_server/pyproject.toml
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
[project]
|
||||||
|
name = "mcp-server"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "MCP server implementation for Docs"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = {file = "LICENSE"}
|
||||||
|
authors = [
|
||||||
|
{name = "DINUM", email = "dev@mail.numerique.gouv.fr"},
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"dotenv>=0.9.9",
|
||||||
|
"fastmcp>=2.3.4",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"ruff",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.dynamic]
|
||||||
|
version = {attr = "docs_mcp_server.__version__"}
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py310"
|
||||||
|
lint.select = [
|
||||||
|
# pycodestyle
|
||||||
|
"E", "W",
|
||||||
|
# Pyflakes
|
||||||
|
"F",
|
||||||
|
# pyupgrade
|
||||||
|
"UP",
|
||||||
|
# flake8-bugbear
|
||||||
|
"B",
|
||||||
|
# flake8-simplify
|
||||||
|
"SIM",
|
||||||
|
# isort
|
||||||
|
"I",
|
||||||
|
# flake8-logging-format
|
||||||
|
"G",
|
||||||
|
# flake8-pie
|
||||||
|
"PIE",
|
||||||
|
# flake8-comprehensions
|
||||||
|
"C4",
|
||||||
|
# flake8-django
|
||||||
|
"DJ",
|
||||||
|
# flake8-bandit
|
||||||
|
"S",
|
||||||
|
# flake8-builtins
|
||||||
|
"A",
|
||||||
|
# flake8-datetimez
|
||||||
|
"DTZ",
|
||||||
|
# flake8-gettext
|
||||||
|
"INT",
|
||||||
|
# Pylint
|
||||||
|
"PL",
|
||||||
|
# flake8-fixme
|
||||||
|
"FIX",
|
||||||
|
# flake8-self
|
||||||
|
"SLF",
|
||||||
|
# flake8-return
|
||||||
|
"RET",
|
||||||
|
# pep8-naming (N)
|
||||||
|
"N",
|
||||||
|
# pydocstyle
|
||||||
|
"D",
|
||||||
|
# flake8-pytest-style (PT)
|
||||||
|
"PT",
|
||||||
|
]
|
||||||
|
lint.ignore = [
|
||||||
|
# incorrect-blank-line-before-class
|
||||||
|
"D203",
|
||||||
|
# missing-blank-line-after-summary
|
||||||
|
"D205",
|
||||||
|
# multi-line-summary-first-line
|
||||||
|
"D212",
|
||||||
|
]
|
||||||
|
lint.per-file-ignores = {"**/tests/*"= [
|
||||||
|
# flake8-bandit
|
||||||
|
"S",
|
||||||
|
# flake8-self
|
||||||
|
"SLF",
|
||||||
|
# magic-value-comparison
|
||||||
|
"PLR2004",
|
||||||
|
]}
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["mcp_server"]
|
||||||
488
src/mcp_server/uv.lock
generated
Normal file
488
src/mcp_server/uv.lock
generated
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 1
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2025.4.26"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.1.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dotenv"
|
||||||
|
version = "0.9.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exceptiongroup"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastmcp"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "exceptiongroup" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "mcp" },
|
||||||
|
{ name = "openapi-pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "typer" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/50/11/2ccd6219eb65692a298e764fa84a15fd756e03c811c7ea217129d6ca545f/fastmcp-2.4.0.tar.gz", hash = "sha256:a08d812939d16c0d4490bdbdaf17ab136f1bdaa8ddcc14a37e33335727343c05", size = 1020290 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/a4/26396706c1b48ebd051da4f7d321594207364077a5c308827722536b927c/fastmcp-2.4.0-py3-none-any.whl", hash = "sha256:fccd0768028a31eec488707fb6bbe4f8659f84ca0c206c4c32dd33947c0faae9", size = 101108 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx-sse"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcp"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "httpx-sse" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "python-multipart" },
|
||||||
|
{ name = "sse-starlette" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcp-server"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dotenv" },
|
||||||
|
{ name = "fastmcp" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||||
|
{ name = "fastmcp", specifier = ">=2.3.4" },
|
||||||
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
|
{ name = "ruff", marker = "extra == 'dev'" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openapi-pydantic"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.11.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/77/ab/5250d56ad03884ab5efd07f734203943c8a8ab40d551e208af81d0257bf2/pydantic-2.11.4.tar.gz", hash = "sha256:32738d19d63a226a52eed76645a98ee07c1f410ee41d93b4afbfa85ed8111c2d", size = 786540 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/12/46b65f3534d099349e38ef6ec98b1a5a81f42536d17e0ba382c28c67ba67/pydantic-2.11.4-py3-none-any.whl", hash = "sha256:d9615eaa9ac5a063471da949c8fc16376a84afb5024688b3ff885693506764eb", size = 443900 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.33.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-settings"
|
||||||
|
version = "2.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-multipart"
|
||||||
|
version = "0.0.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "14.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.11.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/4a3c5a97faaae6b428b336dcca81d03ad04779f8072c267ad2bd860126bf/ruff-0.11.10.tar.gz", hash = "sha256:d522fb204b4959909ecac47da02830daec102eeb100fb50ea9554818d47a5fa6", size = 4165632 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/9f/596c628f8824a2ce4cd12b0f0b4c0629a62dfffc5d0f742c19a1d71be108/ruff-0.11.10-py3-none-linux_armv6l.whl", hash = "sha256:859a7bfa7bc8888abbea31ef8a2b411714e6a80f0d173c2a82f9041ed6b50f58", size = 10316243 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/38/c1e0b77ab58b426f8c332c1d1d3432d9fc9a9ea622806e208220cb133c9e/ruff-0.11.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:968220a57e09ea5e4fd48ed1c646419961a0570727c7e069842edd018ee8afed", size = 11083636 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/41/b75e15961d6047d7fe1b13886e56e8413be8467a4e1be0a07f3b303cd65a/ruff-0.11.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1067245bad978e7aa7b22f67113ecc6eb241dca0d9b696144256c3a879663bca", size = 10441624 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/2c/e396b6703f131406db1811ea3d746f29d91b41bbd43ad572fea30da1435d/ruff-0.11.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4854fd09c7aed5b1590e996a81aeff0c9ff51378b084eb5a0b9cd9518e6cff2", size = 10624358 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/8c/ee6cca8bdaf0f9a3704796022851a33cd37d1340bceaf4f6e991eb164e2e/ruff-0.11.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b4564e9f99168c0f9195a0fd5fa5928004b33b377137f978055e40008a082c5", size = 10176850 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/ce/4e27e131a434321b3b7c66512c3ee7505b446eb1c8a80777c023f7e876e6/ruff-0.11.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6a9cc5b62c03cc1fea0044ed8576379dbaf751d5503d718c973d5418483641", size = 11759787 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/de/1e2e77fc72adc7cf5b5123fd04a59ed329651d3eab9825674a9e640b100b/ruff-0.11.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:607ecbb6f03e44c9e0a93aedacb17b4eb4f3563d00e8b474298a201622677947", size = 12430479 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/ed/af0f2340f33b70d50121628ef175523cc4c37619e98d98748c85764c8d88/ruff-0.11.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b3a522fa389402cd2137df9ddefe848f727250535c70dafa840badffb56b7a4", size = 11919760 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/09/d7b3d3226d535cb89234390f418d10e00a157b6c4a06dfbe723e9322cb7d/ruff-0.11.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f071b0deed7e9245d5820dac235cbdd4ef99d7b12ff04c330a241ad3534319f", size = 14041747 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/b3/a63b4e91850e3f47f78795e6630ee9266cb6963de8f0191600289c2bb8f4/ruff-0.11.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a60e3a0a617eafba1f2e4186d827759d65348fa53708ca547e384db28406a0b", size = 11550657 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/63/a4f95c241d79402ccdbdb1d823d156c89fbb36ebfc4289dce092e6c0aa8f/ruff-0.11.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:da8ec977eaa4b7bf75470fb575bea2cb41a0e07c7ea9d5a0a97d13dbca697bf2", size = 10489671 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/9b/c2238bfebf1e473495659c523d50b1685258b6345d5ab0b418ca3f010cd7/ruff-0.11.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ddf8967e08227d1bd95cc0851ef80d2ad9c7c0c5aab1eba31db49cf0a7b99523", size = 10160135 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/ef/ba7251dd15206688dbfba7d413c0312e94df3b31b08f5d695580b755a899/ruff-0.11.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5a94acf798a82db188f6f36575d80609072b032105d114b0f98661e1679c9125", size = 11170179 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/9f/5c336717293203ba275dbfa2ea16e49b29a9fd9a0ea8b6febfc17e133577/ruff-0.11.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3afead355f1d16d95630df28d4ba17fb2cb9c8dfac8d21ced14984121f639bad", size = 11626021 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/2b/162fa86d2639076667c9aa59196c020dc6d7023ac8f342416c2f5ec4bda0/ruff-0.11.10-py3-none-win32.whl", hash = "sha256:dc061a98d32a97211af7e7f3fa1d4ca2fcf919fb96c28f39551f35fc55bdbc19", size = 10494958 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/f3/66643d8f32f50a4b0d09a4832b7d919145ee2b944d43e604fbd7c144d175/ruff-0.11.10-py3-none-win_amd64.whl", hash = "sha256:5cc725fbb4d25b0f185cb42df07ab6b76c4489b4bfb740a175f3a59c70e8a224", size = 11650285 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/3a/2e8704d19f376c799748ff9cb041225c1d59f3e7711bc5596c8cfdc24925/ruff-0.11.10-py3-none-win_arm64.whl", hash = "sha256:ef69637b35fb8b210743926778d0e45e1bffa850a7c61e428c6b971549b5f5d1", size = 10765278 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shellingham"
|
||||||
|
version = "1.5.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sniffio"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sse-starlette"
|
||||||
|
version = "2.3.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "0.46.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typer"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "shellingham" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6c/89/c527e6c848739be8ceb5c44eb8208c52ea3515c6cf6406aa61932887bf58/typer-0.15.4.tar.gz", hash = "sha256:89507b104f9b6a0730354f27c39fae5b63ccd0c95b1ce1f1a6ba0cfd329997c3", size = 101559 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/62/d4ba7afe2096d5659ec3db8b15d8665bdcb92a3c6ff0b95e99895b335a9c/typer-0.15.4-py3-none-any.whl", hash = "sha256:eb0651654dcdea706780c466cf06d8f174405a659ffff8f163cfbfee98c0e173", size = 45258 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.13.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.34.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "15.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user