feat(data-ana): python graphql federation subgraph with strawberry

- strawberry-graphql[asgi] dependency added

- 13 Federation 2 types: ClassPerformance/StudentWeakness/Dashboards/Mastery/ErrorBook

- 11 Query resolvers delegate to existing analytics/mastery services

- RouterAuthMiddleware validates Router-Authorization header on /graphql

- GraphQL endpoint mounted at /graphql alongside existing REST endpoints
This commit is contained in:
SpecialX
2026-07-15 00:57:02 +08:00
parent 1dcdcf23fd
commit a3f4fd013e
7 changed files with 818 additions and 1 deletions

View File

@@ -14,6 +14,8 @@ dependencies = [
"grpcio-health-checking>=1.66.0",
# protobuf 运行时buf generate 生成的 stub 依赖)
"protobuf>=5.28.0",
# GraphQL Federation 2 子图v2.1 M1Apollo Router 组合)
"strawberry-graphql[asgi]>=0.257.0",
]
[tool.uv.sources]

View File

@@ -84,6 +84,12 @@ class Settings(BaseSettings):
readyz_iam_grpc_timeout_s: float = 2.0
readyz_cdc_lag_threshold: int = 1000 # lag 超过此值判定 not_ready
# GraphQL Federation 2 子图v2.1 M1ADR-036
# Apollo Router 请求子图时必须携带 Router-Authorization Header共享密钥
# 与 TS 服务使用相同的 secret由 Secret 注入)
router_auth_secret: str = "" # 留空则 /graphql 返回 403除非 dev_mode=True
graphql_path: str = "/graphql" # GraphQL 端点路径
# 向后兼容:旧代码引用 settings.port / settings.kafka_group_id
@property
def port(self) -> int:

View File

@@ -0,0 +1,102 @@
"""GraphQL 端点 Router-Authorization 校验v2.1 M1, ADR-036.
对齐 TS 服务的 RouterAuthGuardpackages/shared-ts/src/federation/router-auth.guard.ts.
强制约束:
- Apollo Router 请求子图时必须携带 ``Router-Authorization`` Header
- 拒绝任何非 Router 发起的 GraphQL 请求
- 健康检查端点豁免
- dev_mode=True 时跳过校验(仅限本地)
部署模式:
- 生产:``DATA_ANA_ROUTER_AUTH_SECRET`` 环境变量配置共享密钥(与 TS 服务相同)
- 开发:``DATA_ANA_DEV_MODE=true`` 时跳过校验
"""
from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from ..config import settings
ROUTER_AUTH_HEADER = "router-authorization"
# 健康检查端点豁免前缀(这些路径不经过 GraphQL 校验)
_EXEMPT_PATH_PREFIXES: tuple[str, ...] = (
"/health",
"/ready",
"/healthz",
"/readyz",
"/metrics",
"/docs",
"/openapi.json",
"/redoc",
)
def _clean_path(path: str) -> str:
"""去除 query string 和 fragment."""
return path.split("?", 1)[0].split("#", 1)[0]
def is_graphql_request(path: str) -> bool:
"""判断是否为 GraphQL 端点请求."""
clean = _clean_path(path)
graphql_path = settings.graphql_path
return clean == graphql_path or clean.startswith(graphql_path + "/")
def _auth_failure_response(status_code: int, detail: str) -> JSONResponse:
"""构造认证失败响应(直接返回 JSONResponse避免依赖异常传播."""
return JSONResponse(status_code=status_code, content={"detail": detail})
class RouterAuthMiddleware(BaseHTTPMiddleware):
"""FastAPI 中间件:仅对 /graphql 端点校验 Router-Authorization Header.
对齐 NestJS 的 RouterAuthGuardpackages/shared-ts/src/federation/router-auth.guard.ts.
实现说明:
- 在 ``dispatch`` 内直接返回 JSONResponse 而非抛 HTTPException
避免 BaseHTTPMiddleware 异常传播到 ServerErrorMiddleware 时被吞为 500.
"""
async def dispatch(
self,
request: Request,
call_next: Any, # noqa: ANN401 - Starlette 接口约定为 callable
) -> Response:
# 1. dev_mode 跳过校验(仅限本地开发)
if settings.dev_mode:
return await call_next(request)
path = request.url.path
# 2. 非 GraphQL 路径放行REST 路由已有 PermissionGuard
if not is_graphql_request(path):
return await call_next(request)
# 3. 健康检查端点豁免
clean = _clean_path(path)
if any(clean == p or clean.startswith(p + "/") for p in _EXEMPT_PATH_PREFIXES):
return await call_next(request)
# 4. secret 未配置 → 503生产配置错误
expected = settings.router_auth_secret
if not expected:
return _auth_failure_response(
status_code=503,
detail="Router authorization not configured on server",
)
# 5. Header 缺失或不匹配 → 403
provided = request.headers.get(ROUTER_AUTH_HEADER, "")
if not provided or provided != expected:
return _auth_failure_response(
status_code=403,
detail="Direct GraphQL access denied; must go through Apollo Router",
)
return await call_next(request)

