gengwiki 0.1.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.
gengwiki-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zhixiang Feng
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,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: gengwiki
3
+ Version: 0.1.0
4
+ Summary: Python SDK for gengwiki, local first with the API as a fallback
5
+ Keywords: gengwiki,meme,sdk,llm,function-calling
6
+ Author: Zhixiang Feng
7
+ Author-email: Zhixiang Feng <contact@zhixiangfeng.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Natural Language :: Chinese (Simplified)
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Project-URL: Homepage, https://gengwiki.niox.lol
23
+ Project-URL: Documentation, https://gengwiki.niox.lol/docs
24
+ Project-URL: Repository, https://github.com/Niox1337/gengwiki-python
25
+ Description-Content-Type: text/markdown
26
+
27
+ # gengwiki-python
28
+
29
+ 梗Wiki 的 Python SDK,支持 Python 3.10 及以上。
30
+
31
+ ```bash
32
+ pip install gengwiki
33
+ ```
34
+
35
+ 到[个人中心](https://gengwiki.niox.lol/me)创建 Key,然后保存到本机,之后的代码里就不用再写它了。
36
+
37
+ ```bash
38
+ gengwiki keys add 老哥 # 粘贴时不显示,也不会留在命令历史里
39
+ gengwiki status # 查看当前 Key、人设和额度
40
+ ```
41
+
42
+ ```python
43
+ from gengwiki import GengWiki
44
+
45
+ client = GengWiki() # 也可以传入 Key,或者设置环境变量 GENGWIKI_API_KEY
46
+
47
+ client.search_memes("躺平")
48
+ client.meme("tang-ping").body
49
+ client.persona().as_message() # {"role": "system", "content": "..."}
50
+ ```
51
+
52
+ 更多用法见 [example/](https://github.com/Niox1337/gengwiki-python/tree/main/example)。
53
+
54
+ ## 切换人设
55
+
56
+ 一个 Key 终身绑定一个人设,所以切换人设就是切换到绑定了它的 Key。没有这样的 Key 时,登录后会自动创建一个并保存。
57
+
58
+ ```python
59
+ persona = client.use_persona("温柔姐姐") # 按标题或 id
60
+ messages = [persona.as_message(), {"role": "user", "content": "你好"}]
61
+ ```
62
+
63
+ `client.personas("关键词")` 可以浏览人设目录。Claude 的系统提示不放在消息列表里,所以要写成 `system=persona.prompt`。
64
+
65
+ ## 命令行
66
+
67
+ 安装后可以用 `gengwiki`,也可以用 `python -m gengwiki`。
68
+
69
+ ```bash
70
+ gengwiki login # 登录,密码不会保存
71
+ gengwiki status # 当前站点、Key、人设和额度
72
+ gengwiki search 躺平 # 也有 meme、trending、personas
73
+ gengwiki use 温柔姐姐 # 切换人设
74
+ gengwiki sync --images # 下载全部内容供离线使用
75
+ gengwiki keys # 也有 add、use、remove、secret、remote、rotate、revoke
76
+ ```
77
+
78
+ ## 给大模型用的工具
79
+
80
+ ```python
81
+ tools = client.tools.openai() # OpenAI 和 DeepSeek 等的 Chat Completions
82
+ tools = client.tools.responses() # OpenAI 的 Responses API
83
+ tools = client.tools.anthropic() # Claude
84
+
85
+ while True:
86
+ reply = llm.chat.completions.create(model=model, messages=messages, tools=tools).choices[0].message
87
+ results = client.tools.run(reply)
88
+ if not results:
89
+ break
90
+ messages += [reply, *results]
91
+ ```
92
+
93
+ Claude 的回复用法相同,只是追加时写成 `{"role": "assistant", "content": reply.content}`。Responses API 把 `response` 传给 `run`,追加时写成 `input += [*response.output, *results]`。
94
+
95
+ 共有 5 个工具,分别是 `search_memes`、`get_meme`、`trending_memes`、`search_stickers` 和 `search_lexicon`。可以用 `client.tools.openai(["search_memes"])` 只选其中几个。
96
+
97
+ ## 开发
98
+
99
+ ```bash
100
+ uv run python -m unittest discover -s tests -t .
101
+ ```
102
+
103
+ `tests/test_live.py` 用来对照真实服务,只有设置了 `GENGWIKI_LIVE_API` 和 `GENGWIKI_LIVE_SITE` 才会运行,需要指向一套载入了 `tests/live_seed.sql` 的临时环境,绝对不要指向生产环境。
@@ -0,0 +1,77 @@
1
+ # gengwiki-python
2
+
3
+ 梗Wiki 的 Python SDK,支持 Python 3.10 及以上。
4
+
5
+ ```bash
6
+ pip install gengwiki
7
+ ```
8
+
9
+ 到[个人中心](https://gengwiki.niox.lol/me)创建 Key,然后保存到本机,之后的代码里就不用再写它了。
10
+
11
+ ```bash
12
+ gengwiki keys add 老哥 # 粘贴时不显示,也不会留在命令历史里
13
+ gengwiki status # 查看当前 Key、人设和额度
14
+ ```
15
+
16
+ ```python
17
+ from gengwiki import GengWiki
18
+
19
+ client = GengWiki() # 也可以传入 Key,或者设置环境变量 GENGWIKI_API_KEY
20
+
21
+ client.search_memes("躺平")
22
+ client.meme("tang-ping").body
23
+ client.persona().as_message() # {"role": "system", "content": "..."}
24
+ ```
25
+
26
+ 更多用法见 [example/](https://github.com/Niox1337/gengwiki-python/tree/main/example)。
27
+
28
+ ## 切换人设
29
+
30
+ 一个 Key 终身绑定一个人设,所以切换人设就是切换到绑定了它的 Key。没有这样的 Key 时,登录后会自动创建一个并保存。
31
+
32
+ ```python
33
+ persona = client.use_persona("温柔姐姐") # 按标题或 id
34
+ messages = [persona.as_message(), {"role": "user", "content": "你好"}]
35
+ ```
36
+
37
+ `client.personas("关键词")` 可以浏览人设目录。Claude 的系统提示不放在消息列表里,所以要写成 `system=persona.prompt`。
38
+
39
+ ## 命令行
40
+
41
+ 安装后可以用 `gengwiki`,也可以用 `python -m gengwiki`。
42
+
43
+ ```bash
44
+ gengwiki login # 登录,密码不会保存
45
+ gengwiki status # 当前站点、Key、人设和额度
46
+ gengwiki search 躺平 # 也有 meme、trending、personas
47
+ gengwiki use 温柔姐姐 # 切换人设
48
+ gengwiki sync --images # 下载全部内容供离线使用
49
+ gengwiki keys # 也有 add、use、remove、secret、remote、rotate、revoke
50
+ ```
51
+
52
+ ## 给大模型用的工具
53
+
54
+ ```python
55
+ tools = client.tools.openai() # OpenAI 和 DeepSeek 等的 Chat Completions
56
+ tools = client.tools.responses() # OpenAI 的 Responses API
57
+ tools = client.tools.anthropic() # Claude
58
+
59
+ while True:
60
+ reply = llm.chat.completions.create(model=model, messages=messages, tools=tools).choices[0].message
61
+ results = client.tools.run(reply)
62
+ if not results:
63
+ break
64
+ messages += [reply, *results]
65
+ ```
66
+
67
+ Claude 的回复用法相同,只是追加时写成 `{"role": "assistant", "content": reply.content}`。Responses API 把 `response` 传给 `run`,追加时写成 `input += [*response.output, *results]`。
68
+
69
+ 共有 5 个工具,分别是 `search_memes`、`get_meme`、`trending_memes`、`search_stickers` 和 `search_lexicon`。可以用 `client.tools.openai(["search_memes"])` 只选其中几个。
70
+
71
+ ## 开发
72
+
73
+ ```bash
74
+ uv run python -m unittest discover -s tests -t .
75
+ ```
76
+
77
+ `tests/test_live.py` 用来对照真实服务,只有设置了 `GENGWIKI_LIVE_API` 和 `GENGWIKI_LIVE_SITE` 才会运行,需要指向一套载入了 `tests/live_seed.sql` 的临时环境,绝对不要指向生产环境。
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "gengwiki"
3
+ version = "0.1.0"
4
+ description = "Python SDK for gengwiki, local first with the API as a fallback"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "gengwiki",
11
+ "meme",
12
+ "sdk",
13
+ "llm",
14
+ "function-calling",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Natural Language :: Chinese (Simplified)",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = []
30
+
31
+ [[project.authors]]
32
+ name = "Zhixiang Feng"
33
+ email = "contact@zhixiangfeng.com"
34
+
35
+ [project.urls]
36
+ Homepage = "https://gengwiki.niox.lol"
37
+ Documentation = "https://gengwiki.niox.lol/docs"
38
+ Repository = "https://github.com/Niox1337/gengwiki-python"
39
+
40
+ [project.scripts]
41
+ gengwiki = "gengwiki.__main__:main"
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.8,<1"]
45
+ build-backend = "uv_build"
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "gengwiki"
3
+ version = "0.1.0"
4
+ description = "Python SDK for gengwiki, local first with the API as a fallback"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Zhixiang Feng", email = "contact@zhixiangfeng.com" }]
10
+ keywords = ["gengwiki", "meme", "sdk", "llm", "function-calling"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Natural Language :: Chinese (Simplified)",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Homepage = "https://gengwiki.niox.lol"
28
+ Documentation = "https://gengwiki.niox.lol/docs"
29
+ Repository = "https://github.com/Niox1337/gengwiki-python"
30
+
31
+ [project.scripts]
32
+ gengwiki = "gengwiki.__main__:main"
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.8,<1"]
36
+ build-backend = "uv_build"
@@ -0,0 +1,55 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .client import GengWiki # noqa: E402
4
+ from .errors import ( # noqa: E402
5
+ AccountApiUnavailable,
6
+ AuthError,
7
+ BadRequestError,
8
+ BadResponseError,
9
+ ConflictError,
10
+ ForbiddenError,
11
+ GengWikiError,
12
+ NetworkError,
13
+ NotFoundError,
14
+ RateLimitError,
15
+ ServerError,
16
+ UnavailableError,
17
+ )
18
+ from .models import ( # noqa: E402
19
+ ApiKey,
20
+ Lexicon,
21
+ LexiconEntry,
22
+ LocalKey,
23
+ Meme,
24
+ MemeSummary,
25
+ Persona,
26
+ RateLimit,
27
+ Sticker,
28
+ StickerPack,
29
+ )
30
+
31
+ __all__ = [
32
+ "AccountApiUnavailable",
33
+ "ApiKey",
34
+ "AuthError",
35
+ "BadRequestError",
36
+ "BadResponseError",
37
+ "ConflictError",
38
+ "ForbiddenError",
39
+ "GengWiki",
40
+ "GengWikiError",
41
+ "Lexicon",
42
+ "LexiconEntry",
43
+ "LocalKey",
44
+ "Meme",
45
+ "MemeSummary",
46
+ "NetworkError",
47
+ "NotFoundError",
48
+ "Persona",
49
+ "RateLimit",
50
+ "RateLimitError",
51
+ "ServerError",
52
+ "Sticker",
53
+ "StickerPack",
54
+ "UnavailableError",
55
+ ]
@@ -0,0 +1,179 @@
1
+ """The command line. Mostly for first-time setup, so a password or key never has to sit in a script or shell history."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import sys
6
+
7
+ from . import GengWiki, GengWikiError, __version__
8
+
9
+
10
+ def _memes(rows) -> None:
11
+ if not rows:
12
+ print("没有找到")
13
+ for m in rows:
14
+ print(f"{m.title} [{m.slug}] {m.summary}")
15
+
16
+
17
+ def _login(c: GengWiki, a) -> None:
18
+ user = c.keys.login(a.email or input("邮箱 "), getpass.getpass("密码 "))
19
+ print(f"已登录 {user.get('email', '')},7 天内有效,密码没有保存")
20
+
21
+
22
+ def _logout(c: GengWiki, a) -> None:
23
+ c.keys.logout()
24
+ print("已退出")
25
+
26
+
27
+ def _status(c: GengWiki, a) -> None:
28
+ print(f"站点 {c.base_url}")
29
+ print(f"登录 {'已登录' if c.keys.logged_in else '未登录'}")
30
+ if not c._key:
31
+ print(f"Key 没有,到 {c.site_url}/me 创建后运行 gengwiki keys add 名字")
32
+ return
33
+ saved = next((k for k in c.keys.local() if k.current), None)
34
+ print(f"Key {saved.name if saved else '来自参数或环境变量'} {c._key[:15]}")
35
+ persona = c.persona()
36
+ print(f"人设 {persona.title if persona else '没有'}")
37
+ try:
38
+ r = c.rate_limit()
39
+ print(f"额度 今天 {r.used_today}/{r.per_day},这一分钟 {r.used_this_minute}/{r.per_minute}")
40
+ except GengWikiError as e:
41
+ print(f"额度 暂时查不到,{e.message}")
42
+
43
+
44
+ def _keys_local(c: GengWiki, a) -> None:
45
+ rows = c.keys.local()
46
+ if not rows:
47
+ print(f"还没有保存 Key,到 {c.site_url}/me 创建后运行 gengwiki keys add 名字")
48
+ for k in rows:
49
+ print(f"{'*' if k.current else ' '} {k.name} {k.prefix} {k.persona_title or ''}".rstrip())
50
+
51
+
52
+ def _keys_add(c: GengWiki, a) -> None:
53
+ print(f"已保存 {c.keys.add(a.name, getpass.getpass('粘贴 Key ').strip()).name}")
54
+
55
+
56
+ def _keys_use(c: GengWiki, a) -> None:
57
+ print(f"已切换到 {c.keys.use(a.name).name}")
58
+
59
+
60
+ def _keys_remove(c: GengWiki, a) -> None:
61
+ c.keys.remove(a.name)
62
+ print(f"已从本机删除 {a.name},服务器上依然有效")
63
+
64
+
65
+ def _keys_secret(c: GengWiki, a) -> None:
66
+ print(c.keys.secret(a.name))
67
+
68
+
69
+ def _keys_remote(c: GengWiki, a) -> None:
70
+ for k in c.keys.list():
71
+ print(f"{k.name} {k.prefix} 最后使用 {k.last_used_at or '从未'}")
72
+
73
+
74
+ def _keys_rotate(c: GengWiki, a) -> None:
75
+ print(f"已换新密钥并保存到 {c.keys.rotate(a.name).name},旧密钥立即失效")
76
+
77
+
78
+ def _keys_revoke(c: GengWiki, a) -> None:
79
+ c.keys.revoke(a.name)
80
+ print(f"已吊销 {a.name}")
81
+
82
+
83
+ def _personas(c: GengWiki, a) -> None:
84
+ rows = c.personas(a.q)
85
+ if not rows:
86
+ print("没有找到")
87
+ for p in rows:
88
+ print(f"{p.title} {p.description}".rstrip())
89
+
90
+
91
+ def _use(c: GengWiki, a) -> None:
92
+ print(f"已切换到 {c.use_persona(a.persona).title}")
93
+
94
+
95
+ def _search(c: GengWiki, a) -> None:
96
+ _memes(c.search_memes(a.q))
97
+
98
+
99
+ def _trending(c: GengWiki, a) -> None:
100
+ _memes(c.trending(a.tag))
101
+
102
+
103
+ def _meme(c: GengWiki, a) -> None:
104
+ m = c.meme(a.slug)
105
+ print(m.title)
106
+ if m.aliases:
107
+ print("别名 " + "、".join(m.aliases))
108
+ print(f"\n{m.body or m.summary}")
109
+ if m.usage_scenario:
110
+ print(f"\n使用场景 {m.usage_scenario}")
111
+ for example in m.usage_examples:
112
+ print(f"\n {example}")
113
+
114
+
115
+ def _sync(c: GengWiki, a) -> None:
116
+ s = c.sync(images=a.images)
117
+ print(f"梗 {s['memes']} 个,表情 {s['stickers']} 个,图片 {s['images']} 张,发出 {s['requests']} 个请求")
118
+
119
+
120
+ def _parser() -> argparse.ArgumentParser:
121
+ parser = argparse.ArgumentParser(prog="gengwiki", description="梗Wiki 命令行")
122
+ parser.add_argument("--version", action="version", version=__version__)
123
+ parser.set_defaults(run=None)
124
+ sub = parser.add_subparsers(metavar="命令")
125
+
126
+ def command(parent, name, run, help_text, *args):
127
+ p = parent.add_parser(name, help=help_text)
128
+ p.set_defaults(run=run)
129
+ for arg, kwargs in args:
130
+ p.add_argument(arg, **kwargs)
131
+ return p
132
+
133
+ optional = {"nargs": "?", "default": ""}
134
+ command(sub, "status", _status, "当前站点、Key、人设和额度")
135
+ command(sub, "login", _login, "登录以管理 Key", ("email", optional))
136
+ command(sub, "logout", _logout, "退出登录")
137
+ command(sub, "search", _search, "搜索梗", ("q", optional))
138
+ command(sub, "meme", _meme, "查看一个梗", ("slug", {}))
139
+ command(sub, "trending", _trending, "热门榜", ("tag", optional))
140
+ command(sub, "personas", _personas, "浏览人设", ("q", optional))
141
+ command(sub, "use", _use, "切换人设", ("persona", {}))
142
+ command(sub, "sync", _sync, "下载全部内容供离线使用", ("--images", {"action": "store_true"}))
143
+
144
+ keys = command(sub, "keys", _keys_local, "列出已保存的 Key").add_subparsers(metavar="操作")
145
+ command(keys, "add", _keys_add, "保存在网站上创建的 Key", ("name", {}))
146
+ command(keys, "use", _keys_use, "切换到已保存的 Key", ("name", {}))
147
+ command(keys, "remove", _keys_remove, "只从本机删除", ("name", {}))
148
+ command(keys, "secret", _keys_secret, "输出密钥,交给别的程序", ("name", {}))
149
+ command(keys, "remote", _keys_remote, "列出账号上的 Key,需要登录")
150
+ command(keys, "rotate", _keys_rotate, "换新密钥,需要登录", ("name", {}))
151
+ command(keys, "revoke", _keys_revoke, "吊销并从本机删除,需要登录", ("name", {}))
152
+ return parser
153
+
154
+
155
+ def main(argv: list[str] | None = None) -> int:
156
+ for stream in (sys.stdout, sys.stderr):
157
+ if hasattr(stream, "reconfigure"):
158
+ # A pipe on a non-Chinese Windows is cp1252, which would turn all the text into question marks.
159
+ # On any encoding, one character it lacks must not end the program.
160
+ encoding = stream.encoding if "梗".encode(stream.encoding, "ignore") else "utf-8"
161
+ stream.reconfigure(encoding=encoding, errors="replace")
162
+ parser = _parser()
163
+ args = parser.parse_args(argv)
164
+ if args.run is None:
165
+ parser.print_help()
166
+ return 0
167
+ try:
168
+ with GengWiki() as client:
169
+ args.run(client, args)
170
+ except (GengWikiError, ValueError) as e:
171
+ print(f"错误 {getattr(e, 'message', e)}", file=sys.stderr)
172
+ return 1
173
+ except (KeyboardInterrupt, EOFError):
174
+ return 130
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())
@@ -0,0 +1,136 @@
1
+ """Stdlib HTTP with typed errors. Knows nothing about caching."""
2
+
3
+ import email.message
4
+ import gzip
5
+ import http.client
6
+ import ipaddress
7
+ import json
8
+ import time
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ import zlib
13
+
14
+ from . import __version__
15
+ from .errors import (
16
+ AuthError,
17
+ BadRequestError,
18
+ BadResponseError,
19
+ ConflictError,
20
+ ForbiddenError,
21
+ GengWikiError,
22
+ NetworkError,
23
+ NotFoundError,
24
+ RateLimitError,
25
+ ServerError,
26
+ UnavailableError,
27
+ )
28
+
29
+ _BY_STATUS = {400: BadRequestError, 401: AuthError, 403: ForbiddenError, 404: NotFoundError, 409: ConflictError}
30
+
31
+
32
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
33
+ """urllib would forward the Authorization header to wherever a redirect points."""
34
+
35
+ def redirect_request(self, *args, **kwargs):
36
+ return None
37
+
38
+
39
+ def check_url(url: str) -> str:
40
+ """Bearer tokens only travel over HTTPS, plain HTTP is for a dev server on this machine."""
41
+ parts = urllib.parse.urlsplit(url)
42
+ if parts.scheme == "https" and parts.hostname:
43
+ return url.rstrip("/")
44
+ if parts.scheme == "http" and parts.hostname:
45
+ try:
46
+ loopback = ipaddress.ip_address(parts.hostname).is_loopback
47
+ except ValueError:
48
+ loopback = parts.hostname == "localhost"
49
+ if loopback:
50
+ return url.rstrip("/")
51
+ raise ValueError(f"{url!r} must be https, or http on localhost")
52
+
53
+
54
+ def _raise_for(status: int, body: bytes, headers: email.message.Message) -> None:
55
+ code, message = None, f"HTTP {status}"
56
+ try:
57
+ err = json.loads(body)["error"]
58
+ code, message = err["code"], err["message"]
59
+ except (ValueError, KeyError, TypeError):
60
+ pass
61
+ if status == 429:
62
+ try:
63
+ retry_after = float(headers.get("Retry-After", 0))
64
+ except ValueError:
65
+ retry_after = 0
66
+ raise RateLimitError(message, retry_after=retry_after)
67
+ if status >= 500:
68
+ raise ServerError(message, status, code)
69
+ raise _BY_STATUS.get(status, GengWikiError)(message, status, code)
70
+
71
+
72
+ class Http:
73
+ def __init__(self, timeout: float = 15):
74
+ self.timeout = timeout
75
+ self.count = 0
76
+ self._opener = urllib.request.build_opener(_NoRedirect)
77
+
78
+ # ponytail: urllib opens one connection per request. Requests are rare by design, pool with http.client if that changes.
79
+ def _once(self, req: urllib.request.Request) -> tuple[bytes, email.message.Message]:
80
+ self.count += 1
81
+ try:
82
+ with self._opener.open(req, timeout=self.timeout) as res:
83
+ body, headers = res.read(), res.headers
84
+ if headers.get("Content-Encoding") == "gzip":
85
+ body = gzip.decompress(body)
86
+ return body, headers
87
+ except urllib.error.HTTPError as e:
88
+ with e:
89
+ _raise_for(e.code, e.read(), e.headers)
90
+ raise # unreachable, _raise_for always raises
91
+ # A connection cut mid-response or a damaged body is the same outage to the caller.
92
+ except (OSError, EOFError, http.client.HTTPException, zlib.error) as e:
93
+ raise NetworkError(f"cannot reach {req.host}: {e!r}") from e
94
+
95
+ def request(
96
+ self,
97
+ method: str,
98
+ url: str,
99
+ *,
100
+ token: str | None = None,
101
+ params: dict | None = None,
102
+ body: dict | None = None,
103
+ retries: int = 0,
104
+ ) -> tuple[bytes, email.message.Message]:
105
+ if params:
106
+ url += "?" + urllib.parse.urlencode(params)
107
+ headers = {"User-Agent": f"gengwiki-python/{__version__}", "Accept-Encoding": "gzip"}
108
+ if token:
109
+ headers["Authorization"] = f"Bearer {token}"
110
+ data = None
111
+ if body is not None:
112
+ data = json.dumps(body).encode()
113
+ headers["Content-Type"] = "application/json"
114
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
115
+ for attempt in range(retries + 1):
116
+ try:
117
+ return self._once(req)
118
+ except UnavailableError as e:
119
+ # A long Retry-After is the daily limit, waiting it out would hang the caller.
120
+ wait = e.retry_after if isinstance(e, RateLimitError) else 0.5 * 2**attempt
121
+ if attempt == retries or wait > 5:
122
+ raise
123
+ time.sleep(wait)
124
+ raise AssertionError("unreachable")
125
+
126
+ def json(self, method: str, url: str, *, expect: type | None = None, **kwargs):
127
+ """The decoded body. With expect, anything but that type is a bad response, so it never reaches the cache."""
128
+ body, headers = self.request(method, url, **kwargs)
129
+ try:
130
+ value = json.loads(body) if body else None
131
+ except ValueError as e:
132
+ raise BadResponseError(f"expected JSON from {url}, got {headers.get_content_type()}", code="bad_response") from e
133
+ # ponytail: checks the outer type, not each row. Check fields if something in front ever rewrites rows.
134
+ if expect and not isinstance(value, expect):
135
+ raise BadResponseError(f"expected a JSON {expect.__name__} from {url}", code="bad_response")
136
+ return value