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.
Files changed (62) hide show
  1. qqflow_sdk-0.9.0/.gitignore +33 -0
  2. qqflow_sdk-0.9.0/LICENSE +21 -0
  3. qqflow_sdk-0.9.0/PKG-INFO +59 -0
  4. qqflow_sdk-0.9.0/README.md +37 -0
  5. qqflow_sdk-0.9.0/openapitools.json +5 -0
  6. qqflow_sdk-0.9.0/pyproject.toml +50 -0
  7. qqflow_sdk-0.9.0/scripts/regen.py +288 -0
  8. qqflow_sdk-0.9.0/src/qqflow_sdk/LICENSE +21 -0
  9. qqflow_sdk-0.9.0/src/qqflow_sdk/__init__.py +39 -0
  10. qqflow_sdk-0.9.0/src/qqflow_sdk/client.py +1003 -0
  11. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/__init__.py +125 -0
  12. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/api/__init__.py +4 -0
  13. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/api/default_api.py +5227 -0
  14. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/api_client.py +836 -0
  15. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/api_response.py +21 -0
  16. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/configuration.py +595 -0
  17. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/exceptions.py +218 -0
  18. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/__init__.py +54 -0
  19. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_conflict.py +95 -0
  20. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_deregistered.py +100 -0
  21. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_not_registered.py +97 -0
  22. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_phase.py +37 -0
  23. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_qq_mismatch.py +102 -0
  24. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_registered.py +100 -0
  25. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_status.py +37 -0
  26. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/account_view.py +106 -0
  27. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/accounts_list.py +95 -0
  28. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_header.py +90 -0
  29. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_member.py +92 -0
  30. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_message.py +111 -0
  31. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_messages.py +125 -0
  32. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/chatlab_meta.py +94 -0
  33. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/contact_out.py +98 -0
  34. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/contacts.py +102 -0
  35. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/delete_api_v1_accounts_qq200_response.py +149 -0
  36. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/group_member.py +110 -0
  37. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/group_members.py +104 -0
  38. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/health.py +91 -0
  39. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/media_brief.py +95 -0
  40. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/media_envelope.py +96 -0
  41. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/media_info.py +135 -0
  42. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/message_out.py +152 -0
  43. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/messages_native.py +108 -0
  44. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/notification_frame.py +117 -0
  45. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/page.py +93 -0
  46. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/post_api_v1_accounts200_response.py +135 -0
  47. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/pull_envelope.py +120 -0
  48. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/pull_message.py +118 -0
  49. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/pull_sync.py +93 -0
  50. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/session_chatlab.py +104 -0
  51. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/session_native.py +94 -0
  52. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/sessions_chatlab.py +102 -0
  53. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/sessions_native.py +98 -0
  54. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/sync_frame.py +98 -0
  55. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/sync_result.py +91 -0
  56. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/watermark_entry.py +92 -0
  57. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/models/watermark_value.py +86 -0
  58. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/py.typed +0 -0
  59. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/qqflow_sdk/rest.py +201 -0
  60. qqflow_sdk-0.9.0/src/qqflow_sdk/generated/spec.json +1610 -0
  61. qqflow_sdk-0.9.0/src/qqflow_sdk/py.typed +0 -0
  62. 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
@@ -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,5 @@
1
+ {
2
+ "generator-cli": {
3
+ "version": "7.25.0"
4
+ }
5
+ }
@@ -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
+ ]