View File

@@ -0,0 +1,614 @@
"""GraphQL Federation 2 子图 schemav2.1 M1.
使用 strawberry-graphql 构建 Apollo Federation 2 子图,供 Apollo Router 组合.
设计要点:
- 所有 Resolver 委托给 analytics_service / mastery_service / clickhouse_repository
- 不重新实现业务逻辑仅做数据形状映射dict → strawberry type
- 异步 Resolverasync def与现有服务一致
- 降级响应ClickHouse 不可达时 service 层已返回骨架数据(零值),
Resolver 直接转换即可,不抛异常
- UserContext 从请求头提取x-user-id / x-user-roles与 REST 端点一致
- Router-Authorization 校验由 router_auth.RouterAuthMiddleware 在 main.py 中注册
"""
from typing import Any
import strawberry
import structlog
from starlette.requests import Request
from starlette.responses import Response
from strawberry.asgi import GraphQL
from strawberry.federation import Schema
from strawberry.types import Info
from .. import analytics_service
from ..shared.permissions import UserContext
logger = structlog.get_logger(__name__)
# ===== 辅助函数 =====
def _get_user_context(info: Info) -> UserContext:
"""从 GraphQL context 提取 UserContext.
Apollo Router 从 JWT 提取用户信息后注入 x-user-id / x-user-roles 头,
与 REST 端点使用相同的 header 约定.
"""
user = info.context.get("user")
if isinstance(user, UserContext):
return user
# 降级:无 user context如未配置 Gateway 注入头)
return UserContext(user_id="", roles=[])
def _to_float(value: Any, default: float = 0.0) -> float:
"""安全转换为 floatNone / 异常返回 default."""
try:
return float(value) if value is not None else default
except (TypeError, ValueError):
return default
def _to_int(value: Any, default: int = 0) -> int:
"""安全转换为 intNone / 异常返回 default."""
try:
return int(value) if value is not None else default
except (TypeError, ValueError):
return default
def _first_str(*candidates: Any) -> str:
"""从多个候选值中取第一个非空字符串."""
for c in candidates:
if c:
return str(c)
return ""
# ===== 类型定义 =====
@strawberry.type
class WeakPoint:
"""学生薄弱知识点."""
knowledge_point_id: str
title: str
mastery: float
error_count: int
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "WeakPoint":
return cls(
knowledge_point_id=_first_str(d.get("knowledgePointId"), d.get("knowledge_point_id")),
title=_first_str(d.get("title"), d.get("knowledge_point_id")),
mastery=_to_float(d.get("mastery")),
error_count=_to_int(d.get("errorCount") or d.get("error_count")),
)
@strawberry.type
class ClassPerformance:
"""班级成绩分析."""
class_id: str
average_score: float
pass_rate: float
total_students: int
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "ClassPerformance":
return cls(
class_id=_first_str(d.get("classId"), d.get("class_id")),
average_score=_to_float(d.get("averageScore")),
pass_rate=_to_float(d.get("passRate")),
total_students=_to_int(d.get("totalStudents")),
)
@strawberry.type
class StudentWeakness:
"""学生薄弱知识点列表."""
student_id: str
weak_points: list[WeakPoint]
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "StudentWeakness":
raw_points = d.get("weakPoints") or d.get("weak_points") or []
return cls(
student_id=_first_str(d.get("studentId"), d.get("student_id")),
weak_points=[WeakPoint.from_dict(p) for p in raw_points],
)
@strawberry.type
class TrendPoint:
"""学习趋势数据点."""
date: int
score: float
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "TrendPoint":
return cls(
date=_to_int(d.get("date")),
score=_to_float(d.get("score")),
)
@strawberry.type
class LearningTrend:
"""学习趋势."""
student_id: str
points: list[TrendPoint]
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "LearningTrend":
raw_points = d.get("points") or []
return cls(
student_id=_first_str(d.get("studentId"), d.get("student_id")),
points=[TrendPoint.from_dict(p) for p in raw_points],
)
@strawberry.type
class TeacherDashboard:
"""教师仪表盘."""
user_id: str
total_classes: int
total_students: int
class_avg_score: float
pending_homework_count: int
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "TeacherDashboard":
return cls(
user_id=_first_str(d.get("userId"), d.get("user_id")),
total_classes=_to_int(d.get("totalClasses")),
total_students=_to_int(d.get("totalStudents")),
class_avg_score=_to_float(d.get("classAvgScore") or d.get("averageScore")),
pending_homework_count=_to_int(d.get("pendingHomeworkCount")),
)
@strawberry.type
class StudentDashboard:
"""学生仪表盘."""
user_id: str
avg_score: float
class_rank: int
total_students: int
pending_homework: int
@classmethod
def from_dict(cls, d: dict[str, Any], user_id: str = "") -> "StudentDashboard":
"""从 student_dashboard service 结果构造.
service 返回的 records 含 score / rank_in_class 等字段,
avg_score / class_rank 从 records 聚合.
total_students / pending_homework 需额外聚合(当前返回 0P6 演进).
"""
records = d.get("records") or []
scores = [r.get("score") for r in records if r.get("score") is not None]
avg_score = sum(scores) / len(scores) if scores else 0.0
class_rank = _to_int(records[0].get("rank_in_class")) if records else 0
return cls(
user_id=user_id or _first_str(d.get("studentId"), d.get("userId")),
avg_score=_to_float(avg_score),
class_rank=class_rank,
total_students=0, # service 未返回需班级聚合查询P6 演进)
pending_homework=0, # service 未返回,需 homework_submissions 聚合P6 演进)
)
@strawberry.type
class ParentDashboard:
"""家长仪表盘."""
user_id: str
student_id: str
child_avg_score: float
child_class_rank: int
@classmethod
def from_dict(cls, d: dict[str, Any], user_id: str = "") -> "ParentDashboard":
"""从 parent_dashboard service 结果构造(复用 student_dashboard 查询)."""
records = d.get("records") or []
scores = [r.get("score") for r in records if r.get("score") is not None]
avg_score = sum(scores) / len(scores) if scores else 0.0
class_rank = _to_int(records[0].get("rank_in_class")) if records else 0
return cls(
user_id=user_id,
student_id=_first_str(d.get("childId"), d.get("studentId")),
child_avg_score=_to_float(avg_score),
child_class_rank=class_rank,
)
@strawberry.type
class AdminDashboard:
"""管理员仪表盘."""
user_id: str
total_teachers: int
total_students: int
total_classes: int
school_avg_score: float
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "AdminDashboard":
"""从 admin_dashboard service 结果构造.
service 返回 teacher_dashboard 聚合数据 + AI 用量统计,
total_teachers 需 iam 聚合(当前返回 0P6 演进).
"""
return cls(
user_id=_first_str(d.get("userId"), d.get("user_id")),
total_teachers=0, # service 未返回,需 iam 聚合P6 演进)
total_students=_to_int(d.get("totalStudents")),
total_classes=_to_int(d.get("totalClasses")),
school_avg_score=_to_float(d.get("classAvgScore") or d.get("averageScore")),
)
@strawberry.type
class StudentMastery:
"""学生知识点掌握度明细."""
student_id: str
overall_mastery: float
mastered_count: int
progressing_count: int
weak_count: int
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "StudentMastery":
"""从 mastery_snapshot service 结果构造,三档分布从 knowledgePoints 聚合."""
kps = d.get("knowledgePoints") or []
mastered = sum(1 for kp in kps if kp.get("mastery_label") == "mastered")
progressing = sum(1 for kp in kps if kp.get("mastery_label") == "progressing")
weak = sum(1 for kp in kps if kp.get("mastery_label") == "weak")
return cls(
student_id=_first_str(d.get("studentId"), d.get("student_id")),
overall_mastery=_to_float(d.get("overallMastery")),
mastered_count=mastered,
progressing_count=progressing,
weak_count=weak,
)
@strawberry.type
class MasterySummary:
"""学生掌握度汇总(轻量级)."""
student_id: str
overall_mastery: float
mastered_count: int
progressing_count: int
weak_count: int
total_knowledge_points: int
mastery_level: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "MasterySummary":
return cls(
student_id=_first_str(d.get("studentId"), d.get("student_id")),
overall_mastery=_to_float(d.get("overallMastery")),
mastered_count=_to_int(d.get("masteredCount")),
progressing_count=_to_int(d.get("progressingCount")),
weak_count=_to_int(d.get("weakCount")),
total_knowledge_points=_to_int(d.get("totalKnowledgePoints")),
mastery_level=_first_str(d.get("masteryLevel")) or "weak",
)
@strawberry.type
class ErrorBookItem:
"""错题本条目."""
question_id: str
knowledge_point_id: str
knowledge_point_title: str
error_count: int
last_error_time: int
content: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "ErrorBookItem":
return cls(
question_id=_first_str(d.get("question_id"), d.get("questionId")),
knowledge_point_id=_first_str(d.get("knowledge_point_id"), d.get("knowledgePointId")),
knowledge_point_title=_first_str(
d.get("knowledge_point_title"), d.get("knowledgePointTitle")
),
error_count=_to_int(d.get("error_count") or d.get("errorCount")),
last_error_time=_to_int(d.get("last_error_time") or d.get("lastErrorTime")),
content=d.get("content") or "",
)
@strawberry.type
class ErrorBookStats:
"""错题本统计."""
student_id: str
total_error_questions: int
total_error_count: int
recent_7d_errors: int
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "ErrorBookStats":
return cls(
student_id=_first_str(d.get("studentId"), d.get("student_id")),
total_error_questions=_to_int(d.get("totalErrorQuestions")),
total_error_count=_to_int(d.get("totalErrorCount")),
recent_7d_errors=_to_int(d.get("recent7dErrors") or d.get("recent_7d_errors")),
)
# ===== Query 根类型Resolver 委托给现有 service =====
@strawberry.type
class Query:
"""GraphQL Query 根类型.
所有 Resolver 委托给 analytics_service / mastery_service / clickhouse_repository
不重新实现业务逻辑,仅做 dict → strawberry type 的形状映射.
"""
@strawberry.field
async def class_performance(
self,
info: Info,
class_id: str,
subject_id: str = "",
start_date: int = 0,
end_date: int = 0,
) -> ClassPerformance:
"""班级成绩分析."""
user = _get_user_context(info)
result = await analytics_service.get_class_performance(
user=user,
class_id=class_id,
subject_id=subject_id,
start_date=start_date,
end_date=end_date,
)
return ClassPerformance.from_dict(result)
@strawberry.field
async def student_weakness(
self,
info: Info,
student_id: str,
subject_id: str = "",
) -> StudentWeakness:
"""学生薄弱知识点."""
user = _get_user_context(info)
result = await analytics_service.get_student_weakness(
user=user,
student_id=student_id,
subject_id=subject_id,
)
return StudentWeakness.from_dict(result)
@strawberry.field
async def learning_trend(
self,
info: Info,
student_id: str,
subject_id: str = "",
start_date: int = 0,
end_date: int = 0,
) -> LearningTrend:
"""学习趋势."""
user = _get_user_context(info)
result = await analytics_service.get_learning_trend(
user=user,
student_id=student_id,
subject_id=subject_id,
start_date=start_date,
end_date=end_date,
)
return LearningTrend.from_dict(result)
@strawberry.field
async def teacher_dashboard(
self,
info: Info,
user_id: str,
class_id: str = "",
) -> TeacherDashboard:
"""教师仪表盘."""
user = _get_user_context(info)
result = await analytics_service.get_teacher_dashboard(
user=user,
class_id=class_id,
)
return TeacherDashboard.from_dict(result)
@strawberry.field
async def student_dashboard(
self,
info: Info,
user_id: str,
) -> StudentDashboard:
"""学生仪表盘."""
user = _get_user_context(info)
result = await analytics_service.get_student_dashboard(
user=user,
student_id=user_id,
)
return StudentDashboard.from_dict(result, user_id=user_id)
@strawberry.field
async def parent_dashboard(
self,
info: Info,
user_id: str,
student_id: str,
) -> ParentDashboard:
"""家长仪表盘."""
user = _get_user_context(info)
result = await analytics_service.get_parent_dashboard(
user=user,
child_id=student_id,
)
return ParentDashboard.from_dict(result, user_id=user_id)
@strawberry.field
async def admin_dashboard(
self,
info: Info,
user_id: str,
scope: str = "",
scope_id: str = "",
) -> AdminDashboard:
"""管理员仪表盘."""
user = _get_user_context(info)
result = await analytics_service.get_admin_dashboard(
user=user,
school_id=scope_id,
)
return AdminDashboard.from_dict(result)
@strawberry.field
async def student_mastery(
self,
info: Info,
student_id: str,
subject_id: str = "",
) -> StudentMastery:
"""学生知识点掌握度明细."""
user = _get_user_context(info)
result = await analytics_service.get_student_mastery(
user=user,
student_id=student_id,
subject_id=subject_id,
)
return StudentMastery.from_dict(result)
@strawberry.field
async def mastery_summary(
self,
info: Info,
student_id: str,
subject_id: str = "",
) -> MasterySummary:
"""学生掌握度汇总."""
user = _get_user_context(info)
result = await analytics_service.get_mastery_summary(
user=user,
student_id=student_id,
subject_id=subject_id,
)
return MasterySummary.from_dict(result)
@strawberry.field
async def error_book_items(
self,
info: Info,
student_id: str,
subject_id: str = "",
limit: int = 100,
offset: int = 0,
) -> list[ErrorBookItem]:
"""错题本列表."""
user = _get_user_context(info)
result = await analytics_service.list_error_book_items(
user=user,
student_id=student_id,
subject_id=subject_id,
limit=limit,
offset=offset,
)
items = result.get("items") or []
return [ErrorBookItem.from_dict(item) for item in items]
@strawberry.field
async def error_book_stats(
self,
info: Info,
student_id: str,
subject_id: str = "",
) -> ErrorBookStats:
"""错题本统计."""
user = _get_user_context(info)
result = await analytics_service.get_error_book_stats(
user=user,
student_id=student_id,
subject_id=subject_id,
)
return ErrorBookStats.from_dict(result)
# ===== Federation 2 Schema =====
schema = Schema(
query=Query,
# 显式声明所有类型(确保 Federation 2 子图完整暴露)
types=[
ClassPerformance,
StudentWeakness,
WeakPoint,
LearningTrend,
TrendPoint,
TeacherDashboard,
StudentDashboard,
ParentDashboard,
AdminDashboard,
StudentMastery,
MasterySummary,
ErrorBookItem,
ErrorBookStats,
],
)
# ===== ASGI app + context getter =====
async def _build_context(request: Request) -> dict[str, Any]:
"""从请求头提取 UserContext与 REST 端点使用相同的 header 约定).
Apollo Router 从 JWT 提取用户信息后注入 x-user-id / x-user-roles 头.
"""
user_id = request.headers.get("x-user-id", "")
user_roles_header = request.headers.get("x-user-roles", "")
roles = (
[r.strip() for r in user_roles_header.split(",") if r.strip()] if user_roles_header else []
)
return {
"request": request,
"user": UserContext(user_id=user_id, roles=roles),
}
class _DataAnaGraphQLApp(GraphQL):
"""data-ana GraphQL ASGI handler.
重写 get_context 以注入 UserContextstrawberry 0.257+ 不再支持
context_getter 构造参数,需通过子类重写).
"""
async def get_context(
self,
request: Request,
response: Response, # noqa: ARG002 - strawberry 调用约定
) -> dict[str, Any]:
return await _build_context(request)
# GraphQL ASGI app在 main.py 中挂载到 /graphql
# 生产环境通过 Router-Authorization Header 校验RouterAuthMiddleware 拦截)
# 开发环境通过 dev_mode 控制中间件放行schema 本身不输出堆栈,避免泄露)
graphql_app: GraphQL = _DataAnaGraphQLApp(schema)

