wangdongxu

jira:NYJ-1460 desc:init

... ... @@ -206,4 +206,4 @@ test/
# OS
.DS_Store
Thumbs.db
logs/
... ...
# 应用镜像 - 基于基础镜像部署代码
FROM aigc-embedding-service-base:latest
# 设置工作目录
WORKDIR /app
# 复制应用代码(包含配置文件)
COPY app/ ./app/
# 暴露端口
EXPOSE 8000
# 启动命令
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
... ...
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from api.http_log import LoggingMiddleware
from fastapi import FastAPI
from api.router import api_router
from config import settings
def config_fastapi(fastapi_app: FastAPI):
# 配置请求/响应日志中间件(需在CORS之前添加)
fastapi_app.add_middleware(LoggingMiddleware)
# 配置CORS
fastapi_app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
fastapi_app.include_router(api_router)
@fastapi_app.exception_handler(Exception)
async def generic_exception_handler(request, exc: Exception):
# 返回自定义的错误响应
error_detail = str(exc)
return JSONResponse(
status_code=200,
content={"code": "error", "message": error_detail},
)
@fastapi_app.get("/", tags=["根路径"])
async def root():
"""根路径."""
return {
"name": settings.app_name,
"version": settings.app_version,
"docs": "/docs",
}
return fastapi_app
... ...
import json
import time
import logging
logger = logging.getLogger(__name__)
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import StreamingResponse
class LoggingMiddleware(BaseHTTPMiddleware):
"""HTTP请求/响应日志中间件."""
async def dispatch(self, request: Request, call_next):
# 记录请求开始时间
start_time = time.time()
# 获取请求信息
method = request.method
url = str(request.url)
client_host = request.client.host if request.client else "unknown"
# 读取请求体
request_body = None
if method in ("POST", "PUT", "PATCH"):
try:
body = await request.body()
if body:
request_body = body.decode("utf-8", errors="replace")
# 尝试格式化为JSON
try:
request_body = json.loads(request_body)
except json.JSONDecodeError:
pass # 保持原始字符串
except Exception:
pass
# 记录请求日志
logger.info(f"→ HTTP Request | {method} {url} | Client: {client_host}")
if request_body:
logger.info(f" Request Body: {request_body}")
# 处理请求
response = await call_next(request)
# 计算处理时间
process_time = time.time() - start_time
# 读取响应体(仅处理非流式响应)
response_body = None
if not isinstance(response, StreamingResponse):
try:
response_body_bytes = b""
async for chunk in response.body_iterator:
response_body_bytes += chunk
# 重新构建响应
response_body = response_body_bytes.decode("utf-8", errors="replace")
# 尝试格式化为JSON
try:
response_body = json.loads(response_body)
except json.JSONDecodeError:
pass # 保持原始字符串
# 重建响应对象
response = StreamingResponse(
iter([response_body_bytes]),
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
)
except Exception:
pass
# 记录响应日志
status_code = response.status_code
logger.info(f"← HTTP Response | {method} {url} | Status: {status_code} | Time: {process_time:.3f}s")
if response_body:
logger.info(f" Response Body: {response_body}")
return response
\ No newline at end of file
... ...
... ... @@ -3,9 +3,7 @@
from fastapi import APIRouter
# 创建主路由
api_router = APIRouter(prefix="/api/v1")
# 注册各场景路由
api_router = APIRouter(prefix="/aigc-embedding/api")
def register_router(router: APIRouter, prefix: str):
api_router.include_router(router, prefix=prefix)
... ...
from aabd.base.cfg_loader import load_yaml_by_name_with_env
from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars
settings = EnhanceDict(load_yaml_by_name_with_env('config'))
settings.update(read_prefixed_env_vars('APP_'))
"""自定义异常模块."""
class EmbeddingServiceError(Exception):
"""Embedding服务基础异常."""
def __init__(self, message: str, code: str = "EMBEDDING_ERROR"):
self.message = message
self.code = code
super().__init__(self.message)
class EmbeddingAPIError(EmbeddingServiceError):
"""Embedding API调用异常."""
def __init__(self, message: str, status_code: int = 500):
super().__init__(message, code="EMBEDDING_API_ERROR")
self.status_code = status_code
class DatabaseError(EmbeddingServiceError):
"""数据库操作异常."""
def __init__(self, message: str):
super().__init__(message, code="DATABASE_ERROR")
class NotFoundError(EmbeddingServiceError):
"""资源不存在异常."""
def __init__(self, message: str):
super().__init__(message, code="NOT_FOUND")
class ValidationError(EmbeddingServiceError):
"""参数校验异常."""
def __init__(self, message: str):
super().__init__(message, code="VALIDATION_ERROR")
"""FastAPI应用入口."""
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.exceptions import EmbeddingServiceError
from app.core.config import settings
from app.db import connect as db_client_connect, disconnect as db_client_disconnect
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理."""
# 启动时建立数据库连接
try:
await db_client_connect()
db_type = settings.get("db_type", "es")
print(f"✓ {db_type.upper()} database connected")
except Exception as e:
print(f"✗ Failed to connect to database: {e}")
raise
yield
# 关闭时断开数据库连接
try:
await db_client_disconnect()
db_type = settings.get("db_type", "es")
print(f"✓ {db_type.upper()} database disconnected")
except Exception as e:
print(f"✗ Error disconnecting from database: {e}")
def create_app() -> FastAPI:
"""创建FastAPI应用."""
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="基于向量的CRUD服务,支持多种Embedding场景",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(api_router)
# 异常处理
@app.exception_handler(EmbeddingServiceError)
async def embedding_service_exception_handler(request, exc):
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=500,
content={"code": exc.code, "message": exc.message},
)
# @app.get("/health", tags=["健康检查"])
# async def health_check():
# """健康检查端点."""
# db_healthy = await db_client.health_check()
# db_type = settings.get("db_type", "es")
# return {
# "status": "healthy" if db_healthy else "unhealthy",
# "database": {
# "type": db_type,
# "status": "connected" if db_healthy else "disconnected",
# },
# }
@app.get("/", tags=["根路径"])
async def root():
"""根路径."""
return {
"name": settings.app_name,
"version": settings.app_version,
"docs": "/docs",
}
return app
# 创建应用实例
app = create_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host='0.0.0.0',
port=settings.port,
reload=settings.debug,
)
from app.db import es_client
app_name: "Embedding Service"
app_version: "1.0.0"
port: 8000
debug: true
debug: false
db_es_enable: true
db_es_url: "http://localhost:9200"
db_postgres_enable: false
db_postgres_url: "postgresql://postgres:postgres@localhost:5432/postgres"
service:
face:
db_client: "es"
... ...
from aabd.base.cfg_loader import load_yaml_by_file_with_env
from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars
from pathlib import Path
cfg_dir = Path(__file__).parent.absolute() / 'cfg'
settings = EnhanceDict(load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix()))
settings.update(read_prefixed_env_vars('APP_'))
... ...
"""数据库模块,提供统一的向量数据库抽象层.
支持Elasticsearch和PostgreSQL两种后端,通过配置自动切换。
使用示例:
from app.db import db_client
# 连接数据库
await db_client.connect()
# 创建集合
await db_client.create_collection("face_vectors", vector_dim=512)
# 插入数据
await db_client.insert("face_vectors", "user_001", vector, {"name": "张三"})
# 向量搜索
results = await db_client.search("face_vectors", query_vector, top_k=10)
# 断开连接
await db_client.disconnect()
"""
from typing import Optional
from app.core.config import settings
from config import settings
es_client = None
pg_client = None
... ...
"""Elasticsearch向量数据库客户端实现."""
from typing import Any, Dict, List, Optional
from typing import Any, Optional
from app.core.config import settings
from app.db.base import VectorDBClient
from config import settings
from db.base import VectorDBClient
class ElasticsearchClient(VectorDBClient):
... ... @@ -18,7 +18,7 @@ class ElasticsearchClient(VectorDBClient):
Args:
es_url: Elasticsearch连接URL,默认从配置读取
"""
self.es_url = es_url or settings.get("db_es_url", "http://localhost:9200")
self.es_url = es_url
self._client: Optional[Any] = None
async def connect(self) -> None:
... ...
"""PostgreSQL向量数据库客户端实现."""
import json
from typing import Any, Dict, List, Optional
from typing import Any, Optional
from app.db.base import VectorDBClient
from db.base import VectorDBClient
class PostgresClient(VectorDBClient):
... ... @@ -24,7 +23,6 @@ class PostgresClient(VectorDBClient):
async def connect(self) -> None:
"""建立PG连接池."""
import psycopg
from psycopg_pool import AsyncConnectionPool
self._pool = AsyncConnectionPool(
... ...
# 基础镜像 - 安装依赖
FROM python:3.12-slim
ARG DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai
RUN cat > /etc/apt/sources.list <<EOF
deb http://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse
EOF
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends gcc curl vim && rm -rf /var/lib/apt/lists/*
# 安装uv工具
RUN pip install --no-cache-dir uv
WORKDIR /app
# 复制依赖文件
COPY ../pyproject.toml uv.lock ./
# 使用uv安装项目依赖(包含es和postgres可选依赖)
RUN uv pip install --system --no-cache -e ".[es,postgres]"
\ No newline at end of file
... ...
import os
from pathlib import Path
os.environ["APP_LOG_TYPE"] = "file"
os.environ['PROJECT_ROOT'] = Path(__file__).parent.absolute().as_posix()
from aabd.base.patched_logging import set_global_logger, get_logger
set_global_logger()
logger = get_logger(__name__)
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from api.http_config import config_fastapi
from config import settings
from db import connect as db_client_connect, disconnect as db_client_disconnect
@asynccontextmanager
async def lifespan(fastapi_app: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理."""
# 启动时建立数据库连接
try:
await db_client_connect()
logger.info(f"✓ database connected")
except Exception as e:
logger.error(f"✗ Failed to connect to database: {e}")
raise
yield
# 关闭时断开数据库连接
try:
await db_client_disconnect()
logger.info(f"✓ database disconnected")
except Exception as e:
logger.error(f"✗ Error disconnecting from database: {e}")
# 创建应用实例
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="基于向量的CRUD服务,支持多种Embedding场景",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
config_fastapi(app)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host='0.0.0.0',
port=settings.port,
reload=settings.debug,
log_config=None
)
... ...
... ... @@ -4,15 +4,13 @@ version = "0.1.0"
description = "基于向量的CRUD服务,支持多种Embedding场景"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"pydantic>=2.9.0",
"pydantic-settings>=2.6.0",
"httpx>=0.27.0",
"python-multipart>=0.0.12",
"aabd>=0.4.1",
"path>=17.1.1",
"omegaconf>=2.3.0",
"fastapi==0.135.3",
"uvicorn[standard]==0.42.0",
"pydantic==2.12.5",
"aabd==0.4.3",
"path==17.1.1",
"omegaconf==2.3.0",
"elasticsearch>=9.3.0",
]
[project.optional-dependencies]
... ... @@ -24,11 +22,11 @@ dev = [
"mypy>=1.13.0",
]
es = [
"elasticsearch>=8.15.0",
"elasticsearch==9.3.0",
]
postgres = [
"psycopg[binary,pool]>=3.2.0",
"psycopg-pool>=3.3.0",
"psycopg[binary,pool]==3.3.3",
"psycopg-pool==3.3.0",
]
[tool.black]
... ...
... ... @@ -4,11 +4,11 @@ requires-python = ">=3.12"
[[package]]
name = "aabd"
version = "0.4.1"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cf/e3098e0bf36aa4181c280bae6e72998ac6a38ad9c622f0a44019e79892bf/aabd-0.4.1.tar.gz", hash = "sha256:9a1143e5c55e31e935d3a27ba4c576af46cf53fa112115ef3e374b83a199409c", size = 5797147, upload-time = "2026-01-06T06:12:33.511Z" }
sdist = { url = "https://files.pythonhosted.org/packages/24/85/29114249674f75d11ea3e8945ee62bc3399beff7462a02176f98bd4e6c45/aabd-0.4.3.tar.gz", hash = "sha256:7b905fd3b3913e6a4ff0bbe97225e043f35d68fb0a4a27165f4cfc3bba909ed4", size = 5800143, upload-time = "2026-04-02T06:19:08.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/36/fd16dfc2f57b4ebe3560e40b4b1329726f95548d94f0d66a0195aee0b3ab/aabd-0.4.1-py3-none-any.whl", hash = "sha256:ddb9c652223b7ef793552c59ed1909e140c85aac66f8704ed605f649ec704d04", size = 5821241, upload-time = "2026-01-06T06:12:18.452Z" },
{ url = "https://files.pythonhosted.org/packages/6a/09/6bcb559910bf2285a5fffb1f9cf1e73ff8ac988b23a55639cc222a635c7a/aabd-0.4.3-py3-none-any.whl", hash = "sha256:82efa8e32510b9039a80454af4476b13376f20ed20731727d2e380937e251ce2", size = 5824954, upload-time = "2026-04-02T06:19:06.604Z" },
]
[[package]]
... ... @@ -17,13 +17,11 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aabd" },
{ name = "elasticsearch" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "omegaconf" },
{ name = "path" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
... ... @@ -45,23 +43,21 @@ postgres = [
[package.metadata]
requires-dist = [
{ name = "aabd", specifier = ">=0.4.1" },
{ name = "aabd", specifier = "==0.4.3" },
{ name = "black", marker = "extra == 'dev'", specifier = ">=24.10.0" },
{ name = "elasticsearch", marker = "extra == 'es'", specifier = ">=8.15.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "elasticsearch", specifier = ">=9.3.0" },
{ name = "elasticsearch", marker = "extra == 'es'", specifier = "==9.3.0" },
{ name = "fastapi", specifier = "==0.135.3" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" },
{ name = "omegaconf", specifier = ">=2.3.0" },
{ name = "path", specifier = ">=17.1.1" },
{ name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'postgres'", specifier = ">=3.2.0" },
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = ">=3.3.0" },
{ name = "pydantic", specifier = ">=2.9.0" },
{ name = "pydantic-settings", specifier = ">=2.6.0" },
{ name = "omegaconf", specifier = "==2.3.0" },
{ name = "path", specifier = "==17.1.1" },
{ name = "psycopg", extras = ["binary", "pool"], marker = "extra == 'postgres'", specifier = "==3.3.3" },
{ name = "psycopg-pool", marker = "extra == 'postgres'", specifier = "==3.3.0" },
{ name = "pydantic", specifier = "==2.12.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-multipart", specifier = ">=0.0.12" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.42.0" },
]
provides-extras = ["dev", "es", "postgres"]
... ... @@ -196,7 +192,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.135.2"
version = "0.135.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
... ... @@ -205,9 +201,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
{ url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
]
[[package]]
... ... @@ -220,19 +216,6 @@ wheels = [
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
... ... @@ -262,21 +245,6 @@ wheels = [
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
... ... @@ -624,20 +592,6 @@ wheels = [
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
... ... @@ -697,15 +651,6 @@ wheels = [
]
[[package]]
name = "python-multipart"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
... ...