Metadata-Version: 2.4
Name: sfs-v2
Version: 0.0.1.dev2
Summary: Python-first SDK for SqlFileSystem v2
License-Expression: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.8
Description-Content-Type: text/markdown

# SFS v2：面向 Python 爬虫的 SQL 文件系统

`sfs-v2` 提供虚拟目录与文件、批量读写、分页查询、内容扫描和压缩存储。Python SDK 通过随 wheel 附带的 Rust 原生库工作：本地可直接使用 SQLite；多个爬虫协作时，可以连接多个 SFS 服务实例，由服务实例共享一个 PostgreSQL 数据库。虚拟路径（如 `/pages/a.html`）不是操作系统文件路径。

> **当前为预发布版本。** Python 是唯一活跃维护的 SDK；Kotlin/Node.js 原型未纳入本次发布。SQLite 是当前经过 Python 集成验收的入口；PostgreSQL、MySQL 直连和 gRPC 远程 Python 入口均位于 `sfs_v2.experimental`，其接口与行为仍可能变化。

## 安装与支持范围

```bash
python -m pip install "sfs-v2==0.0.1.dev2"
python -c "import sfs_v2; print(sfs_v2.__version__)"
```

| 平台 | 本次 PyPI wheel | 说明 |
|---|---|---|
| Windows x86_64 | `win_amd64` | 原生库为 `sfs_ffi.dll` |
| Linux x86_64 | `manylinux_2_28_x86_64` | glibc ≥ 2.28，原生库为 `libsfs_ffi.so` |
| macOS、ARM64、Alpine/musl | 未提供 | `pip` 无匹配 wheel；不要安装其他平台的 wheel |

要求 Python ≥ 3.8。**分发名是 `sfs-v2`，Python 导入名是 `sfs_v2`**；直接 `import sfs-v2` 不符合 Python 语法。PyPI 上的 `sfs` 是另一个项目，本版不提供 `sfs` 导入别名，因此可避免与其模块名冲突。此前 `0.0.1.dev1` 使用过 `import sfs`，升级后须改用 `import sfs_v2`，建议在虚拟环境中升级并删除不需要的旧安装。wheel 内只包含对应平台的原生库，不需要另行安装 Rust；本包**不包含** `sfs` 服务端/命令行程序。

## 三分钟上手：本地 SQLite

以下代码可直接运行，数据库目录会由 SFS 使用；文件地址以 `/` 为根，文件内容可以是任意字节。

```python
from tempfile import TemporaryDirectory
from sfs_v2 import OpenOptions, Sfs, WriteItem

with TemporaryDirectory() as database_dir:
    with Sfs.open_sqlite(database_dir) as store:
        store.makedirs("/pages/2026")
        store.write_text("/pages/2026/a.html", "<h1>Hello</h1>")
        store.write_many([
            WriteItem("/pages/2026/b.html", b"<p>B</p>"),
            WriteItem("/pages/2026/c.bin", b"\x00\xff"),
        ])
        assert store.read_text("/pages/2026/a.html") == "<h1>Hello</h1>"
        for result in store.read_many(["/pages/2026/b.html", "/pages/2026/c.bin"]):
            print(result.path, result.data)  # ReadResult(path, data)，不是 bytes 列表
        print([item.full_path for item in store.list_files("/pages/2026")])

    # 写入句柄关闭后，现有字典若被使用，只读句柄仍能正确解码。
    with Sfs.open_sqlite(database_dir, options=OpenOptions(readonly=True)) as reader:
        assert reader.read("/pages/2026/c.bin") == b"\x00\xff"
```

`Sfs.open_sqlite(directory, options=OpenOptions(...))` 接受**数据库目录**，不是 `.db` 文件。`OpenOptions` 目前公开 `readonly=False` 和 `compression_level=19`（可设置 1–22）；例如写入密集、希望降低 CPU 消耗时可尝试 `OpenOptions(compression_level=3)`，再用真实数据评估。只读打开不会训练字典或写入数据库。请使用 `with` / `close()` 及时关闭句柄，`close()` 可重复调用；关闭后继续操作会抛出 `SfsClosedError`。

### 常用文件操作

