Metadata-Version: 2.4
Name: mdy-hap-sdk
Version: 0.1.0
Summary: 明道云 HAP API Python SDK — ThinkPHP 风格链式调用
Author-email: Your Name <your@email.com>
License: MIT
Keywords: api,hap,mingdao,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27.0
Requires-Dist: python-dotenv>=1.0.0
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# mdy-hap-sdk

明道云 HAP API Python SDK — ThinkPHP 风格链式调用

## 安装

```bash
pip install mdy-hap-sdk
```

依赖：Python >= 3.10, httpx >= 0.27

## 快速开始

```python
from mdy import MingdaoClient

client = MingdaoClient(app_key="your_app_key", sign="your_sign")

# 链式查询
records = (
    client.worksheet("sheet_id")
    .where("name", "startswith", "张")
    .where("age", "gt", 18)
    .page(1, 20)
    .order("created_at", "desc")
    .select()
)

# 增删改
rid = client.worksheet("sheet_id").create({"name": "张三", "age": 25})
client.worksheet("sheet_id").update(rid, {"name": "李四"})
client.worksheet("sheet_id").delete(rid)
```

## 客户端配置

### 直接创建

```python
from mdy import MingdaoClient

client = MingdaoClient(
    app_key="your_app_key",      # 必填: HAP 应用密钥
    sign="your_sign",            # 必填: HAP 签名
    host="https://api.mingdao.com",  # 可选: 私有部署时替换域名
    timeout=(10.0, 60.0, 60.0),  # 可选: (connect, read, write) 超时秒数
    max_retries=0,               # 可选: 限流自动重试次数
)
```

### 从环境变量创建

```python
# .env 文件:
#   HAP_APP_KEY=your_app_key
#   HAP_SIGN=your_sign
#   HAP_HOST=https://api.mingdao.com  (可选)

client = MingdaoClient.from_env()
client = MingdaoClient.from_env(dotenv_path="./custom.env")
```

### 上下文管理器（自动释放连接）

```python
with MingdaoClient(app_key="...", sign="...") as client:
    records = client.worksheet("sheet_id").select()
# 退出 with 块时自动 close
```

## 链式查询 API

### 基础构建方法

| 方法 | 说明 | 示例 |
|------|------|------|
| `.where(field, operator, value)` | AND 筛选条件（可多次调用） | `.where("name", "contains", "张")` |
| `.or_where(field, operator, value)` | OR 筛选条件 | `.where("a", "eq", 1).or_where("b", "eq", 2)` |
| `.fields("f1", "f2")` | 指定返回字段 | `.fields("name", "phone")` |
| `.order(field, "asc"\|"desc")` | 排序（可多次调用） | `.order("age", "desc")` |
| `.view(view_id)` | 应用视图筛选 | `.view("view_xxx")` |
| `.search("keyword")` | 关键词搜索 | `.search("张三")` |
| `.page(page_index, page_size)` | 分页，page_index 从 1 开始 | `.page(2, 50)` |
| `.use_field_id_as_key()` | 返回数据以字段ID为 key | `.use_field_id_as_key()` |
| `.include_total_count()` | 响应中包含总记录数 | `.include_total_count()` |
| `.include_system_fields()` | 返回系统字段 | `.include_system_fields()` |

### 终端方法

| 方法 | 返回值 | 说明 |
|------|--------|------|
| `.select()` | `List[dict]` | 执行查询，返回记录列表 |
| `.select_with_meta()` | `dict` | 返回 `{rows, total, pageIndex, pageSize}` |
| `.first()` | `dict \| None` | 返回第一条匹配记录 |
| `.count()` | `int` | 返回符合条件的记录总数 |
| `.all()` | `List[dict]` | 自动翻页获取全部记录 |

### 高级筛选：直接传入原始 filter JSON

复杂组合查询可使用 `.filter()` 传入明道云原生 filter 结构：

```python
records = ws.filter({
    "type": "group",
    "logic": "OR",
    "children": [
        {
            "type": "group", "logic": "AND",
            "children": [
                {"type": "condition", "field": "f_status", "operator": "eq", "value": ["active"]},
                {"type": "condition", "field": "f_dept", "operator": "in", "value": ["dept_id_1"]},
            ],
        },
        {
            "type": "group", "logic": "AND",
            "children": [
                {"type": "condition", "field": "f_status", "operator": "eq", "value": ["active"]},
                {"type": "condition", "field": "f_dept", "operator": "notin", "value": ["dept_id_1"]},
            ],
        },
    ],
}).page(1, 10).select()
```

