Metadata-Version: 2.4
Name: tamar-file-hub-client
Version: 0.4.2
Summary: A Python SDK for gRPC-based file management system
Home-page: https://github.com/Tamar-Edge-AI/file-hub-client
Author: Oscar Ou
Author-email: oscar.ou@tamaredge.ai
License: MIT
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Communications :: File Sharing
Classifier: Framework :: AsyncIO
Classifier: Typing :: Typed
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: grpcio<2.0.0,>=1.67.1
Requires-Dist: grpcio-tools<1.68.0,>=1.67.1
Requires-Dist: protobuf<6.0.0,>=5.29.4
Requires-Dist: pydantic>=2.0.0
Requires-Dist: typing-extensions>=4.0.0; python_version < "3.10"
Requires-Dist: requests>=2.28.0
Requires-Dist: aiohttp>=3.8.0
Requires-Dist: aiofiles>=23.0.0
Requires-Dist: python-magic; platform_system != "Windows"
Requires-Dist: python-magic-bin~=0.4.14; platform_system == "Windows"
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: license
Dynamic: requires-dist
Dynamic: requires-python
Dynamic: summary

# File Hub Client (tamar-file-hub-client)

[file-hub-server](https://github.com/Tamar-Edge-AI/file-hub-server) 的官方 Python SDK。基于 gRPC 通信，提供异步与同步两套客户端，覆盖文件管理、文件夹、文档、媒体压缩变体、协同电子表格 (Taple) 全部接口。

> 服务端通过 gRPC 调度 [file-hub-executor](https://github.com/Tamar-Edge-AI/file-hub-executor) 完成压缩与跨境同步；本 SDK 只与服务端直接通信。

## 目录

- [安装](#安装)
- [快速开始](#快速开始)
- [客户端构造](#客户端构造)
  - [环境变量](#环境变量)
  - [用户上下文](#用户上下文)
- [API 导航](#api-导航)
- [API 参考](#api-参考)
  - [BlobService — 文件传输与变体](#blobservice--文件传输与变体)
  - [FileService — 元数据与分享](#fileservice--元数据与分享)
  - [FolderService — 文件夹管理](#folderservice--文件夹管理)
  - [DocumentService — 文档管理](#documentservice--文档管理)
  - [TapleService — 协同电子表格](#tapleservice--协同电子表格)
- [数据模型](#数据模型)
- [异常](#异常)
- [传输细节](#传输细节)
- [开发](#开发)

## 安装

```bash
pip install tamar-file-hub-client
```

支持 Python 3.8 ~ 3.12。包内含 `py.typed`，完整类型注解。

依赖：`grpcio`、`grpcio-tools`、`protobuf==5.27.2`、`pydantic>=2`、`requests`、`aiohttp`、`aiofiles`、`python-magic`（Windows: `python-magic-bin`）。

## 快速开始

### 同步客户端

```python
from file_hub_client import TamarFileHubClient, Role

with TamarFileHubClient(
    host="grpc.example.com",
    port=50051,
    secure=True,
    credentials={"api_key": "your-api-key"},
) as client:
    client.set_user_context(
        org_id="org-123",
        user_id="user-456",
        role=Role.ACCOUNT,
    )

    result = client.blobs.upload(file=b"hello world")
    print(result.file.id, result.upload_file.file_size)
```

### 异步客户端

```python
import asyncio
from file_hub_client import AsyncTamarFileHubClient, Role

async def main():
    async with AsyncTamarFileHubClient(host="grpc.example.com") as client:
        client.set_user_context(org_id="org-123", user_id="user-456", role=Role.ACCOUNT)
        files = await client.files.list_files(folder_id="folder-1", page_size=20)
        for f in files.files:
            print(f.id, f.file_name)

asyncio.run(main())
```

两套客户端方法签名一致，按运行环境选用。也可通过 `get_client()` / `get_async_client()` 获取单例。

## 客户端构造

```python
TamarFileHubClient(
    host="grpc.example.com",          # 或 FILE_HUB_HOST
    port=50051,                         # 或 FILE_HUB_PORT（可省略）
    secure=True,                        # 或 FILE_HUB_SECURE
    credentials={"api_key": "..."},    # 或 FILE_HUB_API_KEY
    auto_connect=True,
    retry_count=3,
    retry_delay=1.0,
    user_context=UserContext(...),
    request_context=RequestContext(...),
    enable_logging=True,
    log_level="INFO",
    options=[
        ("grpc.max_send_message_length", 100 * 1024 * 1024),
        ("grpc.max_receive_message_length", 100 * 1024 * 1024),
    ],
)
```

### 环境变量

构造参数均可由环境变量提供，构造时显式传参的优先级高于环境变量。便于 12-factor 部署：

| 变量 | 对应参数 | 默认值 | 说明 |
|------|----------|--------|------|
| `FILE_HUB_HOST` | `host` | `localhost` | gRPC 服务器地址，可以是域名或 IP |
| `FILE_HUB_PORT` | `port` | （空） | gRPC 端口；留空时直接以 `host` 作为完整地址（线上走域名常这样用） |
| `FILE_HUB_SECURE` | `secure` | `false` | 是否启用 TLS，取值 `true` / `false` |
| `FILE_HUB_API_KEY` | `credentials` | （空） | API Key，存在时自动转为 `{"api_key": ...}` |
| `FILE_HUB_RETRY_COUNT` | `retry_count` | `3` | channel 创建失败时的重试次数；网络连接由首次 RPC 懒建立 |
| `FILE_HUB_RETRY_DELAY` | `retry_delay` | `1.0` | channel 创建重试间隔（秒） |

```bash
# 本地开发
export FILE_HUB_HOST=localhost
export FILE_HUB_PORT=50051
export FILE_HUB_SECURE=false

# 生产（域名 + TLS，无需指定端口）
export FILE_HUB_HOST=api.filehub.example.com
export FILE_HUB_SECURE=true
export FILE_HUB_API_KEY=prod-api-key-xxxxx
```

```python
# 全部走环境变量，无需传参
with TamarFileHubClient() as client:
    client.set_user_context(org_id="org-123", user_id="user-456")
    ...
```

> `UserContext`（org_id / user_id / role / actor_id / user_ip）需要通过 `set_user_context()` 显式设置，不从环境变量读取——它属于业务上下文而非连接配置。完整模板见仓库 `.env.example`。

### 用户上下文

```python
client.set_user_context(
    org_id="org-123",
    user_id="user-456",
    role=Role.ACCOUNT,        # ACCOUNT | AGENT | SYSTEM
    actor_id="operator-id",   # 可选，操作者
    user_ip="192.168.1.1",    # 可选，审计日志
)
```

上下文随每次 gRPC 调用透传到服务端，用于资源归属、权限校验、操作审计。

---

## API 导航

共 **96** 个公开方法，分布在 5 个服务中。所有方法的 `request_id` 和 `**metadata` 参数均为可选，用于链路追踪和 gRPC metadata 注入，下文参数表中省略。

### BlobService (`client.blobs`) — 26 个方法

| 方法 | 说明 |
|------|------|
| [`upload`](#blobupload) | 上传文件（bytes / Path / URL） |
| [`generate_upload_url`](#blobgenerate_upload_url) | 获取预签名上传 URL |
| [`generate_resumable_upload_url`](#blobgenerate_resumable_upload_url) | 获取 GCS 专属断点续传 URL |
| [`upload_oss_multipart`](#blobupload_oss_multipart) | OSS multipart 直传并支持断点恢复 |
| [`create_oss_multipart_upload`](#blobcreate_oss_multipart_upload) | 创建 OSS multipart upload |
| [`sign_oss_upload_part`](#blobsign_oss_upload_part) | 签发 OSS 分片上传 URL |
| [`list_oss_uploaded_parts`](#bloblist_oss_uploaded_parts) | 从 OSS 查询已上传分片 |
| [`complete_oss_multipart_upload`](#blobcomplete_oss_multipart_upload) | 完成 OSS multipart upload 并触发服务端校验 |
| [`abort_oss_multipart_upload`](#blobabort_oss_multipart_upload) | 取消 OSS multipart upload |
| [`confirm_upload_completed`](#blobconfirm_upload_completed) | 确认上传完成 |
| [`download`](#blobdownload) | 下载文件到本地 |
| [`download_to_bytes`](#blobdownload_to_bytes) | 下载文件到内存 |
| [`generate_download_url`](#blobgenerate_download_url) | 获取下载 URL |
| [`batch_generate_download_url`](#blobbatch_generate_download_url) | 批量获取下载 URL |
| [`create_download_task`](#blobcreate_download_task) | 创建单文件 / 批量归档下载任务 |
| [`get_download_task_status`](#blobget_download_task_status) | 查询下载任务状态 |
| [`generate_download_task_url`](#blobgenerate_download_task_url) | 获取已完成下载任务的下载 URL |
| [`run_download_task_dispatch_sweep`](#blobrun_download_task_dispatch_sweep) | 触发下载任务投递补偿扫描 |
| [`run_download_task_reconcile_sweep`](#blobrun_download_task_reconcile_sweep) | 触发下载任务结果对账扫描 |
| [`get_gcs_url`](#blobget_gcs_url) | 获取 GCS 对象 URL |
| [`batch_get_gcs_url`](#blobbatch_get_gcs_url) | 批量获取 GCS 对象 URL |
| [`get_compression_status`](#blobget_compression_status) | 查询压缩状态 |
| [`get_compressed_variants`](#blobget_compressed_variants) | 获取压缩变体列表 |
| [`trigger_recompression`](#blobtrigger_recompression) | 触发重新压缩 |
| [`generate_variant_download_url`](#blobgenerate_variant_download_url) | 获取变体下载 URL |
| [`batch_get_file_status`](#blobbatch_get_file_status) | 批量查询文件状态（上传/压缩/同步） |

### FileService (`client.files`) — 13 个方法

| 方法 | 说明 |
|------|------|
| [`get_file`](#fileget_file) | 获取文件详情 |
| [`get_files`](#fileget_files) | 批量获取文件详情 |
| [`list_files`](#filelist_files) | 分页列表文件 |
| [`rename_file`](#filerename_file) | 重命名文件 |
| [`delete_file`](#filedelete_file) | 删除文件 |
| [`generate_share_link`](#filegenerate_share_link) | 创建分享链接 |
| [`visit_file`](#filevisit_file) | 记录文件访问 |
| [`get_compression_status`](#fileget_compression_status-1) | 查询压缩状态 |
| [`get_compressed_variants`](#fileget_compressed_variants-1) | 获取压缩变体列表 |
| [`trigger_recompression`](#filetrigger_recompression-1) | 触发重新压缩 |
| [`generate_variant_download_url`](#filegenerate_variant_download_url-1) | 获取变体下载 URL |
| [`import_from_gcs`](#fileimport_from_gcs) | 从 GCS 导入文件 |
| [`generate_signed_gcs_url`](#filegenerate_signed_gcs_url) | 生成 GCS 签名 URL |

### FolderService (`client.folders`) — 9 个方法

| 方法 | 说明 |
|------|------|
| [`create_folder`](#foldercreate_folder) | 创建文件夹 |
| [`get_folder`](#folderget_folder) | 查询单个文件夹 |
| [`get_or_create_default_folder`](#folderget_or_create_default_folder) | 查询或创建默认根文件夹 |
| [`rename_folder`](#folderrename_folder) | 重命名文件夹 |
| [`move_folder`](#foldermove_folder) | 移动文件夹 |
| [`update_folder`](#folderupdate_folder) | 同时更新文件夹名称和/或父文件夹 |
| [`delete_folder`](#folderdelete_folder) | 删除文件夹 |
| [`list_folders`](#folderlist_folders) | 列出文件夹 |
| [`list_folder_tree`](#folderlist_folder_tree) | 以树形结构列出文件夹 |

### DocumentService (`client.documents`) — 12 个方法

| 方法 | 说明 |
|------|------|
| [`create_document`](#documentcreate_document) | 创建文档及初始内容 |
| [`get_document`](#documentget_document) | 按 document_id 或 content_id 获取文档 |
| [`update_document`](#documentupdate_document) | 更新文档内容和标题 |
| [`delete_document`](#documentdelete_document) | 删除文档 |
| [`search_documents`](#documentsearch_documents) | 搜索可见文档 |
| [`list_owner_documents`](#documentlist_owner_documents) | 列出用户拥有的文档 |
| [`list_recently_visited_documents`](#documentlist_recently_visited_documents) | 列出最近访问文档 |
| [`share_document`](#documentshare_document) | 分享文档权限 |
| [`clone_document`](#documentclone_document) | 克隆文档到目标 org/user |
| [`append_document_content`](#documentappend_document_content) | 追加文档内容 |
| [`update_document_content`](#documentupdate_document_content) | 更新指定 content 节点 |
| [`add_document_agent_operations`](#documentadd_document_agent_operations) | 批量创建 agent operation |

### TapleService (`client.taples`) — 36 个方法

**表操作**

| 方法 | 说明 |
|------|------|
| [`create_table`](#taplecreate_table) | 创建表 |
| [`get_table`](#tapleget_table) | 获取表详情（table_id 或 file_id） |
| [`update_table`](#tapleupdate_table) | 更新表名/描述 |
| [`delete_table`](#tapledelete_table) | 删除表 |

**Sheet 操作**

| 方法 | 说明 |
|------|------|
| [`create_sheet`](#taplecreate_sheet) | 创建 Sheet |
| [`get_sheet`](#tapleget_sheet) | 获取 Sheet 详情 |
| [`list_sheets`](#taplelist_sheets) | 列出表内所有 Sheet |
| [`update_sheet`](#tapleupdate_sheet) | 更新 Sheet 名/描述/位置 |
| [`delete_sheet`](#tapledelete_sheet) | 删除 Sheet |
| [`get_sheet_version`](#tapleget_sheet_version) | 获取 Sheet 当前版本号 |
| [`get_sheet_data`](#tapleget_sheet_data) | 获取 Sheet 完整数据 |
| [`batch_edit_sheet`](#taplebatch_edit_sheet) | 批量混合编辑（行/列/单元格） |

**列操作**

| 方法 | 说明 |
|------|------|
| [`create_column`](#taplecreate_column) | 创建列 |
| [`update_column`](#tapleupdate_column) | 更新列属性 |
| [`delete_column`](#tapledelete_column) | 删除列 |
| [`batch_edit_columns`](#taplebatch_edit_columns) | 批量列操作 |
| [`get_column_data`](#tapleget_column_data) | 获取列数据 |

**行操作**

| 方法 | 说明 |
|------|------|
| [`create_row`](#taplecreate_row) | 创建行 |
| [`update_row`](#tapleupdate_row) | 更新行属性 |
| [`delete_row`](#tapledelete_row) | 删除行 |
| [`batch_edit_rows`](#taplebatch_edit_rows) | 批量行操作 |
| [`get_row_data`](#tapleget_row_data) | 获取行数据 |

**单元格操作**

| 方法 | 说明 |
|------|------|
| [`edit_cell`](#tapleedit_cell) | 编辑单元格 |
| [`delete_cell`](#tapledelete_cell) | 清空单元格 |
| [`batch_edit_cells`](#taplebatch_edit_cells) | 批量单元格操作 |
| [`get_cell_data`](#tapleget_cell_data) | 获取单元格数据 |

**视图操作**

| 方法 | 说明 |
|------|------|
| [`create_table_view`](#taplecreate_table_view) | 创建视图 |
| [`get_table_view`](#tapleget_table_view) | 获取视图 |
| [`list_table_views`](#taplelist_table_views) | 列出视图 |
| [`update_table_view`](#tapleupdate_table_view) | 更新视图 |
| [`update_table_view_config`](#tapleupdate_table_view_config) | 更新视图配置 |
| [`delete_table_view`](#tapledelete_table_view) | 删除视图 |
| [`batch_create_table_views`](#taplebatch_create_table_views) | 批量创建视图 |

**导入导出**

| 方法 | 说明 |
|------|------|
| [`clone_table_data`](#tapleclone_table_data) | 克隆表到另一用户/组织 |
| [`export_table_data`](#tapleexport_table_data) | 导出表数据 |
| [`import_table_data`](#tapleimport_table_data) | 导入数据到表 |

---

## API 参考

> 以下示例均使用同步客户端。异步客户端方法签名相同，调用时加 `await` 即可。

### BlobService — 文件传输与变体

#### blob.upload

上传文件，支持 bytes / 文件路径 / URL 三种输入。

```python
upload(
    file: Union[str, Path, BinaryIO, bytes] = None,
    *,
    folder_id: str = None,
    mode: UploadMode = UploadMode.NORMAL,        # NORMAL | RESUMABLE | STREAM | OSS_MULTIPART
    is_temporary: bool = False,
    expire_seconds: int = None,
    keep_original_filename: bool = False,
    forbid_overwrite: bool = True,
    url: str = None,                              # 从 URL 拉取上传
    file_name: str = None,
    mime_type: str = None,                        # 显式 MIME，覆盖自动探测
    part_size: int = None,                        # OSS multipart 分片大小
    oss_upload_id: str = None,                    # OSS multipart 恢复用 upload_id
    oss_file_id: str = None,                      # OSS multipart 恢复用 file_id
    abort_on_error: bool = False,                 # OSS multipart 失败时是否自动取消会话
) → FileUploadResponse
```

```python
from pathlib import Path
from file_hub_client import UploadMode

# 上传字节
result = client.blobs.upload(file=b"hello world")

# 上传本地文件
result = client.blobs.upload(file=Path("/data/photo.jpg"), keep_original_filename=True)

# GCS 断点续传模式
result = client.blobs.upload(file=Path("/data/large.mp4"), mode=UploadMode.RESUMABLE)

# OSS multipart 断点续传模式
result = client.blobs.upload(
    file=Path("/data/large.zip"),
    mode=UploadMode.OSS_MULTIPART,
    part_size=8 * 1024 * 1024,
)

# 从 URL 拉取
result = client.blobs.upload(url="https://example.com/data.csv", file_name="data.csv")

# 临时文件（自动过期）
result = client.blobs.upload(file=b"tmp", is_temporary=True, expire_seconds=3600)
```

#### blob.generate_upload_url

创建统一上传入口。默认返回普通桶直传预签名 PUT URL；设置 `is_resumable_mode=True` 时，由 Server 根据客户端 IP/区域策略选择 GCS resumable 或 OSS multipart，调用方只需要根据响应里的 `storage_type` 和 `upload_strategy` 执行后续上传流程。

```python
generate_upload_url(
    file_name: str,
    file_size: int,
    folder_id: str = None,
    file_type: str = "dat",
    mime_type: str = None,
    file_hash: str = None,
    is_temporary: bool = False,
    expire_seconds: int = None,
    keep_original_filename: bool = False,
    forbid_overwrite: bool = True,
    is_resumable_mode: bool = False,
    part_size: int = None,
) → UploadUrlResponse
```

```python
resp = client.blobs.generate_upload_url(file_name="report.pdf", file_size=1024 * 1024)
print(resp.upload_url)  # 预签名 PUT URL
print(resp.file.id)     # 已创建的文件 ID
# 自行 PUT 时必须原样透传服务端返回的 headers。
import requests
with open("report.pdf", "rb") as file_obj:
    requests.put(resp.upload_url, data=file_obj, headers=resp.headers)
print(resp.storage_type)     # gcs / oss
print(resp.upload_strategy)  # single_put / already_uploaded
print(resp.headers)          # PUT 时需要携带的 header

resp = client.blobs.generate_upload_url(
    file_name="video.mp4",
    file_size=5_000_000_000,
    is_resumable_mode=True,
    part_size=8 * 1024 * 1024,
)
print(resp.storage_type)     # gcs / oss，由 Server 决定
print(resp.upload_strategy)  # gcs_resumable / oss_multipart / already_uploaded
print(resp.upload_id)        # OSS multipart 时返回
print(resp.part_size)        # OSS multipart 时返回
```

#### blob.generate_resumable_upload_url

获取 GCS 专用断点续传 session URL，适用于明确目标为 GCS 的内部调用。OSS 断点续传请使用 `generate_upload_url(is_resumable_mode=True)` 返回的 OSS multipart 字段。

```python
generate_resumable_upload_url(
    file_name: str,
    file_size: int,
    folder_id: str = None,
    file_type: str = "dat",
    mime_type: str = None,
    file_hash: str = None,
    is_temporary: bool = False,
    expire_seconds: int = None,
    keep_original_filename: bool = False,
    forbid_overwrite: bool = False,
) → UploadUrlResponse
```

返回的 `upload_url` 已经是 GCS session URL，不需要再调用 `start_resumable_session`；自行上传时同样必须原样透传 `resp.headers`。

#### blob.confirm_upload_completed

通知服务端客户端已完成上传（用于预签名 / 断点续传场景）。

#### blob.upload_oss_multipart

通过 OSS multipart 直传文件。SDK 会创建 multipart upload、签发分片 URL、跳过 OSS 已存在且大小匹配的分片，并在完成后调用 Server 校验最终对象。

默认情况下，分片上传失败不会自动 abort OSS multipart session；SDK 会抛出 `UploadError`，并在异常中携带 `file_id`、`upload_id`、`part_number`，方便客户端保存断点后继续恢复上传。如果业务希望一次性上传失败后立即放弃本次会话，可以传入 `abort_on_error=True`，SDK 会在抛出异常前尝试调用 `abort_oss_multipart_upload()`。

```python
upload_oss_multipart(
    file: Union[str, Path, BinaryIO, bytes],
    *,
    folder_id: str = None,
    is_temporary: bool = False,
    expire_seconds: int = None,
    keep_original_filename: bool = False,
    forbid_overwrite: bool = True,
    file_name: str = None,
    mime_type: str = None,
    part_size: int = None,
    upload_id: str = None,
    file_id: str = None,
    abort_on_error: bool = False,
) → FileUploadResponse
```

```python
from pathlib import Path

# 新建 OSS multipart 上传
result = client.blobs.upload_oss_multipart(
    file=Path("/data/archive.zip"),
    part_size=8 * 1024 * 1024,
)

# 恢复已有上传；file_id/upload_id/part_size 应由客户端 checkpoint 保存
result = client.blobs.upload_oss_multipart(
    file=Path("/data/archive.zip"),
    file_id="file-123",
    upload_id="oss-upload-id",
    part_size=8 * 1024 * 1024,
)
```

<a id="blobcreate_oss_multipart_upload"></a>
<a id="blobsign_oss_upload_part"></a>
<a id="bloblist_oss_uploaded_parts"></a>
<a id="blobcomplete_oss_multipart_upload"></a>
<a id="blobabort_oss_multipart_upload"></a>

#### OSS multipart 低阶接口

当业务方需要自己控制分片上传、保存 checkpoint 或做跨进程恢复时，可以直接调用低阶接口。

```python
create_resp = client.blobs.create_oss_multipart_upload(
    file_name="archive.zip",
    file_size=512 * 1024 * 1024,
    part_size=8 * 1024 * 1024,
)

sign_resp = client.blobs.sign_oss_upload_part(
    file_id=create_resp.file.id,
    upload_id=create_resp.upload_id,
    part_numbers=[1, 2, 3],
)

parts_resp = client.blobs.list_oss_uploaded_parts(
    file_id=create_resp.file.id,
    upload_id=create_resp.upload_id,
)

result = client.blobs.complete_oss_multipart_upload(
    file_id=create_resp.file.id,
    upload_id=create_resp.upload_id,
    parts=parts_resp.parts,
)

client.blobs.abort_oss_multipart_upload(
    file_id=create_resp.file.id,
    upload_id=create_resp.upload_id,
)
```

恢复上传时，客户端应保存 `file_id`、`upload_id`、`part_size` 和已上传分片 ETag。恢复后以 `list_oss_uploaded_parts()` 返回的 OSS 远端结果为准，缺失或大小不匹配的分片重新上传。Server 不维护 multipart session 表。

#### blob.confirm_upload_completed

通知 Server 客户端已完成上传。Server 会根据持久化上传记录判断策略：预签名 / GCS 断点续传只校验对象是否真实存在且大小匹配；OSS multipart 需要同时传入 `upload_id` 和 `parts`，由 Server 先完成 OSS 原生 multipart session 再校验对象。

```python
confirm_upload_completed(
    file_id: str,
    upload_id: str | None = None,
    parts: list[OssUploadedPart | dict] | None = None,
) → None
```

```python
url_resp = client.blobs.generate_resumable_upload_url(file_name="big.zip", file_size=5_000_000_000)
# ... 客户端自行上传到 url_resp.upload_url ...
client.blobs.confirm_upload_completed(file_id=url_resp.upload_file.id)
```

#### blob.download

下载文件到本地路径。

```python
download(
    file_id: str,
    *,
    save_path: Union[str, Path] = None,
    chunk_size: int = None,
) → Union[bytes, Path]
```

```python
client.blobs.download(file_id="file-123", save_path="/local/output.bin")
```

#### blob.download_to_bytes

下载文件内容到内存。

```python
download_to_bytes(file_id: str) → bytes
```

```python
content = client.blobs.download_to_bytes(file_id="file-123")
```

#### blob.generate_download_url

获取预签名下载 URL。

```python
generate_download_url(
    file_id: str,
    *,
    is_cdn: bool = True,
    expire_seconds: int = None,
    network_preference: str = None,
) → str
```

```python
url = client.blobs.generate_download_url(file_id="file-123", expire_seconds=3600)
```

#### blob.batch_generate_download_url

批量获取下载 URL。

```python
batch_generate_download_url(
    file_ids: List[str],
    *,
    is_cdn: bool = True,
    expire_seconds: int = None,
    network_preference: str = None,
) → BatchDownloadUrlResponse
```

#### blob.create_download_task

创建下载任务。单个文件会生成 `direct` 任务；多个文件会生成 `archive` 任务，由服务端异步投递给 Executor 打包，完成后再通过任务 URL 下载。

```python
create_download_task(
    file_items: List[Union[CreateDownloadTaskFileItem, str, dict]],
    *,
    download_name: str = None,
    business_refs: List[Union[DownloadTaskBusinessRef, dict]] = None,
) → CreateDownloadTaskResponse
```

```python
from file_hub_client import CreateDownloadTaskFileItem, DownloadTaskBusinessRef

resp = client.blobs.create_download_task(
    file_items=[
        CreateDownloadTaskFileItem(
            file_id="file-1",
            canvas_id="canvas-1",
            node_id="node-1",
            business_refs=[
                DownloadTaskBusinessRef(ref_type="canvas", ref_id="canvas-1"),
            ],
        ),
        CreateDownloadTaskFileItem(
            file_id="file-2",
            canvas_id="canvas-1",
            node_id="node-2",
        ),
    ],
    download_name="materials.zip",
    business_refs=[
        DownloadTaskBusinessRef(ref_type="download_scene", ref_id="export-panel"),
    ],
)

download_id = resp.task.download_id
print(resp.task.status, resp.task.progress)
```

`file_items` 也可以直接传 `["file-1", "file-2"]`。如果业务需要审计下载来源，建议显式传 `canvas_id`、`node_id` 或 `business_refs`。

#### blob.get_download_task_status

查询下载任务状态。常见状态包括 `created`、`pending`、`queued`、`processing`、`completed`、`failed`、`expired`、`cancelled`。

```python
get_download_task_status(
    download_ids: List[str] = None,
    *,
    statuses: List[str] = None,
    modes: List[str] = None,
    download_name: str = None,
    limit: int = None,
    offset: int = None,
    include_items: bool = None,
) → GetDownloadTaskStatusResponse
```

```python
status_resp = client.blobs.get_download_task_status(
    download_ids=[download_id],
    download_name="canvas-selection",
    include_items=True,
)

task = status_resp.tasks[0]
print(task.status, task.progress, task.success_count, task.failed_count)

for failed in task.failed_files:
    print(failed.file_id, failed.error.error_code if failed.error else None)
```

`failed_files` 和 `items[].error` 返回结构化错误码、阶段、参数和诊断 ID；面向用户的多语言文案应由调用方或 Gateway 根据 `error_code` 自行翻译。

#### blob.generate_download_task_url

为已完成任务生成下载 URL。该 URL 可能指向单文件直链，也可能指向归档 zip；服务端会根据任务结果和区域策略选择可用存储。

```python
generate_download_task_url(
    download_id: str,
    *,
    expire_seconds: int = None,
    network_preference: str = None,
) → GenerateDownloadTaskUrlResponse
```

```python
if task.status == "completed" and task.result and task.result.can_download:
    url_resp = client.blobs.generate_download_task_url(
        download_id=download_id,
        expire_seconds=900,
        network_preference="default",  # default | public | internal | fallback
    )
    print(url_resp.url, url_resp.download_name)
```

#### blob.run_download_task_dispatch_sweep

触发一次有界的下载任务投递补偿扫描。这个接口通常只给 Gateway cron、Cloud Scheduler 或内部运维任务调用，业务用户请求路径不应直接调用。

```python
run_download_task_dispatch_sweep(
    *,
    max_dispatches: int = None,
    max_concurrency: int = None,
    lock_seconds: int = None,
) → DownloadTaskSweepResponse
```

#### blob.run_download_task_reconcile_sweep

触发一次有界的下载任务结果对账扫描，用于兜底 Executor 回调丢失或任务长时间停留在 `queued` / `processing` 的情况。

```python
run_download_task_reconcile_sweep(
    *,
    max_reconcile: int = None,
    max_concurrency: int = None,
    lock_seconds: int = None,
    stale_after_seconds: int = None,
) → DownloadTaskSweepResponse
```

#### blob.get_gcs_url

获取文件的 GCS 对象 URL。

```python
get_gcs_url(file_id: str) → GetGcsUrlResponse
```

#### blob.batch_get_gcs_url

批量获取 GCS 对象 URL。

```python
batch_get_gcs_url(file_ids: List[str], *, variant_name: str = "original") → BatchGcsUrlResponse
```

`variant_name` 默认 `original`；指定压缩变体不存在时，服务端会按回退链返回可用版本。

#### blob.get_compression_status

查询文件压缩状态。

```python
get_compression_status(file_id: str) → CompressionStatusResponse
```

```python
status = client.blobs.get_compression_status(file_id="image-456")
print(status.status)  # "pending" | "processing" | "completed" | "failed"
```

#### blob.get_compressed_variants

获取文件的所有压缩变体信息。

```python
get_compressed_variants(
    file_id: str,
    *,
    variant_type: str = None,          # 按类型过滤
) → GetVariantsResponse
```

```python
variants = client.blobs.get_compressed_variants(file_id="image-456")
for v in variants.variants:
    print(v.variant_name, v.width, v.height, v.file_size)
```

#### blob.trigger_recompression

触发重新压缩。

```python
trigger_recompression(
    file_id: str,
    *,
    force_reprocess: bool = False,
) → RecompressionResponse
```

#### blob.generate_variant_download_url

获取指定压缩变体的下载 URL。

```python
generate_variant_download_url(
    file_id: str,
    variant_name: str,                 # e.g. "thumbnail", "medium", "720p"
    *,
    expire_seconds: int = 3600,
    is_cdn: bool = False,
    network_preference: str = None,
) → VariantDownloadUrlResponse
```

```python
resp = client.blobs.generate_variant_download_url(file_id="img-1", variant_name="thumbnail")
print(resp.url)
```

#### blob.batch_get_file_status

批量查询文件的上传 / 压缩 / 同步三态。

```python
batch_get_file_status(
    file_ids: List[str],
    *,
    include_details: bool = False,
) → BatchFileStatusResponse
```

```python
batch = client.blobs.batch_get_file_status(file_ids=["a", "b"], include_details=True)
for s in batch.statuses:
    print(s.file_id, s.upload_status, s.compression_status, s.sync_status)
```

---

### FileService — 元数据与分享

#### file.get_file

获取单个文件详情。

```python
get_file(file_id: str) → GetFileResponse
```

#### file.get_files

批量获取文件详情。

```python
get_files(file_ids: List[str]) → GetFilesResponse
```

#### file.list_files

分页列出文件，支持多维度过滤。

```python
list_files(
    folder_id: str = None,
    file_name: str = None,
    file_type: List[str] = None,
    created_by_role: str = None,       # "account" | "agent" | "system"
    created_by: str = None,
    page_size: int = 20,
    page: int = 1,
) → FileListResponse
```

```python
resp = client.files.list_files(folder_id="folder-1", file_type=["image", "video"], page_size=50)
for f in resp.files:
    print(f.id, f.file_name)
```

#### file.rename_file

```python
rename_file(file_id: str, new_name: str) → File
```

#### file.delete_file

```python
delete_file(file_id: str) → None
```

#### file.generate_share_link

创建分享链接，支持密码、过期、次数限制。

```python
generate_share_link(
    file_id: str,
    *,
    is_public: bool = True,
    access_scope: str = "view",
    expire_seconds: int = 86400,
    max_access: int = None,
    share_password: str = None,
) → str
```

```python
share_id = client.files.generate_share_link(
    file_id="file-123",
    expire_seconds=86400 * 7,
    max_access=100,
    share_password="s3cret",
)
```

#### file.visit_file

记录文件访问（审计日志）。

```python
visit_file(
    file_share_id: str,
    access_type: str = "view",
    access_duration: int = 0,
    metadata: Dict[str, Any] = None,
) → None
```

#### file.get_compression_status

```python
get_compression_status(file_id: str) → CompressionStatusResponse
```

#### file.get_compressed_variants

```python
get_compressed_variants(file_id: str, *, variant_type: str = None) → GetVariantsResponse
```

#### file.trigger_recompression

```python
trigger_recompression(file_id: str, *, force_reprocess: bool = False) → RecompressionResponse
```

#### file.generate_variant_download_url

```python
generate_variant_download_url(
    file_id: str,
    variant_name: str,
    *,
    expire_seconds: int = 3600,
    is_cdn: bool = False,
    network_preference: str = None,
) → VariantDownloadUrlResponse
```

#### file.import_from_gcs

从 GCS URI 导入文件元数据。

```python
import_from_gcs(
    gcs_uri: str,
    operation_type: str,
    *,
    folder_id: str = None,
    file_name: str = None,
    keep_original_filename: bool = False,
    created_by_role: str = None,
    created_by: str = None,
) → ImportFromGcsResponse
```

```python
resp = client.files.import_from_gcs(
    gcs_uri="gs://bucket/path/to/file.pdf",
    operation_type="copy",
    folder_id="folder-1",
)
```

#### file.generate_signed_gcs_url

生成 GCS 签名 URL。

```python
generate_signed_gcs_url(gcs_uri: str, *, expire_seconds: int = None) → SignedGcsUrlResponse
```

---

### FolderService — 文件夹管理

#### folder.create_folder

```python
create_folder(folder_name: str, *, parent_id: str = None) → FolderInfo
```

```python
folder = client.folders.create_folder(folder_name="Documents", parent_id="root")
```

#### folder.get_folder

```python
get_folder(folder_id: str) → FolderInfo
```

#### folder.get_or_create_default_folder

```python
get_or_create_default_folder(default_folder_name: str) → FolderInfo
```

#### folder.rename_folder

```python
rename_folder(folder_id: str, new_name: str) → FolderInfo
```

#### folder.move_folder

```python
move_folder(folder_id: str, new_parent_id: str) → FolderInfo
```

#### folder.update_folder

```python
update_folder(
    folder_id: str,
    folder_name: str = None,
    parent_id: str = None,
    parent_id_provided: bool = False,
) → FolderInfo
```

#### folder.delete_folder

```python
delete_folder(folder_id: str) → None
```

#### folder.list_folders

```python
list_folders(
    parent_id: str = None,
    folder_name: str = None,
    created_by_role: str = None,
    created_by: str = None,
) → FolderListResponse
```

```python
folders = client.folders.list_folders(parent_id="root")
for f in folders.items:
    print(f.id, f.folder_name)
```

#### folder.list_folder_tree

```python
list_folder_tree() → FolderTreeResponse
```

---

### DocumentService — 文档管理

#### document.create_document

```python
create_document(
    title: str,
    content: dict = None,
    *,
    folder_id: str = None,
    default_folder_name: str = None,
) → DocumentWithContentResponse
```

```python
doc = client.documents.create_document(
    title="会议纪要",
    content={"blocks": []},
    folder_id="folder-1",
)
print(doc.document.id, doc.content)
```

#### document.get_document

```python
get_document(
    *,
    document_id: str = None,
    content_id: str = None,
    record_visit: bool = False,
    ip_address: str = None,
    device: str = None,
    referrer: str = None,
) → DocumentWithContentResponse
```

#### document.update_document

```python
update_document(
    document_id: str,
    content: dict,
    *,
    title: str = None,
) → DocumentWithContentResponse
```

#### document.delete_document

```python
delete_document(document_id: str) → None
```

#### document.search_documents

```python
search_documents(
    *,
    search_text: str = None,
    limit: int = 100,
    offset: int = 0,
) → DocumentListResponse
```

#### document.list_owner_documents

```python
list_owner_documents(
    *,
    user_id: str = None,
    limit: int = 100,
    offset: int = 0,
) → DocumentListResponse
```

#### document.list_recently_visited_documents

```python
list_recently_visited_documents(
    *,
    user_id: str = None,
    limit: int = 100,
    offset: int = 0,
) → DocumentListResponse
```

#### document.share_document

```python
share_document(
    document_id: str,
    target_user_id: str,
    *,
    permission_type: str = "viewer",
) → DocumentPermissionResponse
```

#### document.clone_document

```python
clone_document(
    source_document_id: str,
    target_org_id: str,
    target_user_id: str,
    *,
    target_folder_id: str = None,
    new_document_title: str = None,
    default_folder_name: str = None,
) → CloneDocumentResponse
```

#### document.append_document_content

```python
append_document_content(
    document_id: str,
    content: dict,
    *,
    target_org_id: str = None,
    target_user_id: str = None,
) → AppendDocumentContentResponse
```

#### document.update_document_content

```python
update_document_content(
    document_id: str,
    content_id: str,
    content: dict,
    *,
    target_org_id: str = None,
    target_user_id: str = None,
) → DocumentWithContentResponse
```

#### document.add_document_agent_operations

```python
add_document_agent_operations(operations: List[Dict[str, Any]]) → AddDocumentAgentOperationsResponse
```

---

### TapleService — 协同电子表格

> Taple 操作支持 `idempotency_key` 参数实现幂等；行/列/单元格操作支持 `sheet_version`（乐观锁）和 `client_id`（客户端标识）。

#### taple.create_table

```python
create_table(
    name: str,
    *,
    folder_id: str = None,
    description: str = None,
    idempotency_key: str = None,
) → TableResponse
```

```python
table = client.taples.create_table(name="Sales Q1", folder_id="folder-1")
```

#### taple.get_table

通过 `table_id` 或 `file_id` 获取表详情。

```python
get_table(*, table_id: str = None, file_id: str = None) → TableResponse
```

#### taple.update_table

```python
update_table(table_id: str, *, name: str = None, description: str = None) → TableResponse
```

#### taple.delete_table

```python
delete_table(table_id: str) → None
```

#### taple.create_sheet

```python
create_sheet(
    table_id: str,
    name: str,
    *,
    description: str = None,
    position: int = None,
) → SheetResponse
```

#### taple.get_sheet

```python
get_sheet(sheet_id: str) → SheetResponse
```

#### taple.list_sheets

```python
list_sheets(table_id: str) → ListSheetsResponse
```

#### taple.update_sheet

```python
update_sheet(
    sheet_id: str,
    *,
    name: str = None,
    description: str = None,
    position: int = None,
) → SheetResponse
```

#### taple.delete_sheet

```python
delete_sheet(sheet_id: str) → None
```

#### taple.get_sheet_version

获取 Sheet 当前版本号（用于乐观锁）。

```python
get_sheet_version(sheet_id: str) → Any
```

#### taple.get_sheet_data

获取 Sheet 完整数据，可指定版本。

```python
get_sheet_data(sheet_id: str, version: int = None) → Any
```

#### taple.batch_edit_sheet

批量混合编辑，一次请求中包含行/列/单元格操作。

```python
batch_edit_sheet(
    sheet_id: str,
    operations: List[Any],
    sheet_version: int = None,
    client_id: str = None,
) → Any
```

#### taple.create_column

```python
create_column(
    sheet_id: str,
    name: str,
    column_type: str = "text",
    *,
    sheet_version: int = None,
    client_id: str = None,
    position: int = None,
    width: int = None,
    description: str = None,
    properties: Dict[str, Any] = None,
) → ColumnResponse
```

```python
col = client.taples.create_column(sheet_id="sheet-1", name="Revenue", column_type="number")
```

#### taple.update_column

```python
update_column(
    sheet_id: str,
    column_key: str,
    *,
    sheet_version: int = None,
    client_id: str = None,
    name: str = None,
    column_type: str = None,
    width: int = None,
    hidden: bool = None,
    description: str = None,
    properties: Dict[str, Any] = None,
) → ColumnResponse
```

#### taple.delete_column

```python
delete_column(
    sheet_id: str,
    column_key: str,
    *,
    sheet_version: int = None,
    client_id: str = None,
) → None
```

#### taple.batch_edit_columns

```python
batch_edit_columns(
    sheet_id: str,
    operations: List[Dict[str, Any]],
    *,
    sheet_version: int = None,
    client_id: str = None,
) → Any
```

#### taple.get_column_data

```python
get_column_data(sheet_id: str, column_key: str) → Any
```

#### taple.create_row

```python
create_row(
    sheet_id: str,
    *,
    sheet_version: int = None,
    client_id: str = None,
    position: int = None,
    height: int = None,
    hidden: bool = None,
) → RowResponse
```

#### taple.update_row

```python
update_row(
    sheet_id: str,
    row_key: str,
    *,
    sheet_version: int = None,
    client_id: str = None,
    position: int = None,
    height: int = None,
    hidden: bool = None,
) → RowResponse
```

#### taple.delete_row

```python
delete_row(
    sheet_id: str,
    row_key: str,
    *,
    sheet_version: int = None,
    client_id: str = None,
) → None
```

#### taple.batch_edit_rows

```python
batch_edit_rows(
    sheet_id: str,
    operations: List[Dict[str, Any]],
    *,
    sheet_version: int = None,
    client_id: str = None,
) → Any
```

#### taple.get_row_data

```python
get_row_data(sheet_id: str, row_key: str) → Any
```

#### taple.edit_cell

编辑单元格内容。

```python
edit_cell(
    sheet_id: str,
    column_key: str,
    row_key: str,
    *,
    sheet_version: int = None,
    client_id: str = None,
    raw_value: str = None,
    formatted_value: str = None,
    formula: str = None,
    data_type: str = None,
    styles: Dict[str, Any] = None,
) → CellResponse
```

```python
client.taples.edit_cell(
    sheet_id="sheet-1",
    column_key="col-revenue",
    row_key="row-1",
    raw_value="15000",
    data_type="number",
)
```

#### taple.delete_cell

```python
delete_cell(
    sheet_id: str,
    column_key: str,
    row_key: str,
    *,
    sheet_version: int = None,
    client_id: str = None,
) → None
```

#### taple.batch_edit_cells

```python
batch_edit_cells(
    sheet_id: str,
    operations: List[Dict[str, Any]],
    *,
    sheet_version: int = None,
    client_id: str = None,
) → Any
```

```python
client.taples.batch_edit_cells(
    sheet_id="sheet-1",
    operations=[
        {"column_key": "col-name", "row_key": "row-1", "raw_value": "Alice"},
        {"column_key": "col-name", "row_key": "row-2", "raw_value": "Bob"},
    ],
)
```

#### taple.get_cell_data

```python
get_cell_data(sheet_id: str, column_key: str, row_key: str) → Any
```

#### taple.create_table_view

```python
create_table_view(
    sheet_id: str,
    name: str,
    view_type: str,
    *,
    filter_criteria: Dict[str, Any] = None,
    sort_criteria: Dict[str, Any] = None,
    visible_columns: Dict[str, bool] = None,
    group_criteria: Dict[str, Any] = None,
    is_hidden: bool = False,
    is_default: bool = False,
    config: Dict[str, Any] = None,
) → TableViewResponse
```

```python
view = client.taples.create_table_view(
    sheet_id="sheet-1",
    name="High Revenue",
    view_type="filter",
    filter_criteria={"column": "Revenue", "operator": ">", "value": "10000"},
    sort_criteria={"column": "Revenue", "order": "desc"},
)
```

#### taple.get_table_view

```python
get_table_view(view_id: str) → TableViewResponse
```

#### taple.list_table_views

```python
list_table_views(
    *,
    table_id: str = None,
    sheet_id: str = None,
    view_type: str = None,
) → ListTableViewsResponse
```

#### taple.update_table_view

```python
update_table_view(
    view_id: str,
    *,
    name: str = None,
    filter_criteria: Dict[str, Any] = None,
    sort_criteria: Dict[str, Any] = None,
    visible_columns: Dict[str, bool] = None,
    group_criteria: Dict[str, Any] = None,
    is_hidden: bool = None,
    is_default: bool = None,
    config: Dict[str, Any] = None,
) → TableViewResponse
```

#### taple.update_table_view_config

快捷更新视图配置（仅 config 字段）。

```python
update_table_view_config(view_id: str, config: Dict[str, Any]) → TableViewResponse
```

#### taple.delete_table_view

```python
delete_table_view(view_id: str) → None
```

#### taple.batch_create_table_views

```python
batch_create_table_views(sheet_id: str, views: List[Dict[str, Any]]) → BatchCreateTableViewsResponse
```

#### taple.clone_table_data

克隆表到另一用户/组织。

```python
clone_table_data(
    source_table_id: str,
    target_org_id: str,
    target_user_id: str,
    *,
    target_folder_id: str = None,
    new_table_name: str = None,
    include_views: bool = False,
) → CloneTableDataResponse
```

```python
clone = client.taples.clone_table_data(
    source_table_id="table-1",
    target_org_id="org-other",
    target_user_id="user-other",
    new_table_name="Sales Copy",
    include_views=True,
)
```

#### taple.export_table_data

导出表数据，支持多种格式。

```python
export_table_data(
    table_id: str,
    format: ExportFormat,              # CSV | XLSX | JSON | PDF | DOCX | PPTX | HTML | MARKDOWN
    *,
    sheet_ids: List[str] = None,
    options: Dict[str, Any] = None,
) → ExportTableDataResponse
```

```python
from file_hub_client import ExportFormat

export = client.taples.export_table_data(table_id="table-1", format=ExportFormat.CSV)
# export.exported_file_id 可通过 blobs.download 下载
```

#### taple.import_table_data

从文件导入数据到表。

```python
import_table_data(
    file_id: str,
    *,
    target_table_id: str = None,       # 导入到已有表
    table_name: str = None,            # 或创建新表
    folder_id: str = None,
    import_mode: str = "APPEND",       # APPEND | OVERWRITE
    skip_first_row: bool = True,
    auto_detect_types: bool = True,
    clear_existing_data: bool = False,
    column_mapping: Dict[str, str] = None,
    date_format: str = "YYYY-MM-DD",
    csv_delimiter: str = ",",
    csv_encoding: str = "UTF-8",
    max_rows: int = 0,                 # 0 = 不限
) → ImportTableDataResponse
```

```python
# 先上传 CSV
upload = client.blobs.upload(file=Path("/data/sales.csv"))

# 导入到新表
result = client.taples.import_table_data(
    file_id=upload.file.id,
    table_name="Imported Sales",
    folder_id="folder-1",
    import_mode="APPEND",
    auto_detect_types=True,
)
```

---

## 数据模型

所有响应模型基于 Pydantic v2，支持 `.model_dump()` 序列化。主要类型：

- 文件：`File`、`UploadFile`、`FileUploadResponse`、`UploadUrlResponse`、`FileListResponse`、`GetFileResponse`
- 压缩：`CompressedVariant`、`CompressionStatusResponse`、`GetVariantsResponse`
- 状态枚举：`FileUploadStatus`、`FileCompressionStatus`、`FileSyncStatus`
- 状态聚合：`FileStatusInfo`、`BatchFileStatusResponse`
- 文件夹：`FolderInfo`、`FolderListResponse`、`FolderTreeNode`、`FolderTreeResponse`
- 文档：`DocumentInfo`、`DocumentWithContentResponse`、`DocumentListResponse`、`DocumentPermissionResponse`
- 上下文：`UserContext`、`RequestContext`、`Role`
- Taple：`TableResponse`、`SheetResponse`、`ColumnResponse`、`RowResponse`、`CellResponse`、`TableViewResponse`
- 导入导出：`ExportFormat`、`ExportTableDataResponse`、`ImportTableDataResponse`、`CloneTableDataResponse`

## 异常

全部继承自 `FileHubError`：

| 异常 | 触发 |
|------|------|
| `FileNotFoundError` | 文件不存在 |
| `FolderNotFoundError` | 文件夹不存在 |
| `DocumentNotFoundError` | 文档不存在 |
| `UploadError` | 上传失败 |
| `DownloadError` | 下载失败 |
| `ExportError` | 导出 / 转换失败 |
| `StorageError` | 存储后端错误 |
| `PermissionError` | 鉴权 / 权限不足 |
| `ValidationError` | 参数校验失败 |
| `ConnectionError` | gRPC 连接失败 |
| `TimeoutError` | 操作超时 |

每个异常包含 `.code` / `.message` / `.details`。OSS multipart 分片上传失败时，`UploadError` 还会提供 `.file_id`、`.upload_id`、`.part_number`，用于保存断点并继续恢复上传。

```python
from file_hub_client.exceptions import FileNotFoundError, UploadError

try:
    client.blobs.download_to_bytes(file_id="nonexistent")
except FileNotFoundError as e:
    print(e.message, e.details)
```

## 传输细节

- **主通道**：gRPC over HTTP/2，支持 TLS；SDK 内部以单 channel 起步，并按活跃 RPC 数在请求路径上惰性扩展多 channel。
- **大文件**：上传 / 下载默认通过服务端签发的预签名 URL 走 HTTP 直传/直下，绕过 gRPC 消息体大小限制；SDK 内置 `HttpUploader` / `HttpDownloader` 处理分块、续传、流式。
- **MIME 探测**：上传时若未显式提供 `mime_type`，通过 `python-magic` 读取魔数自动判定（覆盖 26+ 常见格式）。
- **重试**：仅对明确查询类 gRPC 方法（如 `Get` / `List` / `Search` / `BatchGet`）在明显传输故障时做一次换 channel failover retry；上传、创建、更新、删除和 `Generate*Url` 默认不自动重试。

## 开发

```bash
# 编辑安装
pip install -e ".[dev]"

# 生成 gRPC 代码
python make_grpc.py

# 测试
pytest tests/
TEST_MODE=async pytest tests/file/    # 仅异步用例
TEST_MODE=sync  pytest tests/file/    # 仅同步用例

# 构建发行包
python setup.py sdist bdist_wheel
```

## 设计要点

1. **同步 / 异步双 API，签名一致** — 上层应用按运行环境选用，不需要重写业务代码。
2. **gRPC + HTTP 混合传输** — 元数据走 gRPC，大文件走预签名 URL，兼顾低延迟与大对象支持。
3. **上下文驱动** — `UserContext` / `RequestContext` 在每个调用透传，服务端据此做归属、权限、审计。
4. **状态聚合接口** — `batch_get_file_status` 一次拿到上传 / 压缩 / 同步三态，便于客户端轮询。
5. **类型完备** — Pydantic v2 + `py.typed`，IDE 提示与静态检查友好。