| 同步 `Sfs` 方法 | 用途 |
|---|---|
| `mkdir(path)` / `makedirs(path)` | 建立目录 / 递归建立目录 |
| `write(path, bytes)` / `write_text(path, str)` | 写入二进制 / UTF-8 文本 |
| `write_many([WriteItem(path, data), ...])` | 一批写入；正常返回后才是该批写入成功 ACK |
| `read(path)` / `read_text(path)` | 读取二进制 / UTF-8 文本；整段内容会进入内存 |
| `read_many([path, ...])` | 返回 `ReadResult(path, data)` 列表 |
| `stat(path)` / `exists_file(path)` / `exists_dir(path)` | 元数据及存在性检查 |
| `list_files(path)` / `list_dirs(path)` | 目录查询，返回 `FileInfo` 列表 |
| `iter_files(path)` / `iter_dirs(path)` | 自动分页，适合大目录 |
| `dir_info(path)` / `fs_info()` | 目录或全库文件与目录数量 |
| `rename(old, new)` / `remove(path, recursive=False)` | 重命名 / 删除；递归删除须显式指定 |
| `scan(options)` | 按条件遍历文件元数据和内容，须关闭扫描句柄 |
| `maintenance.shrink()` | 回收无引用的存储空间，属于维护操作 |

`FileInfo` 包含 `full_path`、`size`、`is_file`、`created_at`、`updated_at` 等字段；目录的 `size` 为 `None`。单次列表默认最多 1000 条：

```python
from sfs_v2 import CursorPage, OffsetPage

page = store.list_files("/pages", page=OffsetPage(limit=100, offset=0))
next_page = store.list_files("/pages", page=CursorPage(limit=100, start_after=page[-1].full_path)) if page else []
for info in store.iter_files("/pages", page_size=1000):
    print(info.full_path, info.size)
```

`OffsetPage` / `CursorPage` 的 `limit` 范围为 1–10000；列表/迭代器只返回该目录的**直接子项**，大量结果优先使用 `iter_files`，不要一次取完。以上 `store` 指已打开的 `Sfs` 句柄。

### 带过滤条件的扫描

扫描返回 `ScannedFile(info, data)`；与只列元数据不同，**每条结果还带有文件内容**。`ScanFilter` 可组合路径前缀、无前导点的扩展名、名称和文件大小/时间条件。时间过滤要求带时区的 `datetime`。

```python
from sfs_v2 import ScanErrorMode, ScanFilter, ScanOptions, ScanOrder

options = ScanOptions(
    filter=ScanFilter(path_prefix="/pages", extensions=("html",)),
    order=ScanOrder.PATH,
    batch_size=256,
    error_mode=ScanErrorMode.COLLECT,
)
with store.scan(options) as scan:
    for item in scan:
        print(item.info.full_path, len(item.data))
    print(scan.stats)          # ScanStats(scanned, errors, skipped)
    print(scan.take_errors())  # COLLECT 模式收集的 ScanError 列表
```

默认 `error_mode=ABORT`，遇错立即报错，避免悄悄漏掉数据；`SKIP` 会跳过，`COLLECT` 会继续并保留有界错误信息（最多约 10000 条或 8 MiB 错误文本）。**扫描单文件内容上限为 4 MiB**；超过上限时按所选错误模式处理。若确需读取较大的单个文件，可在普通 `read(path)` 所受的内存和后端限制内单独读取；当前 Python SDK **没有公开的分块流式读写 API**，不要把底层 Rust/C 流的上限当成 Python 能力。`SfsScan.stats` / `take_errors()` 须在其同步 context manager **退出前**调用。

## asyncio 爬虫写入

`AsyncSfs` 为阻塞的原生调用使用专用单线程 executor，并为并发写入配置有界队列和单 writer 批量提交；不会让耗时 ctypes 操作直接阻塞事件循环。

