wz-opensdk 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.
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: wz-opensdk
3
+ Version: 0.1.0
4
+ Summary: 万桩开放平台 Python OpenSDK
5
+ License: MIT
6
+ Project-URL: Homepage, https://gitee.com/wz-open-platform/wz-opensdk-python
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.27
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
12
+
13
+ # wz-opensdk
14
+
15
+ 万桩开放平台 Python SDK。覆盖 Apifox 项目 `wz-open` 中文目录下的公开 API(设备、充电、续费、开票、回调日志)。`inner/*` 内部接口不在本 SDK 中。SDK 只做 client,不解析平台推送到合作方的入站回调。
16
+
17
+ 发行名:`wz-opensdk`。导入名:`wzopensdk`。Python **3.9+**。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ pip install wz-opensdk
23
+ ```
24
+
25
+ 尚未发布到 PyPI 时,可从 Gitee 安装:
26
+
27
+ ```bash
28
+ pip install git+https://gitee.com/wz-open-platform/wz-opensdk-python.git@v0.1.0
29
+ ```
30
+
31
+ 本地开发:
32
+
33
+ ```bash
34
+ python3 -m venv .venv
35
+ source .venv/bin/activate
36
+ pip install -e ".[dev]"
37
+ ```
38
+
39
+ ## 快速开始
40
+
41
+ ```python
42
+ import os
43
+ from wzopensdk import WzOpenClient, Endpoints
44
+ from wzopensdk.device import DeviceNumRequest
45
+
46
+ client = WzOpenClient(
47
+ endpoint=Endpoints.PROD, # 测试环境用 Endpoints.TEST
48
+ app_id=os.environ["WZ_OPEN_APP_ID"],
49
+ app_secret=os.environ["WZ_OPEN_APP_SECRET"],
50
+ )
51
+
52
+ device = client.device.find_one(DeviceNumRequest("18000001"))
53
+ ```
54
+
55
+ 也可 `WzOpenClient.from_env()`(读 `WZ_OPEN_APP_ID` / `WZ_OPEN_APP_SECRET`,可选 `WZ_OPEN_ENDPOINT`,缺省测试环境)。
56
+
57
+ 按域调用,方法不堆在 `WzOpenClient` 上:
58
+
59
+ - `client.device` 设备绑定、计费模式、状态、在线日志
60
+ - `client.charge.start` / `stop`
61
+ - `client.renew`
62
+ - `client.invoice`
63
+ - `client.callback.log` / `retry_finish_order`
64
+
65
+ 默认请求超时 10s。
66
+
67
+ ## 签名
68
+
69
+ SDK 按接入指引自动加 `app_id`、`timestamp`、`sign`(HMAC-SHA256 大写 hex)。调用方不要自己拼签,也不要传 `company_id`。金额单位为分。
70
+
71
+ 契约快照:[../api/openapi.json](../api/openapi.json)(汽车桩 `saveMode`/`getMode` 在 SDK 中拆成独立方法;`/open/device/setting/sync` 属于 inner,本 SDK 不实现)。公开能力合计 40 条出站 API。
72
+
73
+ ## 日志
74
+
75
+ 使用标准库 `logging`,logger 名 `wzopensdk`。可传入自定义 logger。
76
+
77
+ 成功请求的 info 日志会带 `method`、`path`;响应头有 `X-B3-TraceId` 时再带 `traceId`。失败时同一字段出现在 error 日志里,并可从 `WzOpenAPIError.trace_id` 读取。
78
+
79
+ ## HTTP
80
+
81
+ 可配 timeout(默认 10s)、retry_count(失败重试且每次重签)、retry_wait、user_agent、proxy_url,或直接传入 `httpx.Client`。
82
+
83
+ ## 环境
84
+
85
+ | 环境 | Base URL |
86
+ | --- | --- |
87
+ | 测试 | `https://dev-api-open.wanzhuangkj.com` |
88
+ | 正式 | `https://api-open.wanzhuangkj.com` |
89
+
90
+ ## 开发
91
+
92
+ ```bash
93
+ pip install -e ".[dev]"
94
+ python3 -m pytest -q
95
+ # 或 make test
96
+ ```
97
+
98
+ 现网验证(需要凭证):
99
+
100
+ ```bash
101
+ export WZ_OPEN_APP_ID=...
102
+ export WZ_OPEN_APP_SECRET=...
103
+ make verify
104
+ ```
@@ -0,0 +1,92 @@
1
+ # wz-opensdk
2
+
3
+ 万桩开放平台 Python SDK。覆盖 Apifox 项目 `wz-open` 中文目录下的公开 API(设备、充电、续费、开票、回调日志)。`inner/*` 内部接口不在本 SDK 中。SDK 只做 client,不解析平台推送到合作方的入站回调。
4
+
5
+ 发行名:`wz-opensdk`。导入名:`wzopensdk`。Python **3.9+**。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ pip install wz-opensdk
11
+ ```
12
+
13
+ 尚未发布到 PyPI 时,可从 Gitee 安装:
14
+
15
+ ```bash
16
+ pip install git+https://gitee.com/wz-open-platform/wz-opensdk-python.git@v0.1.0
17
+ ```
18
+
19
+ 本地开发:
20
+
21
+ ```bash
22
+ python3 -m venv .venv
23
+ source .venv/bin/activate
24
+ pip install -e ".[dev]"
25
+ ```
26
+
27
+ ## 快速开始
28
+
29
+ ```python
30
+ import os
31
+ from wzopensdk import WzOpenClient, Endpoints
32
+ from wzopensdk.device import DeviceNumRequest
33
+
34
+ client = WzOpenClient(
35
+ endpoint=Endpoints.PROD, # 测试环境用 Endpoints.TEST
36
+ app_id=os.environ["WZ_OPEN_APP_ID"],
37
+ app_secret=os.environ["WZ_OPEN_APP_SECRET"],
38
+ )
39
+
40
+ device = client.device.find_one(DeviceNumRequest("18000001"))
41
+ ```
42
+
43
+ 也可 `WzOpenClient.from_env()`(读 `WZ_OPEN_APP_ID` / `WZ_OPEN_APP_SECRET`,可选 `WZ_OPEN_ENDPOINT`,缺省测试环境)。
44
+
45
+ 按域调用,方法不堆在 `WzOpenClient` 上:
46
+
47
+ - `client.device` 设备绑定、计费模式、状态、在线日志
48
+ - `client.charge.start` / `stop`
49
+ - `client.renew`
50
+ - `client.invoice`
51
+ - `client.callback.log` / `retry_finish_order`
52
+
53
+ 默认请求超时 10s。
54
+
55
+ ## 签名
56
+
57
+ SDK 按接入指引自动加 `app_id`、`timestamp`、`sign`(HMAC-SHA256 大写 hex)。调用方不要自己拼签,也不要传 `company_id`。金额单位为分。
58
+
59
+ 契约快照:[../api/openapi.json](../api/openapi.json)(汽车桩 `saveMode`/`getMode` 在 SDK 中拆成独立方法;`/open/device/setting/sync` 属于 inner,本 SDK 不实现)。公开能力合计 40 条出站 API。
60
+
61
+ ## 日志
62
+
63
+ 使用标准库 `logging`,logger 名 `wzopensdk`。可传入自定义 logger。
64
+
65
+ 成功请求的 info 日志会带 `method`、`path`;响应头有 `X-B3-TraceId` 时再带 `traceId`。失败时同一字段出现在 error 日志里,并可从 `WzOpenAPIError.trace_id` 读取。
66
+
67
+ ## HTTP
68
+
69
+ 可配 timeout(默认 10s)、retry_count(失败重试且每次重签)、retry_wait、user_agent、proxy_url,或直接传入 `httpx.Client`。
70
+
71
+ ## 环境
72
+
73
+ | 环境 | Base URL |
74
+ | --- | --- |
75
+ | 测试 | `https://dev-api-open.wanzhuangkj.com` |
76
+ | 正式 | `https://api-open.wanzhuangkj.com` |
77
+
78
+ ## 开发
79
+
80
+ ```bash
81
+ pip install -e ".[dev]"
82
+ python3 -m pytest -q
83
+ # 或 make test
84
+ ```
85
+
86
+ 现网验证(需要凭证):
87
+
88
+ ```bash
89
+ export WZ_OPEN_APP_ID=...
90
+ export WZ_OPEN_APP_SECRET=...
91
+ make verify
92
+ ```
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wz-opensdk"
7
+ version = "0.1.0"
8
+ description = "万桩开放平台 Python OpenSDK"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ urls = { Homepage = "https://gitee.com/wz-open-platform/wz-opensdk-python" }
13
+ dependencies = [
14
+ "httpx>=0.27",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest>=8.0"]
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: wz-opensdk
3
+ Version: 0.1.0
4
+ Summary: 万桩开放平台 Python OpenSDK
5
+ License: MIT
6
+ Project-URL: Homepage, https://gitee.com/wz-open-platform/wz-opensdk-python
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.27
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
12
+
13
+ # wz-opensdk
14
+
15
+ 万桩开放平台 Python SDK。覆盖 Apifox 项目 `wz-open` 中文目录下的公开 API(设备、充电、续费、开票、回调日志)。`inner/*` 内部接口不在本 SDK 中。SDK 只做 client,不解析平台推送到合作方的入站回调。
16
+
17
+ 发行名:`wz-opensdk`。导入名:`wzopensdk`。Python **3.9+**。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ pip install wz-opensdk
23
+ ```
24
+
25
+ 尚未发布到 PyPI 时,可从 Gitee 安装:
26
+
27
+ ```bash
28
+ pip install git+https://gitee.com/wz-open-platform/wz-opensdk-python.git@v0.1.0
29
+ ```
30
+
31
+ 本地开发:
32
+
33
+ ```bash
34
+ python3 -m venv .venv
35
+ source .venv/bin/activate
36
+ pip install -e ".[dev]"
37
+ ```
38
+
39
+ ## 快速开始
40
+
41
+ ```python
42
+ import os
43
+ from wzopensdk import WzOpenClient, Endpoints
44
+ from wzopensdk.device import DeviceNumRequest
45
+
46
+ client = WzOpenClient(
47
+ endpoint=Endpoints.PROD, # 测试环境用 Endpoints.TEST
48
+ app_id=os.environ["WZ_OPEN_APP_ID"],
49
+ app_secret=os.environ["WZ_OPEN_APP_SECRET"],
50
+ )
51
+
52
+ device = client.device.find_one(DeviceNumRequest("18000001"))
53
+ ```
54
+
55
+ 也可 `WzOpenClient.from_env()`(读 `WZ_OPEN_APP_ID` / `WZ_OPEN_APP_SECRET`,可选 `WZ_OPEN_ENDPOINT`,缺省测试环境)。
56
+
57
+ 按域调用,方法不堆在 `WzOpenClient` 上:
58
+
59
+ - `client.device` 设备绑定、计费模式、状态、在线日志
60
+ - `client.charge.start` / `stop`
61
+ - `client.renew`
62
+ - `client.invoice`
63
+ - `client.callback.log` / `retry_finish_order`
64
+
65
+ 默认请求超时 10s。
66
+
67
+ ## 签名
68
+
69
+ SDK 按接入指引自动加 `app_id`、`timestamp`、`sign`(HMAC-SHA256 大写 hex)。调用方不要自己拼签,也不要传 `company_id`。金额单位为分。
70
+
71
+ 契约快照:[../api/openapi.json](../api/openapi.json)(汽车桩 `saveMode`/`getMode` 在 SDK 中拆成独立方法;`/open/device/setting/sync` 属于 inner,本 SDK 不实现)。公开能力合计 40 条出站 API。
72
+
73
+ ## 日志
74
+
75
+ 使用标准库 `logging`,logger 名 `wzopensdk`。可传入自定义 logger。
76
+
77
+ 成功请求的 info 日志会带 `method`、`path`;响应头有 `X-B3-TraceId` 时再带 `traceId`。失败时同一字段出现在 error 日志里,并可从 `WzOpenAPIError.trace_id` 读取。
78
+
79
+ ## HTTP
80
+
81
+ 可配 timeout(默认 10s)、retry_count(失败重试且每次重签)、retry_wait、user_agent、proxy_url,或直接传入 `httpx.Client`。
82
+
83
+ ## 环境
84
+
85
+ | 环境 | Base URL |
86
+ | --- | --- |
87
+ | 测试 | `https://dev-api-open.wanzhuangkj.com` |
88
+ | 正式 | `https://api-open.wanzhuangkj.com` |
89
+
90
+ ## 开发
91
+
92
+ ```bash
93
+ pip install -e ".[dev]"
94
+ python3 -m pytest -q
95
+ # 或 make test
96
+ ```
97
+
98
+ 现网验证(需要凭证):
99
+
100
+ ```bash
101
+ export WZ_OPEN_APP_ID=...
102
+ export WZ_OPEN_APP_SECRET=...
103
+ make verify
104
+ ```
@@ -0,0 +1,26 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/wz_opensdk.egg-info/PKG-INFO
4
+ src/wz_opensdk.egg-info/SOURCES.txt
5
+ src/wz_opensdk.egg-info/dependency_links.txt
6
+ src/wz_opensdk.egg-info/requires.txt
7
+ src/wz_opensdk.egg-info/top_level.txt
8
+ src/wzopensdk/__init__.py
9
+ src/wzopensdk/callback.py
10
+ src/wzopensdk/charge.py
11
+ src/wzopensdk/client.py
12
+ src/wzopensdk/device.py
13
+ src/wzopensdk/endpoints.py
14
+ src/wzopensdk/errors.py
15
+ src/wzopensdk/invoice.py
16
+ src/wzopensdk/renew.py
17
+ src/wzopensdk/_internal/__init__.py
18
+ src/wzopensdk/_internal/http.py
19
+ src/wzopensdk/_internal/params.py
20
+ src/wzopensdk/_internal/signer.py
21
+ tests/test_client.py
22
+ tests/test_contract_error.py
23
+ tests/test_device.py
24
+ tests/test_live_smoke.py
25
+ tests/test_service.py
26
+ tests/test_signer.py
@@ -0,0 +1,4 @@
1
+ httpx>=0.27
2
+
3
+ [dev]
4
+ pytest>=8.0
@@ -0,0 +1 @@
1
+ wzopensdk
@@ -0,0 +1,13 @@
1
+ """万桩开放平台 Python OpenSDK。"""
2
+
3
+ from wzopensdk.client import WzOpenClient
4
+ from wzopensdk.endpoints import Endpoints
5
+ from wzopensdk.errors import WzOpenAPIError, WzOpenConfigError, WzOpenError
6
+
7
+ __all__ = [
8
+ "Endpoints",
9
+ "WzOpenAPIError",
10
+ "WzOpenClient",
11
+ "WzOpenConfigError",
12
+ "WzOpenError",
13
+ ]
@@ -0,0 +1,207 @@
1
+ """Sign, send, retry, and decode open-platform envelopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from typing import Any, Callable, TypeVar
9
+
10
+ import httpx
11
+
12
+ from wzopensdk._internal import params as paramx
13
+ from wzopensdk._internal import signer
14
+ from wzopensdk.errors import WzOpenAPIError, WzOpenError
15
+
16
+ CODE_SUCCESS = 1
17
+ HEADER_B3_TRACE_ID = "X-B3-TraceId"
18
+ T = TypeVar("T")
19
+
20
+
21
+ class HttpInvoker:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ endpoint: str,
26
+ app_id: str,
27
+ app_secret: str,
28
+ timeout: float = 10.0,
29
+ retry_count: int = 0,
30
+ retry_wait: float = 0.2,
31
+ user_agent: str = "wz-opensdk/python",
32
+ proxy_url: str | None = None,
33
+ insecure_skip_verify: bool = False,
34
+ logger: logging.Logger | None = None,
35
+ http_client: httpx.Client | None = None,
36
+ clock: Callable[[], int] | None = None,
37
+ ) -> None:
38
+ self.endpoint = endpoint.rstrip("/")
39
+ self.app_id = app_id
40
+ self.app_secret = app_secret
41
+ self.timeout = timeout if timeout > 0 else 10.0
42
+ self.retry_count = max(0, retry_count)
43
+ self.retry_wait = retry_wait if retry_wait > 0 else 0.2
44
+ self.user_agent = user_agent or "wz-opensdk/python"
45
+ self.log = logger or logging.getLogger("wzopensdk")
46
+ self.clock = clock or (lambda: int(time.time()))
47
+ if http_client is not None:
48
+ self.http = http_client
49
+ self._owns_client = False
50
+ else:
51
+ self.http = httpx.Client(
52
+ timeout=self.timeout,
53
+ proxy=proxy_url,
54
+ verify=not insecure_skip_verify,
55
+ )
56
+ self._owns_client = True
57
+
58
+ def invoke(
59
+ self,
60
+ method: str,
61
+ path: str,
62
+ operation: str,
63
+ incoming: Any,
64
+ decode: Callable[[Any], T],
65
+ ) -> T:
66
+ business = paramx.to_params(incoming)
67
+ wait = self.retry_wait
68
+ attempts = self.retry_count + 1
69
+ last: WzOpenError | None = None
70
+ for attempt in range(attempts):
71
+ if attempt > 0:
72
+ time.sleep(wait)
73
+ wait *= 2
74
+ try:
75
+ return self._attempt_once(method, path, operation, business, decode)
76
+ except WzOpenError as exc:
77
+ last = exc
78
+ if not self._should_retry(exc) or attempt == attempts - 1:
79
+ raise
80
+ raise last or WzOpenError("request failed")
81
+
82
+ def invoke_map(self, method: str, path: str, operation: str, incoming: Any) -> dict[str, Any] | None:
83
+ return self.invoke(method, path, operation, incoming, lambda data: data)
84
+
85
+ def invoke_void(self, method: str, path: str, operation: str, incoming: Any) -> None:
86
+ self.invoke(method, path, operation, incoming, lambda data: data)
87
+
88
+ def _attempt_once(
89
+ self,
90
+ method: str,
91
+ path: str,
92
+ operation: str,
93
+ business: dict[str, Any],
94
+ decode: Callable[[Any], T],
95
+ ) -> T:
96
+ payload = dict(business)
97
+ payload["app_id"] = self.app_id
98
+ payload["timestamp"] = self.clock()
99
+ payload["sign"] = signer.sign(payload, self.app_secret)
100
+
101
+ method = method.upper()
102
+ url = self.endpoint + path
103
+ headers = {
104
+ "Accept": "application/json",
105
+ "User-Agent": self.user_agent,
106
+ }
107
+ request_kwargs: dict[str, Any] = {
108
+ "headers": headers,
109
+ "timeout": self.timeout,
110
+ }
111
+ if method in {"GET", "HEAD"}:
112
+ qs = paramx.encode_query(payload)
113
+ if qs:
114
+ url = f"{url}?{qs}"
115
+ else:
116
+ request_kwargs["json"] = payload
117
+ headers["Content-Type"] = "application/json"
118
+
119
+ try:
120
+ resp = self.http.request(method, url, **request_kwargs)
121
+ except httpx.TimeoutException as exc:
122
+ self.log.error(
123
+ "open api request failed method=%s path=%s operation=%s err=%s",
124
+ method,
125
+ path,
126
+ operation,
127
+ exc,
128
+ )
129
+ raise WzOpenError(f"request timed out: {exc}") from exc
130
+ except httpx.HTTPError as exc:
131
+ self.log.error(
132
+ "open api request failed method=%s path=%s operation=%s err=%s",
133
+ method,
134
+ path,
135
+ operation,
136
+ exc,
137
+ )
138
+ raise WzOpenError(f"network error: {exc}") from exc
139
+
140
+ status = resp.status_code
141
+ trace_id = _response_trace_id(resp)
142
+ if status < 200 or status >= 300:
143
+ self._log_failure(method, path, operation, status, trace_id)
144
+ raise WzOpenAPIError.http_status(status, operation, trace_id)
145
+
146
+ try:
147
+ env = resp.json()
148
+ except json.JSONDecodeError as exc:
149
+ raise WzOpenError(f"decode envelope: {exc}") from exc
150
+ if not isinstance(env, dict):
151
+ raise WzOpenError("decode envelope: not an object")
152
+
153
+ code = int(env.get("code") or 0)
154
+ if code != CODE_SUCCESS:
155
+ raise WzOpenAPIError(status, code, str(env.get("message") or ""), operation, trace_id)
156
+
157
+ self._log_success(method, path, operation, status, trace_id)
158
+ data = env.get("data")
159
+ return decode(data)
160
+
161
+ def _log_success(self, method: str, path: str, operation: str, status: int, trace_id: str) -> None:
162
+ if trace_id:
163
+ self.log.info(
164
+ "open api request method=%s path=%s operation=%s status=%s traceId=%s",
165
+ method,
166
+ path,
167
+ operation,
168
+ status,
169
+ trace_id,
170
+ )
171
+ return
172
+ self.log.info(
173
+ "open api request method=%s path=%s operation=%s status=%s",
174
+ method,
175
+ path,
176
+ operation,
177
+ status,
178
+ )
179
+
180
+ def _log_failure(self, method: str, path: str, operation: str, status: int, trace_id: str) -> None:
181
+ if trace_id:
182
+ self.log.error(
183
+ "open api request failed method=%s path=%s operation=%s status=%s traceId=%s",
184
+ method,
185
+ path,
186
+ operation,
187
+ status,
188
+ trace_id,
189
+ )
190
+ return
191
+ self.log.error(
192
+ "open api request failed method=%s path=%s operation=%s status=%s",
193
+ method,
194
+ path,
195
+ operation,
196
+ status,
197
+ )
198
+
199
+ def _should_retry(self, err: WzOpenError) -> bool:
200
+ if isinstance(err, WzOpenAPIError):
201
+ return err.status >= 500
202
+ cause = err.__cause__
203
+ return isinstance(cause, httpx.HTTPError)
204
+
205
+
206
+ def _response_trace_id(resp: httpx.Response) -> str:
207
+ return (resp.headers.get(HEADER_B3_TRACE_ID) or "").strip()
@@ -0,0 +1,52 @@
1
+ """Convert request models to signed parameter maps and GET query strings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, is_dataclass
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ from wzopensdk._internal import signer
10
+
11
+
12
+ def to_params(value: Any) -> dict[str, Any]:
13
+ if value is None:
14
+ return {}
15
+ if isinstance(value, dict):
16
+ raw = value
17
+ elif is_dataclass(value) and not isinstance(value, type):
18
+ raw = asdict(value)
19
+ elif hasattr(value, "__dict__"):
20
+ raw = {k: v for k, v in vars(value).items() if not k.startswith("_")}
21
+ else:
22
+ raw = {}
23
+ out: dict[str, Any] = {}
24
+ for key, item in raw.items():
25
+ if signer.is_blank(item):
26
+ continue
27
+ out[str(key)] = item
28
+ return out
29
+
30
+
31
+ def encode_query(params: dict[str, Any]) -> str:
32
+ if not params:
33
+ return ""
34
+ parts: list[str] = []
35
+ for key in sorted(params.keys()):
36
+ value = params[key]
37
+ if signer.is_blank(value):
38
+ continue
39
+ items = _as_slice(value)
40
+ if items is not None:
41
+ q_key = key if key.endswith("[]") else key + "[]"
42
+ for item in items:
43
+ parts.append(f"{quote(q_key, safe='')}={quote(item, safe='')}")
44
+ continue
45
+ parts.append(f"{quote(str(key), safe='')}={quote(signer.stringify(value), safe='')}")
46
+ return "&".join(parts)
47
+
48
+
49
+ def _as_slice(value: Any) -> list[str] | None:
50
+ if isinstance(value, (list, tuple)):
51
+ return [signer.stringify(item) for item in value if not signer.is_blank(item)]
52
+ return None