Metadata-Version: 2.4
Name: zancli-auth
Version: 0.3.2
Summary: Reuse the zancli Youzanyun login session from Python command-line tools
License-Expression: LicenseRef-Proprietary
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.11
Classifier: Operating System :: OS Independent
Classifier: Environment :: Console
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# zancli-auth

`zancli-auth` 是 zancli 鉴权模块的 Python 移植版。Python 命令行工具可以直接用它登录有赞云，复用同一个会话。它和 zancli 共用 `~/.yzcloud/.netrc`，在任意一边登录或登出，另一边都会生效。

- Python `>=3.9`，按 3.11 开发和测试
- 只用标准库，没有第三方依赖
- 登录流程、凭据文件格式与 zancli `v1.0.25` 一致

## 安装

```bash
pip install zancli-auth
```

## 接入指南

### 1. 声明依赖

在你的项目里声明依赖。建议锁住小版本：

```toml
# pyproject.toml
[project]
dependencies = [
    "zancli-auth>=0.3,<0.4",
]
```

或者写进 `requirements.txt`：

```text
zancli-auth>=0.3,<0.4
```

### 2. 拿到会话

所有接入都从 `ensure_session()` 开始。它先读 `~/.yzcloud/.netrc`，再向网关校验 sid。会话有效时直接返回 `Session`。会话缺失或过期时，它会打开浏览器走登录流程，成功后返回新会话。

```python
import zancli_auth

session = zancli_auth.ensure_session()
session.sid                  # 会话 id，即 zancli 的 sid
session.cookie               # "sid=<sid>"，可以直接放进 Cookie 请求头
session.user.mobile          # 手机号
session.user.user_id         # 用户 id（字符串）
session.user.nick_name       # 昵称，可能为空
session.default_app()        # ~/.yzcloud/.extra 里的默认应用，没有时为 ""
```

`Session` 和 `User` 的 `repr` 会隐藏 sid 和 token，打日志时不会泄露凭据。

只想读会话、不想触发登录时，用 `load_session()`。它的行为等同于 `ensure_session(interactive=False)`，会话缺失或过期时会抛异常。

### 3. 处理错误

所有异常都继承 `ZancliAuthError`。各异常的含义见下表。接入时要分清两类错误：一类说明会话不能用，需要重新登录；另一类只是查询失败，不能当成已登出。

| 异常 | 含义 | 建议处理 |
| --- | --- | --- |
| `NotLoggedInError` | 本地没有会话 | 引导执行登录 |
| `SessionExpiredError` | 网关返回 code `400`（登录失效） | 引导重新登录 |
| `ResponseError` | 其他业务码、非 JSON、无 code | 当作服务故障，不要当成已登出 |
| `RpcError` | 业务接口返回 `success=false` | 按业务错误处理，`.code` / `.message` 是网关原文 |
| `LoginError` | 浏览器登录失败或 5 分钟超时 | 提示重试 |
| `HostConfigError` | `ZANCLI_API_HOST` 不是 `https://diy.youzanyun.com` | 修正或删除环境变量 |

`ensure_session()` 遇到 `ResponseError` 或网络错误时直接抛出，不会触发重新登录。

### 4. 完整示例：给你的 CLI 加上登录

下面这段 argparse 程序可以直接运行。它在执行业务命令前确认登录态，并把 `login` / `logout` / `whoami` 转交给本包：

```python
import argparse
import sys

import zancli_auth


def main() -> int:
    parser = argparse.ArgumentParser(prog="mytool")
    parser.add_argument("--no-browser", action="store_true", help="不弹浏览器，未登录时直接失败")
    sub = parser.add_subparsers(dest="cmd", required=True)
    sub.add_parser("login")
    sub.add_parser("logout")
    sub.add_parser("whoami")
    args = parser.parse_args()

    try:
        if args.cmd == "login":
            user = zancli_auth.login()
            print(f"登录成功：{user.mobile}")
        elif args.cmd == "logout":
            zancli_auth.logout()        # 和 zancli logout 效果相同，两边同时登出
        elif args.cmd == "whoami":
            session = zancli_auth.ensure_session(interactive=not args.no_browser)
            print(session.user.mobile)
    except (zancli_auth.NotLoggedInError, zancli_auth.SessionExpiredError):
        print("未登录或登录已失效，请执行 `mytool login`（或 `zancli login`）", file=sys.stderr)
        return 2
    except zancli_auth.ZancliAuthError as exc:
        print(f"错误：{exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

用户在 zancli 里登录过，`mytool whoami` 会直接复用那个会话。用户在 `mytool` 里登录后，zancli 也会处于登录状态。

### 5. 非交互环境

在 CI、Agent 或没有浏览器的服务器上，调用 `ensure_session(interactive=False)` 或 `load_session()`。这样会话失效时直接抛异常，不会卡在等待浏览器回调上。

远程机器上需要交互登录时，`login()` 会把登录 URL 打印到 stderr。回调监听的是远程机器的 `127.0.0.1` 随机端口，本地浏览器访问不到。这时有两种办法：用 SSH 端口转发，或者在本地执行 `zancli login`，再把 `~/.yzcloud/.netrc` 拷到远程机器。

### 6. 在测试里隔离登录态

本包只通过 `HOME` 定位 `~/.yzcloud`（Windows 上是 `USERPROFILE`）。写测试时，把 `HOME` 指向临时目录，就不会读写开发者真实的会话：

```python
import os
import tempfile
import unittest
from unittest import mock