```python
import asyncio
from tempfile import TemporaryDirectory
from sfs_v2 import AsyncOptions, AsyncSfs

async def main():
    with TemporaryDirectory() as database_dir:
        store = await AsyncSfs.open_sqlite(
            database_dir,
            async_options=AsyncOptions(
                queue_capacity=256,
                max_queued_bytes=256 * 1024 * 1024,
                write_batch_size=100,
                flush_interval=0.05,
            ),
        )
        async with store:
            await store.makedirs("/crawl")
            await asyncio.gather(*(
                store.write_text("/crawl/page-%d.html" % i, "<p>%d</p>" % i)
                for i in range(10)
            ))
            print(await store.read_text("/crawl/page-0.html"))

asyncio.run(main())
```

`AsyncOptions` 上述数值即默认值；`queue_capacity` 限制待写任务数，`max_queued_bytes` 限制待写字节数。`await write(...)` 或 `await write_many(...)` **正常返回**，才表示底层写操作成功提交；排进队列不算 ACK，同批失败会通知等待该批的调用方。`async with` / `await aclose()` 会停止接纳新写入、排空已有请求、等待在途原生调用后再关闭。**取消任务、超时、连接断开或 ACK 丢失，不保证数据没有写入**；需要业务侧用稳定路径、幂等写入或额外校验处理结果不明的请求，不能无条件重放。

异步读、目录操作和维护方法与同步 API 对应，需 `await`；大目录可用 `async for info in store.iter_files("/pages")`。异步扫描是 `store.scan(options)` 返回的 `AsyncSfsScan`，**`scan()` 本身不需要 `await`**。以下代码放在上例的 `async def main()`、`async with store:` 内：

```python
from sfs_v2 import ScanFilter, ScanOptions

scan = store.scan(ScanOptions(filter=ScanFilter(extensions=("html",))))
async with scan:
    async for item in scan:
        print(item.info.full_path)
print(await scan.stats())
print(await scan.take_errors())
```

上例在 `async def` 内运行；`AsyncSfsScan` 关闭后仍可读取最终统计和收集的错误，与同步扫描的 `stats` 属性用法不同。不提供 `scan.to_list()`；扫描大量内容时应逐条处理。

## 多爬虫部署：SFS 服务与 PostgreSQL（实验性 Python 远程入口）

推荐拓扑是 **Python 爬虫 →（HTTPS/gRPC）→ 一个或多个 SFS 服务实例 →（PostgreSQL TLS）→ 同一个 PostgreSQL 数据库**。Python 爬虫只需 SFS 地址与 bearer token，**不要把数据库 DSN/凭据下发给每个爬虫，也不要让爬虫直连 PostgreSQL**。每个 SFS 实例使用自己的 HTTP/gRPC 监听端口，数据库名保持相同；跨机器还须规划连接数、备份及故障恢复。

`pip install sfs-v2` **不会**安装 `sfs serve`。下面命令必须在已单独部署 SFS CLI 的服务机器上执行；CLI 的 `--port` 是 Web/REST，`--rpc-port` 才是 Python `connect_remote` 使用的 gRPC 端口。

```bash
# 本机开发示例（仅回环地址允许明文；CLI 单独安装）：
sfs serve --backend sqlite --db ./sfs-data --host 127.0.0.1 --port 51237 --rpc-port 51337
```

```python
from sfs_v2.experimental import connect_remote

with connect_remote("http://127.0.0.1:51337") as remote:
    remote.makedirs("/crawl")
    remote.write("/crawl/example.html", b"<html>stored by SFS</html>")
    assert remote.read("/crawl/example.html") == b"<html>stored by SFS</html>"
```

生产跨主机部署示意（**不包含真实凭据**）：

```bash
# 在每台 SFS 机器上由密钥管理器注入：
# SFS_CONN_STR='host=pg.internal.example port=5432 dbname=sfs user=... password=... sslmode=require'
# SFS_AUTH_TOKEN='<随机且足够长的持久 token>'
: "${SFS_CONN_STR:?必须注入 PG 连接串，包含 sslmode=require}"
: "${SFS_AUTH_TOKEN:?必须注入 SFS bearer token}"
export SFS_PG_TLS_CA_FILE=/etc/sfs/pg-ca.pem  # 私有 CA；公有 CA 可使用系统信任根
sfs serve --backend postgres --host 0.0.0.0 --port 51237 --rpc-port 51337 \
  --tls-cert /etc/sfs/server-chain.pem --tls-key /etc/sfs/server-key.pem
# 第二实例使用同一数据库和认证配置；如在同一主机，另选两个未占用的监听端口。
```

