qqflow-sdk 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qqflow_sdk-0.9.0/.gitignore +33 -0
- qqflow_sdk-0.9.0/LICENSE +21 -0
- qqflow_sdk-0.9.0/PKG-INFO +59 -0
- qqflow_sdk-0.9.0/README.md +37 -0
- qqflow_sdk-0.9.0/openapitools.json +5 -0
- qqflow_sdk-0.9.0/pyproject.toml +50 -0
- qqflow_sdk-0.9.0/scripts/regen.py +288 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/LICENSE +21 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/__init__.py +39 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/client.py +1003 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/__init__.py +125 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/api/__init__.py +4 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/api/default_api.py +5227 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/api_client.py +836 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/api_response.py +21 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/configuration.py +595 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/exceptions.py +218 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/__init__.py +54 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_conflict.py +95 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_deregistered.py +100 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_not_registered.py +97 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_phase.py +37 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_qq_mismatch.py +102 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_registered.py +100 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_status.py +37 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_view.py +106 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/accounts_list.py +95 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_header.py +90 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_member.py +92 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_message.py +111 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_messages.py +125 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_meta.py +94 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/contact_out.py +98 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/contacts.py +102 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/delete_api_v1_accounts_qq200_response.py +149 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/group_member.py +110 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/group_members.py +104 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/health.py +91 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/media_brief.py +95 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/media_envelope.py +96 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/media_info.py +135 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/message_out.py +152 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/messages_native.py +108 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/notification_frame.py +117 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/page.py +93 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/post_api_v1_accounts200_response.py +135 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/pull_envelope.py +120 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/pull_message.py +118 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/pull_sync.py +93 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/session_chatlab.py +104 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/session_native.py +94 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/sessions_chatlab.py +102 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/sessions_native.py +98 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/sync_frame.py +98 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/sync_result.py +91 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/watermark_entry.py +92 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/watermark_value.py +86 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/py.typed +0 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/rest.py +201 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/generated/spec.json +1610 -0
- qqflow_sdk-0.9.0/src/qqflow_sdk/py.typed +0 -0
- qqflow_sdk-0.9.0/tests/test_behavior.py +1536 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# 本目录自己的忽略规则。放这份文件有两个必须一起成立的理由:
|
|
2
|
+
#
|
|
3
|
+
# 1) cargo:仓库根那条 /dist 只锚定仓库根,管不到 clients/python/dist,而
|
|
4
|
+
# python -m build 的产物正好落在那里。cargo 收的是「git 跟踪的文件 + 未跟踪但
|
|
5
|
+
# 也没被 ignore 的文件」,所以预演一跑就会把 wheel/sdist 静默带进根 crate。
|
|
6
|
+
# 2) hatchling:它只读「项目根向上第一个 .gitignore」,命中即停、不与父级的合并。
|
|
7
|
+
# 放了这份,仓库根那几十行对 hatchling 就整体失效——所以**密钥与临时物的兜底必须
|
|
8
|
+
# 在这里重复一遍**,否则有人把 keys.json 拷进 clients/python,它就会被打进 sdist。
|
|
9
|
+
# (真正挡住 .venv 的一直是 hatchling 的硬编码目录集,不是仓库根那条带路径前缀、
|
|
10
|
+
# 相对本项目匹配不上的规则;这里用无前缀写法,换后端也不裸奔。)
|
|
11
|
+
#
|
|
12
|
+
# 改完这份文件务必复跑:
|
|
13
|
+
# git ls-files clients/python | git check-ignore -v --no-index --stdin
|
|
14
|
+
# 输出必须为空——否则说明某条规则把**已跟踪**文件也吞了,它会静默从包里消失。
|
|
15
|
+
.venv/
|
|
16
|
+
dist/
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.py[cod]
|
|
19
|
+
|
|
20
|
+
# 密钥与本地配置(与仓库根同源,为 hatchling 重复一次)
|
|
21
|
+
keys.json
|
|
22
|
+
all_keys.json
|
|
23
|
+
*-server.json
|
|
24
|
+
.env
|
|
25
|
+
*.local
|
|
26
|
+
|
|
27
|
+
# 临时物与噪声
|
|
28
|
+
*.log
|
|
29
|
+
*.bak
|
|
30
|
+
*.json.tmp
|
|
31
|
+
.DS_Store
|
|
32
|
+
Thumbs.db
|
|
33
|
+
Desktop.ini
|
qqflow_sdk-0.9.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ren-yc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: qqflow-sdk
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Typed Python client for qqflow-server's HTTP API (generated models + handwritten behavior layer)
|
|
5
|
+
Project-URL: Homepage, https://github.com/ren-yc/qqflow-server
|
|
6
|
+
Project-URL: Repository, https://github.com/ren-yc/qqflow-server
|
|
7
|
+
Project-URL: Issues, https://github.com/ren-yc/qqflow-server/issues
|
|
8
|
+
Author: ren-yc
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: pydantic>=2.7
|
|
14
|
+
Requires-Dist: python-dateutil>=2.8
|
|
15
|
+
Requires-Dist: typing-extensions>=4.7
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: openapi-generator-cli>=7; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff==0.16.1; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# qqflow-sdk
|
|
24
|
+
|
|
25
|
+
Typed Python client for [qqflow-server](https://github.com/ren-yc/qqflow-server) — a headless, read-only HTTP/SSE service over local QQ NT chat databases.
|
|
26
|
+
|
|
27
|
+
Generated from the server's OpenAPI description (types + request plumbing), with a handwritten behavior layer on top: transport failures folded into a proper error tree, readiness polling, pagination helpers, SSE streaming, and media download helpers.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install qqflow-sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Minimal example
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
|
|
40
|
+
from qqflow_sdk import Client
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def main() -> None:
|
|
44
|
+
client = Client(base_url="http://127.0.0.1:5032", token="your-token")
|
|
45
|
+
await client.health()
|
|
46
|
+
sessions = await client.list_all_sessions()
|
|
47
|
+
await client.aclose()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
asyncio.run(main())
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Scope
|
|
54
|
+
|
|
55
|
+
The server exposes a strictly read-only surface: accounts, sessions, messages, contacts, group members, media export, and an SSE push stream. This client mirrors that surface one-to-one; see the server repository for the API reference and the interface contract the client is tested against.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# qqflow-sdk
|
|
2
|
+
|
|
3
|
+
Typed Python client for [qqflow-server](https://github.com/ren-yc/qqflow-server) — a headless, read-only HTTP/SSE service over local QQ NT chat databases.
|
|
4
|
+
|
|
5
|
+
Generated from the server's OpenAPI description (types + request plumbing), with a handwritten behavior layer on top: transport failures folded into a proper error tree, readiness polling, pagination helpers, SSE streaming, and media download helpers.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install qqflow-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Minimal example
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
|
|
18
|
+
from qqflow_sdk import Client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def main() -> None:
|
|
22
|
+
client = Client(base_url="http://127.0.0.1:5032", token="your-token")
|
|
23
|
+
await client.health()
|
|
24
|
+
sessions = await client.list_all_sessions()
|
|
25
|
+
await client.aclose()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
asyncio.run(main())
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Scope
|
|
32
|
+
|
|
33
|
+
The server exposes a strictly read-only surface: accounts, sessions, messages, contacts, group members, media export, and an SSE push stream. This client mirrors that surface one-to-one; see the server repository for the API reference and the interface contract the client is tested against.
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qqflow-sdk"
|
|
7
|
+
version = "0.9.0"
|
|
8
|
+
description = "Typed Python client for qqflow-server's HTTP API (generated models + handwritten behavior layer)"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
authors = [{ name = "ren-yc" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"httpx>=0.27",
|
|
16
|
+
"pydantic>=2.7",
|
|
17
|
+
"python-dateutil>=2.8",
|
|
18
|
+
"typing-extensions>=4.7",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
# ruff is pinned exactly: the lint gate below selects rules explicitly,
|
|
23
|
+
# but rule *behavior* still drifts with the ruff version - an unpinned
|
|
24
|
+
# CI step could go red (or silently green) on a day nobody changed code.
|
|
25
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "openapi-generator-cli>=7", "ruff==0.16.1"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/ren-yc/qqflow-server"
|
|
29
|
+
Repository = "https://github.com/ren-yc/qqflow-server"
|
|
30
|
+
Issues = "https://github.com/ren-yc/qqflow-server/issues"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/qqflow_sdk"]
|
|
34
|
+
|
|
35
|
+
# Explicit lint config: relying on ruff defaults would tie CI to whatever
|
|
36
|
+
# a version bump decides the default set is. The generated tree is
|
|
37
|
+
# excluded *here only* - CLI --extend-exclude resolves relative to the
|
|
38
|
+
# cwd (from repo root that spelling silently scans the whole generated
|
|
39
|
+
# tree), and passing --config shifts config-relative excludes to cwd too.
|
|
40
|
+
# Keeping the exclude in this file makes it config-dir-relative, which is
|
|
41
|
+
# the one form that works from both clients/python and the repo root.
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
extend-exclude = ["src/qqflow_sdk/generated"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E4", "E7", "E9", "F", "I", "RUF", "G", "LOG", "BLE", "UP"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regenerate clients/python/src/qqflow_sdk/generated from the server's
|
|
3
|
+
OpenAPI description.
|
|
4
|
+
|
|
5
|
+
Source of truth is the committed golden snapshot's body (the same bytes the
|
|
6
|
+
server served when the snapshot was pinned), normalized to OpenAPI 3.0 the
|
|
7
|
+
same way the Rust regen tool does it:
|
|
8
|
+
|
|
9
|
+
1. placeholder-masked volatile values (the golden masks a few string
|
|
10
|
+
properties) are restored to plain string schemas;
|
|
11
|
+
2. `type: [T, null]` becomes `T` + `nullable: true` (3.1 -> 3.0);
|
|
12
|
+
3. `oneOf: [T, {type: null}]` - utoipa's spelling of `Option<T>` - keeps
|
|
13
|
+
only the non-null arm (the handlers omit the key entirely when absent);
|
|
14
|
+
4. path-template placeholders get matching `parameters` declarations (the
|
|
15
|
+
server emits them since the same batch; kept here so regeneration also
|
|
16
|
+
works from a description fetched over HTTP).
|
|
17
|
+
|
|
18
|
+
The normalized spec is committed next to the generated client, and CI
|
|
19
|
+
asserts that rerunning this script produces no diff. Edit nothing under
|
|
20
|
+
generated/ by hand.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
python scripts/regen.py # regenerate (needs openapi-generator)
|
|
24
|
+
python scripts/regen.py --check # exit 1 if a regen would change spec or tree
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
39
|
+
GOLDEN = REPO / "tests" / "golden" / "openapi.json"
|
|
40
|
+
OUT = Path(__file__).resolve().parents[1] / "src" / "qqflow_sdk" / "generated"
|
|
41
|
+
PKG = OUT.parent.name # the generated package directory (its import root)
|
|
42
|
+
PKGDIR = Path(__file__).resolve().parents[1] # clients/python
|
|
43
|
+
CONFIG = PKGDIR / "openapitools.json" # pins the generator jar version
|
|
44
|
+
# The wrapper version is pinned in the npx call itself: the floating default
|
|
45
|
+
# would let a new wrapper (or its default jar) change generation without any
|
|
46
|
+
# repo change - exactly what the no-diff gate must be able to blame.
|
|
47
|
+
WRAPPER = "@openapitools/openapi-generator-cli@2.41.0"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize(node):
|
|
51
|
+
"""3.1-to-3.0 normalization, in place. Deterministic."""
|
|
52
|
+
if isinstance(node, list):
|
|
53
|
+
for item in node:
|
|
54
|
+
normalize(item)
|
|
55
|
+
return
|
|
56
|
+
if not isinstance(node, dict):
|
|
57
|
+
return
|
|
58
|
+
types = node.get("type")
|
|
59
|
+
if isinstance(types, list) and len(types) == 2 and "null" in types:
|
|
60
|
+
rest = [t for t in types if t != "null"]
|
|
61
|
+
node["nullable"] = True
|
|
62
|
+
node["type"] = rest[0] if len(rest) == 1 else rest
|
|
63
|
+
arms = node.get("oneOf")
|
|
64
|
+
if isinstance(arms, list):
|
|
65
|
+
kept = [a for a in arms if a.get("type") != "null"]
|
|
66
|
+
if len(kept) != len(arms):
|
|
67
|
+
if len(kept) == 1:
|
|
68
|
+
only = kept[0]
|
|
69
|
+
node.pop("oneOf")
|
|
70
|
+
node.update(only)
|
|
71
|
+
else:
|
|
72
|
+
node["oneOf"] = kept
|
|
73
|
+
for value in list(node.values()):
|
|
74
|
+
normalize(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_spec() -> dict:
|
|
78
|
+
"""Get the normalized 3.0 spec.
|
|
79
|
+
|
|
80
|
+
Preferred source: ``cargo run -p qqflow-regen -- --dump-spec`` - the
|
|
81
|
+
same normalization the Rust client's generator consumes, taken from the
|
|
82
|
+
live ``document()`` rather than the golden snapshot (whose placeholder
|
|
83
|
+
masking would burn wrong types - e.g. an integer watermark restored as
|
|
84
|
+
string - into generated models). Falls back to the snapshot when cargo
|
|
85
|
+
is unavailable, with a loud warning: the fallback can mis-type masked
|
|
86
|
+
properties.
|
|
87
|
+
"""
|
|
88
|
+
import subprocess
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
raw = subprocess.run(
|
|
92
|
+
["cargo", "run", "--locked", "-p", "qqflow-regen", "--", "--dump-spec"],
|
|
93
|
+
cwd=str(REPO),
|
|
94
|
+
check=True, capture_output=True, text=True,
|
|
95
|
+
).stdout
|
|
96
|
+
return json.loads(raw)
|
|
97
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
98
|
+
print(
|
|
99
|
+
"WARNING: falling back to the golden snapshot; volatile-masked"
|
|
100
|
+
" properties will be typed as strings. Reason: "
|
|
101
|
+
+ str(exc),
|
|
102
|
+
file=sys.stderr,
|
|
103
|
+
)
|
|
104
|
+
import re
|
|
105
|
+
|
|
106
|
+
doc = json.loads(GOLDEN.read_text(encoding="utf-8"))["body"]
|
|
107
|
+
|
|
108
|
+
def restore(node):
|
|
109
|
+
if isinstance(node, list):
|
|
110
|
+
for item in node:
|
|
111
|
+
restore(item)
|
|
112
|
+
return
|
|
113
|
+
if not isinstance(node, dict):
|
|
114
|
+
return
|
|
115
|
+
for key, value in list(node.items()):
|
|
116
|
+
if value == "<volatile>":
|
|
117
|
+
node[key] = {"type": "string"}
|
|
118
|
+
else:
|
|
119
|
+
restore(value)
|
|
120
|
+
|
|
121
|
+
restore(doc)
|
|
122
|
+
normalize(doc)
|
|
123
|
+
doc["openapi"] = "3.0.3"
|
|
124
|
+
for path, item in doc.get("paths", {}).items():
|
|
125
|
+
names = re.findall(r"\{(\w+)\}", path)
|
|
126
|
+
if not names:
|
|
127
|
+
continue
|
|
128
|
+
for op in item.values():
|
|
129
|
+
if not isinstance(op, dict):
|
|
130
|
+
continue
|
|
131
|
+
op["parameters"] = [
|
|
132
|
+
{
|
|
133
|
+
"name": name,
|
|
134
|
+
"in": "path",
|
|
135
|
+
"required": True,
|
|
136
|
+
"style": "simple",
|
|
137
|
+
"schema": {"type": "string"},
|
|
138
|
+
}
|
|
139
|
+
for name in names
|
|
140
|
+
]
|
|
141
|
+
return doc
|
|
142
|
+
|
|
143
|
+
def tree_digest(root: Path) -> str:
|
|
144
|
+
# Byte-compiled artifacts are excluded deliberately: a local pytest run
|
|
145
|
+
# drops __pycache__/*.pyc INSIDE the generated tree (the scratch
|
|
146
|
+
# regeneration never makes them), and without this filter the digest
|
|
147
|
+
# gate reports a false "stale tree" on a byte-identical checkout.
|
|
148
|
+
sha = hashlib.sha256()
|
|
149
|
+
for path in sorted(root.rglob("*")):
|
|
150
|
+
if not path.is_file():
|
|
151
|
+
continue
|
|
152
|
+
if "__pycache__" in path.parts or path.suffix == ".pyc":
|
|
153
|
+
continue
|
|
154
|
+
sha.update(str(path.relative_to(root)).encode())
|
|
155
|
+
sha.update(path.read_bytes())
|
|
156
|
+
return sha.hexdigest()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def normalize_generated(root: Path) -> int:
|
|
160
|
+
"""Strip trailing whitespace and trailing blank lines from generated files.
|
|
161
|
+
|
|
162
|
+
The generator leaves trailing spaces inside docstrings and a blank line at the
|
|
163
|
+
end of several files. This repository checks whitespace with ``git diff
|
|
164
|
+
--check`` against the empty tree, so a committed file is inspected forever -
|
|
165
|
+
not only in the change that introduced it: a single unnormalized regeneration
|
|
166
|
+
turns the whole-repository check red and stays red until the bytes change.
|
|
167
|
+
Normalizing inside the pipeline keeps every regeneration clean without editing
|
|
168
|
+
``generated/`` by hand, which the module docstring forbids.
|
|
169
|
+
|
|
170
|
+
Returns the number of files rewritten.
|
|
171
|
+
"""
|
|
172
|
+
changed = 0
|
|
173
|
+
for path in sorted(root.rglob("*")):
|
|
174
|
+
if not path.is_file():
|
|
175
|
+
continue
|
|
176
|
+
raw = path.read_bytes()
|
|
177
|
+
try:
|
|
178
|
+
text = raw.decode("utf-8")
|
|
179
|
+
except UnicodeDecodeError:
|
|
180
|
+
continue
|
|
181
|
+
lines = [line.rstrip() for line in text.splitlines()]
|
|
182
|
+
while lines and not lines[-1]:
|
|
183
|
+
lines.pop()
|
|
184
|
+
fixed = ("\n".join(lines) + "\n").encode("utf-8") if lines else b""
|
|
185
|
+
if fixed != raw:
|
|
186
|
+
path.write_bytes(fixed)
|
|
187
|
+
changed += 1
|
|
188
|
+
return changed
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def write_spec(spec, path):
|
|
192
|
+
text = json.dumps(spec, indent=2, ensure_ascii=False, sort_keys=True) + "\n"
|
|
193
|
+
path.write_text(text, encoding="utf-8")
|
|
194
|
+
return text
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def generate_tree(spec_path, dest_root):
|
|
198
|
+
# Run the generator from spec_path into dest_root/PKG, keeping only the
|
|
199
|
+
# library face, rewriting the absolute imports the generator emits
|
|
200
|
+
# (nested under our package they would resolve to the handwritten one),
|
|
201
|
+
# and normalizing whitespace. Returns the number of files normalized.
|
|
202
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
203
|
+
tmp_out = Path(tmp) / "gen"
|
|
204
|
+
cmd = ["npx.cmd" if sys.platform == "win32" else "npx",
|
|
205
|
+
"--yes", WRAPPER,
|
|
206
|
+
"--openapitools", str(CONFIG),
|
|
207
|
+
"generate",
|
|
208
|
+
"-i", str(spec_path),
|
|
209
|
+
"-g", "python",
|
|
210
|
+
"-o", str(tmp_out),
|
|
211
|
+
"--package-name", PKG,
|
|
212
|
+
"--library", "httpx"]
|
|
213
|
+
subprocess.run(cmd, check=True, cwd=str(PKGDIR))
|
|
214
|
+
shutil.copytree(tmp_out / PKG, dest_root / PKG)
|
|
215
|
+
pkg_dir = dest_root / PKG
|
|
216
|
+
for py_file in pkg_dir.rglob("*.py"):
|
|
217
|
+
text = py_file.read_text(encoding="utf-8")
|
|
218
|
+
fixed = (
|
|
219
|
+
text
|
|
220
|
+
.replace("from " + PKG + ".", "from " + PKG + ".generated." + PKG + ".")
|
|
221
|
+
.replace("from " + PKG + " import",
|
|
222
|
+
"from " + PKG + ".generated." + PKG + " import")
|
|
223
|
+
.replace("import " + PKG + ".", "import " + PKG + ".generated." + PKG + ".")
|
|
224
|
+
)
|
|
225
|
+
if fixed != text:
|
|
226
|
+
py_file.write_text(fixed, encoding="utf-8")
|
|
227
|
+
return normalize_generated(dest_root)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main():
|
|
231
|
+
parser = argparse.ArgumentParser()
|
|
232
|
+
parser.add_argument("--check", action="store_true",
|
|
233
|
+
help="fail if a regeneration would change spec or the tree")
|
|
234
|
+
args = parser.parse_args()
|
|
235
|
+
|
|
236
|
+
spec = build_spec()
|
|
237
|
+
fresh_spec = json.dumps(spec, indent=2, ensure_ascii=False, sort_keys=True) + "\n"
|
|
238
|
+
|
|
239
|
+
if args.check:
|
|
240
|
+
# The digest half of --check actually runs the generator (unlike the
|
|
241
|
+
# old spec-only check), so CI needs node/npx + a JRE. Fail with a
|
|
242
|
+
# readable line instead of a subprocess traceback.
|
|
243
|
+
import shutil as _shutil
|
|
244
|
+
npx = "npx.cmd" if sys.platform == "win32" else "npx"
|
|
245
|
+
if _shutil.which(npx) is None:
|
|
246
|
+
print("regen --check needs npx (Node.js) on PATH to regenerate the tree",
|
|
247
|
+
file=sys.stderr)
|
|
248
|
+
return 1
|
|
249
|
+
# Two comparisons, because "the spec did not change" is NOT the gate
|
|
250
|
+
# it looks like: model output and whitespace can drift while the
|
|
251
|
+
# description stays byte-identical (generator version, templates).
|
|
252
|
+
# The whole tree is regenerated into a scratch dir and compared by
|
|
253
|
+
# digest; spec equality alone could not see any of that.
|
|
254
|
+
committed_spec = (OUT / "spec.json").read_text(encoding="utf-8")
|
|
255
|
+
if committed_spec != fresh_spec:
|
|
256
|
+
print("generated spec is stale: rerun scripts/regen.py and commit",
|
|
257
|
+
file=sys.stderr)
|
|
258
|
+
return 1
|
|
259
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
260
|
+
tmp_spec = Path(tmp) / "spec.json"
|
|
261
|
+
tmp_spec.write_text(fresh_spec, encoding="utf-8")
|
|
262
|
+
gen_root = Path(tmp) / "tree"
|
|
263
|
+
gen_root.mkdir()
|
|
264
|
+
generate_tree(tmp_spec, gen_root)
|
|
265
|
+
fresh_digest = tree_digest(gen_root / PKG)
|
|
266
|
+
committed_digest = tree_digest(OUT / PKG)
|
|
267
|
+
if fresh_digest != committed_digest:
|
|
268
|
+
print("generated tree is stale: rerun scripts/regen.py and commit\n"
|
|
269
|
+
" committed " + committed_digest + "\n"
|
|
270
|
+
" regenerated " + fresh_digest, file=sys.stderr)
|
|
271
|
+
return 1
|
|
272
|
+
print("generated spec and tree are up to date:", OUT, fresh_digest[:12])
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
OUT.mkdir(parents=True, exist_ok=True)
|
|
276
|
+
spec_path = OUT / "spec.json"
|
|
277
|
+
write_spec(spec, spec_path)
|
|
278
|
+
|
|
279
|
+
for child in OUT.iterdir():
|
|
280
|
+
if child.name != "spec.json":
|
|
281
|
+
shutil.rmtree(child) if child.is_dir() else child.unlink()
|
|
282
|
+
normalized = generate_tree(spec_path, OUT)
|
|
283
|
+
print("regenerated: " + str(OUT) + " (normalized " + str(normalized) + " files)")
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
sys.exit(main())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ren-yc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Typed Python client for qqflow-server.
|
|
2
|
+
|
|
3
|
+
Two layers with different ownership:
|
|
4
|
+
|
|
5
|
+
- :mod:`qqflow_sdk.generated` - request/response models produced from the
|
|
6
|
+
server's OpenAPI description. Never edit by hand; rerun
|
|
7
|
+
``scripts/regen.py`` and commit the result (CI asserts no diff).
|
|
8
|
+
- :mod:`qqflow_sdk.client` - the handwritten behavior layer (readiness
|
|
9
|
+
polling, cursor draining, SSE watching, media retries). This is the API
|
|
10
|
+
most downstreams should use.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .client import (
|
|
14
|
+
CONNECT_TIMEOUT,
|
|
15
|
+
READ_TIMEOUT,
|
|
16
|
+
STALL_TIMEOUT,
|
|
17
|
+
BadDate,
|
|
18
|
+
Client,
|
|
19
|
+
ClientError,
|
|
20
|
+
MessageEvent,
|
|
21
|
+
NotReady,
|
|
22
|
+
ShapeError,
|
|
23
|
+
StatusError,
|
|
24
|
+
TransportError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CONNECT_TIMEOUT",
|
|
29
|
+
"READ_TIMEOUT",
|
|
30
|
+
"STALL_TIMEOUT",
|
|
31
|
+
"BadDate",
|
|
32
|
+
"Client",
|
|
33
|
+
"ClientError",
|
|
34
|
+
"MessageEvent",
|
|
35
|
+
"NotReady",
|
|
36
|
+
"ShapeError",
|
|
37
|
+
"StatusError",
|
|
38
|
+
"TransportError",
|
|
39
|
+
]
|