等价于 `ws.query().filter({...}).select()`，`ws.filter()` 只是省略了 `.query()` 的快捷写法。

### 查询示例

```python
# 无筛选直接查询
records = client.worksheet("sheet_id").select()

# 多条件 AND
records = (
    client.worksheet("sheet_id")
    .where("status", "eq", "active")
    .where("age", "gt", 18)
    .page(1, 20)
    .select()
)

# OR 条件
records = (
    client.worksheet("sheet_id")
    .where("status", "eq", "active")
    .or_where("status", "eq", "pending")
    .select()
)

# 指定返回字段 + 排序
records = (
    client.worksheet("sheet_id")
    .fields("name", "phone", "age")
    .order("age", "desc")
    .order("name", "asc")
    .page(1, 20)
    .select()
)

# 获取第一条
record = client.worksheet("sheet_id").where("name", "eq", "张三").first()

# 计数
total = client.worksheet("sheet_id").where("status", "eq", "active").count()

# 带元数据的分页查询
result = (
    client.worksheet("sheet_id")
    .where("status", "eq", "active")
    .page(1, 20)
    .include_total_count()
    .select_with_meta()
)
print(f"第 {result['pageIndex']} 页, 共 {result['total']} 条")
```

## 筛选运算符速查表

### 文本类型字段（type 2）

| 运算符 | 说明 | value 类型 | 示例 |
|--------|------|-----------|------|
| `eq` | 等于 | `str` | `.where("name", "eq", "张三")` |
| `ne` | 不等于 | `str` | `.where("name", "ne", "张三")` |
| `contains` | 包含 | `str` | `.where("name", "contains", "张")` |
| `notcontains` | 不包含 | `str` | `.where("name", "notcontains", "李")` |
| `startswith` | 开头是 | `str` | `.where("name", "startswith", "张")` |
| `notstartswith` | 开头不是 | `str` | `.where("name", "notstartswith", "张")` |
| `endswith` | 结尾是 | `str` | `.where("name", "endswith", "王")` |
| `notendswith` | 结尾不是 | `str` | `.where("name", "notendswith", "王")` |
| `isempty` | 为空 | `None` | `.where("remark", "isempty", None)` |
| `isnotempty` | 不为空 | `None` | `.where("remark", "isnotempty", None)` |

### 数值类型字段（type 6）

| 运算符 | 说明 | value 类型 | 示例 |
|--------|------|-----------|------|
| `eq` | 等于 | `int \| float` | `.where("age", "eq", 25)` |
| `ne` | 不等于 | `int \| float` | `.where("age", "ne", 25)` |
| `gt` | 大于 | `int \| float` | `.where("age", "gt", 18)` |
| `ge` | 大于等于 | `int \| float` | `.where("age", "ge", 18)` |
| `lt` | 小于 | `int \| float` | `.where("age", "lt", 60)` |
| `le` | 小于等于 | `int \| float` | `.where("age", "le", 60)` |
| `between` | 在范围内 | `[min, max]` | `.where("age", "between", [20, 40])` |
| `notbetween` | 不在范围内 | `[min, max]` | `.where("age", "notbetween", [20, 40])` |
| `isempty` | 为空 | `None` | `.where("age", "isempty", None)` |
| `isnotempty` | 不为空 | `None` | `.where("age", "isnotempty", None)` |

### 单选字段（type 9）

> **注意**: 筛选时 value 必须使用 option key 的数组格式 `[key]`

| 运算符 | 说明 | value 类型 | 示例 |
|--------|------|-----------|------|
| `eq` | 等于该选项 | `[key]` | `.where("status", "eq", ["opt_xxx"])` |
| `ne` | 不等于该选项 | `[key]` | `.where("status", "ne", ["opt_xxx"])` |
| `in` | 在指定选项中 | `[key1, key2]` | `.where("status", "in", ["opt_a", "opt_b"])` |
| `notin` | 不在指定选项中 | `[key1, key2]` | `.where("status", "notin", ["opt_a"])` |
| `isempty` | 为空 | `None` | `.where("status", "isempty", None)` |
| `isnotempty` | 不为空 | `None` | `.where("status", "isnotempty", None)` |