```python
import os
from sfs_v2.experimental import connect_remote

# 证书必须由爬虫机器信任，且其主机名与 sfs.example.internal 匹配。
with connect_remote(
    "https://sfs.example.internal:51337",
    token=os.environ["SFS_AUTH_TOKEN"],
) as remote:
    remote.write("/crawl/example.html", b"<p>saved</p>")
```

爬虫需异步 gRPC 时，可在 `async def` 中使用 `async with await sfs_v2.experimental.connect_remote_async(endpoint, token=token) as remote:`；其连接及读写都通过专用 executor，不在事件循环执行阻塞 FFI。普通 gRPC 请求默认有 120 秒客户端截止时间，爬虫进程可用 `SFS_REMOTE_REQUEST_TIMEOUT_SECS=1..600` 配置。超时后的写入状态可能未知，不能据此盲目重试。

**两条 TLS 链路需分别配置**：

- **爬虫 → SFS**：非 loopback 监听必须同时提供有效 bearer token 与 SFS 的 TLS 证书/密钥。Web/REST 与 gRPC 可共用证书，但端口不同；即使 `--rpc-port 0` 仅开放 HTTP，非 loopback 仍需 HTTPS + token。Python `connect_remote("https://...", token=...)` 验证系统信任的 CA 和主机名；当前公开 Python 接口没有私有/自签 CA 参数，不要关闭证书校验。`token` 传不带 `Bearer ` 前缀的原始值；SDK 会构造认证头，不要打印它。
- **SFS → PostgreSQL**：在 **SFS 机器**的 PG DSN 中明确写 `sslmode=require`，以强制 TLS 并验证 CA 与服务器主机名；私有 CA 可通过该 SFS 进程的 `SFS_PG_TLS_CA_FILE` 指向绝对 PEM 路径。此模式比 libpq 的普通 `require` 更严格。未指定 `sslmode` 或 `prefer` **不会自动升级成 TLS**，远端地址会被拒；除可信隧道中的明确例外，不使用 `sslmode=disable`。轮换 CA 后需重启 SFS 实例，使业务连接与字典连接的信任配置一致。不要把 PG CA/密码误当成 Python 爬虫的连接参数。

远程 Python 入口依然是**实验性**：目前没有公开的按调用设置 deadline、私有 CA 或文件分块流接口；服务端旧版本不支持 ScanV2 时，扫描会明确报 `SfsUnsupportedError`，不会退回到丢失错误/统计的旧扫描协议。普通远程 `read` / `write` 是整段消息，受 300 MiB gRPC unary **消息**上限、解压保护以及 Python 内存/异步队列预算等更严格限制；这不是承诺单个 Python 文件恰可达到 300 MiB。

### 其他实验性 SQL 入口

`from sfs_v2.experimental import connect_postgres, connect_mysql` 可以在 Python 中直接连接 SQL 后端，但尚未完成稳定 Python 集成矩阵；多爬虫推荐上述 SFS 服务拓扑，不建议把 PG/MySQL 密钥分发给所有客户端。PostgreSQL 的服务端同时支持 REST 与 gRPC；MySQL 当前服务模式仅支持 HTTP，**不要**将 MySQL 服务实例配置为 Python gRPC 后端。SQL 连接协议/安全设置不同，不能把 PostgreSQL 的 TLS 参数照搬到 MySQL URL。

## 字典压缩、持久化与备份

可写的 Python SQLite 打开默认启用 zstd 字典训练；可写的 PostgreSQL SFS 服务也会挂载数据库字典管理器。**训练默认开启不等于从第一个文件起就使用字典**：通常累计约 5000 次成功写入后才会后台评估；只有压缩收益超过 5% 才启用候选字典，否则仍采用常规压缩。`OpenOptions` 当前不公开设置训练间隔/开关的 Python 参数。