View File

@@ -1,6 +1,6 @@
"""数据分析服务入口FastAPI HTTP :3006.
端点清单3 基础 + 11 业务 = 14 个):
端点清单3 基础 + 11 业务 + 1 GraphQL = 15 个):
基础:
GET / 根信息
GET /healthz 活性检查liveness
@@ -20,12 +20,18 @@
GET /analytics/class/{class_id}/mastery-distribution 班级掌握度分布
GET /analytics/student/{student_id}/mastery 学生掌握度明细
GraphQLv2.1 M1Apollo Federation 2 子图):
POST /graphql GraphQL 端点Router-Authorization 校验)
- Resolver 委托给 analytics_service / mastery_service
- 由 Apollo Router 组合不直接对外暴露dev_mode 除外)
设计要点:
- 所有业务端点返回 ActionState[T]coord-cross-review §5.3 P0 整改)
- 降级标记在顶层 details.degraded不放 error.details
- /readyz 检查 4 依赖clickhouse / cdc_consumer / redis / iam_grpc
- gRPC server :50055 在 lifespan 启动
- CDC 消费者在 lifespan 启动
- /graphql 由 strawberry-graphql 提供RouterAuthMiddleware 拦截
"""
from collections.abc import AsyncGenerator
@@ -44,6 +50,8 @@ from prometheus_client import make_asgi_app
from . import analytics_service, cdc_consumer, grpc_server, warning_service
from .config import settings
from .graphql.router_auth import RouterAuthMiddleware
from .graphql.schema import graphql_app
from .repository import (
clickhouse_repository,
iam_client,
@@ -181,6 +189,13 @@ app = FastAPI(
FastAPIInstrumentor.instrument_app(app)
app.mount("/metrics", make_asgi_app())
# GraphQL Federation 2 子图v2.1 M1
# RouterAuthMiddleware 仅对 /graphql 端点校验 Router-Authorization Header
# REST 路由放行(已有 PermissionGuard / DataScope 校验)
app.add_middleware(RouterAuthMiddleware)
# 挂载 strawberry ASGI handler 到 /graphqlApollo Router 访问入口)
app.mount(settings.graphql_path, graphql_app)
router = APIRouter()
@@ -200,6 +215,7 @@ async def root() -> dict[str, Any]:
"healthz": "/healthz",
"readyz": "/readyz",
"business": "/analytics/*",
"graphql": settings.graphql_path,
},
}