获取 option key 的方法：先查询一条包含该选项的记录，从返回的字段值中提取 `key`。

### 多选字段（type 10）

| 运算符 | 说明 | value 类型 |
|--------|------|-----------|
| `eq` | 等于该选项组合 | `[key]` |
| `ne` | 不等于该选项组合 | `[key]` |
| `in` | 在指定选项中 | `[key1, key2]` |
| `notin` | 不在指定选项中 | `[key1, key2]` |
| `concurrent` | 同时包含 | `[key1, key2]` |
| `isempty` | 为空 | `None` |
| `isnotempty` | 不为空 | `None` |

### 日期 / 日期时间字段（type 15 / 16）

| 运算符 | 说明 | value 类型 | 示例 |
|--------|------|-----------|------|
| `between` | 在范围内 | `[start, end]` | `.where("date", "between", ["2025-01-01", "2025-12-31"])` |
| `notbetween` | 不在范围内 | `[start, end]` | `.where("date", "notbetween", ["2025-01-01", "2025-12-31"])` |
| `isempty` | 为空 | `None` | `.where("date", "isempty", None)` |
| `isnotempty` | 不为空 | `None` | `.where("date", "isnotempty", None)` |

### 成员字段（type 26）

| 运算符 | 说明 | value 类型 |
|--------|------|-----------|
| `eq` | 等于该成员 | `[accountId]` |
| `ne` | 不等于该成员 | `[accountId]` |
| `in` | 在指定成员中 | `[accountId1, accountId2]` |
| `notin` | 不在指定成员中 | `[accountId1, accountId2]` |
| `concurrent` | 同时包含 | `[accountId1, accountId2]` |
| `isempty` | 为空 | `None` |
| `isnotempty` | 不为空 | `None` |

### 关联记录字段（type 29）

| 运算符 | 说明 | value 类型 |
|--------|------|-----------|
| `eq` | 等于该关联记录 | `[recordId]` |
| `ne` | 不等于该关联记录 | `[recordId]` |
| `in` | 在指定记录中 | `[recordId1, recordId2]` |
| `notin` | 不在指定记录中 | `[recordId1, recordId2]` |
| `concurrent` | 同时包含 | `[recordId1, recordId2]` |
| `isempty` | 为空 | `None` |
| `isnotempty` | 不为空 | `None` |

### 附件字段（type 14）

| 运算符 | 说明 | value 类型 |
|--------|------|-----------|
| `isempty` | 为空 | `None` |
| `isnotempty` | 不为空 | `None` |

## 记录 CRUD

```python
ws = client.worksheet("sheet_id")

# 创建记录 —— 返回新记录 ID
rid = ws.create({
    "name": "张三",
    "age": 25,
    "phone": "13800138000",
})
# 可选参数: trigger_workflow=False 跳过工作流

# 查询单条记录 —— 返回 dict，不存在返回 None
record = ws.find(rid)

# 更新记录 —— 返回 bool
ws.update(rid, {"name": "李四", "age": 30})
# 可选参数: trigger_workflow=False, field_options={...}
# field_options 用于字段级别的额外控制:
#   ws.update(rid, {"status": "新选项"}, field_options={"status": {"allowNewOptions": True}})

# 删除记录 —— 返回 bool
ws.delete(rid)
# 可选参数: permanent=True 彻底删除（不进回收站）, trigger_workflow=False

# 批量创建 —— 返回 ID 列表
ids = ws.batch_create([
    {"name": "张三", "age": 25},
    {"name": "李四", "age": 30},
])

# 批量更新 —— 返回 {"successful": [...], "failed": [...]}
result = ws.batch_update(
    ["row_id_1", "row_id_2"],
    {"status": "已完成"},
)

# 批量删除 —— 返回删除的记录数量
count = ws.batch_delete(["row_id_1", "row_id_2", "row_id_3"])
# 可选参数: permanent=True 彻底删除, trigger_workflow=False
```

### 字段值格式说明