- **SQLite**：字典元数据位于数据库目录中的 `dict.meta.db`，字典文件位于 `dicts/`。只读连接会挂载已有字典用于解码，但不会训练。备份/迁移时应保证数据库文件、卷文件与字典元数据取自**同一一致性快照**；不能仅复制一个主库文件。
- **PostgreSQL**：字典版本和备份保存在同一个 PG 数据库的 `DictVersions` / `DictVersionsBackup` 等表中，多个 SFS 实例共享，不依赖每台服务机器的 `dict.meta.db`。只读实例不训练，但必须能加载所需版本；缺失或损坏时会报错，**不会把压缩字节当原文返回**。

写入 API 的成功返回意味着底层操作已提交；SQLite 采用 WAL、`synchronous=FULL`，成功 ACK 才可视作持久化写入完成。PG 事务会设置 `synchronous_commit=on`，但实际落盘仍要求 PG 服务端正确启用 `fsync`；异步复制或故障切换不自动保证零数据丢失。`write_many` 用于批量写；多个独立 `write` 不是一个跨请求的大事务。发生超时或连接中断时，ACK 未到不等于事务未成功。生产环境仍需演练备份恢复与故障切换，不要只依赖单份数据库或字典文件。

## 错误与维护

所有 SFS 运行时错误派生自 `SfsError`，保留 `code`、`operation`、`path`、`database_code` 和 `retryable`；常见子类有 `SfsNotFoundError`、`SfsReadonlyError`、`SfsCorruptDataError`、`SfsDictionaryError`、`SfsDatabaseLockedError` 与 `SfsResourceExhaustedError`。

```python
from sfs_v2 import SfsError, SfsNotFoundError

try:
    data = store.read("/missing.html")
except SfsNotFoundError:
    data = None
except SfsError as exc:
    print(exc.code, exc.operation, exc.path, exc.database_code, exc.retryable)
    raise
```

`retryable` 只标记部分**明确可安全重试**的情况（如已被 PG 中止的事务）；不要把所有网络异常/提交失败都当成未写入。需要回收无引用数据时，可在合适的维护窗口执行 `report = store.maintenance.shrink()`，查看 `report.succeeded` 和 `report.vacuum_failed_volumes`；异步版本为 `await store.maintenance.shrink()`。这不是日常每次写入后需要调用的方法。

| 现象 | 检查 |
|---|---|
| `No matching distribution` | Python/CPU 架构及 glibc 是否符合上方 wheel 平台表；目前没有 macOS/ARM64 wheel |
| 无法加载原生库或 ABI 不匹配 | 确认安装的是 `sfs-v2`，清除开发时残留的 `SFS_NATIVE_PATH`，避免混用不同版本原生库 |
| 只读时字典缺失或损坏 | 检查完整数据库、卷与字典是否来自一致性备份，不要忽略报错 |
| 远程 TLS/认证失败 | 检查 SFS 证书主机名和 CA、服务端 token、是否连到 gRPC 端口；PG TLS 是另一条链路 |
| 远程写超时 | 结果不确定；先根据业务键检查已写入状态，再决定是否幂等补偿 |

## 源码开发与验收

仅从源代码开发 Python 绑定时，需要先在**目标平台**编译 `sfs-ffi`；`SFS_NATIVE_PATH` 用于指向开发产物，会覆盖 wheel 自带的原生库，因此不要在普通 PyPI 用户环境中设置它。

```bash
cargo build --release -p sfs-ffi
# 在 Python SDK 的源码目录运行本地测试（需安装 pytest，并让原生库可加载）：
python -m pytest test_open_options.py test_sfs.py test_async_sfs.py -v
```

- Windows 开发产物：`target/release/sfs_ffi.dll`；Linux：`target/release/libsfs_ffi.so`。
- 公开 Python 契约通过原生库 ABI v2 握手；发现 Python 与原生库不匹配时会明确拒绝加载。
- Python 包版本 `0.0.1.dev2` 与底层 Rust crate 的内部版本号不必相同；请以已安装的 Python 包版本和 ABI 握手为准。

项目发布元数据标注许可证为 MIT。当前 `sfs-v2` 是预发布版，部署前请在自己的爬虫负载和目标数据库上完成备份、恢复、并发与故障测试。
