From da4bef4a2a48c7f6120195f1df277c3ee37aff01 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Sun, 10 May 2026 18:23:27 +0800 Subject: [PATCH 1/7] docs: add Python client design spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-05-10-python-client-design.md | 290 ++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-10-python-client-design.md diff --git a/docs/superpowers/specs/2026-05-10-python-client-design.md b/docs/superpowers/specs/2026-05-10-python-client-design.md new file mode 100644 index 00000000..49df70db --- /dev/null +++ b/docs/superpowers/specs/2026-05-10-python-client-design.md @@ -0,0 +1,290 @@ +# Memind Python Client Design Spec + +## Overview + +为 memind 提供官方 Python SDK,使 Python 用户能够方便地与 memind API 交互。设计对标 openai-python / anthropic-python 的双客户端模式,提供同步和异步两种使用方式。 + +## 核心决策 + +| 决策项 | 选择 | 理由 | +|--------|------|------| +| Python 版本 | 3.10+ | 支持 `X \| Y` 联合类型语法 | +| HTTP 库 | httpx | 原生支持同步/异步,现代 API | +| 数据模型 | Pydantic v2 | 类型验证、序列化、IDE 补全 | +| 构建工具 | Hatch/Hatchling | 现代标准,单一 pyproject.toml | +| 代码位置 | memind-clients/python/ | 与 Java client 平级 | +| PyPI 包名 | memind-client | 明确表明是 client SDK | +| import 名 | memind | 简洁,与项目名一致 | +| 客户端模式 | 双客户端 (sync + async) | 行业标准,类型安全 | + +## 项目结构 + +``` +memind-clients/python/ +├── pyproject.toml +├── README.md +├── LICENSE +├── src/ +│ └── memind/ +│ ├── __init__.py # 公开 API 导出 +│ ├── _client.py # MemindClient (同步) +│ ├── _async_client.py # AsyncMemindClient (异步) +│ ├── _base_client.py # 共享逻辑基类 +│ ├── _constants.py # 默认值、版本号 +│ ├── _exceptions.py # 异常层次 +│ ├── _http.py # HTTP 传输层封装 (重试逻辑) +│ ├── types/ +│ │ ├── __init__.py +│ │ ├── memory.py # 记忆相关请求/响应模型 +│ │ ├── message.py # Message, ContentBlock, Source +│ │ ├── health.py # HealthResponse +│ │ └── common.py # ApiResult, Strategy 枚举 +│ └── resources/ +│ ├── __init__.py +│ ├── memory.py # Memory 资源 (同步) +│ └── async_memory.py # Memory 资源 (异步) +└── tests/ + ├── conftest.py + ├── test_client.py + ├── test_async_client.py + ├── test_memory.py + └── test_models.py +``` + +## 数据模型 + +### 通用类型 + +```python +class Strategy(str, Enum): + SIMPLE = "SIMPLE" + DEEP = "DEEP" + +class ApiResult[T](BaseModel): + code: str + message: str | None = None + data: T | None = None + timestamp: int | None = None + trace_id: str | None = None +``` + +### Message 体系 + +```python +# Source 类型 (discriminated union via "type" field) +Source = UrlSource | Base64Source + +# ContentBlock 类型 (discriminated union via "type" field) +ContentBlock = TextBlock | ImageBlock | AudioBlock | VideoBlock + +class Message(BaseModel): + role: Role # USER | ASSISTANT + content: list[ContentBlock] + timestamp: str | None = None + user_name: str | None = None + source_client: str | None = None + + @classmethod + def user(cls, text: str, *, timestamp: str | None = None) -> "Message": ... + + @classmethod + def assistant(cls, text: str, *, timestamp: str | None = None) -> "Message": ... +``` + +### RawContent 体系 + +```python +class RawContent(BaseModel): + type: str + +class ConversationContent(RawContent): + type: Literal["conversation"] = "conversation" + messages: list[Message] + +class MapRawContent(RawContent): + type: str + properties: dict[str, str] +``` + +### 请求模型 + +```python +class ExtractMemoryRequest(BaseModel): + user_id: str + agent_id: str + raw_content: RawContent + source_client: str | None = None + +class AddMessageRequest(BaseModel): + user_id: str + agent_id: str + message: Message + source_client: str | None = None + +class CommitMemoryRequest(BaseModel): + user_id: str + agent_id: str + source_client: str | None = None + +class RetrieveMemoryRequest(BaseModel): + user_id: str + agent_id: str + query: str + strategy: Strategy + trace: bool | None = None +``` + +### 响应模型 + +```python +class HealthResponse(BaseModel): + status: str + service: str + +class RetrievedItem(BaseModel): + id: str + text: str + vector_score: float | None = None + final_score: float | None = None + occurred_at: str | None = None + +class RetrievedInsight(BaseModel): + id: str + text: str + tier: str | None = None + +class RetrievedRawData(BaseModel): + raw_data_id: str + caption: str | None = None + max_score: float | None = None + item_ids: list[str] | None = None + +class RetrieveMemoryResponse(BaseModel): + status: str | None = None + items: list[RetrievedItem] = [] + insights: list[RetrievedInsight] = [] + raw_data: list[RetrievedRawData] = [] + evidences: list[str] = [] + strategy: str | None = None + query: str | None = None +``` + +## 客户端 API + +### 同步客户端 + +```python +from memind import MemindClient, Strategy, Message +from memind.types import ConversationContent + +# 创建客户端(参数 > 环境变量 > 默认值) +client = MemindClient( + base_url="http://localhost:8080", # 或 MEMIND_BASE_URL + api_token="sk-xxx", # 或 MEMIND_API_TOKEN + timeout=30.0, # 秒 + max_retries=2, +) + +# 健康检查 +health = client.health() + +# 记忆操作(通过 memory 命名空间) +client.memory.extract(user_id="u1", agent_id="a1", raw_content=...) +client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("...")) +client.memory.commit(user_id="u1", agent_id="a1") +result = client.memory.retrieve(user_id="u1", agent_id="a1", query="...", strategy=Strategy.SIMPLE) + +# 资源管理 +client.close() +# 或 +with MemindClient(...) as client: + ... +``` + +### 异步客户端 + +```python +from memind import AsyncMemindClient + +async with AsyncMemindClient(base_url="...") as client: + result = await client.memory.retrieve( + user_id="u1", agent_id="a1", query="...", strategy=Strategy.DEEP + ) +``` + +### 配置优先级 + +1. 构造函数参数(最高) +2. 环境变量:`MEMIND_BASE_URL`, `MEMIND_API_TOKEN` +3. 默认值:timeout=30s, max_retries=2 + +## 异常层次 + +``` +MemindError (基类, extends Exception) +├── MemindAPIError (API 错误, 含 status_code/error_code/trace_id/body) +│ ├── MemindAuthenticationError (401) +│ └── MemindRateLimitError (429, 含 retry_after) +├── MemindConnectionError (网络不可达) +└── MemindTimeoutError (超时) +``` + +所有异常携带足够调试信息,通过 `__cause__` 链保留底层 httpx 异常。 + +## 重试策略 + +- 重试条件:网络错误、408、429、500、502、503、504 +- 退避算法:指数退避 + 抖动(0.5s → 1s → 2s) +- 429 时尊重 `Retry-After` 响应头 +- 所有 POST 操作均可重试(memind API 操作幂等) +- 默认最大重试 2 次 + +## 内部架构 + +### BaseClient + +共享配置解析、URL 构建、请求头构造、响应处理逻辑: +- `_build_headers()` → User-Agent + Authorization + Content-Type +- `_build_url(path)` → `{base_url}/open/v1{path}` +- `_process_response(response, response_type)` → 解析 ApiResult 包装,成功返回 data,失败抛异常 + +### JSON 序列化约定 + +- 请求:`model_dump(by_alias=True, exclude_none=True)` → camelCase JSON +- 响应:camelCase JSON → `model_validate()` → snake_case 属性 +- 通过 Pydantic `ConfigDict(alias_generator=to_camel, populate_by_name=True)` 实现 + +### 日志 + +- 使用标准 `logging` 模块,logger 名称:`memind` +- DEBUG:请求/响应详情 +- WARNING:重试事件 +- INFO:客户端生命周期事件 + +## 依赖 + +### 运行时 + +- `httpx >= 0.27.0` — HTTP 客户端 +- `pydantic >= 2.0.0` — 数据模型 + +### 开发 + +- `pytest >= 8.0` — 测试框架 +- `pytest-asyncio >= 0.23` — 异步测试 +- `pytest-httpx >= 0.30` — httpx mock +- `ruff` — Linting + formatting +- `mypy` — 类型检查 + +## 测试策略 + +- 单元测试:模型序列化/反序列化、异常构造、配置解析 +- 集成测试:使用 pytest-httpx mock HTTP 交互,验证完整请求/响应流程 +- 异步测试:使用 pytest-asyncio 测试 AsyncMemindClient +- 覆盖率目标:>90% + +## 版本与发布 + +- 版本号与 memind 主项目对齐:0.2.0 +- 发布到 PyPI,包名 `memind-client` +- GitHub Actions CI/CD 发布流程(参考现有 Java client release workflow) From 304bb4fd6a90bf475fb646e887c21026a67523ed Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Sun, 10 May 2026 18:27:17 +0800 Subject: [PATCH 2/7] docs: fix Python client design spec issues found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ApiResult.timestamp type: int → str (ISO-8601, matches Java Instant) - Add missing RetrievalTraceView model and trace field in response - Fix RetrievedItem score fields: remove incorrect Optional (Java primitives) - Document MapRawContent custom serialization (properties spread as top-level) - Document Base64Source.media_type snake_case exception - Add timeout granularity (connect=5s, read=30s) matching Java client - Clarify Request model export strategy for advanced users Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-05-10-python-client-design.md | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-05-10-python-client-design.md b/docs/superpowers/specs/2026-05-10-python-client-design.md index 49df70db..a75aa346 100644 --- a/docs/superpowers/specs/2026-05-10-python-client-design.md +++ b/docs/superpowers/specs/2026-05-10-python-client-design.md @@ -64,7 +64,7 @@ class ApiResult[T](BaseModel): code: str message: str | None = None data: T | None = None - timestamp: int | None = None + timestamp: str | None = None # ISO-8601 (Instant in Java) trace_id: str | None = None ``` @@ -104,6 +104,9 @@ class ConversationContent(RawContent): class MapRawContent(RawContent): type: str properties: dict[str, str] + # 注意:序列化时 properties 中的键值对展开为顶层字段 + # {"type": "xxx", "key1": "val1"} 而非 {"type": "xxx", "properties": {...}} + # 需要自定义 model_serializer 实现 ``` ### 请求模型 @@ -144,8 +147,8 @@ class HealthResponse(BaseModel): class RetrievedItem(BaseModel): id: str text: str - vector_score: float | None = None - final_score: float | None = None + vector_score: float + final_score: float occurred_at: str | None = None class RetrievedInsight(BaseModel): @@ -156,9 +159,19 @@ class RetrievedInsight(BaseModel): class RetrievedRawData(BaseModel): raw_data_id: str caption: str | None = None - max_score: float | None = None + max_score: float item_ids: list[str] | None = None +class RetrievalTraceView(BaseModel): + """可观测性追踪数据,当请求 trace=True 时返回""" + trace_id: str | None = None + started_at: str | None = None + completed_at: str | None = None + truncated: bool | None = None + stages: list[dict] = [] # 复杂嵌套结构,使用 dict 保持灵活性 + merge: dict | None = None + final_results: dict | None = None + class RetrieveMemoryResponse(BaseModel): status: str | None = None items: list[RetrievedItem] = [] @@ -167,10 +180,15 @@ class RetrieveMemoryResponse(BaseModel): evidences: list[str] = [] strategy: str | None = None query: str | None = None + trace: RetrievalTraceView | None = None # 当请求 trace=True 时返回 ``` ## 客户端 API +### 设计说明 + +Python client 的 resource 方法采用展开参数方式(更 Pythonic,IDE 补全更好),而非 Java 的 Request 对象方式。Request 模型类仍然保留并导出,供高级用户直接构造和传递使用。 + ### 同步客户端 ```python @@ -216,7 +234,15 @@ async with AsyncMemindClient(base_url="...") as client: 1. 构造函数参数(最高) 2. 环境变量:`MEMIND_BASE_URL`, `MEMIND_API_TOKEN` -3. 默认值:timeout=30s, max_retries=2 +3. 默认值:timeout=30s (connect=5s, read=30s), max_retries=2 + +### timeout 配置 + +支持两种方式: +- 简单模式:`timeout=30.0`(统一超时) +- 细粒度模式:`timeout=httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)` + +默认值与 Java client 对齐:connect=5s, read=30s。 ## 异常层次 @@ -253,6 +279,8 @@ MemindError (基类, extends Exception) - 请求:`model_dump(by_alias=True, exclude_none=True)` → camelCase JSON - 响应:camelCase JSON → `model_validate()` → snake_case 属性 - 通过 Pydantic `ConfigDict(alias_generator=to_camel, populate_by_name=True)` 实现 +- 例外:`Base64Source.media_type` 在 JSON 中为 snake_case `"media_type"`(与 Java `@JsonProperty("media_type")` 对齐),需单独配置 alias +- `MapRawContent` 序列化时需自定义 `model_serializer`,将 properties 展开为顶层字段 ### 日志 From 98a1a01080fd94df078f44928b84071c1a20ea79 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Sun, 10 May 2026 18:31:50 +0800 Subject: [PATCH 3/7] docs: final review fixes for Python client design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace RetrievalTraceView dict fields with proper typed models (StageView, MergeView, FinalView) matching Java records - Add py.typed marker and _version.py to project structure - Fix ApiResult generic syntax for Python 3.10+ compatibility (Generic[T] instead of class[T] which requires 3.12) - Fix MapRawContent.properties type: dict[str, str] → dict[str, Any] - Add default values (0.0) to score fields matching Java primitive defaults - Document version management strategy (importlib.metadata) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-05-10-python-client-design.md | 59 +++++++++++++++---- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-05-10-python-client-design.md b/docs/superpowers/specs/2026-05-10-python-client-design.md index a75aa346..53cfda15 100644 --- a/docs/superpowers/specs/2026-05-10-python-client-design.md +++ b/docs/superpowers/specs/2026-05-10-python-client-design.md @@ -27,10 +27,12 @@ memind-clients/python/ ├── src/ │ └── memind/ │ ├── __init__.py # 公开 API 导出 +│ ├── py.typed # PEP 561 类型标记 +│ ├── _version.py # 版本号单一来源 │ ├── _client.py # MemindClient (同步) │ ├── _async_client.py # AsyncMemindClient (异步) │ ├── _base_client.py # 共享逻辑基类 -│ ├── _constants.py # 默认值、版本号 +│ ├── _constants.py # 默认值 │ ├── _exceptions.py # 异常层次 │ ├── _http.py # HTTP 传输层封装 (重试逻辑) │ ├── types/ @@ -56,11 +58,15 @@ memind-clients/python/ ### 通用类型 ```python +from typing import Generic, TypeVar + +T = TypeVar("T") + class Strategy(str, Enum): SIMPLE = "SIMPLE" DEEP = "DEEP" -class ApiResult[T](BaseModel): +class ApiResult(BaseModel, Generic[T]): code: str message: str | None = None data: T | None = None @@ -103,7 +109,7 @@ class ConversationContent(RawContent): class MapRawContent(RawContent): type: str - properties: dict[str, str] + properties: dict[str, Any] # 注意:序列化时 properties 中的键值对展开为顶层字段 # {"type": "xxx", "key1": "val1"} 而非 {"type": "xxx", "properties": {...}} # 需要自定义 model_serializer 实现 @@ -147,8 +153,8 @@ class HealthResponse(BaseModel): class RetrievedItem(BaseModel): id: str text: str - vector_score: float - final_score: float + vector_score: float = 0.0 + final_score: float = 0.0 occurred_at: str | None = None class RetrievedInsight(BaseModel): @@ -159,7 +165,7 @@ class RetrievedInsight(BaseModel): class RetrievedRawData(BaseModel): raw_data_id: str caption: str | None = None - max_score: float + max_score: float = 0.0 item_ids: list[str] | None = None class RetrievalTraceView(BaseModel): @@ -168,9 +174,39 @@ class RetrievalTraceView(BaseModel): started_at: str | None = None completed_at: str | None = None truncated: bool | None = None - stages: list[dict] = [] # 复杂嵌套结构,使用 dict 保持灵活性 - merge: dict | None = None - final_results: dict | None = None + stages: list["StageView"] = [] + merge: "MergeView | None" = None + final_results: "FinalView | None" = None + +class StageView(BaseModel): + stage: str | None = None + tier: str | None = None + method: str | None = None + status: str | None = None + input_count: int | None = None + candidate_count: int | None = None + result_count: int | None = None + degraded: bool = False + skipped: bool = False + started_at: str | None = None + duration_millis: int | None = None + attributes: dict[str, Any] | None = None + candidates: list[dict[str, Any]] | None = None + +class MergeView(BaseModel): + input_count: int = 0 + output_count: int = 0 + deduplicated_count: int = 0 + source_count: int = 0 + status: str | None = None + +class FinalView(BaseModel): + strategy: str | None = None + status: str | None = None + item_count: int = 0 + insight_count: int = 0 + raw_data_count: int = 0 + evidence_count: int = 0 class RetrieveMemoryResponse(BaseModel): status: str | None = None @@ -314,5 +350,8 @@ MemindError (基类, extends Exception) ## 版本与发布 - 版本号与 memind 主项目对齐:0.2.0 +- 版本单一来源:`src/memind/_version.py` 中定义 `__version__ = "0.2.0"` +- `pyproject.toml` 通过 `dynamic = ["version"]` + hatch-vcs 或直接引用 `_version.py` +- User-Agent 通过 `importlib.metadata.version("memind-client")` 动态读取,避免硬编码 - 发布到 PyPI,包名 `memind-client` -- GitHub Actions CI/CD 发布流程(参考现有 Java client release workflow) +- GitHub Actions CI/CD 发布流程(参考现有 Java client release workflow,使用 workflow_dispatch 触发) From 27feb3b2eb04bd0449394fdf0051b02deb143a33 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Sun, 10 May 2026 18:36:46 +0800 Subject: [PATCH 4/7] docs: third review pass - PyPI naming, deps, and lifecycle - Change PyPI package name from memind-client to memind (matches openai/anthropic convention: pip name = import name) - Tighten dependency constraints with upper bounds: httpx >=0.25.0,<1 and pydantic >=2.1.0,<3 - Document base_url required validation behavior - Document closed client guard (raises MemindError after close) - Add note to register PyPI name early Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-05-10-python-client-design.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-05-10-python-client-design.md b/docs/superpowers/specs/2026-05-10-python-client-design.md index 53cfda15..1d8b8e23 100644 --- a/docs/superpowers/specs/2026-05-10-python-client-design.md +++ b/docs/superpowers/specs/2026-05-10-python-client-design.md @@ -13,7 +13,7 @@ | 数据模型 | Pydantic v2 | 类型验证、序列化、IDE 补全 | | 构建工具 | Hatch/Hatchling | 现代标准,单一 pyproject.toml | | 代码位置 | memind-clients/python/ | 与 Java client 平级 | -| PyPI 包名 | memind-client | 明确表明是 client SDK | +| PyPI 包名 | memind | 与 import 名一致,符合 openai/anthropic 惯例 | | import 名 | memind | 简洁,与项目名一致 | | 客户端模式 | 双客户端 (sync + async) | 行业标准,类型安全 | @@ -272,6 +272,13 @@ async with AsyncMemindClient(base_url="...") as client: 2. 环境变量:`MEMIND_BASE_URL`, `MEMIND_API_TOKEN` 3. 默认值:timeout=30s (connect=5s, read=30s), max_retries=2 +`base_url` 为必需配置:若构造函数未传且环境变量未设置,立即抛出 `MemindError`,提示用户提供。`api_token` 为可选:未提供时不发送 Authorization 头。 + +### 客户端生命周期 + +- 调用 `close()` 后再使用客户端的任何方法,抛出 `MemindError("Client has been closed")` +- 内部通过 `_closed: bool` 标志位实现,每次请求前检查 + ### timeout 配置 支持两种方式: @@ -329,8 +336,8 @@ MemindError (基类, extends Exception) ### 运行时 -- `httpx >= 0.27.0` — HTTP 客户端 -- `pydantic >= 2.0.0` — 数据模型 +- `httpx >= 0.25.0, <1` — HTTP 客户端 +- `pydantic >= 2.1.0, <3` — 数据模型 ### 开发 @@ -352,6 +359,7 @@ MemindError (基类, extends Exception) - 版本号与 memind 主项目对齐:0.2.0 - 版本单一来源:`src/memind/_version.py` 中定义 `__version__ = "0.2.0"` - `pyproject.toml` 通过 `dynamic = ["version"]` + hatch-vcs 或直接引用 `_version.py` -- User-Agent 通过 `importlib.metadata.version("memind-client")` 动态读取,避免硬编码 -- 发布到 PyPI,包名 `memind-client` +- User-Agent 通过 `importlib.metadata.version("memind")` 动态读取,避免硬编码 +- 发布到 PyPI,包名 `memind`(`pip install memind`) - GitHub Actions CI/CD 发布流程(参考现有 Java client release workflow,使用 workflow_dispatch 触发) +- 建议尽早注册 PyPI 包名,防止被抢注 From 615cece15fac6d8ff7af9556185aa65b71ec97fe Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Sun, 10 May 2026 18:39:16 +0800 Subject: [PATCH 5/7] docs: fourth review - response handling and serialization base class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document ApiResult success判断逻辑 (code "200" or "success") - Document failure error extraction from ApiResult - Introduce MemindModel base class for unified ConfigDict - Document extra="ignore" for forward compatibility - Clarify Base64Source alias override mechanism Co-Authored-By: Claude Opus 4.6 (1M context) --- .../superpowers/specs/2026-05-10-python-client-design.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-05-10-python-client-design.md b/docs/superpowers/specs/2026-05-10-python-client-design.md index 1d8b8e23..77a9049e 100644 --- a/docs/superpowers/specs/2026-05-10-python-client-design.md +++ b/docs/superpowers/specs/2026-05-10-python-client-design.md @@ -316,14 +316,17 @@ MemindError (基类, extends Exception) - `_build_headers()` → User-Agent + Authorization + Content-Type - `_build_url(path)` → `{base_url}/open/v1{path}` - `_process_response(response, response_type)` → 解析 ApiResult 包装,成功返回 data,失败抛异常 +- 成功判断:HTTP 2xx 且 `ApiResult.code` 为 `"200"` 或 `"success"`(与 Java `ApiResult.isSuccess()` 对齐) +- 失败时从 ApiResult 中提取 `code`、`message`、`traceId` 构造 `MemindAPIError` ### JSON 序列化约定 +- 所有模型继承自统一基类 `MemindModel(BaseModel)`,配置 `model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)` - 请求:`model_dump(by_alias=True, exclude_none=True)` → camelCase JSON -- 响应:camelCase JSON → `model_validate()` → snake_case 属性 -- 通过 Pydantic `ConfigDict(alias_generator=to_camel, populate_by_name=True)` 实现 -- 例外:`Base64Source.media_type` 在 JSON 中为 snake_case `"media_type"`(与 Java `@JsonProperty("media_type")` 对齐),需单独配置 alias +- 响应:camelCase JSON → `model_validate()` → snake_case 属性(Pydantic 自动通过 alias 匹配) +- 例外:`Base64Source.media_type` 在 JSON 中为 snake_case `"media_type"`(与 Java `@JsonProperty("media_type")` 对齐),需通过 `Field(alias="media_type")` 覆盖全局 alias_generator - `MapRawContent` 序列化时需自定义 `model_serializer`,将 properties 展开为顶层字段 +- 响应反序列化配置 `model_config = ConfigDict(extra="ignore")`,忽略未知字段(与 Java `@JsonIgnoreProperties(ignoreUnknown=true)` 对齐) ### 日志 From 216fc3c6a30649131c50728401aa85846520b650 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Sun, 10 May 2026 18:40:49 +0800 Subject: [PATCH 6/7] docs: fifth review - Role enum, User-Agent format, thread safety - Add missing Role enum definition (USER/ASSISTANT) - Specify User-Agent format: memind-python/{version} - Document thread/coroutine safety guarantee for client instances Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/superpowers/specs/2026-05-10-python-client-design.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-05-10-python-client-design.md b/docs/superpowers/specs/2026-05-10-python-client-design.md index 77a9049e..00f50607 100644 --- a/docs/superpowers/specs/2026-05-10-python-client-design.md +++ b/docs/superpowers/specs/2026-05-10-python-client-design.md @@ -62,6 +62,10 @@ from typing import Generic, TypeVar T = TypeVar("T") +class Role(str, Enum): + USER = "user" + ASSISTANT = "assistant" + class Strategy(str, Enum): SIMPLE = "SIMPLE" DEEP = "DEEP" @@ -276,6 +280,7 @@ async with AsyncMemindClient(base_url="...") as client: ### 客户端生命周期 +- `MemindClient` 和 `AsyncMemindClient` 实例是线程安全/协程安全的,可在多线程或多协程中共享使用 - 调用 `close()` 后再使用客户端的任何方法,抛出 `MemindError("Client has been closed")` - 内部通过 `_closed: bool` 标志位实现,每次请求前检查 @@ -313,7 +318,7 @@ MemindError (基类, extends Exception) ### BaseClient 共享配置解析、URL 构建、请求头构造、响应处理逻辑: -- `_build_headers()` → User-Agent + Authorization + Content-Type +- `_build_headers()` → User-Agent (`memind-python/{version}`) + Authorization + Content-Type - `_build_url(path)` → `{base_url}/open/v1{path}` - `_process_response(response, response_type)` → 解析 ApiResult 包装,成功返回 data,失败抛异常 - 成功判断:HTTP 2xx 且 `ApiResult.code` 为 `"200"` 或 `"success"`(与 Java `ApiResult.isSuccess()` 对齐) From 0eb8d47f7eb76df634910a980fe5109380811766 Mon Sep 17 00:00:00 2001 From: starboyate <2925776766@qq.com> Date: Mon, 11 May 2026 00:39:54 +0800 Subject: [PATCH 7/7] feat: add official Python client --- .github/workflows/python-client.yml | 70 ++++ .github/workflows/release-python-client.yml | 89 +++++ .../specs/2026-05-10-python-client-design.md | 373 ------------------ memind-clients/python/.gitignore | 13 + memind-clients/python/LICENSE | 202 ++++++++++ memind-clients/python/README.md | 71 ++++ memind-clients/python/pyproject.toml | 67 ++++ memind-clients/python/src/memind/__init__.py | 97 +++++ .../python/src/memind/_async_client.py | 100 +++++ .../python/src/memind/_base_client.py | 185 +++++++++ memind-clients/python/src/memind/_client.py | 100 +++++ .../python/src/memind/_constants.py | 23 ++ .../python/src/memind/_exceptions.py | 75 ++++ memind-clients/python/src/memind/_http.py | 197 +++++++++ memind-clients/python/src/memind/_models.py | 28 ++ memind-clients/python/src/memind/_version.py | 15 + memind-clients/python/src/memind/py.typed | 1 + .../python/src/memind/resources/__init__.py | 18 + .../src/memind/resources/async_memory.py | 136 +++++++ .../python/src/memind/resources/memory.py | 131 ++++++ .../python/src/memind/types/__init__.py | 77 ++++ .../python/src/memind/types/common.py | 43 ++ .../python/src/memind/types/health.py | 22 ++ .../python/src/memind/types/memory.py | 126 ++++++ .../python/src/memind/types/message.py | 112 ++++++ memind-clients/python/tests/conftest.py | 15 + .../python/tests/test_async_client.py | 172 ++++++++ .../python/tests/test_base_client.py | 188 +++++++++ memind-clients/python/tests/test_client.py | 201 ++++++++++ .../python/tests/test_exceptions.py | 72 ++++ memind-clients/python/tests/test_models.py | 335 ++++++++++++++++ .../python/tests/test_public_api.py | 39 ++ memind-clients/python/tests/test_retry.py | 180 +++++++++ pom.xml | 12 + 34 files changed, 3212 insertions(+), 373 deletions(-) create mode 100644 .github/workflows/python-client.yml create mode 100644 .github/workflows/release-python-client.yml delete mode 100644 docs/superpowers/specs/2026-05-10-python-client-design.md create mode 100644 memind-clients/python/.gitignore create mode 100644 memind-clients/python/LICENSE create mode 100644 memind-clients/python/README.md create mode 100644 memind-clients/python/pyproject.toml create mode 100644 memind-clients/python/src/memind/__init__.py create mode 100644 memind-clients/python/src/memind/_async_client.py create mode 100644 memind-clients/python/src/memind/_base_client.py create mode 100644 memind-clients/python/src/memind/_client.py create mode 100644 memind-clients/python/src/memind/_constants.py create mode 100644 memind-clients/python/src/memind/_exceptions.py create mode 100644 memind-clients/python/src/memind/_http.py create mode 100644 memind-clients/python/src/memind/_models.py create mode 100644 memind-clients/python/src/memind/_version.py create mode 100644 memind-clients/python/src/memind/py.typed create mode 100644 memind-clients/python/src/memind/resources/__init__.py create mode 100644 memind-clients/python/src/memind/resources/async_memory.py create mode 100644 memind-clients/python/src/memind/resources/memory.py create mode 100644 memind-clients/python/src/memind/types/__init__.py create mode 100644 memind-clients/python/src/memind/types/common.py create mode 100644 memind-clients/python/src/memind/types/health.py create mode 100644 memind-clients/python/src/memind/types/memory.py create mode 100644 memind-clients/python/src/memind/types/message.py create mode 100644 memind-clients/python/tests/conftest.py create mode 100644 memind-clients/python/tests/test_async_client.py create mode 100644 memind-clients/python/tests/test_base_client.py create mode 100644 memind-clients/python/tests/test_client.py create mode 100644 memind-clients/python/tests/test_exceptions.py create mode 100644 memind-clients/python/tests/test_models.py create mode 100644 memind-clients/python/tests/test_public_api.py create mode 100644 memind-clients/python/tests/test_retry.py diff --git a/.github/workflows/python-client.yml b/.github/workflows/python-client.yml new file mode 100644 index 00000000..32e2e0a9 --- /dev/null +++ b/.github/workflows/python-client.yml @@ -0,0 +1,70 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Python Client + +on: + push: + branches: + - main + paths: + - 'memind-clients/python/**' + - '.github/workflows/python-client.yml' + - '.github/workflows/release-python-client.yml' + pull_request: + paths: + - 'memind-clients/python/**' + - '.github/workflows/python-client.yml' + - '.github/workflows/release-python-client.yml' + +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: memind-clients/python/pyproject.toml + + - name: Install + working-directory: memind-clients/python + run: pip install -e ".[dev]" + + - name: Ruff format + working-directory: memind-clients/python + run: ruff format --check . + + - name: Ruff check + working-directory: memind-clients/python + run: ruff check . + + - name: Mypy + working-directory: memind-clients/python + run: mypy src + + - name: Pytest + working-directory: memind-clients/python + run: pytest --cov=memind --cov-fail-under=90 -q diff --git a/.github/workflows/release-python-client.yml b/.github/workflows/release-python-client.yml new file mode 100644 index 00000000..21f7c6ff --- /dev/null +++ b/.github/workflows/release-python-client.yml @@ -0,0 +1,89 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Release Python Client to PyPI + +# Release prerequisites: +# - The `memind` project must exist on PyPI and be owned by OpenMemind maintainers. +# - PyPI Trusted Publishing must trust this workflow path and the `pypi` environment. +# - The GitHub repository must define an environment named `pypi`. + +on: + workflow_dispatch: + inputs: + version: + description: Python client release version, for example 0.2.0 + required: true + type: string + +permissions: + contents: read + id-token: write + +jobs: + release: + name: Build and publish Python client + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + environment: pypi + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: memind-clients/python/pyproject.toml + + - name: Install build tools + working-directory: memind-clients/python + run: pip install -e ".[dev]" + + - name: Validate release version + working-directory: memind-clients/python + shell: bash + run: | + version="${{ inputs.version }}" + if [[ -z "${version}" || "${version}" == *"-SNAPSHOT" ]]; then + echo "Release version must be non-empty and must not end with -SNAPSHOT." >&2 + exit 1 + fi + + package_version="$(python -c 'from memind import __version__; print(__version__)')" + if [[ "${package_version}" != "${version}" ]]; then + echo "Workflow version ${version} does not match package version ${package_version}." >&2 + exit 1 + fi + + - name: Verify + working-directory: memind-clients/python + run: | + ruff format --check . + ruff check . + mypy src + pytest --cov=memind --cov-fail-under=90 -q + + - name: Build + working-directory: memind-clients/python + run: python -m build + + - name: Check package metadata + working-directory: memind-clients/python + run: twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: memind-clients/python/dist diff --git a/docs/superpowers/specs/2026-05-10-python-client-design.md b/docs/superpowers/specs/2026-05-10-python-client-design.md deleted file mode 100644 index 00f50607..00000000 --- a/docs/superpowers/specs/2026-05-10-python-client-design.md +++ /dev/null @@ -1,373 +0,0 @@ -# Memind Python Client Design Spec - -## Overview - -为 memind 提供官方 Python SDK,使 Python 用户能够方便地与 memind API 交互。设计对标 openai-python / anthropic-python 的双客户端模式,提供同步和异步两种使用方式。 - -## 核心决策 - -| 决策项 | 选择 | 理由 | -|--------|------|------| -| Python 版本 | 3.10+ | 支持 `X \| Y` 联合类型语法 | -| HTTP 库 | httpx | 原生支持同步/异步,现代 API | -| 数据模型 | Pydantic v2 | 类型验证、序列化、IDE 补全 | -| 构建工具 | Hatch/Hatchling | 现代标准,单一 pyproject.toml | -| 代码位置 | memind-clients/python/ | 与 Java client 平级 | -| PyPI 包名 | memind | 与 import 名一致,符合 openai/anthropic 惯例 | -| import 名 | memind | 简洁,与项目名一致 | -| 客户端模式 | 双客户端 (sync + async) | 行业标准,类型安全 | - -## 项目结构 - -``` -memind-clients/python/ -├── pyproject.toml -├── README.md -├── LICENSE -├── src/ -│ └── memind/ -│ ├── __init__.py # 公开 API 导出 -│ ├── py.typed # PEP 561 类型标记 -│ ├── _version.py # 版本号单一来源 -│ ├── _client.py # MemindClient (同步) -│ ├── _async_client.py # AsyncMemindClient (异步) -│ ├── _base_client.py # 共享逻辑基类 -│ ├── _constants.py # 默认值 -│ ├── _exceptions.py # 异常层次 -│ ├── _http.py # HTTP 传输层封装 (重试逻辑) -│ ├── types/ -│ │ ├── __init__.py -│ │ ├── memory.py # 记忆相关请求/响应模型 -│ │ ├── message.py # Message, ContentBlock, Source -│ │ ├── health.py # HealthResponse -│ │ └── common.py # ApiResult, Strategy 枚举 -│ └── resources/ -│ ├── __init__.py -│ ├── memory.py # Memory 资源 (同步) -│ └── async_memory.py # Memory 资源 (异步) -└── tests/ - ├── conftest.py - ├── test_client.py - ├── test_async_client.py - ├── test_memory.py - └── test_models.py -``` - -## 数据模型 - -### 通用类型 - -```python -from typing import Generic, TypeVar - -T = TypeVar("T") - -class Role(str, Enum): - USER = "user" - ASSISTANT = "assistant" - -class Strategy(str, Enum): - SIMPLE = "SIMPLE" - DEEP = "DEEP" - -class ApiResult(BaseModel, Generic[T]): - code: str - message: str | None = None - data: T | None = None - timestamp: str | None = None # ISO-8601 (Instant in Java) - trace_id: str | None = None -``` - -### Message 体系 - -```python -# Source 类型 (discriminated union via "type" field) -Source = UrlSource | Base64Source - -# ContentBlock 类型 (discriminated union via "type" field) -ContentBlock = TextBlock | ImageBlock | AudioBlock | VideoBlock - -class Message(BaseModel): - role: Role # USER | ASSISTANT - content: list[ContentBlock] - timestamp: str | None = None - user_name: str | None = None - source_client: str | None = None - - @classmethod - def user(cls, text: str, *, timestamp: str | None = None) -> "Message": ... - - @classmethod - def assistant(cls, text: str, *, timestamp: str | None = None) -> "Message": ... -``` - -### RawContent 体系 - -```python -class RawContent(BaseModel): - type: str - -class ConversationContent(RawContent): - type: Literal["conversation"] = "conversation" - messages: list[Message] - -class MapRawContent(RawContent): - type: str - properties: dict[str, Any] - # 注意:序列化时 properties 中的键值对展开为顶层字段 - # {"type": "xxx", "key1": "val1"} 而非 {"type": "xxx", "properties": {...}} - # 需要自定义 model_serializer 实现 -``` - -### 请求模型 - -```python -class ExtractMemoryRequest(BaseModel): - user_id: str - agent_id: str - raw_content: RawContent - source_client: str | None = None - -class AddMessageRequest(BaseModel): - user_id: str - agent_id: str - message: Message - source_client: str | None = None - -class CommitMemoryRequest(BaseModel): - user_id: str - agent_id: str - source_client: str | None = None - -class RetrieveMemoryRequest(BaseModel): - user_id: str - agent_id: str - query: str - strategy: Strategy - trace: bool | None = None -``` - -### 响应模型 - -```python -class HealthResponse(BaseModel): - status: str - service: str - -class RetrievedItem(BaseModel): - id: str - text: str - vector_score: float = 0.0 - final_score: float = 0.0 - occurred_at: str | None = None - -class RetrievedInsight(BaseModel): - id: str - text: str - tier: str | None = None - -class RetrievedRawData(BaseModel): - raw_data_id: str - caption: str | None = None - max_score: float = 0.0 - item_ids: list[str] | None = None - -class RetrievalTraceView(BaseModel): - """可观测性追踪数据,当请求 trace=True 时返回""" - trace_id: str | None = None - started_at: str | None = None - completed_at: str | None = None - truncated: bool | None = None - stages: list["StageView"] = [] - merge: "MergeView | None" = None - final_results: "FinalView | None" = None - -class StageView(BaseModel): - stage: str | None = None - tier: str | None = None - method: str | None = None - status: str | None = None - input_count: int | None = None - candidate_count: int | None = None - result_count: int | None = None - degraded: bool = False - skipped: bool = False - started_at: str | None = None - duration_millis: int | None = None - attributes: dict[str, Any] | None = None - candidates: list[dict[str, Any]] | None = None - -class MergeView(BaseModel): - input_count: int = 0 - output_count: int = 0 - deduplicated_count: int = 0 - source_count: int = 0 - status: str | None = None - -class FinalView(BaseModel): - strategy: str | None = None - status: str | None = None - item_count: int = 0 - insight_count: int = 0 - raw_data_count: int = 0 - evidence_count: int = 0 - -class RetrieveMemoryResponse(BaseModel): - status: str | None = None - items: list[RetrievedItem] = [] - insights: list[RetrievedInsight] = [] - raw_data: list[RetrievedRawData] = [] - evidences: list[str] = [] - strategy: str | None = None - query: str | None = None - trace: RetrievalTraceView | None = None # 当请求 trace=True 时返回 -``` - -## 客户端 API - -### 设计说明 - -Python client 的 resource 方法采用展开参数方式(更 Pythonic,IDE 补全更好),而非 Java 的 Request 对象方式。Request 模型类仍然保留并导出,供高级用户直接构造和传递使用。 - -### 同步客户端 - -```python -from memind import MemindClient, Strategy, Message -from memind.types import ConversationContent - -# 创建客户端(参数 > 环境变量 > 默认值) -client = MemindClient( - base_url="http://localhost:8080", # 或 MEMIND_BASE_URL - api_token="sk-xxx", # 或 MEMIND_API_TOKEN - timeout=30.0, # 秒 - max_retries=2, -) - -# 健康检查 -health = client.health() - -# 记忆操作(通过 memory 命名空间) -client.memory.extract(user_id="u1", agent_id="a1", raw_content=...) -client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("...")) -client.memory.commit(user_id="u1", agent_id="a1") -result = client.memory.retrieve(user_id="u1", agent_id="a1", query="...", strategy=Strategy.SIMPLE) - -# 资源管理 -client.close() -# 或 -with MemindClient(...) as client: - ... -``` - -### 异步客户端 - -```python -from memind import AsyncMemindClient - -async with AsyncMemindClient(base_url="...") as client: - result = await client.memory.retrieve( - user_id="u1", agent_id="a1", query="...", strategy=Strategy.DEEP - ) -``` - -### 配置优先级 - -1. 构造函数参数(最高) -2. 环境变量:`MEMIND_BASE_URL`, `MEMIND_API_TOKEN` -3. 默认值:timeout=30s (connect=5s, read=30s), max_retries=2 - -`base_url` 为必需配置:若构造函数未传且环境变量未设置,立即抛出 `MemindError`,提示用户提供。`api_token` 为可选:未提供时不发送 Authorization 头。 - -### 客户端生命周期 - -- `MemindClient` 和 `AsyncMemindClient` 实例是线程安全/协程安全的,可在多线程或多协程中共享使用 -- 调用 `close()` 后再使用客户端的任何方法,抛出 `MemindError("Client has been closed")` -- 内部通过 `_closed: bool` 标志位实现,每次请求前检查 - -### timeout 配置 - -支持两种方式: -- 简单模式:`timeout=30.0`(统一超时) -- 细粒度模式:`timeout=httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0)` - -默认值与 Java client 对齐:connect=5s, read=30s。 - -## 异常层次 - -``` -MemindError (基类, extends Exception) -├── MemindAPIError (API 错误, 含 status_code/error_code/trace_id/body) -│ ├── MemindAuthenticationError (401) -│ └── MemindRateLimitError (429, 含 retry_after) -├── MemindConnectionError (网络不可达) -└── MemindTimeoutError (超时) -``` - -所有异常携带足够调试信息,通过 `__cause__` 链保留底层 httpx 异常。 - -## 重试策略 - -- 重试条件:网络错误、408、429、500、502、503、504 -- 退避算法:指数退避 + 抖动(0.5s → 1s → 2s) -- 429 时尊重 `Retry-After` 响应头 -- 所有 POST 操作均可重试(memind API 操作幂等) -- 默认最大重试 2 次 - -## 内部架构 - -### BaseClient - -共享配置解析、URL 构建、请求头构造、响应处理逻辑: -- `_build_headers()` → User-Agent (`memind-python/{version}`) + Authorization + Content-Type -- `_build_url(path)` → `{base_url}/open/v1{path}` -- `_process_response(response, response_type)` → 解析 ApiResult 包装,成功返回 data,失败抛异常 -- 成功判断:HTTP 2xx 且 `ApiResult.code` 为 `"200"` 或 `"success"`(与 Java `ApiResult.isSuccess()` 对齐) -- 失败时从 ApiResult 中提取 `code`、`message`、`traceId` 构造 `MemindAPIError` - -### JSON 序列化约定 - -- 所有模型继承自统一基类 `MemindModel(BaseModel)`,配置 `model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)` -- 请求:`model_dump(by_alias=True, exclude_none=True)` → camelCase JSON -- 响应:camelCase JSON → `model_validate()` → snake_case 属性(Pydantic 自动通过 alias 匹配) -- 例外:`Base64Source.media_type` 在 JSON 中为 snake_case `"media_type"`(与 Java `@JsonProperty("media_type")` 对齐),需通过 `Field(alias="media_type")` 覆盖全局 alias_generator -- `MapRawContent` 序列化时需自定义 `model_serializer`,将 properties 展开为顶层字段 -- 响应反序列化配置 `model_config = ConfigDict(extra="ignore")`,忽略未知字段(与 Java `@JsonIgnoreProperties(ignoreUnknown=true)` 对齐) - -### 日志 - -- 使用标准 `logging` 模块,logger 名称:`memind` -- DEBUG:请求/响应详情 -- WARNING:重试事件 -- INFO:客户端生命周期事件 - -## 依赖 - -### 运行时 - -- `httpx >= 0.25.0, <1` — HTTP 客户端 -- `pydantic >= 2.1.0, <3` — 数据模型 - -### 开发 - -- `pytest >= 8.0` — 测试框架 -- `pytest-asyncio >= 0.23` — 异步测试 -- `pytest-httpx >= 0.30` — httpx mock -- `ruff` — Linting + formatting -- `mypy` — 类型检查 - -## 测试策略 - -- 单元测试:模型序列化/反序列化、异常构造、配置解析 -- 集成测试:使用 pytest-httpx mock HTTP 交互,验证完整请求/响应流程 -- 异步测试:使用 pytest-asyncio 测试 AsyncMemindClient -- 覆盖率目标:>90% - -## 版本与发布 - -- 版本号与 memind 主项目对齐:0.2.0 -- 版本单一来源:`src/memind/_version.py` 中定义 `__version__ = "0.2.0"` -- `pyproject.toml` 通过 `dynamic = ["version"]` + hatch-vcs 或直接引用 `_version.py` -- User-Agent 通过 `importlib.metadata.version("memind")` 动态读取,避免硬编码 -- 发布到 PyPI,包名 `memind`(`pip install memind`) -- GitHub Actions CI/CD 发布流程(参考现有 Java client release workflow,使用 workflow_dispatch 触发) -- 建议尽早注册 PyPI 包名,防止被抢注 diff --git a/memind-clients/python/.gitignore b/memind-clients/python/.gitignore new file mode 100644 index 00000000..03be96e5 --- /dev/null +++ b/memind-clients/python/.gitignore @@ -0,0 +1,13 @@ +.coverage +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.uv-cache/ +.venv/ +build/ +dist/ +htmlcov/ +src/*.egg-info/ +uv.lock +__pycache__/ +*.py[cod] diff --git a/memind-clients/python/LICENSE b/memind-clients/python/LICENSE new file mode 100644 index 00000000..a4cd42b9 --- /dev/null +++ b/memind-clients/python/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 OpenMemind + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/memind-clients/python/README.md b/memind-clients/python/README.md new file mode 100644 index 00000000..fd054189 --- /dev/null +++ b/memind-clients/python/README.md @@ -0,0 +1,71 @@ +# Memind Python Client + +Official Python client for the Memind memory engine API. + +## Installation + +```bash +pip install memind +``` + +## Synchronous Usage + +```python +from memind import MemindClient, Message, Strategy +from memind.types import ConversationContent + +with MemindClient(base_url="http://localhost:8080") as client: + health = client.health() + + client.memory.extract( + user_id="user-1", + agent_id="agent-1", + raw_content=ConversationContent(messages=[Message.user("I like coffee")]), + ) + + client.memory.commit(user_id="user-1", agent_id="agent-1") + + result = client.memory.retrieve( + user_id="user-1", + agent_id="agent-1", + query="What does the user like?", + strategy=Strategy.SIMPLE, + trace=True, + ) +``` + +## Asynchronous Usage + +```python +from memind import AsyncMemindClient, Strategy + +async with AsyncMemindClient(base_url="http://localhost:8080") as client: + result = await client.memory.retrieve( + user_id="user-1", + agent_id="agent-1", + query="What does the user like?", + strategy=Strategy.DEEP, + ) +``` + +## Configuration + +Configuration precedence: + +1. Constructor arguments +2. Environment variables: `MEMIND_BASE_URL`, `MEMIND_API_TOKEN` +3. Defaults: connect timeout `5s`, read timeout `30s`, max retries `2` + +`base_url` is required. `api_token` is optional; when omitted no `Authorization` header is sent. + +## Development + +```bash +pip install -e ".[dev]" +ruff format . +ruff check . +mypy src +pytest --cov=memind --cov-fail-under=90 -q +python -m build +twine check dist/* +``` diff --git a/memind-clients/python/pyproject.toml b/memind-clients/python/pyproject.toml new file mode 100644 index 00000000..82026dd6 --- /dev/null +++ b/memind-clients/python/pyproject.toml @@ -0,0 +1,67 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "memind" +dynamic = ["version"] +description = "Official Python client for the Memind memory engine API" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenMemind", email = "dev@openmemind.ai" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.25.0,<1", + "pydantic>=2.1.0,<3", +] + +[project.urls] +Homepage = "https://github.com/openmemind/memind" +Repository = "https://github.com/openmemind/memind" +Issues = "https://github.com/openmemind/memind/issues" + +[tool.hatch.version] +path = "src/memind/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/memind"] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-httpx>=0.30", + "pytest-cov>=5.0", + "ruff", + "mypy", + "build>=1.2", + "twine>=5.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py310" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] + +[tool.mypy] +python_version = "3.10" +strict = true diff --git a/memind-clients/python/src/memind/__init__.py b/memind-clients/python/src/memind/__init__.py new file mode 100644 index 00000000..e3c094b5 --- /dev/null +++ b/memind-clients/python/src/memind/__init__.py @@ -0,0 +1,97 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from memind._async_client import AsyncMemindClient +from memind._client import MemindClient +from memind._exceptions import ( + MemindAPIError, + MemindAuthenticationError, + MemindConnectionError, + MemindError, + MemindRateLimitError, + MemindTimeoutError, +) +from memind._version import __version__ +from memind.types import ( + AddMessageRequest, + ApiResult, + AudioBlock, + Base64Source, + CommitMemoryRequest, + ContentBlock, + ConversationContent, + ExtractMemoryRequest, + FinalView, + HealthResponse, + ImageBlock, + MapRawContent, + MergeView, + Message, + RawContent, + RawContentValue, + RetrievalTraceView, + RetrievedInsight, + RetrievedItem, + RetrievedRawData, + RetrieveMemoryRequest, + RetrieveMemoryResponse, + Role, + Source, + StageView, + Strategy, + TextBlock, + UrlSource, + VideoBlock, +) + +__all__ = [ + "AddMessageRequest", + "ApiResult", + "AsyncMemindClient", + "AudioBlock", + "Base64Source", + "CommitMemoryRequest", + "ContentBlock", + "ConversationContent", + "ExtractMemoryRequest", + "FinalView", + "HealthResponse", + "ImageBlock", + "MapRawContent", + "MemindAPIError", + "MemindAuthenticationError", + "MemindClient", + "MemindConnectionError", + "MemindError", + "MemindRateLimitError", + "MemindTimeoutError", + "MergeView", + "Message", + "RawContent", + "RawContentValue", + "RetrievalTraceView", + "RetrieveMemoryRequest", + "RetrieveMemoryResponse", + "RetrievedInsight", + "RetrievedItem", + "RetrievedRawData", + "Role", + "Source", + "StageView", + "Strategy", + "TextBlock", + "UrlSource", + "VideoBlock", + "__version__", +] diff --git a/memind-clients/python/src/memind/_async_client.py b/memind-clients/python/src/memind/_async_client.py new file mode 100644 index 00000000..892639af --- /dev/null +++ b/memind-clients/python/src/memind/_async_client.py @@ -0,0 +1,100 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from types import TracebackType +from typing import Any, TypeVar + +import httpx + +from memind._base_client import BaseClient +from memind._http import RetryConfig, async_request_with_retries +from memind.resources.async_memory import AsyncMemoryResource +from memind.types.health import HealthResponse + +T = TypeVar("T") + + +class AsyncMemindClient(BaseClient): + def __init__( + self, + *, + base_url: str | None = None, + api_token: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int = 2, + http_client: httpx.AsyncClient | None = None, + ) -> None: + super().__init__( + base_url=base_url, + api_token=api_token, + timeout=timeout, + max_retries=max_retries, + ) + self._http_client = http_client or httpx.AsyncClient(timeout=self._timeout) + self.memory = AsyncMemoryResource(self) + + async def health(self) -> HealthResponse: + result = await self._get("/health", HealthResponse) + assert result is not None + return result + + async def close(self) -> None: + if not self._closed: + await self._http_client.aclose() + self._mark_closed() + + async def __aenter__(self) -> AsyncMemindClient: + self._ensure_open() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + async def _get(self, path: str, response_type: type[T] | None) -> T | None: + self._ensure_open() + response = await async_request_with_retries( + self._http_client, + "GET", + self._build_url(path), + retry_config=self._retry_config, + headers=self._build_headers(), + ) + return self._process_response(response, response_type) + + async def _post( + self, + path: str, + body: Any, + response_type: type[T] | None, + *, + retry: bool = False, + ) -> T | None: + self._ensure_open() + retry_config = self._retry_config if retry else RetryConfig(max_retries=0) + response = await async_request_with_retries( + self._http_client, + "POST", + self._build_url(path), + retry_config=retry_config, + headers=self._build_headers(), + json=self._serialize_body(body), + ) + return self._process_response(response, response_type) diff --git a/memind-clients/python/src/memind/_base_client.py b/memind-clients/python/src/memind/_base_client.py new file mode 100644 index 00000000..78bc5445 --- /dev/null +++ b/memind-clients/python/src/memind/_base_client.py @@ -0,0 +1,185 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import os +from typing import Any, TypeVar + +import httpx +from pydantic import TypeAdapter, ValidationError + +from memind._constants import ( + API_PREFIX, + DEFAULT_CONNECT_TIMEOUT, + DEFAULT_MAX_RETRIES, + DEFAULT_READ_TIMEOUT, + ENV_API_TOKEN, + ENV_BASE_URL, +) +from memind._exceptions import ( + MemindAPIError, + MemindAuthenticationError, + MemindError, + MemindRateLimitError, +) +from memind._http import RetryConfig, _retry_after_delay +from memind._version import __version__ +from memind.types.common import ApiResult + +T = TypeVar("T") + + +class BaseClient: + def __init__( + self, + *, + base_url: str | None = None, + api_token: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int = DEFAULT_MAX_RETRIES, + ) -> None: + resolved_base_url = base_url if base_url is not None else os.getenv(ENV_BASE_URL) + if resolved_base_url is None or not resolved_base_url.strip(): + raise MemindError("base_url is required; pass base_url or set MEMIND_BASE_URL") + if max_retries < 0: + raise MemindError("max_retries must be non-negative") + + self._base_url = resolved_base_url.strip().rstrip("/") + self._api_token = _normalize_token( + api_token if api_token is not None else os.getenv(ENV_API_TOKEN) + ) + self._timeout = _normalize_timeout(timeout) + self._retry_config = RetryConfig(max_retries=max_retries) + self._closed = False + + def _build_url(self, path: str) -> str: + normalized_path = path if path.startswith("/") else f"/{path}" + return f"{self._base_url}{API_PREFIX}{normalized_path}" + + def _build_headers(self) -> dict[str, str]: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": f"memind-python/{__version__}", + } + if self._api_token is not None: + headers["Authorization"] = f"Bearer {self._api_token}" + return headers + + def _serialize_body(self, body: Any) -> Any: + if hasattr(body, "model_dump"): + return body.model_dump(by_alias=True, exclude_none=True) + return body + + def _process_response( + self, response: httpx.Response, response_type: type[T] | None + ) -> T | None: + body = _response_json(response) + try: + result = ApiResult[Any].model_validate(body) + except ValidationError as exc: + raise MemindAPIError( + f"Failed to parse response: {response.text}", + status_code=response.status_code, + error_code="parse_error", + body=body if isinstance(body, dict) else None, + ) from exc + + if 200 <= response.status_code < 300 and result.is_success(): + if response_type is None or result.data is None: + return None + try: + return TypeAdapter(response_type).validate_python(result.data) + except ValidationError as exc: + raise MemindAPIError( + f"Failed to parse response data: {response.text}", + status_code=response.status_code, + error_code="parse_error", + trace_id=result.trace_id, + body=result.data if isinstance(result.data, dict) else None, + ) from exc + + message = result.message or f"Memind API error: HTTP {response.status_code}" + raise self._build_api_error(response, result, body, message) + + def _ensure_open(self) -> None: + if self._closed: + raise MemindError("Client has been closed") + + def _mark_closed(self) -> None: + self._closed = True + + def _build_api_error( + self, + response: httpx.Response, + result: ApiResult[Any], + body: Any, + message: str, + ) -> MemindAPIError: + body_dict = body if isinstance(body, dict) else None + if response.status_code == 401: + return MemindAuthenticationError( + message, + status_code=response.status_code, + error_code=result.code, + trace_id=result.trace_id, + body=body_dict, + ) + if response.status_code == 429: + return MemindRateLimitError( + message, + status_code=response.status_code, + error_code=result.code, + trace_id=result.trace_id, + body=body_dict, + retry_after=_retry_after_seconds(response), + ) + return MemindAPIError( + message, + status_code=response.status_code, + error_code=result.code, + trace_id=result.trace_id, + body=body_dict, + ) + + +def _normalize_token(token: str | None) -> str | None: + if token is None: + return None + normalized = token.strip() + return normalized or None + + +def _normalize_timeout(timeout: float | httpx.Timeout | None) -> httpx.Timeout: + if isinstance(timeout, httpx.Timeout): + return timeout + if timeout is not None: + return httpx.Timeout(timeout) + return httpx.Timeout(DEFAULT_READ_TIMEOUT, connect=DEFAULT_CONNECT_TIMEOUT) + + +def _response_json(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError as exc: + raise MemindAPIError( + f"Failed to parse response: {response.text}", + status_code=response.status_code, + error_code="parse_error", + ) from exc + + +def _retry_after_seconds(response: httpx.Response) -> float | None: + return _retry_after_delay(response) diff --git a/memind-clients/python/src/memind/_client.py b/memind-clients/python/src/memind/_client.py new file mode 100644 index 00000000..e5c9407a --- /dev/null +++ b/memind-clients/python/src/memind/_client.py @@ -0,0 +1,100 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from types import TracebackType +from typing import Any, TypeVar + +import httpx + +from memind._base_client import BaseClient +from memind._http import RetryConfig, request_with_retries +from memind.resources.memory import MemoryResource +from memind.types.health import HealthResponse + +T = TypeVar("T") + + +class MemindClient(BaseClient): + def __init__( + self, + *, + base_url: str | None = None, + api_token: str | None = None, + timeout: float | httpx.Timeout | None = None, + max_retries: int = 2, + http_client: httpx.Client | None = None, + ) -> None: + super().__init__( + base_url=base_url, + api_token=api_token, + timeout=timeout, + max_retries=max_retries, + ) + self._http_client = http_client or httpx.Client(timeout=self._timeout) + self.memory = MemoryResource(self) + + def health(self) -> HealthResponse: + result = self._get("/health", HealthResponse) + assert result is not None + return result + + def close(self) -> None: + if not self._closed: + self._http_client.close() + self._mark_closed() + + def __enter__(self) -> MemindClient: + self._ensure_open() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.close() + + def _get(self, path: str, response_type: type[T] | None) -> T | None: + self._ensure_open() + response = request_with_retries( + self._http_client, + "GET", + self._build_url(path), + retry_config=self._retry_config, + headers=self._build_headers(), + ) + return self._process_response(response, response_type) + + def _post( + self, + path: str, + body: Any, + response_type: type[T] | None, + *, + retry: bool = False, + ) -> T | None: + self._ensure_open() + retry_config = self._retry_config if retry else RetryConfig(max_retries=0) + response = request_with_retries( + self._http_client, + "POST", + self._build_url(path), + retry_config=retry_config, + headers=self._build_headers(), + json=self._serialize_body(body), + ) + return self._process_response(response, response_type) diff --git a/memind-clients/python/src/memind/_constants.py b/memind-clients/python/src/memind/_constants.py new file mode 100644 index 00000000..71dd7a3a --- /dev/null +++ b/memind-clients/python/src/memind/_constants.py @@ -0,0 +1,23 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +API_PREFIX = "/open/v1" +DEFAULT_CONNECT_TIMEOUT = 5.0 +DEFAULT_MAX_RETRIES = 2 +DEFAULT_READ_TIMEOUT = 30.0 +ENV_API_TOKEN = "MEMIND_API_TOKEN" +ENV_BASE_URL = "MEMIND_BASE_URL" +RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504}) diff --git a/memind-clients/python/src/memind/_exceptions.py b/memind-clients/python/src/memind/_exceptions.py new file mode 100644 index 00000000..726cfca4 --- /dev/null +++ b/memind-clients/python/src/memind/_exceptions.py @@ -0,0 +1,75 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from typing import Any + + +class MemindError(Exception): + """Base exception for all memind client errors.""" + + +class MemindAPIError(MemindError): + """Raised when the API returns a non-success response.""" + + def __init__( + self, + message: str, + *, + status_code: int, + error_code: str | None = None, + trace_id: str | None = None, + body: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.error_code = error_code + self.trace_id = trace_id + self.body = body + + +class MemindAuthenticationError(MemindAPIError): + """Raised on 401 Unauthorized responses.""" + + +class MemindRateLimitError(MemindAPIError): + """Raised on 429 Too Many Requests responses.""" + + def __init__( + self, + message: str, + *, + status_code: int = 429, + error_code: str | None = None, + trace_id: str | None = None, + body: dict[str, Any] | None = None, + retry_after: float | None = None, + ) -> None: + super().__init__( + message, + status_code=status_code, + error_code=error_code, + trace_id=trace_id, + body=body, + ) + self.retry_after = retry_after + + +class MemindConnectionError(MemindError): + """Raised when a network connection cannot be established.""" + + +class MemindTimeoutError(MemindError): + """Raised when a request times out.""" diff --git a/memind-clients/python/src/memind/_http.py b/memind-clients/python/src/memind/_http.py new file mode 100644 index 00000000..9d84f58a --- /dev/null +++ b/memind-clients/python/src/memind/_http.py @@ -0,0 +1,197 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import asyncio +import datetime as dt +import email.utils +import logging +import random +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +import httpx + +from memind._constants import DEFAULT_MAX_RETRIES, RETRYABLE_STATUS_CODES +from memind._exceptions import MemindConnectionError, MemindTimeoutError + +SyncSleep = Callable[[float], None] +AsyncSleep = Callable[[float], Awaitable[None]] +logger = logging.getLogger("memind") + + +@dataclass(frozen=True) +class RetryConfig: + max_retries: int = DEFAULT_MAX_RETRIES + initial_delay: float = 0.5 + max_delay: float = 2.0 + jitter: float = 0.25 + + +def request_with_retries( + client: httpx.Client, + method: str, + url: str, + *, + retry_config: RetryConfig, + sleep: SyncSleep = time.sleep, + **kwargs: Any, +) -> httpx.Response: + for attempt in range(retry_config.max_retries + 1): + try: + logger.debug("%s %s", method, url) + response = client.request(method, url, **kwargs) + logger.debug("Response status: %s", response.status_code) + except httpx.TimeoutException as exc: + if attempt >= retry_config.max_retries: + raise MemindTimeoutError(f"Request timed out: {method} {url}") from exc + delay = _retry_delay(retry_config, attempt) + logger.warning( + "Retrying %s %s after timeout: attempt=%d delay=%s", + method, + url, + attempt + 1, + delay, + ) + sleep(delay) + continue + except httpx.TransportError as exc: + if attempt >= retry_config.max_retries: + raise MemindConnectionError(f"Connection failed: {method} {url}") from exc + delay = _retry_delay(retry_config, attempt) + logger.warning( + "Retrying %s %s after transport error: attempt=%d delay=%s", + method, + url, + attempt + 1, + delay, + ) + sleep(delay) + continue + + if _should_retry_response(response) and attempt < retry_config.max_retries: + delay = _retry_delay(retry_config, attempt, response) + logger.warning( + "Retrying %s %s after HTTP %s: attempt=%d delay=%s", + method, + url, + response.status_code, + attempt + 1, + delay, + ) + sleep(delay) + continue + + return response + + raise AssertionError("retry loop exhausted without returning or raising") + + +async def async_request_with_retries( + client: httpx.AsyncClient, + method: str, + url: str, + *, + retry_config: RetryConfig, + sleep: AsyncSleep = asyncio.sleep, + **kwargs: Any, +) -> httpx.Response: + for attempt in range(retry_config.max_retries + 1): + try: + logger.debug("%s %s", method, url) + response = await client.request(method, url, **kwargs) + logger.debug("Response status: %s", response.status_code) + except httpx.TimeoutException as exc: + if attempt >= retry_config.max_retries: + raise MemindTimeoutError(f"Request timed out: {method} {url}") from exc + delay = _retry_delay(retry_config, attempt) + logger.warning( + "Retrying %s %s after timeout: attempt=%d delay=%s", + method, + url, + attempt + 1, + delay, + ) + await sleep(delay) + continue + except httpx.TransportError as exc: + if attempt >= retry_config.max_retries: + raise MemindConnectionError(f"Connection failed: {method} {url}") from exc + delay = _retry_delay(retry_config, attempt) + logger.warning( + "Retrying %s %s after transport error: attempt=%d delay=%s", + method, + url, + attempt + 1, + delay, + ) + await sleep(delay) + continue + + if _should_retry_response(response) and attempt < retry_config.max_retries: + delay = _retry_delay(retry_config, attempt, response) + logger.warning( + "Retrying %s %s after HTTP %s: attempt=%d delay=%s", + method, + url, + response.status_code, + attempt + 1, + delay, + ) + await sleep(delay) + continue + + return response + + raise AssertionError("retry loop exhausted without returning or raising") + + +def _should_retry_response(response: httpx.Response) -> bool: + return response.status_code in RETRYABLE_STATUS_CODES + + +def _retry_delay( + retry_config: RetryConfig, + attempt: int, + response: httpx.Response | None = None, +) -> float: + retry_after = _retry_after_delay(response) if response is not None else None + if retry_after is not None: + return retry_after + + delay = float(min(retry_config.initial_delay * (2**attempt), retry_config.max_delay)) + if retry_config.jitter <= 0: + return delay + return delay + random.uniform(0.0, retry_config.jitter) + + +def _retry_after_delay(response: httpx.Response) -> float | None: + header = response.headers.get("Retry-After") + if not header: + return None + + try: + return float(max(float(header), 0.0)) + except ValueError: + try: + parsed = email.utils.parsedate_to_datetime(header) + except (TypeError, ValueError): + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + delta_seconds = (parsed - dt.datetime.now(dt.timezone.utc)).total_seconds() + return float(max(delta_seconds, 0.0)) diff --git a/memind-clients/python/src/memind/_models.py b/memind-clients/python/src/memind/_models.py new file mode 100644 index 00000000..a5dcf8dd --- /dev/null +++ b/memind-clients/python/src/memind/_models.py @@ -0,0 +1,28 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + + +class MemindModel(BaseModel): + """Base model for all memind types.""" + + model_config = ConfigDict( + alias_generator=to_camel, + extra="ignore", + populate_by_name=True, + ) diff --git a/memind-clients/python/src/memind/_version.py b/memind-clients/python/src/memind/_version.py new file mode 100644 index 00000000..9945f84f --- /dev/null +++ b/memind-clients/python/src/memind/_version.py @@ -0,0 +1,15 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +__version__ = "0.2.0" diff --git a/memind-clients/python/src/memind/py.typed b/memind-clients/python/src/memind/py.typed new file mode 100644 index 00000000..00e01cf7 --- /dev/null +++ b/memind-clients/python/src/memind/py.typed @@ -0,0 +1 @@ +# PEP 561 marker - this package supports type checking diff --git a/memind-clients/python/src/memind/resources/__init__.py b/memind-clients/python/src/memind/resources/__init__.py new file mode 100644 index 00000000..ab8d8048 --- /dev/null +++ b/memind-clients/python/src/memind/resources/__init__.py @@ -0,0 +1,18 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from memind.resources.async_memory import AsyncMemoryResource +from memind.resources.memory import MemoryResource + +__all__ = ["AsyncMemoryResource", "MemoryResource"] diff --git a/memind-clients/python/src/memind/resources/async_memory.py b/memind-clients/python/src/memind/resources/async_memory.py new file mode 100644 index 00000000..b74a7803 --- /dev/null +++ b/memind-clients/python/src/memind/resources/async_memory.py @@ -0,0 +1,136 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from memind.types.common import Strategy +from memind.types.memory import ( + AddMessageRequest, + CommitMemoryRequest, + ExtractMemoryRequest, + RetrieveMemoryRequest, + RetrieveMemoryResponse, +) +from memind.types.message import Message, RawContentValue + +if TYPE_CHECKING: + from memind._async_client import AsyncMemindClient + + +class AsyncMemoryResource: + def __init__(self, client: AsyncMemindClient) -> None: + self._client = client + + async def extract( + self, + request: ExtractMemoryRequest | None = None, + *, + user_id: str | None = None, + agent_id: str | None = None, + raw_content: RawContentValue | None = None, + source_client: str | None = None, + ) -> None: + payload = request or _build_extract_request( + user_id=user_id, + agent_id=agent_id, + raw_content=raw_content, + source_client=source_client, + ) + await self._client._post("/memory/extract", payload, None) + + async def add_message( + self, + request: AddMessageRequest | None = None, + *, + user_id: str | None = None, + agent_id: str | None = None, + message: Message | None = None, + source_client: str | None = None, + ) -> None: + payload = request or AddMessageRequest( + user_id=_required(user_id, "user_id"), + agent_id=_required(agent_id, "agent_id"), + message=_required(message, "message"), + source_client=source_client, + ) + await self._client._post("/memory/add-message", payload, None) + + async def commit( + self, + request: CommitMemoryRequest | None = None, + *, + user_id: str | None = None, + agent_id: str | None = None, + source_client: str | None = None, + ) -> None: + payload = request or CommitMemoryRequest( + user_id=_required(user_id, "user_id"), + agent_id=_required(agent_id, "agent_id"), + source_client=source_client, + ) + await self._client._post("/memory/commit", payload, None) + + async def retrieve( + self, + request: RetrieveMemoryRequest | None = None, + *, + user_id: str | None = None, + agent_id: str | None = None, + query: str | None = None, + strategy: Strategy | None = None, + trace: bool | None = None, + ) -> RetrieveMemoryResponse: + payload = request or RetrieveMemoryRequest( + user_id=_required(user_id, "user_id"), + agent_id=_required(agent_id, "agent_id"), + query=_required(query, "query"), + strategy=_required(strategy, "strategy"), + trace=trace, + ) + result = await self._client._post( + "/memory/retrieve", + payload, + RetrieveMemoryResponse, + retry=True, + ) + assert result is not None + return result + + +T = TypeVar("T") + + +def _required(value: T | None, name: str) -> T: + if value is None: + raise TypeError(f"{name} is required") + return value + + +def _build_extract_request( + *, + user_id: str | None, + agent_id: str | None, + raw_content: RawContentValue | None, + source_client: str | None, +) -> ExtractMemoryRequest: + if raw_content is None: + raise TypeError("raw_content is required") + return ExtractMemoryRequest( + user_id=_required(user_id, "user_id"), + agent_id=_required(agent_id, "agent_id"), + raw_content=raw_content, + source_client=source_client, + ) diff --git a/memind-clients/python/src/memind/resources/memory.py b/memind-clients/python/src/memind/resources/memory.py new file mode 100644 index 00000000..a3789080 --- /dev/null +++ b/memind-clients/python/src/memind/resources/memory.py @@ -0,0 +1,131 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from memind.types.common import Strategy +from memind.types.memory import ( + AddMessageRequest, + CommitMemoryRequest, + ExtractMemoryRequest, + RetrieveMemoryRequest, + RetrieveMemoryResponse, +) +from memind.types.message import Message, RawContentValue + +if TYPE_CHECKING: + from memind._client import MemindClient + + +class MemoryResource: + def __init__(self, client: MemindClient) -> None: + self._client = client + + def extract( + self, + request: ExtractMemoryRequest | None = None, + *, + user_id: str | None = None, + agent_id: str | None = None, + raw_content: RawContentValue | None = None, + source_client: str | None = None, + ) -> None: + payload = request or _build_extract_request( + user_id=user_id, + agent_id=agent_id, + raw_content=raw_content, + source_client=source_client, + ) + self._client._post("/memory/extract", payload, None) + + def add_message( + self, + request: AddMessageRequest | None = None, + *, + user_id: str | None = None, + agent_id: str | None = None, + message: Message | None = None, + source_client: str | None = None, + ) -> None: + payload = request or AddMessageRequest( + user_id=_required(user_id, "user_id"), + agent_id=_required(agent_id, "agent_id"), + message=_required(message, "message"), + source_client=source_client, + ) + self._client._post("/memory/add-message", payload, None) + + def commit( + self, + request: CommitMemoryRequest | None = None, + *, + user_id: str | None = None, + agent_id: str | None = None, + source_client: str | None = None, + ) -> None: + payload = request or CommitMemoryRequest( + user_id=_required(user_id, "user_id"), + agent_id=_required(agent_id, "agent_id"), + source_client=source_client, + ) + self._client._post("/memory/commit", payload, None) + + def retrieve( + self, + request: RetrieveMemoryRequest | None = None, + *, + user_id: str | None = None, + agent_id: str | None = None, + query: str | None = None, + strategy: Strategy | None = None, + trace: bool | None = None, + ) -> RetrieveMemoryResponse: + payload = request or RetrieveMemoryRequest( + user_id=_required(user_id, "user_id"), + agent_id=_required(agent_id, "agent_id"), + query=_required(query, "query"), + strategy=_required(strategy, "strategy"), + trace=trace, + ) + result = self._client._post("/memory/retrieve", payload, RetrieveMemoryResponse, retry=True) + assert result is not None + return result + + +T = TypeVar("T") + + +def _required(value: T | None, name: str) -> T: + if value is None: + raise TypeError(f"{name} is required") + return value + + +def _build_extract_request( + *, + user_id: str | None, + agent_id: str | None, + raw_content: RawContentValue | None, + source_client: str | None, +) -> ExtractMemoryRequest: + if raw_content is None: + raise TypeError("raw_content is required") + return ExtractMemoryRequest( + user_id=_required(user_id, "user_id"), + agent_id=_required(agent_id, "agent_id"), + raw_content=raw_content, + source_client=source_client, + ) diff --git a/memind-clients/python/src/memind/types/__init__.py b/memind-clients/python/src/memind/types/__init__.py new file mode 100644 index 00000000..47d1cb2a --- /dev/null +++ b/memind-clients/python/src/memind/types/__init__.py @@ -0,0 +1,77 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from memind.types.common import ApiResult, Role, Strategy +from memind.types.health import HealthResponse +from memind.types.memory import ( + AddMessageRequest, + CommitMemoryRequest, + ExtractMemoryRequest, + FinalView, + MergeView, + RetrievalTraceView, + RetrievedInsight, + RetrievedItem, + RetrievedRawData, + RetrieveMemoryRequest, + RetrieveMemoryResponse, + StageView, +) +from memind.types.message import ( + AudioBlock, + Base64Source, + ContentBlock, + ConversationContent, + ImageBlock, + MapRawContent, + Message, + RawContent, + RawContentValue, + Source, + TextBlock, + UrlSource, + VideoBlock, +) + +__all__ = [ + "AddMessageRequest", + "ApiResult", + "AudioBlock", + "Base64Source", + "CommitMemoryRequest", + "ContentBlock", + "ConversationContent", + "ExtractMemoryRequest", + "FinalView", + "HealthResponse", + "ImageBlock", + "MapRawContent", + "MergeView", + "Message", + "RawContent", + "RawContentValue", + "RetrievalTraceView", + "RetrieveMemoryRequest", + "RetrieveMemoryResponse", + "RetrievedInsight", + "RetrievedItem", + "RetrievedRawData", + "Role", + "Source", + "StageView", + "Strategy", + "TextBlock", + "UrlSource", + "VideoBlock", +] diff --git a/memind-clients/python/src/memind/types/common.py b/memind-clients/python/src/memind/types/common.py new file mode 100644 index 00000000..3b4700c2 --- /dev/null +++ b/memind-clients/python/src/memind/types/common.py @@ -0,0 +1,43 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from enum import Enum +from typing import Generic, TypeVar + +from memind._models import MemindModel + +T = TypeVar("T") + + +class Role(str, Enum): + USER = "USER" + ASSISTANT = "ASSISTANT" + + +class Strategy(str, Enum): + SIMPLE = "SIMPLE" + DEEP = "DEEP" + + +class ApiResult(MemindModel, Generic[T]): + code: str + message: str | None = None + data: T | None = None + timestamp: str | None = None + trace_id: str | None = None + + def is_success(self) -> bool: + return self.code in ("200", "success") diff --git a/memind-clients/python/src/memind/types/health.py b/memind-clients/python/src/memind/types/health.py new file mode 100644 index 00000000..d739dfdf --- /dev/null +++ b/memind-clients/python/src/memind/types/health.py @@ -0,0 +1,22 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from memind._models import MemindModel + + +class HealthResponse(MemindModel): + status: str + service: str diff --git a/memind-clients/python/src/memind/types/memory.py b/memind-clients/python/src/memind/types/memory.py new file mode 100644 index 00000000..03a9cfe9 --- /dev/null +++ b/memind-clients/python/src/memind/types/memory.py @@ -0,0 +1,126 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from memind._models import MemindModel +from memind.types.common import Strategy +from memind.types.message import Message, RawContentValue + + +class ExtractMemoryRequest(MemindModel): + user_id: str + agent_id: str + raw_content: RawContentValue + source_client: str | None = None + + +class AddMessageRequest(MemindModel): + user_id: str + agent_id: str + message: Message + source_client: str | None = None + + +class CommitMemoryRequest(MemindModel): + user_id: str + agent_id: str + source_client: str | None = None + + +class RetrieveMemoryRequest(MemindModel): + user_id: str + agent_id: str + query: str + strategy: Strategy + trace: bool | None = None + + +class RetrievedItem(MemindModel): + id: str + text: str + vector_score: float = 0.0 + final_score: float = 0.0 + occurred_at: str | None = None + + +class RetrievedInsight(MemindModel): + id: str + text: str + tier: str | None = None + + +class RetrievedRawData(MemindModel): + raw_data_id: str + caption: str | None = None + max_score: float = 0.0 + item_ids: list[str] | None = None + + +class StageView(MemindModel): + stage: str | None = None + tier: str | None = None + method: str | None = None + status: str | None = None + input_count: int | None = None + candidate_count: int | None = None + result_count: int | None = None + degraded: bool = False + skipped: bool = False + started_at: str | None = None + duration_millis: int | None = None + attributes: dict[str, Any] | None = None + candidates: list[dict[str, Any]] | None = None + + +class MergeView(MemindModel): + input_count: int = 0 + output_count: int = 0 + deduplicated_count: int = 0 + source_count: int = 0 + status: str | None = None + + +class FinalView(MemindModel): + strategy: str | None = None + status: str | None = None + item_count: int = 0 + insight_count: int = 0 + raw_data_count: int = 0 + evidence_count: int = 0 + + +class RetrievalTraceView(MemindModel): + trace_id: str | None = None + started_at: str | None = None + completed_at: str | None = None + truncated: bool | None = None + stages: list[StageView] = Field(default_factory=list) + merge: MergeView | None = None + final_results: FinalView | None = None + + +class RetrieveMemoryResponse(MemindModel): + status: str | None = None + items: list[RetrievedItem] = Field(default_factory=list) + insights: list[RetrievedInsight] = Field(default_factory=list) + raw_data: list[RetrievedRawData] = Field(default_factory=list) + evidences: list[str] = Field(default_factory=list) + strategy: str | None = None + query: str | None = None + trace: RetrievalTraceView | None = None diff --git a/memind-clients/python/src/memind/types/message.py b/memind-clients/python/src/memind/types/message.py new file mode 100644 index 00000000..4b5500c3 --- /dev/null +++ b/memind-clients/python/src/memind/types/message.py @@ -0,0 +1,112 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import Field, model_serializer, model_validator + +from memind._models import MemindModel +from memind.types.common import Role + + +class UrlSource(MemindModel): + type: Literal["url"] = "url" + url: str + + +class Base64Source(MemindModel): + type: Literal["base64"] = "base64" + media_type: str = Field(alias="media_type") + data: str + + +Source = Annotated[UrlSource | Base64Source, Field(discriminator="type")] + + +class TextBlock(MemindModel): + type: Literal["text"] = "text" + text: str + + +class ImageBlock(MemindModel): + type: Literal["image"] = "image" + source: Source + + +class AudioBlock(MemindModel): + type: Literal["audio"] = "audio" + source: Source + + +class VideoBlock(MemindModel): + type: Literal["video"] = "video" + source: Source + + +ContentBlock = Annotated[ + TextBlock | ImageBlock | AudioBlock | VideoBlock, + Field(discriminator="type"), +] + + +class Message(MemindModel): + role: Role + content: list[ContentBlock] + timestamp: str | None = None + user_name: str | None = None + source_client: str | None = None + + @classmethod + def user(cls, text: str, *, timestamp: str | None = None) -> Message: + return cls(role=Role.USER, content=[TextBlock(text=text)], timestamp=timestamp) + + @classmethod + def assistant(cls, text: str, *, timestamp: str | None = None) -> Message: + return cls(role=Role.ASSISTANT, content=[TextBlock(text=text)], timestamp=timestamp) + + +class RawContent(MemindModel): + type: str + + +class ConversationContent(RawContent): + type: Literal["conversation"] = "conversation" + messages: list[Message] + + +class MapRawContent(RawContent): + type: str + properties: dict[str, Any] = Field(default_factory=dict) + + @model_serializer + def _serialize(self) -> dict[str, Any]: + result: dict[str, Any] = {"type": self.type} + result.update(self.properties) + return result + + @model_validator(mode="before") + @classmethod + def _parse_flat_fields(cls, data: Any) -> Any: + if isinstance(data, dict): + if "properties" in data: + return data + type_val = data.get("type", "") + properties = {key: value for key, value in data.items() if key != "type"} + return {"type": type_val, "properties": properties} + return data + + +RawContentValue = ConversationContent | MapRawContent diff --git a/memind-clients/python/tests/conftest.py b/memind-clients/python/tests/conftest.py new file mode 100644 index 00000000..c119421f --- /dev/null +++ b/memind-clients/python/tests/conftest.py @@ -0,0 +1,15 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations diff --git a/memind-clients/python/tests/test_async_client.py b/memind-clients/python/tests/test_async_client.py new file mode 100644 index 00000000..49bfc2e5 --- /dev/null +++ b/memind-clients/python/tests/test_async_client.py @@ -0,0 +1,172 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import pytest + +from memind._async_client import AsyncMemindClient +from memind._exceptions import MemindAPIError, MemindError +from memind.types import ConversationContent, Message, Strategy + + +@pytest.mark.asyncio +async def test_async_health_returns_response(httpx_mock) -> None: + httpx_mock.add_response( + method="GET", + url="https://api.example.test/open/v1/health", + json={"code": "success", "data": {"status": "UP", "service": "memind-server"}}, + ) + + async with AsyncMemindClient(base_url="https://api.example.test") as client: + health = await client.health() + + assert health.status == "UP" + + +@pytest.mark.asyncio +async def test_async_memory_methods_send_payloads(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/extract", + json={"code": "200"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/add-message", + json={"code": "200"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/commit", + json={"code": "200"}, + ) + + client = AsyncMemindClient(base_url="https://api.example.test") + await client.memory.extract( + user_id="u1", + agent_id="a1", + raw_content=ConversationContent(messages=[Message.user("hello")]), + ) + await client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("hello")) + await client.memory.commit(user_id="u1", agent_id="a1") + await client.close() + + requests = httpx_mock.get_requests() + assert len(requests) == 3 + assert b'"rawContent":{"type":"conversation"' in requests[0].content + assert b'"message":{"role":"USER"' in requests[1].content + assert b'"userId":"u1"' in requests[2].content + + +@pytest.mark.asyncio +async def test_async_retrieve_returns_response(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + json={ + "code": "success", + "data": { + "status": "success", + "items": [{"id": "1", "text": "likes coffee"}], + "insights": [], + "rawData": [], + "evidences": [], + "strategy": "DEEP", + "query": "coffee", + }, + }, + ) + + client = AsyncMemindClient(base_url="https://api.example.test") + result = await client.memory.retrieve( + user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.DEEP + ) + await client.close() + + assert result.items[0].text == "likes coffee" + + +@pytest.mark.asyncio +async def test_async_api_error_is_raised_unwrapped(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + status_code=400, + json={"code": "bad_request", "message": "query is required"}, + ) + + client = AsyncMemindClient(base_url="https://api.example.test") + with pytest.raises(MemindAPIError): + await client.memory.retrieve( + user_id="u1", agent_id="a1", query="", strategy=Strategy.SIMPLE + ) + await client.close() + + +@pytest.mark.asyncio +async def test_async_close_then_call_raises_memind_error() -> None: + client = AsyncMemindClient(base_url="https://api.example.test") + await client.close() + + with pytest.raises(MemindError, match="closed"): + await client.health() + + +@pytest.mark.asyncio +async def test_async_mutating_post_methods_do_not_retry_by_default(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/add-message", + status_code=503, + json={"code": "unavailable"}, + ) + + client = AsyncMemindClient(base_url="https://api.example.test", max_retries=2) + with pytest.raises(MemindAPIError) as exc_info: + await client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("hello")) + + assert exc_info.value.status_code == 503 + assert len(httpx_mock.get_requests()) == 1 + await client.close() + + +@pytest.mark.asyncio +async def test_async_retrieve_retries_by_default(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + status_code=503, + json={"code": "unavailable"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + json={ + "code": "success", + "data": {"items": [], "insights": [], "rawData": [], "evidences": []}, + }, + ) + + client = AsyncMemindClient(base_url="https://api.example.test", max_retries=1) + result = await client.memory.retrieve( + user_id="u1", + agent_id="a1", + query="coffee", + strategy=Strategy.SIMPLE, + ) + + assert result.items == [] + assert len(httpx_mock.get_requests()) == 2 + await client.close() diff --git a/memind-clients/python/tests/test_base_client.py b/memind-clients/python/tests/test_base_client.py new file mode 100644 index 00000000..421c72f2 --- /dev/null +++ b/memind-clients/python/tests/test_base_client.py @@ -0,0 +1,188 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from typing import Any, TypeVar + +import httpx +import pytest +from pydantic import BaseModel + +from memind._base_client import BaseClient +from memind._exceptions import ( + MemindAPIError, + MemindAuthenticationError, + MemindError, + MemindRateLimitError, +) +from memind.types.health import HealthResponse + +T = TypeVar("T") + + +class RequiredFieldResponse(BaseModel): + required_field: str + + +class InspectableClient(BaseClient): + def headers(self) -> dict[str, str]: + return self._build_headers() + + def url(self, path: str) -> str: + return self._build_url(path) + + def process(self, response: httpx.Response, response_type: type[T] | None) -> T | None: + return self._process_response(response, response_type) + + +def make_response(status_code: int, payload: dict[str, Any]) -> httpx.Response: + return httpx.Response( + status_code, + json=payload, + request=httpx.Request("GET", "https://x"), + ) + + +def test_base_url_required(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MEMIND_BASE_URL", raising=False) + with pytest.raises(MemindError, match="base_url"): + InspectableClient() + + +def test_blank_constructor_base_url_does_not_fall_back_to_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("MEMIND_BASE_URL", "https://env.example.test") + with pytest.raises(MemindError, match="base_url"): + InspectableClient(base_url=" ") + + +def test_negative_max_retries_is_rejected() -> None: + with pytest.raises(MemindError, match="max_retries"): + InspectableClient(base_url="https://api.example.test", max_retries=-1) + + +def test_base_url_can_come_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MEMIND_BASE_URL", "https://api.example.test/") + client = InspectableClient() + assert client.url("/health") == "https://api.example.test/open/v1/health" + + +def test_constructor_config_overrides_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MEMIND_BASE_URL", "https://env.example.test") + client = InspectableClient(base_url="https://arg.example.test/base/") + assert client.url("memory/retrieve") == "https://arg.example.test/base/open/v1/memory/retrieve" + + +def test_headers_include_user_agent_and_optional_auth() -> None: + client = InspectableClient(base_url="https://api.example.test", api_token=" sk-test ") + headers = client.headers() + assert headers["Accept"] == "application/json" + assert headers["Content-Type"] == "application/json" + assert headers["Authorization"] == "Bearer sk-test" + assert headers["User-Agent"].startswith("memind-python/") + + +def test_headers_skip_empty_token() -> None: + client = InspectableClient(base_url="https://api.example.test", api_token=" ") + assert "Authorization" not in client.headers() + + +def test_process_success_response_returns_typed_data() -> None: + client = InspectableClient(base_url="https://api.example.test") + response = make_response( + 200, + {"code": "success", "data": {"status": "UP", "service": "memind-server"}}, + ) + result = client.process(response, HealthResponse) + assert isinstance(result, HealthResponse) + assert result.status == "UP" + + +def test_process_success_response_without_data_returns_none() -> None: + client = InspectableClient(base_url="https://api.example.test") + response = make_response(200, {"code": "200"}) + assert client.process(response, None) is None + + +def test_process_success_response_data_validation_error_is_api_error() -> None: + client = InspectableClient(base_url="https://api.example.test") + response = make_response(200, {"code": "success", "data": {"unexpected": "value"}}) + with pytest.raises(MemindAPIError) as exc_info: + client.process(response, RequiredFieldResponse) + + assert exc_info.value.status_code == 200 + assert exc_info.value.error_code == "parse_error" + assert exc_info.value.body == {"unexpected": "value"} + + +def test_process_api_error_raises_api_error() -> None: + client = InspectableClient(base_url="https://api.example.test") + response = make_response( + 400, + {"code": "bad_request", "message": "query is required", "traceId": "trace-1"}, + ) + with pytest.raises(MemindAPIError) as exc_info: + client.process(response, HealthResponse) + + assert exc_info.value.status_code == 400 + assert exc_info.value.error_code == "bad_request" + assert exc_info.value.trace_id == "trace-1" + assert exc_info.value.body["message"] == "query is required" + + +def test_process_authentication_error() -> None: + client = InspectableClient(base_url="https://api.example.test") + response = make_response(401, {"code": "unauthorized", "message": "bad token"}) + with pytest.raises(MemindAuthenticationError): + client.process(response, None) + + +def test_process_rate_limit_error() -> None: + client = InspectableClient(base_url="https://api.example.test") + response = httpx.Response( + 429, + json={"code": "rate_limited", "message": "slow down"}, + headers={"Retry-After": "3"}, + request=httpx.Request("POST", "https://x"), + ) + with pytest.raises(MemindRateLimitError) as exc_info: + client.process(response, None) + + assert exc_info.value.retry_after == 3.0 + + +def test_process_rate_limit_error_accepts_http_date_retry_after() -> None: + client = InspectableClient(base_url="https://api.example.test") + response = httpx.Response( + 429, + json={"code": "rate_limited", "message": "slow down"}, + headers={"Retry-After": "Wed, 21 Oct 2099 07:28:00 GMT"}, + request=httpx.Request("POST", "https://x"), + ) + with pytest.raises(MemindRateLimitError) as exc_info: + client.process(response, None) + + assert exc_info.value.retry_after is not None + assert exc_info.value.retry_after > 0 + + +def test_process_parse_error() -> None: + client = InspectableClient(base_url="https://api.example.test") + response = httpx.Response(502, content=b"not json", request=httpx.Request("GET", "https://x")) + with pytest.raises(MemindAPIError) as exc_info: + client.process(response, None) + + assert exc_info.value.error_code == "parse_error" diff --git a/memind-clients/python/tests/test_client.py b/memind-clients/python/tests/test_client.py new file mode 100644 index 00000000..e000ef91 --- /dev/null +++ b/memind-clients/python/tests/test_client.py @@ -0,0 +1,201 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import pytest + +from memind._client import MemindClient +from memind._exceptions import MemindAPIError, MemindError +from memind.types import ConversationContent, Message, RetrieveMemoryRequest, Strategy + + +def test_health_returns_response(httpx_mock) -> None: + httpx_mock.add_response( + method="GET", + url="https://api.example.test/open/v1/health", + json={"code": "success", "data": {"status": "UP", "service": "memind-server"}}, + ) + + with MemindClient(base_url="https://api.example.test") as client: + health = client.health() + + assert health.status == "UP" + request = httpx_mock.get_request() + assert request.headers["User-Agent"].startswith("memind-python/") + + +def test_add_message_sends_payload_and_auth_header(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/add-message", + json={"code": "200"}, + ) + + client = MemindClient(base_url="https://api.example.test", api_token="sk-test") + client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("hello")) + + request = httpx_mock.get_request() + assert request.headers["Authorization"] == "Bearer sk-test" + assert b'"userId":"u1"' in request.content + assert b'"message":{"role":"USER"' in request.content + client.close() + + +def test_extract_sends_raw_content(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/extract", + json={"code": "200"}, + ) + + client = MemindClient(base_url="https://api.example.test") + client.memory.extract( + user_id="u1", + agent_id="a1", + raw_content=ConversationContent(messages=[Message.user("hello")]), + ) + + assert b'"rawContent":{"type":"conversation"' in httpx_mock.get_request().content + client.close() + + +def test_commit_sends_payload(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/commit", + json={"code": "200"}, + ) + + client = MemindClient(base_url="https://api.example.test") + client.memory.commit(user_id="u1", agent_id="a1") + + assert b'"userId":"u1"' in httpx_mock.get_request().content + client.close() + + +def test_retrieve_accepts_expanded_parameters(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + json={ + "code": "success", + "data": { + "status": "success", + "items": [{"id": "1", "text": "likes coffee", "vectorScore": 0.9}], + "insights": [], + "rawData": [], + "evidences": [], + "strategy": "SIMPLE", + "query": "coffee", + }, + }, + ) + + client = MemindClient(base_url="https://api.example.test") + result = client.memory.retrieve( + user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.SIMPLE, trace=True + ) + + assert result.items[0].text == "likes coffee" + assert b'"trace":true' in httpx_mock.get_request().content + client.close() + + +def test_retrieve_accepts_request_object(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + json={ + "code": "success", + "data": {"items": [], "insights": [], "rawData": [], "evidences": []}, + }, + ) + + client = MemindClient(base_url="https://api.example.test") + request = RetrieveMemoryRequest( + user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.DEEP + ) + result = client.memory.retrieve(request) + + assert result.items == [] + client.close() + + +def test_api_error_is_raised_unwrapped(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + status_code=400, + json={"code": "bad_request", "message": "query is required", "traceId": "t1"}, + ) + + client = MemindClient(base_url="https://api.example.test") + with pytest.raises(MemindAPIError) as exc_info: + client.memory.retrieve(user_id="u1", agent_id="a1", query="", strategy=Strategy.SIMPLE) + + assert exc_info.value.status_code == 400 + assert exc_info.value.error_code == "bad_request" + client.close() + + +def test_close_then_call_raises_memind_error() -> None: + client = MemindClient(base_url="https://api.example.test") + client.close() + + with pytest.raises(MemindError, match="closed"): + client.health() + + +def test_mutating_post_methods_do_not_retry_by_default(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/add-message", + status_code=503, + json={"code": "unavailable"}, + ) + + client = MemindClient(base_url="https://api.example.test", max_retries=2) + with pytest.raises(MemindAPIError) as exc_info: + client.memory.add_message(user_id="u1", agent_id="a1", message=Message.user("hello")) + + assert exc_info.value.status_code == 503 + assert len(httpx_mock.get_requests()) == 1 + client.close() + + +def test_retrieve_retries_by_default(httpx_mock) -> None: + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + status_code=503, + json={"code": "unavailable"}, + ) + httpx_mock.add_response( + method="POST", + url="https://api.example.test/open/v1/memory/retrieve", + json={ + "code": "success", + "data": {"items": [], "insights": [], "rawData": [], "evidences": []}, + }, + ) + + client = MemindClient(base_url="https://api.example.test", max_retries=1) + result = client.memory.retrieve( + user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.SIMPLE + ) + + assert result.items == [] + assert len(httpx_mock.get_requests()) == 2 + client.close() diff --git a/memind-clients/python/tests/test_exceptions.py b/memind-clients/python/tests/test_exceptions.py new file mode 100644 index 00000000..331ccd42 --- /dev/null +++ b/memind-clients/python/tests/test_exceptions.py @@ -0,0 +1,72 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from memind._exceptions import ( + MemindAPIError, + MemindAuthenticationError, + MemindConnectionError, + MemindError, + MemindRateLimitError, + MemindTimeoutError, +) + + +class TestMemindError: + def test_base_exception(self) -> None: + err = MemindError("something went wrong") + assert str(err) == "something went wrong" + assert isinstance(err, Exception) + + def test_api_error(self) -> None: + err = MemindAPIError( + "Not Found", + status_code=404, + error_code="RESOURCE_NOT_FOUND", + trace_id="trace-123", + body={"code": "RESOURCE_NOT_FOUND", "message": "Not Found"}, + ) + assert err.status_code == 404 + assert err.error_code == "RESOURCE_NOT_FOUND" + assert err.trace_id == "trace-123" + assert err.body == {"code": "RESOURCE_NOT_FOUND", "message": "Not Found"} + assert isinstance(err, MemindError) + + def test_authentication_error(self) -> None: + err = MemindAuthenticationError("Unauthorized", status_code=401, error_code="UNAUTHORIZED") + assert err.status_code == 401 + assert isinstance(err, MemindAPIError) + assert isinstance(err, MemindError) + + def test_rate_limit_error(self) -> None: + err = MemindRateLimitError( + "Too Many Requests", + status_code=429, + error_code="RATE_LIMITED", + retry_after=2.5, + ) + assert err.status_code == 429 + assert err.retry_after == 2.5 + assert isinstance(err, MemindAPIError) + + def test_connection_error(self) -> None: + err = MemindConnectionError("Connection refused") + assert isinstance(err, MemindError) + assert not isinstance(err, MemindAPIError) + + def test_timeout_error(self) -> None: + err = MemindTimeoutError("Request timed out") + assert isinstance(err, MemindError) + assert not isinstance(err, MemindAPIError) diff --git a/memind-clients/python/tests/test_models.py b/memind-clients/python/tests/test_models.py new file mode 100644 index 00000000..db3c8af0 --- /dev/null +++ b/memind-clients/python/tests/test_models.py @@ -0,0 +1,335 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import json +from typing import Any + +from memind._models import MemindModel +from memind.types.common import ApiResult, Role, Strategy +from memind.types.health import HealthResponse +from memind.types.memory import ( + AddMessageRequest, + CommitMemoryRequest, + ExtractMemoryRequest, + RetrieveMemoryRequest, + RetrieveMemoryResponse, +) +from memind.types.message import ( + Base64Source, + ConversationContent, + ImageBlock, + MapRawContent, + Message, + TextBlock, + UrlSource, +) + + +class TestMemindModel: + def test_camel_case_alias(self) -> None: + class Sample(MemindModel): + user_id: str + agent_id: str + + obj = Sample(user_id="u1", agent_id="a1") + assert obj.model_dump(by_alias=True) == {"userId": "u1", "agentId": "a1"} + + def test_populate_by_name(self) -> None: + class Sample(MemindModel): + user_id: str + + obj = Sample(user_id="u1") + assert obj.user_id == "u1" + + def test_from_camel_case_json(self) -> None: + class Sample(MemindModel): + user_id: str + source_client: str | None = None + + obj = Sample.model_validate({"userId": "u1", "sourceClient": "web"}) + assert obj.user_id == "u1" + assert obj.source_client == "web" + + def test_extra_fields_ignored(self) -> None: + class Sample(MemindModel): + user_id: str + + obj = Sample.model_validate({"userId": "u1", "unknownField": "ignored"}) + assert obj.user_id == "u1" + + def test_exclude_none(self) -> None: + class Sample(MemindModel): + user_id: str + optional_field: str | None = None + + obj = Sample(user_id="u1") + dumped = obj.model_dump(by_alias=True, exclude_none=True) + assert "optionalField" not in dumped + + +class TestCommonTypes: + def test_role_values(self) -> None: + assert Role.USER == "USER" + assert Role.ASSISTANT == "ASSISTANT" + + def test_strategy_values(self) -> None: + assert Strategy.SIMPLE == "SIMPLE" + assert Strategy.DEEP == "DEEP" + + def test_strategy_json_serialization(self) -> None: + assert json.loads(json.dumps(Strategy.SIMPLE)) == "SIMPLE" + + def test_success_result(self) -> None: + data: dict[str, Any] = { + "code": "200", + "message": None, + "data": {"status": "UP", "service": "memind-server"}, + "timestamp": "2026-01-01T00:00:00Z", + "traceId": "trace-1", + } + result = ApiResult[dict[str, str]].model_validate(data) + assert result.code == "200" + assert result.data == {"status": "UP", "service": "memind-server"} + assert result.trace_id == "trace-1" + assert result.is_success() + + def test_unknown_fields_ignored(self) -> None: + data: dict[str, Any] = {"code": "200", "data": None, "extraField": "ignored"} + result = ApiResult[None].model_validate(data) + assert result.code == "200" + + +class TestMessageTypes: + def test_url_source_serialization(self) -> None: + source = UrlSource(url="https://example.com/img.png") + dumped = source.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"type": "url", "url": "https://example.com/img.png"} + + def test_base64_source_media_type_alias(self) -> None: + source = Base64Source(media_type="image/png", data="abc123") + dumped = source.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"type": "base64", "media_type": "image/png", "data": "abc123"} + + def test_base64_source_from_json(self) -> None: + data = {"type": "base64", "media_type": "image/png", "data": "abc123"} + source = Base64Source.model_validate(data) + assert source.media_type == "image/png" + assert source.data == "abc123" + + def test_text_block(self) -> None: + block = TextBlock(text="hello") + dumped = block.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"type": "text", "text": "hello"} + + def test_content_block_discriminated_union_from_json(self) -> None: + msg = Message.model_validate( + { + "role": "USER", + "content": [ + {"type": "text", "text": "hello"}, + { + "type": "image", + "source": {"type": "url", "url": "https://img.com/a.png"}, + }, + ], + } + ) + assert isinstance(msg.content[0], TextBlock) + assert isinstance(msg.content[1], ImageBlock) + assert isinstance(msg.content[1].source, UrlSource) + + def test_user_factory(self) -> None: + msg = Message.user("hello") + assert msg.role == Role.USER + assert len(msg.content) == 1 + assert isinstance(msg.content[0], TextBlock) + assert msg.content[0].text == "hello" + + def test_assistant_factory(self) -> None: + msg = Message.assistant("hi there", timestamp="2026-01-01T00:00:00Z") + assert msg.role == Role.ASSISTANT + assert msg.timestamp == "2026-01-01T00:00:00Z" + + def test_message_serialization(self) -> None: + msg = Message.user("hello") + dumped = msg.model_dump(by_alias=True, exclude_none=True) + assert dumped["role"] == "USER" + assert dumped["content"] == [{"type": "text", "text": "hello"}] + assert "userName" not in dumped + assert "timestamp" not in dumped + + def test_conversation_content(self) -> None: + content = ConversationContent(messages=[Message.user("hi")]) + dumped = content.model_dump(by_alias=True, exclude_none=True) + assert dumped["type"] == "conversation" + assert len(dumped["messages"]) == 1 + + def test_map_raw_content_serialization(self) -> None: + content = MapRawContent(type="document", properties={"title": "Test", "body": "Content"}) + dumped = content.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"type": "document", "title": "Test", "body": "Content"} + + def test_map_raw_content_from_json(self) -> None: + data = {"type": "document", "title": "Test", "body": "Content"} + content = MapRawContent.model_validate(data) + assert content.type == "document" + assert content.properties == {"title": "Test", "body": "Content"} + + +class TestHealthAndMemoryModels: + def test_health_response_from_json(self) -> None: + data = {"status": "UP", "service": "memind-server"} + resp = HealthResponse.model_validate(data) + assert resp.status == "UP" + assert resp.service == "memind-server" + + def test_extract_memory_request(self) -> None: + req = ExtractMemoryRequest( + user_id="u1", + agent_id="a1", + raw_content=ConversationContent(messages=[Message.user("hi")]), + ) + dumped = req.model_dump(by_alias=True, exclude_none=True) + assert dumped["userId"] == "u1" + assert dumped["agentId"] == "a1" + assert dumped["rawContent"]["type"] == "conversation" + assert dumped["rawContent"]["messages"][0]["role"] == "USER" + assert dumped["rawContent"]["messages"][0]["content"][0]["text"] == "hi" + assert "sourceClient" not in dumped + + def test_add_message_request(self) -> None: + req = AddMessageRequest( + user_id="u1", + agent_id="a1", + message=Message.user("hello"), + source_client="python-sdk", + ) + dumped = req.model_dump(by_alias=True, exclude_none=True) + assert dumped["userId"] == "u1" + assert dumped["message"]["role"] == "USER" + assert dumped["sourceClient"] == "python-sdk" + + def test_commit_memory_request(self) -> None: + req = CommitMemoryRequest(user_id="u1", agent_id="a1") + dumped = req.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"userId": "u1", "agentId": "a1"} + + def test_retrieve_memory_request(self) -> None: + req = RetrieveMemoryRequest( + user_id="u1", agent_id="a1", query="coffee", strategy=Strategy.DEEP, trace=True + ) + dumped = req.model_dump(by_alias=True, exclude_none=True) + assert dumped["strategy"] == "DEEP" + assert dumped["trace"] is True + + def test_retrieve_memory_response(self) -> None: + data = { + "status": "OK", + "items": [ + { + "id": "item-1", + "text": "likes coffee", + "vectorScore": 0.95, + "finalScore": 0.88, + "occurredAt": "2026-01-01T00:00:00Z", + } + ], + "insights": [{"id": "ins-1", "text": "prefers hot drinks", "tier": "CORE"}], + "rawData": [ + {"rawDataId": "rd-1", "caption": "chat", "maxScore": 0.9, "itemIds": ["item-1"]} + ], + "evidences": ["evidence-1"], + "strategy": "SIMPLE", + "query": "coffee", + } + resp = RetrieveMemoryResponse.model_validate(data) + assert resp.status == "OK" + assert len(resp.items) == 1 + assert resp.items[0].id == "item-1" + assert resp.items[0].vector_score == 0.95 + assert resp.items[0].final_score == 0.88 + assert resp.items[0].occurred_at == "2026-01-01T00:00:00Z" + assert resp.insights[0].tier == "CORE" + assert resp.raw_data[0].raw_data_id == "rd-1" + assert resp.evidences == ["evidence-1"] + + def test_retrieve_memory_response_with_trace(self) -> None: + data = { + "status": "OK", + "items": [], + "insights": [], + "rawData": [], + "evidences": [], + "strategy": "DEEP", + "query": "test", + "trace": { + "traceId": "t-1", + "startedAt": "2026-01-01T00:00:00Z", + "completedAt": "2026-01-01T00:00:01Z", + "truncated": False, + "stages": [ + { + "stage": "vector_search", + "method": "embedding", + "status": "COMPLETED", + "inputCount": 1, + "candidateCount": 10, + "resultCount": 5, + "degraded": False, + "skipped": False, + "durationMillis": 120, + } + ], + "merge": { + "inputCount": 5, + "outputCount": 4, + "deduplicatedCount": 1, + "sourceCount": 2, + "status": "COMPLETED", + }, + "finalResults": { + "strategy": "DEEP", + "status": "COMPLETED", + "itemCount": 4, + "insightCount": 2, + "rawDataCount": 1, + "evidenceCount": 3, + }, + }, + } + resp = RetrieveMemoryResponse.model_validate(data) + assert resp.trace is not None + assert resp.trace.trace_id == "t-1" + assert len(resp.trace.stages) == 1 + assert resp.trace.stages[0].duration_millis == 120 + assert resp.trace.merge is not None + assert resp.trace.merge.deduplicated_count == 1 + assert resp.trace.final_results is not None + assert resp.trace.final_results.item_count == 4 + + def test_response_ignores_unknown_fields(self) -> None: + data = { + "status": "OK", + "items": [], + "insights": [], + "rawData": [], + "evidences": [], + "strategy": "SIMPLE", + "query": "test", + "futureField": "should be ignored", + } + resp = RetrieveMemoryResponse.model_validate(data) + assert resp.status == "OK" diff --git a/memind-clients/python/tests/test_public_api.py b/memind-clients/python/tests/test_public_api.py new file mode 100644 index 00000000..d7621fd8 --- /dev/null +++ b/memind-clients/python/tests/test_public_api.py @@ -0,0 +1,39 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import memind +from memind import ( + AsyncMemindClient, + ConversationContent, + MemindAPIError, + MemindClient, + MemindError, + Message, + RawContentValue, + Strategy, +) + + +def test_public_exports() -> None: + assert memind.__version__ == "0.2.0" + assert MemindClient is not None + assert AsyncMemindClient is not None + assert MemindError is not None + assert MemindAPIError is not None + assert RawContentValue is not None + assert Message.user("hello").role.value == "USER" + assert Strategy.SIMPLE.value == "SIMPLE" + assert ConversationContent(messages=[Message.user("hi")]).type == "conversation" diff --git a/memind-clients/python/tests/test_retry.py b/memind-clients/python/tests/test_retry.py new file mode 100644 index 00000000..a01bc89b --- /dev/null +++ b/memind-clients/python/tests/test_retry.py @@ -0,0 +1,180 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +import httpx +import pytest + +from memind._exceptions import MemindConnectionError, MemindTimeoutError +from memind._http import RetryConfig, async_request_with_retries, request_with_retries + + +def test_sync_request_retries_503_then_returns_success(httpx_mock) -> None: + sleeps: list[float] = [] + httpx_mock.add_response(status_code=503, json={"code": "unavailable"}) + httpx_mock.add_response(status_code=200, json={"code": "success", "data": {"ok": True}}) + + with httpx.Client() as client: + response = request_with_retries( + client, + "GET", + "https://api.example.test/open/v1/health", + retry_config=RetryConfig(max_retries=2, jitter=0.0), + sleep=sleeps.append, + ) + + assert response.status_code == 200 + assert sleeps == [0.5] + + +def test_retry_logs_without_payload_or_authorization(httpx_mock, caplog) -> None: + sleeps: list[float] = [] + httpx_mock.add_response(status_code=503, json={"code": "unavailable"}) + httpx_mock.add_response(status_code=200, json={"code": "success"}) + + with httpx.Client() as client: + with caplog.at_level("WARNING", logger="memind"): + request_with_retries( + client, + "POST", + "https://api.example.test/open/v1/memory/retrieve", + retry_config=RetryConfig(max_retries=1, jitter=0.0), + sleep=sleeps.append, + headers={"Authorization": "Bearer secret-token"}, + json={"query": "private payload"}, + ) + + log_text = caplog.text + assert ( + "Retrying POST https://api.example.test/open/v1/memory/retrieve after HTTP 503" in log_text + ) + assert "secret-token" not in log_text + assert "private payload" not in log_text + + +def test_sync_request_does_not_retry_when_disabled(httpx_mock) -> None: + sleeps: list[float] = [] + httpx_mock.add_response(status_code=503, json={"code": "unavailable"}) + + with httpx.Client() as client: + response = request_with_retries( + client, + "POST", + "https://api.example.test/open/v1/memory/add-message", + retry_config=RetryConfig(max_retries=0, jitter=0.0), + sleep=sleeps.append, + json={"message": {"role": "USER", "content": [{"type": "text", "text": "hello"}]}}, + ) + + assert response.status_code == 503 + assert sleeps == [] + assert len(httpx_mock.get_requests()) == 1 + + +def test_sync_request_respects_retry_after_header(httpx_mock) -> None: + sleeps: list[float] = [] + httpx_mock.add_response(status_code=429, headers={"Retry-After": "2"}) + httpx_mock.add_response(status_code=200, json={"code": "success"}) + + with httpx.Client() as client: + response = request_with_retries( + client, + "POST", + "https://api.example.test/open/v1/memory/retrieve", + retry_config=RetryConfig(max_retries=2, jitter=0.0), + sleep=sleeps.append, + json={"query": "coffee"}, + ) + + assert response.status_code == 200 + assert sleeps == [2.0] + + +def test_sync_request_ignores_invalid_retry_after_header(httpx_mock) -> None: + sleeps: list[float] = [] + httpx_mock.add_response(status_code=429, headers={"Retry-After": "not-a-date"}) + httpx_mock.add_response(status_code=200, json={"code": "success"}) + + with httpx.Client() as client: + response = request_with_retries( + client, + "POST", + "https://api.example.test/open/v1/memory/retrieve", + retry_config=RetryConfig(max_retries=2, jitter=0.0), + sleep=sleeps.append, + json={"query": "coffee"}, + ) + + assert response.status_code == 200 + assert sleeps == [0.5] + + +def test_sync_request_timeout_maps_to_memind_timeout(httpx_mock) -> None: + request = httpx.Request("GET", "https://api.example.test/open/v1/health") + httpx_mock.add_exception(httpx.ReadTimeout("timed out", request=request)) + httpx_mock.add_exception(httpx.ReadTimeout("timed out", request=request)) + + with httpx.Client() as client: + with pytest.raises(MemindTimeoutError) as exc_info: + request_with_retries( + client, + "GET", + "https://api.example.test/open/v1/health", + retry_config=RetryConfig(max_retries=1, jitter=0.0), + sleep=lambda _delay: None, + ) + + assert "Request timed out" in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, httpx.ReadTimeout) + + +def test_sync_request_transport_error_maps_to_connection_error(httpx_mock) -> None: + request = httpx.Request("GET", "https://api.example.test/open/v1/health") + httpx_mock.add_exception(httpx.ConnectError("connection refused", request=request)) + + with httpx.Client() as client: + with pytest.raises(MemindConnectionError) as exc_info: + request_with_retries( + client, + "GET", + "https://api.example.test/open/v1/health", + retry_config=RetryConfig(max_retries=0), + ) + + assert "Connection failed" in str(exc_info.value) + assert isinstance(exc_info.value.__cause__, httpx.ConnectError) + + +@pytest.mark.asyncio +async def test_async_request_retries_503_then_returns_success(httpx_mock) -> None: + sleeps: list[float] = [] + + async def record_sleep(delay: float) -> None: + sleeps.append(delay) + + httpx_mock.add_response(status_code=503, json={"code": "unavailable"}) + httpx_mock.add_response(status_code=200, json={"code": "success", "data": {"ok": True}}) + + async with httpx.AsyncClient() as client: + response = await async_request_with_retries( + client, + "GET", + "https://api.example.test/open/v1/health", + retry_config=RetryConfig(max_retries=2, jitter=0.0), + sleep=record_sleep, + ) + + assert response.status_code == 200 + assert sleeps == [0.5] diff --git a/pom.xml b/pom.xml index 7aca727f..7b69b00e 100644 --- a/pom.xml +++ b/pom.xml @@ -339,6 +339,18 @@ memind-integrations/codex/** memind-ui/** memind-evaluation/data/** + memind-clients/python/.coverage + memind-clients/python/.mypy_cache/** + memind-clients/python/.pytest_cache/** + memind-clients/python/.ruff_cache/** + memind-clients/python/.uv-cache/** + memind-clients/python/.venv/** + memind-clients/python/build/** + memind-clients/python/dist/** + memind-clients/python/src/*.egg-info/** + memind-clients/python/uv.lock + memind-clients/python/**/__pycache__/** + memind-clients/python/**/*.pyc