| 字段类型 | 创建/更新 值格式 | 返回值格式 |
|---------|-----------------|-----------|
| 文本 | `"文本内容"` | `"文本内容"` |
| 数值 | `123.45` | `"123.45"`（字符串） |
| 单选 | `"选项A"`（选项文本） | `[{"key": "xxx", "value": "选项A"}]` |
| 多选 | `["标签1", "标签3"]` | `[{"key": "xxx", "value": "标签1"}, ...]` |
| 日期 | `"2025-06-15"` | 日期字符串 |
| 日期时间 | `"2025-06-15 14:30:00"` | 日期时间字符串 |
| 成员 | `"account_id"` 或 `"user-api"` | `[{"accountId": "xxx", "fullname": "..."}]` |
| 关联 | `["record_id_1", "record_id_2"]` | 关联数据列表 |

## 关联记录

### relations — 获取关联记录

```python
ws = client.worksheet("sheet_id")

# 获取关联记录
result = ws.relations("row_id", "f_relation_field").select()
# 返回: {"rows": [...], "total": 10}

for r in result["rows"]:
    print(r["id"], r.get("field_name"))

# 带分页和系统字段
result = ws.relations("row_id", "f_relation") \
    .page(1, 50) \
    .return_system_fields() \
    .select()

# 自动翻页获取全部关联记录
all_records = ws.relations("row_id", "f_relation").all()
```

## 工作表管理

```python
from mdy.resources.worksheet import WorksheetResource

# 获取工作表列表
sheets = WorksheetResource.list(client)
# 可选参数: response_format="md", worksheets=["id1", "id2"]
for s in sheets:
    print(s["name"], s["id"])

# 获取工作表结构（字段定义）
structure = client.worksheet("sheet_id").get_structure()
for field in structure["data"]["fields"]:
    print(f"  {field['alias']}: type={field['type']}, name={field['name']}")
# 可选参数: response_format="md" 返回 markdown 格式（节省 token）

# 创建工作表
result = WorksheetResource.create_worksheet(
    client,
    name="测试表",
    alias="test_sheet",
    remark="备注",
    section_id="",          # 可选: 分组 ID
    fields=[
        {"name": "标题", "type": "2", "required": True, "isTitle": True},
        {"name": "数值", "type": "6", "dot": 2, "alias": "f_num"},
        {"name": "单选", "type": "9",
         "options": [{"value": "选项A", "index": 1}, {"value": "选项B", "index": 2}]},
    ],
)
worksheet_id = result["data"]["worksheetId"]

# 修改工作表（增/删/改字段）
WorksheetResource.update_worksheet(
    client, "sheet_id",
    name="新名称",           # 可选
    add_fields=[...],       # 新增字段列表
    edit_fields=[...],      # 修改字段列表
    remove_fields=[...],    # 删除字段 ID 列表
)

# 删除工作表
WorksheetResource.delete_worksheet(client, "sheet_id")
```

## 异常处理

```python
from mdy import MingdaoClient
from mdy.exceptions import APIError, AuthError, RequestError, RateLimitError

client = MingdaoClient(app_key="...", sign="...")

try:
    records = client.worksheet("sheet_id").select()
except AuthError as e:
    print(f"鉴权失败: {e}")        # 密钥错误、签名错误
except RateLimitError as e:
    print(f"被限流: {e}")          # 错误码 51/90000
except APIError as e:
    print(f"业务错误 [{e.error_code}]: {e.error_msg}")  # 其他 API 错误
except RequestError as e:
    print(f"网络异常: {e}")         # 超时、连接失败
```

## 完整示例

```python
from mdy import MingdaoClient

client = MingdaoClient(app_key="your_key", sign="your_sign")

# 获取工作表列表
from mdy.resources.worksheet import WorksheetResource
sheets = WorksheetResource.list(client)
print(f"共 {len(sheets)} 张工作表")

# 查询
active_users = (
    client.worksheet("users")
    .where("status", "eq", "active")
    .where("age", "between", [20, 40])
    .fields("name", "age", "phone")
    .order("age", "desc")
    .page(1, 100)
    .select()
)

# 创建 + 更新 + 删除
rid = client.worksheet("users").create({"name": "新用户", "age": 28})
if client.worksheet("users").update(rid, {"age": 29}):
    print("更新成功")
client.worksheet("users").delete(rid)

# 批量创建
ids = client.worksheet("users").batch_create([
    {"name": "张三", "age": 25},
    {"name": "李四", "age": 30},
])

# 获取全部（慎用大数据量场景）
all_records = client.worksheet("users").where("age", "gt", 18).all()
```

## License

MIT