import zancli_auth


class MyToolTest(unittest.TestCase):
    def setUp(self):
        home = tempfile.mkdtemp()
        patcher = mock.patch.dict(os.environ, {"HOME": home, "USERPROFILE": home})
        patcher.start()
        self.addCleanup(patcher.stop)

    def test_requires_login(self):
        with self.assertRaises(zancli_auth.NotLoggedInError):
            zancli_auth.load_session()
```

要测已登录的业务逻辑，可以 mock `zancli_auth.load_session` 或 `zancli_auth.ensure_session`，让它返回 `zancli_auth.Session(sid="test", user=zancli_auth.User("186...", "", "1", ""))`。

## 命令行

安装后提供 `zancli-auth` 命令，子命令和 JSON 输出与 `zancli auth` 一致：

```bash
zancli-auth login                    # 浏览器登录
zancli-auth status --output json     # {"success":true,"data":{"state":"valid|expired|missing",...}}
zancli-auth session --output json    # 返回 sid / token / mobile / userId，只支持 JSON
zancli-auth logout
```

## 登录流程

1. 在 `127.0.0.1` 随机端口启动回调服务，生成 32 字节随机 `state`。
2. 打开 `https://diy.youzanyun.com/third-login/success?callback=http://127.0.0.1:<port>/third-login/callback?state=...`，同时把 URL 打印到 stderr。
3. 登录页通过 `x-third-info` 请求头把 sid 回传。回调服务校验 `Origin`（只放行 `https://diy.youzanyun.com`）和 `state`（常量时间比较），只接受第一次回调，请求体上限 4 KiB。
4. 向网关校验 sid，通过后原子写入 `~/.yzcloud/.netrc`。写入时目录权限 `0700`，文件权限 `0600`，拒绝符号链接，并和 zancli 一样在 `.netrc.lock` 上加 `flock`。
5. 账号下恰好只有一个应用时，把应用名写入 `~/.yzcloud/.extra`，作为默认应用。

sid 校验失败时不会覆盖已有会话。

## 环境变量

`ZANCLI_API_HOST` 与 zancli 共用。本包只接受 `https://diy.youzanyun.com`，不允许带 userinfo、path、query 或 fragment，其他值一律报 `HostConfigError`，不会发出 sid。

## 与 zancli 的差异

- 没有设置 `ZANCLI_API_HOST` 时，zancli 拼出的地址没有 scheme，REST 客户端会回落到 `http://diy.youzanyun.com`，sid 走明文。本包固定用 `https`。实测生产网关对 https 返回相同结果。
- zancli 的 `ZANCLI_API_HOST` 还允许指向内部预发/QA 网关，本包不支持，只能连生产网关。
- 浏览器打不开时，zancli 直接报错退出。本包会继续等待，用户可以手动打开 stderr 里打印的 URL。远程机器上可以配合 SSH 端口转发使用。
- Windows 上本包用 `msvcrt.locking` 加锁，zancli 用 `LockFileEx`。两者能否互斥未验证。Linux/macOS 上两边都是 `flock`，可以互斥。
- 没有移植 zancli 的 debug trace。

## 测试

```bash
PYTHONPATH=src python3.11 -m unittest discover -s tests -v
```

单测用本地假网关覆盖：凭据文件读写和权限、`ZANCLI_API_HOST` 校验、`400` 与其他业务码的区分、接口请求格式、完整回调登录（state、Origin、CORS 预检、单次消费、超时、坏 sid 不覆盖旧会话），以及 CLI 的 JSON 信封。

## 许可

版权归有赞所有，保留所有权利。本包仅供有赞云开发者接入使用，未授予开源许可。