77
uv.lock generated
View File

@@ -27,6 +27,7 @@ dependencies = [
{ name = "protobuf" },
{ name = "pyyaml" },
{ name = "redis" },
{ name = "strawberry-graphql", extra = ["asgi"] },
{ name = "tenacity" },
]
@@ -54,6 +55,7 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.2" },
{ name = "redis", specifier = ">=5.1.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" },
{ name = "strawberry-graphql", extras = ["asgi"], specifier = ">=0.257.0" },
{ name = "tenacity", specifier = ">=9.0.0" },
]
provides-extras = ["dev"]
@@ -349,6 +351,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
]
[[package]]
name = "cross-web"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/a0/bdb8370215987cc5bde497cb8b976b17ab14841566ecef2aab6ae3e6c7b0/cross_web-0.7.0.tar.gz", hash = "sha256:15fbc8b9a824a055db8127fd6e43e0773074f620fdecb6b2b587d3d0a2bdd459", size = 332407, upload-time = "2026-05-19T14:18:48.849Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/4a/78b52ec2edcbd9b123638f4e0421fd8699ebe653ebf63ac5f82abd2665bc/cross_web-0.7.0-py3-none-any.whl", hash = "sha256:ddea9be3c68b48eaf16561847a5831a559786949c544b3701432e00a4e8d19d9", size = 25207, upload-time = "2026-05-19T14:18:47.614Z" },
]
[[package]]
name = "data-ana-service"
version = "1.0.0"
@@ -359,6 +373,7 @@ dependencies = [
{ name = "edu-shared-py" },
{ name = "grpcio-health-checking" },
{ name = "protobuf" },
{ name = "strawberry-graphql", extra = ["asgi"] },
]
[package.metadata]
@@ -368,6 +383,7 @@ requires-dist = [
{ name = "edu-shared-py", editable = "packages/shared-py" },
{ name = "grpcio-health-checking", specifier = ">=1.66.0" },
{ name = "protobuf", specifier = ">=5.28.0" },
{ name = "strawberry-graphql", extras = ["asgi"], specifier = ">=0.257.0" },
]
[[package]]
@@ -437,6 +453,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
]
[[package]]
name = "graphql-core"
version = "3.2.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/90/f2aff026ab4aebd80eb71905106a0885f4cfde85dcf965543f45bed0d9ee/graphql_core-3.2.11.tar.gz", hash = "sha256:e7e156d10beb127cab5c89ff0da71416fc73d27c484a4757d3b2d35633774802", size = 528407, upload-time = "2026-06-05T13:45:22.915Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/15/b92b4e1d88d02c6eff9733c9eea21846ab435cc4d813d84ccc5d335955df/graphql_core-3.2.11-py3-none-any.whl", hash = "sha256:0b3e35ff41e9adba53021ab0cef475eb18f57c7f53f0f2ca55567fbf3c537ea0", size = 214879, upload-time = "2026-06-05T13:45:21.245Z" },
]
[[package]]
name = "grpcio"
version = "1.82.1"
@@ -1121,6 +1146,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1130,6 +1167,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1234,6 +1280,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "starlette"
version = "1.3.1"
@@ -1247,6 +1302,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "strawberry-graphql"
version = "0.321.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cross-web" },
{ name = "graphql-core" },
{ name = "packaging" },
{ name = "python-dateutil" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/b0/e2e13fadbf291fd7ee1c89a7f3d6872424ad299018a3adfd6ac8459ba21c/strawberry_graphql-0.321.0.tar.gz", hash = "sha256:54e916c83f21219bbe3067b40ca4d10d550a487ea6690336e3be9e5536409214", size = 233479, upload-time = "2026-07-13T20:56:02.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/b7/9d4d280ccb675fb4273a9e7f1d67b67fbd4ce7baaa15dba646bc20759915/strawberry_graphql-0.321.0-py3-none-any.whl", hash = "sha256:bd85d8a3eda351cc4a952d3d6216096b112c94ee9149e18c4cda754bb967c0d9", size = 336994, upload-time = "2026-07-13T20:56:01.075Z" },
]
[package.optional-dependencies]
asgi = [
{ name = "python-multipart" },
{ name = "starlette" },
]
[[package]]
name = "structlog"
version = "26.1.0"