wbapi-codegen 0.1.0__py3-none-any.whl
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,59 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
wbapi-codegen --target ./wbapi-async --swagger-dir ./.wb/swagger
|
|
6
|
+
wbapi-codegen --target ./wbapi-async .wb/swagger/02-products.yaml
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .codegen import run
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="wbapi-codegen",
|
|
21
|
+
description="Generate wbapi-async types, methods and client from Wildberries OpenAPI YAML files.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--target",
|
|
25
|
+
type=Path,
|
|
26
|
+
default=Path("."),
|
|
27
|
+
help="Path to the wbapi-async project root (default: current directory)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--swagger-dir",
|
|
31
|
+
type=Path,
|
|
32
|
+
default=None,
|
|
33
|
+
help="Directory with YAML files (default: <target>/.wb/swagger)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"files",
|
|
37
|
+
nargs="*",
|
|
38
|
+
type=Path,
|
|
39
|
+
help="Explicit YAML files to process (overrides --swagger-dir)",
|
|
40
|
+
)
|
|
41
|
+
args = parser.parse_args()
|
|
42
|
+
|
|
43
|
+
target: Path = args.target.resolve()
|
|
44
|
+
|
|
45
|
+
if args.files:
|
|
46
|
+
yaml_files = [Path(f).resolve() for f in args.files]
|
|
47
|
+
else:
|
|
48
|
+
swagger_dir: Path = args.swagger_dir or (target / ".wb" / "swagger")
|
|
49
|
+
yaml_files = sorted(swagger_dir.glob("*.yaml")) + sorted(swagger_dir.glob("*.yml"))
|
|
50
|
+
|
|
51
|
+
if not yaml_files:
|
|
52
|
+
print(f"No YAML files found. Pass files explicitly or check --swagger-dir.", file=sys.stderr)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
run(yaml_files, target)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
main()
|
wbapi_codegen/codegen.py
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wildberries API code generator for wbapi-async.
|
|
3
|
+
|
|
4
|
+
Parses OpenAPI YAML files and generates:
|
|
5
|
+
- <target>/src/wbapi_async/types/<name>.py
|
|
6
|
+
- <target>/src/wbapi_async/methods/<name>.py
|
|
7
|
+
- <target>/src/wbapi_async/types/__init__.py
|
|
8
|
+
- <target>/src/wbapi_async/methods/__init__.py
|
|
9
|
+
- <target>/src/wbapi_async/client/api.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from keyword import iskeyword
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Helpers
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def camel_to_snake(name: str) -> str:
|
|
32
|
+
"""nmID → nm_id, listGoods → list_goods, X-Nm-Id → x_nm_id"""
|
|
33
|
+
name = re.sub(r"[^a-zA-Z0-9]", "_", name)
|
|
34
|
+
s = _CAMEL_RE.sub("_", name).lower()
|
|
35
|
+
s = re.sub(r"_+", "_", s).strip("_")
|
|
36
|
+
if iskeyword(s):
|
|
37
|
+
s = s + "_"
|
|
38
|
+
return s
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def snake_to_class(name: str) -> str:
|
|
42
|
+
return "".join(w.title() for w in name.split("_"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def path_to_method_name(path: str) -> str:
|
|
46
|
+
parts = [p for p in path.strip("/").split("/") if p]
|
|
47
|
+
if parts and parts[0] == "api":
|
|
48
|
+
parts = parts[1:]
|
|
49
|
+
parts = [p for p in parts if not re.fullmatch(r"v\d+", p)]
|
|
50
|
+
parts = [re.sub(r"\{(\w+)\}", lambda m: camel_to_snake(m.group(1)), p) for p in parts]
|
|
51
|
+
parts = [camel_to_snake(p) for p in parts]
|
|
52
|
+
return "_".join(parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def openapi_type_to_python(schema: dict[str, Any], required: bool = False) -> str:
|
|
56
|
+
if not schema:
|
|
57
|
+
return "Any"
|
|
58
|
+
nullable = schema.get("nullable", False)
|
|
59
|
+
t = schema.get("type")
|
|
60
|
+
ref = schema.get("$ref")
|
|
61
|
+
if ref:
|
|
62
|
+
base = "Any"
|
|
63
|
+
elif t == "integer":
|
|
64
|
+
base = "int"
|
|
65
|
+
elif t == "number":
|
|
66
|
+
base = "float"
|
|
67
|
+
elif t == "boolean":
|
|
68
|
+
base = "bool"
|
|
69
|
+
elif t == "string":
|
|
70
|
+
base = "str"
|
|
71
|
+
elif t == "array":
|
|
72
|
+
items = schema.get("items", {})
|
|
73
|
+
inner = openapi_type_to_python(items, required=True)
|
|
74
|
+
base = f"list[{inner}]"
|
|
75
|
+
elif t == "object":
|
|
76
|
+
base = "dict[str, Any]"
|
|
77
|
+
else:
|
|
78
|
+
base = "Any"
|
|
79
|
+
if not required or nullable:
|
|
80
|
+
return f"{base} | None"
|
|
81
|
+
return base
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Rate limit parsing
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
_RL_TABLE_RE = re.compile(
|
|
89
|
+
r"\|\s*(\d+)\s+(\w+)\s*\|"
|
|
90
|
+
r"\s*(\d+)\s+requests?\s*\|"
|
|
91
|
+
r"\s*(\d+)\s+(\w+)\s*\|"
|
|
92
|
+
r"\s*(\d+)\s+requests?\s*\|",
|
|
93
|
+
re.IGNORECASE,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_PERIOD_UNITS = {"sec": 1, "s": 1, "min": 60, "minute": 60, "hour": 3600}
|
|
97
|
+
_INTERVAL_UNITS = {"ms": 1, "s": 1000, "sec": 1000, "min": 60_000}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_duration(value: int, unit: str) -> int:
|
|
101
|
+
u = unit.lower()
|
|
102
|
+
result = _PERIOD_UNITS.get(u) or _PERIOD_UNITS.get(u.rstrip("s"))
|
|
103
|
+
return value * (result or 60)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_interval(value: int, unit: str) -> int:
|
|
107
|
+
u = unit.lower().rstrip("s")
|
|
108
|
+
return value * _INTERVAL_UNITS.get(u, 1)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class RateLimit:
|
|
113
|
+
period: int = 60
|
|
114
|
+
limit: int = 10
|
|
115
|
+
interval: int = 600
|
|
116
|
+
burst: int = 5
|
|
117
|
+
|
|
118
|
+
def __str__(self) -> str:
|
|
119
|
+
return (
|
|
120
|
+
f"RequestLimit(period={self.period}, limit={self.limit}, "
|
|
121
|
+
f"interval={self.interval}, burst={self.burst})"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_DEFAULT_RATE_LIMIT = RateLimit()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_rate_limit(description: str) -> RateLimit:
|
|
129
|
+
if not description:
|
|
130
|
+
return _DEFAULT_RATE_LIMIT
|
|
131
|
+
m = _RL_TABLE_RE.search(description)
|
|
132
|
+
if not m:
|
|
133
|
+
return _DEFAULT_RATE_LIMIT
|
|
134
|
+
period_val, period_unit, limit_val, interval_val, interval_unit, burst_val = m.groups()
|
|
135
|
+
return RateLimit(
|
|
136
|
+
period=_parse_duration(int(period_val), period_unit),
|
|
137
|
+
limit=int(limit_val),
|
|
138
|
+
interval=_parse_interval(int(interval_val), interval_unit),
|
|
139
|
+
burst=int(burst_val),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Intermediate representation
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class FieldDef:
|
|
149
|
+
py_name: str
|
|
150
|
+
alias: str
|
|
151
|
+
py_type: str
|
|
152
|
+
default: Any
|
|
153
|
+
description: str = ""
|
|
154
|
+
exclude: bool = False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class TypeDef:
|
|
159
|
+
class_name: str
|
|
160
|
+
fields: list[FieldDef]
|
|
161
|
+
description: str = ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class MethodDef:
|
|
166
|
+
class_name: str
|
|
167
|
+
method_name: str
|
|
168
|
+
api_host: str
|
|
169
|
+
http_method: str
|
|
170
|
+
path: str
|
|
171
|
+
path_template: str | None
|
|
172
|
+
return_type: str
|
|
173
|
+
return_type_file: str
|
|
174
|
+
data_key: str | None
|
|
175
|
+
rate_limit: RateLimit
|
|
176
|
+
params: list[FieldDef]
|
|
177
|
+
description: str = ""
|
|
178
|
+
source_url: str = ""
|
|
179
|
+
type_def: TypeDef | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# OpenAPI parser
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _host_from_server(servers: list[dict]) -> str:
|
|
187
|
+
if not servers:
|
|
188
|
+
return "api"
|
|
189
|
+
url: str = servers[0].get("url", "")
|
|
190
|
+
host = url.replace("https://", "").replace("http://", "")
|
|
191
|
+
host = re.sub(r"\.wildberries\.ru$", "", host)
|
|
192
|
+
host = re.sub(r"\.ru$", "", host)
|
|
193
|
+
return host
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _resolve_ref(ref: str, spec: dict) -> dict:
|
|
197
|
+
parts = ref.lstrip("#/").split("/")
|
|
198
|
+
node: Any = spec
|
|
199
|
+
for part in parts:
|
|
200
|
+
if isinstance(node, dict):
|
|
201
|
+
node = node.get(part, {})
|
|
202
|
+
else:
|
|
203
|
+
return {}
|
|
204
|
+
return node if isinstance(node, dict) else {}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_schema(schema: dict, spec: dict) -> dict:
|
|
208
|
+
if "$ref" not in schema:
|
|
209
|
+
return schema
|
|
210
|
+
return _resolve_ref(schema["$ref"], spec)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _collect_fields(schema: dict, spec: dict, required_set: set[str] | None = None) -> list[FieldDef]:
|
|
214
|
+
schema = _resolve_schema(schema, spec)
|
|
215
|
+
props = schema.get("properties", {})
|
|
216
|
+
required_keys = set(schema.get("required", required_set or []))
|
|
217
|
+
fields: list[FieldDef] = []
|
|
218
|
+
for prop_name, prop_schema in props.items():
|
|
219
|
+
prop_schema = _resolve_schema(prop_schema, spec)
|
|
220
|
+
py_name = camel_to_snake(prop_name)
|
|
221
|
+
required = prop_name in required_keys
|
|
222
|
+
py_type = openapi_type_to_python(prop_schema, required=required)
|
|
223
|
+
desc = prop_schema.get("description", "").strip().split("\n")[0]
|
|
224
|
+
fields.append(FieldDef(
|
|
225
|
+
py_name=py_name,
|
|
226
|
+
alias=prop_name,
|
|
227
|
+
py_type=py_type,
|
|
228
|
+
default=None,
|
|
229
|
+
description=desc,
|
|
230
|
+
))
|
|
231
|
+
return fields
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _find_response_array(schema: dict, spec: dict) -> tuple[list[FieldDef], str | None]:
|
|
235
|
+
schema = _resolve_schema(schema, spec)
|
|
236
|
+
|
|
237
|
+
def walk(node: dict, path: list[str]) -> tuple[list[FieldDef] | None, list[str] | None]:
|
|
238
|
+
node = _resolve_schema(node, spec)
|
|
239
|
+
if node.get("type") == "array":
|
|
240
|
+
items = _resolve_schema(node.get("items", {}), spec)
|
|
241
|
+
return _collect_fields(items, spec), path
|
|
242
|
+
if node.get("type") == "object" or "properties" in node:
|
|
243
|
+
for prop, sub in node.get("properties", {}).items():
|
|
244
|
+
result, found_path = walk(_resolve_schema(sub, spec), path + [prop])
|
|
245
|
+
if result is not None:
|
|
246
|
+
return result, found_path
|
|
247
|
+
return None, None
|
|
248
|
+
|
|
249
|
+
fields, path_parts = walk(schema, [])
|
|
250
|
+
if fields is None:
|
|
251
|
+
fields = _collect_fields(schema, spec)
|
|
252
|
+
return fields, None
|
|
253
|
+
dot_path = ".".join(path_parts) if path_parts else None
|
|
254
|
+
return fields, dot_path
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def parse_yaml(yaml_path: Path) -> list[MethodDef]:
|
|
258
|
+
with yaml_path.open() as f:
|
|
259
|
+
spec = yaml.safe_load(f)
|
|
260
|
+
|
|
261
|
+
paths = spec.get("paths", {})
|
|
262
|
+
results: list[MethodDef] = []
|
|
263
|
+
|
|
264
|
+
for path_str, path_item in paths.items():
|
|
265
|
+
path_servers = path_item.get("servers", [])
|
|
266
|
+
|
|
267
|
+
for http_verb in ("get", "post", "put", "delete", "patch"):
|
|
268
|
+
operation = path_item.get(http_verb)
|
|
269
|
+
if not operation:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
servers = operation.get("servers") or path_servers or spec.get("servers", [])
|
|
273
|
+
api_host = _host_from_server(servers)
|
|
274
|
+
http_method = http_verb.upper()
|
|
275
|
+
summary = operation.get("summary", "")
|
|
276
|
+
description = operation.get("description", "")
|
|
277
|
+
rate_limit = parse_rate_limit(description)
|
|
278
|
+
|
|
279
|
+
# request parameters
|
|
280
|
+
parameters = operation.get("parameters", []) + path_item.get("parameters", [])
|
|
281
|
+
param_fields: list[FieldDef] = []
|
|
282
|
+
path_params: list[str] = []
|
|
283
|
+
|
|
284
|
+
for param in parameters:
|
|
285
|
+
param = _resolve_schema(param, spec)
|
|
286
|
+
p_name: str = param.get("name", "")
|
|
287
|
+
p_in: str = param.get("in", "query")
|
|
288
|
+
p_required: bool = param.get("required", p_in == "path")
|
|
289
|
+
p_schema = _resolve_schema(param.get("schema", {}), spec)
|
|
290
|
+
py_name = camel_to_snake(p_name)
|
|
291
|
+
py_type = openapi_type_to_python(p_schema, required=p_required)
|
|
292
|
+
default_val = p_schema.get("default")
|
|
293
|
+
desc = param.get("description", "").strip().split("\n")[0]
|
|
294
|
+
|
|
295
|
+
if p_in == "path":
|
|
296
|
+
path_params.append(py_name)
|
|
297
|
+
param_fields.append(FieldDef(
|
|
298
|
+
py_name=py_name,
|
|
299
|
+
alias=p_name,
|
|
300
|
+
py_type=py_type.replace(" | None", ""),
|
|
301
|
+
default=None,
|
|
302
|
+
description=desc,
|
|
303
|
+
exclude=True,
|
|
304
|
+
))
|
|
305
|
+
else:
|
|
306
|
+
if not p_required and default_val is None:
|
|
307
|
+
py_type = py_type if "| None" in py_type else f"{py_type} | None"
|
|
308
|
+
param_fields.append(FieldDef(
|
|
309
|
+
py_name=py_name,
|
|
310
|
+
alias=p_name,
|
|
311
|
+
py_type=py_type,
|
|
312
|
+
default=default_val,
|
|
313
|
+
description=desc,
|
|
314
|
+
))
|
|
315
|
+
|
|
316
|
+
# request body
|
|
317
|
+
request_body = _resolve_schema(operation.get("requestBody", {}), spec)
|
|
318
|
+
if request_body:
|
|
319
|
+
body_content = request_body.get("content", {})
|
|
320
|
+
body_schema = _resolve_schema(
|
|
321
|
+
body_content.get("application/json", {}).get("schema", {}), spec
|
|
322
|
+
)
|
|
323
|
+
body_required_keys = set(body_schema.get("required", []))
|
|
324
|
+
for prop_name, prop_schema in body_schema.get("properties", {}).items():
|
|
325
|
+
prop_schema = _resolve_schema(prop_schema, spec)
|
|
326
|
+
py_name = camel_to_snake(prop_name)
|
|
327
|
+
required = prop_name in body_required_keys
|
|
328
|
+
py_type = openapi_type_to_python(prop_schema, required=required)
|
|
329
|
+
default_val = prop_schema.get("default")
|
|
330
|
+
desc = prop_schema.get("description", "").strip().split("\n")[0]
|
|
331
|
+
param_fields.append(FieldDef(
|
|
332
|
+
py_name=py_name,
|
|
333
|
+
alias=prop_name,
|
|
334
|
+
py_type=py_type,
|
|
335
|
+
default=default_val,
|
|
336
|
+
description=desc,
|
|
337
|
+
))
|
|
338
|
+
|
|
339
|
+
# response type
|
|
340
|
+
responses = operation.get("responses", {})
|
|
341
|
+
ok_response = _resolve_schema(
|
|
342
|
+
responses.get("200", responses.get("201", {})), spec
|
|
343
|
+
)
|
|
344
|
+
ok_content = ok_response.get("content", {})
|
|
345
|
+
ok_schema = _resolve_schema(
|
|
346
|
+
ok_content.get("application/json", {}).get("schema", {}), spec
|
|
347
|
+
)
|
|
348
|
+
item_fields, data_key = _find_response_array(ok_schema, spec)
|
|
349
|
+
|
|
350
|
+
# build names from summary
|
|
351
|
+
if summary:
|
|
352
|
+
words = re.sub(r"[^a-zA-Z0-9 ]", "", summary).split()
|
|
353
|
+
method_name = "_".join(w.lower() for w in words)
|
|
354
|
+
else:
|
|
355
|
+
verb_map = {"GET": "get", "POST": "post", "PUT": "update", "DELETE": "delete", "PATCH": "patch"}
|
|
356
|
+
method_name = f"{verb_map.get(http_method, http_method.lower())}_{path_to_method_name(path_str)}"
|
|
357
|
+
|
|
358
|
+
class_name = snake_to_class(method_name)
|
|
359
|
+
type_name = class_name + "Item" if data_key else class_name + "Response"
|
|
360
|
+
type_file = camel_to_snake(type_name)
|
|
361
|
+
|
|
362
|
+
path_clean = path_str.lstrip("/")
|
|
363
|
+
if path_params:
|
|
364
|
+
path_template = re.sub(
|
|
365
|
+
r"\{(\w+)\}",
|
|
366
|
+
lambda m: "{" + camel_to_snake(m.group(1)) + "}",
|
|
367
|
+
path_clean,
|
|
368
|
+
)
|
|
369
|
+
path_for_method = ""
|
|
370
|
+
else:
|
|
371
|
+
path_template = None
|
|
372
|
+
path_for_method = path_clean
|
|
373
|
+
|
|
374
|
+
tag = (operation.get("tags") or [""])[0]
|
|
375
|
+
tag_slug = re.sub(r"[^a-zA-Z0-9]", "-", tag).strip("-")
|
|
376
|
+
path_encoded = path_str.replace("/", "~1")
|
|
377
|
+
source_url = (
|
|
378
|
+
f"https://dev.wildberries.ru/en/openapi/"
|
|
379
|
+
f"{yaml_path.stem}#tag/{tag_slug}/paths/{path_encoded}/{http_verb}"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
results.append(MethodDef(
|
|
383
|
+
class_name=class_name,
|
|
384
|
+
method_name=method_name,
|
|
385
|
+
api_host=api_host,
|
|
386
|
+
http_method=http_method,
|
|
387
|
+
path=path_for_method,
|
|
388
|
+
path_template=path_template,
|
|
389
|
+
return_type=type_name,
|
|
390
|
+
return_type_file=type_file,
|
|
391
|
+
data_key=data_key,
|
|
392
|
+
rate_limit=rate_limit,
|
|
393
|
+
params=param_fields,
|
|
394
|
+
description=summary,
|
|
395
|
+
source_url=source_url,
|
|
396
|
+
type_def=TypeDef(
|
|
397
|
+
class_name=type_name,
|
|
398
|
+
fields=item_fields or [],
|
|
399
|
+
description=summary,
|
|
400
|
+
),
|
|
401
|
+
))
|
|
402
|
+
|
|
403
|
+
return results
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# Code generators
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
def _field_line(f: FieldDef, indent: int = 4) -> str:
|
|
411
|
+
pad = " " * indent
|
|
412
|
+
alias_part = f', alias="{f.alias}"' if f.alias != f.py_name else ""
|
|
413
|
+
exclude_part = ", exclude=True" if f.exclude else ""
|
|
414
|
+
|
|
415
|
+
if f.default is None and not f.exclude:
|
|
416
|
+
default_part = "None"
|
|
417
|
+
elif f.default is not None:
|
|
418
|
+
default_part = f'"{f.default}"' if isinstance(f.default, str) else str(f.default)
|
|
419
|
+
else:
|
|
420
|
+
default_part = None
|
|
421
|
+
|
|
422
|
+
if default_part is not None:
|
|
423
|
+
return f"{pad}{f.py_name}: {f.py_type} = Field({default_part}{alias_part}{exclude_part})"
|
|
424
|
+
else:
|
|
425
|
+
args = ", ".join(filter(None, [alias_part.lstrip(", "), exclude_part.lstrip(", ")]))
|
|
426
|
+
return f"{pad}{f.py_name}: {f.py_type} = Field({args})"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def generate_type_file(td: TypeDef) -> str:
|
|
430
|
+
uses_any = any("Any" in f.py_type for f in td.fields)
|
|
431
|
+
imports = ["from pydantic import Field", "", "from .base import BaseType"]
|
|
432
|
+
if uses_any:
|
|
433
|
+
imports.insert(0, "from typing import Any")
|
|
434
|
+
imports.insert(1, "")
|
|
435
|
+
lines = imports + ["", ""]
|
|
436
|
+
lines.append(f"class {td.class_name}(BaseType):")
|
|
437
|
+
if td.description:
|
|
438
|
+
lines.append(f' """{td.description}"""')
|
|
439
|
+
lines.append("")
|
|
440
|
+
if not td.fields:
|
|
441
|
+
lines.append(" pass")
|
|
442
|
+
else:
|
|
443
|
+
for f in td.fields:
|
|
444
|
+
lines.append(_field_line(f))
|
|
445
|
+
lines.append("")
|
|
446
|
+
return "\n".join(lines)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def generate_method_file(md: MethodDef) -> str:
|
|
450
|
+
type_import = f"from ..types.{md.return_type_file} import {md.return_type}"
|
|
451
|
+
uses_field = bool(md.params)
|
|
452
|
+
uses_any = any("Any" in p.py_type for p in md.params)
|
|
453
|
+
|
|
454
|
+
imports: list[str] = []
|
|
455
|
+
if uses_any:
|
|
456
|
+
imports += ["from typing import Any", ""]
|
|
457
|
+
if uses_field:
|
|
458
|
+
imports += ["from pydantic import Field", ""]
|
|
459
|
+
imports += [type_import, "from ..types.request_limit import RequestLimit", "from .base import WbMethod"]
|
|
460
|
+
|
|
461
|
+
lines = imports + ["", ""]
|
|
462
|
+
lines.append(f"class {md.class_name}(WbMethod):")
|
|
463
|
+
|
|
464
|
+
doc_lines = []
|
|
465
|
+
if md.description:
|
|
466
|
+
doc_lines.append(md.description)
|
|
467
|
+
if md.source_url:
|
|
468
|
+
if doc_lines:
|
|
469
|
+
doc_lines.append("")
|
|
470
|
+
doc_lines.append(f"Source: {md.source_url}")
|
|
471
|
+
if doc_lines:
|
|
472
|
+
lines += [' """'] + [f" {dl}" if dl else "" for dl in doc_lines] + [' """', ""]
|
|
473
|
+
|
|
474
|
+
lines.append(f" __return__ = {md.return_type}")
|
|
475
|
+
lines.append(f' __api__ = "{md.api_host}"')
|
|
476
|
+
if md.path_template:
|
|
477
|
+
lines.append(' __method__ = ""')
|
|
478
|
+
lines.append(f' __method_template__ = "{md.path_template}"')
|
|
479
|
+
else:
|
|
480
|
+
lines.append(f' __method__ = "{md.path}"')
|
|
481
|
+
if md.http_method != "GET":
|
|
482
|
+
lines.append(f' __http_method__ = "{md.http_method}"')
|
|
483
|
+
if md.data_key:
|
|
484
|
+
lines.append(f' __data_key__ = "{md.data_key}"')
|
|
485
|
+
lines += ["", f" request_limit: RequestLimit = {md.rate_limit}", ""]
|
|
486
|
+
|
|
487
|
+
if md.params:
|
|
488
|
+
for p in md.params:
|
|
489
|
+
lines.append(_field_line(p))
|
|
490
|
+
lines.append("")
|
|
491
|
+
|
|
492
|
+
return "\n".join(lines)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def generate_types_init(generated: list[tuple[str, str]], manual: list[tuple[str, str]]) -> str:
|
|
496
|
+
all_entries = sorted(set(manual + generated), key=lambda x: x[0])
|
|
497
|
+
lines = [f"from .{stem} import {cls}" for cls, stem in all_entries]
|
|
498
|
+
lines += ["", "", "__all__ = ("]
|
|
499
|
+
lines += [f' "{cls}",' for cls, _ in all_entries]
|
|
500
|
+
lines += [")", ""]
|
|
501
|
+
return "\n".join(lines)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def generate_methods_init(generated: list[tuple[str, str]], manual: list[tuple[str, str]]) -> str:
|
|
505
|
+
lines = ["from .base import WbMethod"]
|
|
506
|
+
all_entries = sorted(set(manual + generated), key=lambda x: x[0])
|
|
507
|
+
lines += [f"from .{stem} import {cls}" for cls, stem in all_entries]
|
|
508
|
+
lines += ["", "", "__all__ = (", ' "WbMethod",']
|
|
509
|
+
lines += [f' "{cls}",' for cls, _ in all_entries]
|
|
510
|
+
lines += [")", ""]
|
|
511
|
+
return "\n".join(lines)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _extract_unofficial_api_methods(api_path: Path) -> str:
|
|
515
|
+
if not api_path.exists():
|
|
516
|
+
return ""
|
|
517
|
+
lines = api_path.read_text().splitlines(keepends=True)
|
|
518
|
+
blocks: list[str] = []
|
|
519
|
+
i = 0
|
|
520
|
+
while i < len(lines):
|
|
521
|
+
if re.match(r" @unofficial\s*(\n|$)", lines[i]):
|
|
522
|
+
block: list[str] = [lines[i]]
|
|
523
|
+
i += 1
|
|
524
|
+
inside_def = False
|
|
525
|
+
while i < len(lines):
|
|
526
|
+
l = lines[i]
|
|
527
|
+
if re.match(r" async def | def ", l):
|
|
528
|
+
if inside_def:
|
|
529
|
+
break
|
|
530
|
+
inside_def = True
|
|
531
|
+
elif inside_def and re.match(r" @\w", l):
|
|
532
|
+
break
|
|
533
|
+
block.append(l)
|
|
534
|
+
i += 1
|
|
535
|
+
blocks.append("".join(block).rstrip())
|
|
536
|
+
else:
|
|
537
|
+
i += 1
|
|
538
|
+
return ("\n\n".join(blocks) + "\n") if blocks else ""
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _extract_names_from_block(block: str) -> tuple[set[str], set[str]]:
|
|
542
|
+
type_names: set[str] = set()
|
|
543
|
+
method_names: set[str] = set()
|
|
544
|
+
for m in re.finditer(r"->\s*(?:list\[)?([A-Z][A-Za-z0-9]+)\]?", block):
|
|
545
|
+
type_names.add(m.group(1))
|
|
546
|
+
for m in re.finditer(r"=\s*([A-Z][A-Za-z0-9]+)\(", block):
|
|
547
|
+
method_names.add(m.group(1))
|
|
548
|
+
return type_names, method_names
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _api_method_wrapper(md: MethodDef) -> str:
|
|
552
|
+
non_path_params = [p for p in md.params if not p.exclude]
|
|
553
|
+
path_params = [p for p in md.params if p.exclude]
|
|
554
|
+
|
|
555
|
+
sig_parts = ["self"]
|
|
556
|
+
for p in path_params:
|
|
557
|
+
sig_parts.append(f"{p.py_name}: {p.py_type}")
|
|
558
|
+
for p in non_path_params:
|
|
559
|
+
default = "None" if p.default is None else (
|
|
560
|
+
f'"{p.default}"' if isinstance(p.default, str) else str(p.default)
|
|
561
|
+
)
|
|
562
|
+
sig_parts.append(f"{p.py_name}: {p.py_type} = {default}")
|
|
563
|
+
|
|
564
|
+
sig = ", ".join(sig_parts)
|
|
565
|
+
ret = f"list[{md.return_type}]"
|
|
566
|
+
|
|
567
|
+
call_args = ", ".join(
|
|
568
|
+
f"{p.py_name}={p.py_name}" for p in path_params + non_path_params
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
lines = [
|
|
572
|
+
f" async def {md.method_name}(",
|
|
573
|
+
f" {sig},",
|
|
574
|
+
f" ) -> {ret}:",
|
|
575
|
+
]
|
|
576
|
+
if md.description:
|
|
577
|
+
lines.append(f' """{md.description}')
|
|
578
|
+
if md.source_url:
|
|
579
|
+
lines += ["", f" Source: {md.source_url}"]
|
|
580
|
+
lines.append(' """')
|
|
581
|
+
lines += [
|
|
582
|
+
f" call = {md.class_name}({call_args})",
|
|
583
|
+
" return await self(call)",
|
|
584
|
+
]
|
|
585
|
+
return "\n".join(lines)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def generate_api_file(method_defs: list[MethodDef], unofficial_block: str) -> str:
|
|
589
|
+
type_imports: set[str] = set()
|
|
590
|
+
method_imports: set[str] = set()
|
|
591
|
+
|
|
592
|
+
for md in method_defs:
|
|
593
|
+
type_imports.add(md.return_type)
|
|
594
|
+
method_imports.add(md.class_name)
|
|
595
|
+
|
|
596
|
+
extra_types, extra_methods = _extract_names_from_block(unofficial_block)
|
|
597
|
+
type_imports |= extra_types
|
|
598
|
+
method_imports |= extra_methods
|
|
599
|
+
|
|
600
|
+
lines = [
|
|
601
|
+
"from typing import Any",
|
|
602
|
+
"",
|
|
603
|
+
"from ..client.session.base import BaseSession",
|
|
604
|
+
"from ..methods import (",
|
|
605
|
+
" WbMethod,",
|
|
606
|
+
*[f" {cls}," for cls in sorted(method_imports)],
|
|
607
|
+
")",
|
|
608
|
+
"from ..types import (",
|
|
609
|
+
*[f" {cls}," for cls in sorted(type_imports)],
|
|
610
|
+
")",
|
|
611
|
+
"from ..utils.token import validate_token",
|
|
612
|
+
"from ..utils.unofficial import unofficial",
|
|
613
|
+
"",
|
|
614
|
+
"",
|
|
615
|
+
"class WbAPI:",
|
|
616
|
+
" def __init__(self, token: str, session: BaseSession | None = None, **kwargs: Any) -> None:",
|
|
617
|
+
' """',
|
|
618
|
+
" WbAPI class.",
|
|
619
|
+
"",
|
|
620
|
+
" Attributes:",
|
|
621
|
+
"",
|
|
622
|
+
" token: Access token",
|
|
623
|
+
"",
|
|
624
|
+
" Source: https://dev.wildberries.ru/en/docs/openapi/api-information#tag/Authorization/How-to-create-a-personal-access-base-or-test-token",
|
|
625
|
+
' """',
|
|
626
|
+
" validate_token(token)",
|
|
627
|
+
" if session is None:",
|
|
628
|
+
' read_timeout = kwargs.get("read_timeout", 60)',
|
|
629
|
+
' base = kwargs.get("base", "wildberries.ru")',
|
|
630
|
+
" session = BaseSession(",
|
|
631
|
+
" base=base,",
|
|
632
|
+
" timeout=read_timeout,",
|
|
633
|
+
" )",
|
|
634
|
+
"",
|
|
635
|
+
" self._token = token",
|
|
636
|
+
" self.session = session",
|
|
637
|
+
"",
|
|
638
|
+
' async def __aenter__(self) -> "WbAPI":',
|
|
639
|
+
" return self",
|
|
640
|
+
"",
|
|
641
|
+
" async def __aexit__(self, exc_type: Any, _exc: Any, _tb: Any) -> None:",
|
|
642
|
+
" await self.session.close()",
|
|
643
|
+
"",
|
|
644
|
+
" async def __call__(self, method: WbMethod) -> Any:",
|
|
645
|
+
" return await method.emit(self)",
|
|
646
|
+
"",
|
|
647
|
+
]
|
|
648
|
+
|
|
649
|
+
for md in method_defs:
|
|
650
|
+
lines.append(_api_method_wrapper(md))
|
|
651
|
+
lines.append("")
|
|
652
|
+
|
|
653
|
+
if unofficial_block.strip():
|
|
654
|
+
lines += [" # --- unofficial methods (hand-written, not generated) ---", ""]
|
|
655
|
+
lines += unofficial_block.splitlines()
|
|
656
|
+
lines.append("")
|
|
657
|
+
|
|
658
|
+
return "\n".join(lines)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# ---------------------------------------------------------------------------
|
|
662
|
+
# Test generator
|
|
663
|
+
# ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
def _mock_value(py_type: str, field_name: str) -> str:
|
|
666
|
+
"""Return a plausible mock value for a given Python type string."""
|
|
667
|
+
t = py_type.replace(" | None", "").strip()
|
|
668
|
+
if t == "int":
|
|
669
|
+
return "1"
|
|
670
|
+
if t == "float":
|
|
671
|
+
return "1.0"
|
|
672
|
+
if t == "bool":
|
|
673
|
+
return "True"
|
|
674
|
+
if t == "str":
|
|
675
|
+
return f'"{field_name}"'
|
|
676
|
+
if t.startswith("list["):
|
|
677
|
+
return "[]"
|
|
678
|
+
if t.startswith("dict["):
|
|
679
|
+
return "{}"
|
|
680
|
+
return "None"
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _build_mock_response(fields: list[FieldDef], data_key: str | None) -> str:
|
|
684
|
+
"""Build the dict literal for api.add_response(...)."""
|
|
685
|
+
# item dict — 3 levels deep inside add_response(...)
|
|
686
|
+
pad_field = " " # 16 spaces
|
|
687
|
+
pad_item = " " # 12 spaces
|
|
688
|
+
field_lines = []
|
|
689
|
+
for f in fields:
|
|
690
|
+
val = _mock_value(f.py_type, f.alias)
|
|
691
|
+
field_lines.append(f'{pad_field}"{f.alias}": {val},')
|
|
692
|
+
|
|
693
|
+
item_dict = "{\n" + "\n".join(field_lines) + f"\n{pad_item}}}"
|
|
694
|
+
|
|
695
|
+
if not data_key:
|
|
696
|
+
return f"[{item_dict}]"
|
|
697
|
+
|
|
698
|
+
# wrap: "data.listGoods" → {"data": {"listGoods": [item]}}
|
|
699
|
+
keys = data_key.split(".")
|
|
700
|
+
result = f"[{item_dict}]"
|
|
701
|
+
for key in reversed(keys):
|
|
702
|
+
result = '{\n' + pad_item + f'"{key}": ' + result + f"\n }}"
|
|
703
|
+
return result
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def generate_test_file(md: MethodDef) -> str:
|
|
707
|
+
"""Render tests/test_methods/test_<name>.py for a MethodDef."""
|
|
708
|
+
class_name = f"Test{md.class_name}"
|
|
709
|
+
test_fn = f"test_{md.method_name}"
|
|
710
|
+
|
|
711
|
+
# required call args (path params + required non-path params without defaults)
|
|
712
|
+
required_params = [p for p in md.params if p.exclude] # path params always required
|
|
713
|
+
# also include non-optional body/query params
|
|
714
|
+
for p in md.params:
|
|
715
|
+
if not p.exclude and "| None" not in p.py_type and p.default is None:
|
|
716
|
+
required_params.append(p)
|
|
717
|
+
|
|
718
|
+
call_args = ", ".join(
|
|
719
|
+
f"{p.py_name}={_mock_value(p.py_type, p.py_name)}" for p in required_params
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
mock_response = _build_mock_response(
|
|
723
|
+
md.type_def.fields if md.type_def else [], md.data_key
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# pick a field to assert on (first non-None field)
|
|
727
|
+
assert_lines: list[str] = []
|
|
728
|
+
if md.type_def and md.type_def.fields:
|
|
729
|
+
for f in md.type_def.fields[:3]:
|
|
730
|
+
val = _mock_value(f.py_type, f.alias)
|
|
731
|
+
if val != "None":
|
|
732
|
+
assert_lines.append(
|
|
733
|
+
f" assert result[0].{f.py_name} == {val}"
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
lines = [
|
|
737
|
+
"import pytest",
|
|
738
|
+
"",
|
|
739
|
+
f"from wbapi_async.types.{md.return_type_file} import {md.return_type}",
|
|
740
|
+
"from tests.mocked_api import MockedAPI",
|
|
741
|
+
"",
|
|
742
|
+
"",
|
|
743
|
+
f"@pytest.mark.unit",
|
|
744
|
+
f"class {class_name}:",
|
|
745
|
+
"",
|
|
746
|
+
f" async def {test_fn}(self, api: MockedAPI) -> None:",
|
|
747
|
+
f" api.add_response(",
|
|
748
|
+
f" {mock_response}",
|
|
749
|
+
f" )",
|
|
750
|
+
"",
|
|
751
|
+
f" result = await api.{md.method_name}({call_args})",
|
|
752
|
+
"",
|
|
753
|
+
f" assert isinstance(result, list)",
|
|
754
|
+
f" assert len(result) == 1",
|
|
755
|
+
f" assert isinstance(result[0], {md.return_type})",
|
|
756
|
+
*assert_lines,
|
|
757
|
+
"",
|
|
758
|
+
]
|
|
759
|
+
return "\n".join(lines)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
# ---------------------------------------------------------------------------
|
|
763
|
+
# File discovery
|
|
764
|
+
# ---------------------------------------------------------------------------
|
|
765
|
+
|
|
766
|
+
def _scan_unofficial_methods(methods_dir: Path) -> set[str]:
|
|
767
|
+
result: list[str] = []
|
|
768
|
+
for py in methods_dir.glob("*.py"):
|
|
769
|
+
if py.name.startswith("_"):
|
|
770
|
+
continue
|
|
771
|
+
text = py.read_text()
|
|
772
|
+
if "__unofficial__" in text and "True" in text:
|
|
773
|
+
result.append(py.stem)
|
|
774
|
+
return set(result)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _scan_existing(directory: Path, skip_files: set[str]) -> list[tuple[str, str]]:
|
|
778
|
+
results = []
|
|
779
|
+
for py in sorted(directory.glob("*.py")):
|
|
780
|
+
stem = py.stem
|
|
781
|
+
if stem.startswith("_") or stem in {"base", "request_limit"}:
|
|
782
|
+
continue
|
|
783
|
+
if stem in skip_files:
|
|
784
|
+
continue
|
|
785
|
+
text = py.read_text()
|
|
786
|
+
for m in re.finditer(r"^class (\w+)\(", text, re.MULTILINE):
|
|
787
|
+
results.append((m.group(1), stem))
|
|
788
|
+
return results
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
# ---------------------------------------------------------------------------
|
|
792
|
+
# Main entry point
|
|
793
|
+
# ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
def run(yaml_files: list[Path], target: Path) -> None:
|
|
796
|
+
src = target / "src" / "wbapi_async"
|
|
797
|
+
methods_dir = src / "methods"
|
|
798
|
+
types_dir = src / "types"
|
|
799
|
+
api_path = src / "client" / "api.py"
|
|
800
|
+
|
|
801
|
+
unofficial_stems = _scan_unofficial_methods(methods_dir)
|
|
802
|
+
print(f"Unofficial methods (protected): {unofficial_stems or 'none'}")
|
|
803
|
+
|
|
804
|
+
unofficial_block = _extract_unofficial_api_methods(api_path)
|
|
805
|
+
|
|
806
|
+
new_type_entries: list[tuple[str, str]] = []
|
|
807
|
+
new_method_entries: list[tuple[str, str]] = []
|
|
808
|
+
all_method_defs: list[MethodDef] = []
|
|
809
|
+
generated_stems_types: set[str] = set()
|
|
810
|
+
generated_stems_methods: set[str] = set()
|
|
811
|
+
|
|
812
|
+
for yaml_path in yaml_files:
|
|
813
|
+
print(f"\nParsing {yaml_path.name} …")
|
|
814
|
+
try:
|
|
815
|
+
methods = parse_yaml(yaml_path)
|
|
816
|
+
except Exception as e:
|
|
817
|
+
print(f" ERROR: {e}")
|
|
818
|
+
continue
|
|
819
|
+
|
|
820
|
+
print(f" Found {len(methods)} endpoints")
|
|
821
|
+
|
|
822
|
+
for md in methods:
|
|
823
|
+
type_stem = md.return_type_file
|
|
824
|
+
type_path = types_dir / f"{type_stem}.py"
|
|
825
|
+
type_path.write_text(generate_type_file(md.type_def))
|
|
826
|
+
print(f" [type] {type_path.relative_to(target)}")
|
|
827
|
+
if (md.return_type, type_stem) not in new_type_entries:
|
|
828
|
+
new_type_entries.append((md.return_type, type_stem))
|
|
829
|
+
generated_stems_types.add(type_stem)
|
|
830
|
+
|
|
831
|
+
method_stem = camel_to_snake(md.class_name)
|
|
832
|
+
if method_stem in unofficial_stems:
|
|
833
|
+
print(f" [method] SKIP (unofficial): {method_stem}.py")
|
|
834
|
+
continue
|
|
835
|
+
(methods_dir / f"{method_stem}.py").write_text(generate_method_file(md))
|
|
836
|
+
print(f" [method] {(methods_dir / method_stem).relative_to(target)}.py")
|
|
837
|
+
if (md.class_name, method_stem) not in new_method_entries:
|
|
838
|
+
new_method_entries.append((md.class_name, method_stem))
|
|
839
|
+
all_method_defs.append(md)
|
|
840
|
+
generated_stems_methods.add(method_stem)
|
|
841
|
+
|
|
842
|
+
manual_type_entries = _scan_existing(types_dir, generated_stems_types)
|
|
843
|
+
unofficial_entries = _scan_existing(methods_dir, generated_stems_methods)
|
|
844
|
+
|
|
845
|
+
always_types = [("BaseType", "base"), ("RequestLimit", "request_limit")]
|
|
846
|
+
(types_dir / "__init__.py").write_text(generate_types_init(
|
|
847
|
+
new_type_entries,
|
|
848
|
+
[e for e in manual_type_entries if e not in new_type_entries] + always_types,
|
|
849
|
+
))
|
|
850
|
+
print(f"\n[init] {(types_dir / '__init__.py').relative_to(target)}")
|
|
851
|
+
|
|
852
|
+
(methods_dir / "__init__.py").write_text(generate_methods_init(
|
|
853
|
+
new_method_entries,
|
|
854
|
+
[e for e in unofficial_entries if e not in new_method_entries],
|
|
855
|
+
))
|
|
856
|
+
print(f"[init] {(methods_dir / '__init__.py').relative_to(target)}")
|
|
857
|
+
|
|
858
|
+
api_path.write_text(generate_api_file(all_method_defs, unofficial_block))
|
|
859
|
+
print(f"[api] {api_path.relative_to(target)}")
|
|
860
|
+
|
|
861
|
+
# --- generate tests ---
|
|
862
|
+
tests_dir = target / "tests" / "test_methods"
|
|
863
|
+
tests_dir.mkdir(parents=True, exist_ok=True)
|
|
864
|
+
init = tests_dir / "__init__.py"
|
|
865
|
+
if not init.exists():
|
|
866
|
+
init.write_text("")
|
|
867
|
+
|
|
868
|
+
# collect stems of unofficial tests (skip overwriting)
|
|
869
|
+
unofficial_test_stems = {f"test_{s}" for s in unofficial_stems}
|
|
870
|
+
|
|
871
|
+
for md in all_method_defs:
|
|
872
|
+
test_stem = f"test_{camel_to_snake(md.class_name)}"
|
|
873
|
+
test_path = tests_dir / f"{test_stem}.py"
|
|
874
|
+
if test_stem in unofficial_test_stems:
|
|
875
|
+
print(f" [test] SKIP (unofficial): {test_stem}.py")
|
|
876
|
+
continue
|
|
877
|
+
test_path.write_text(generate_test_file(md))
|
|
878
|
+
print(f"[test] {test_path.relative_to(target)}")
|
|
879
|
+
|
|
880
|
+
print("\nDone.")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wbapi-codegen
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Code generator for wbapi-async — generates types, methods and API client from Wildberries OpenAPI specs
|
|
5
|
+
Project-URL: Repository, https://github.com/serdukow/wbapi-codegen
|
|
6
|
+
Author-email: Andrei Serdiukov <asyncdf@gmail.com>
|
|
7
|
+
Maintainer-email: Andrei Serdiukov <asyncdf@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: api,codegen,openapi,wb,wildberries
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: pyyaml>=6.0
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# wbapi-codegen
|
|
20
|
+
|
|
21
|
+
Code generator for [wbapi-async](https://github.com/serdukow/wbapi-async).
|
|
22
|
+
|
|
23
|
+
Parses Wildberries OpenAPI YAML specs and generates:
|
|
24
|
+
- `src/wbapi_async/types/` — Pydantic response models
|
|
25
|
+
- `src/wbapi_async/methods/` — `WbMethod` request classes
|
|
26
|
+
- `src/wbapi_async/client/api.py` — `WbAPI` wrapper methods
|
|
27
|
+
|
|
28
|
+
Files marked `__unofficial__ = True` are never overwritten.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install wbapi-codegen
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# process all YAMLs from .wb/swagger/ into current directory
|
|
40
|
+
wbapi-codegen --target /path/to/wbapi-async
|
|
41
|
+
|
|
42
|
+
# custom swagger directory
|
|
43
|
+
wbapi-codegen --target /path/to/wbapi-async --swagger-dir /path/to/yaml/files
|
|
44
|
+
|
|
45
|
+
# single file
|
|
46
|
+
wbapi-codegen --target /path/to/wbapi-async /path/to/02-products.yaml
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
1. Downloads OpenAPI YAMLs from Wildberries developer portal
|
|
52
|
+
2. Parses paths, parameters, request bodies and response schemas
|
|
53
|
+
3. Generates typed Python files following the `wbapi-async` conventions
|
|
54
|
+
4. Creates a PR in the `wbapi-async` repo via GitHub Actions (runs daily at 04:00 UTC)
|
|
55
|
+
|
|
56
|
+
## Setup for GitHub Actions
|
|
57
|
+
|
|
58
|
+
In the `wbapi-codegen` repo, add a secret `WBAPI_PAT` — a GitHub Personal Access Token
|
|
59
|
+
with `repo` scope on the `wbapi-async` repository.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
wbapi_codegen/__init__.py,sha256=4g6KTyijeHYp95tXdx8g-KdCwTPr0T1dZLFrYbuC-y0,81
|
|
2
|
+
wbapi_codegen/__main__.py,sha256=0pV6279xYTEwRkhxw-99zPpIj2iSPJKkyz-TSm6EXfY,1547
|
|
3
|
+
wbapi_codegen/codegen.py,sha256=M5DF2SvqMuFNxE6dSxeIL42tlU39aNklfWlZ95loZlM,31039
|
|
4
|
+
wbapi_codegen-0.1.0.dist-info/METADATA,sha256=JMyZFU3CrwDW68fO6jWqgpdmzO9VTuy3lPgWPxugdJY,1971
|
|
5
|
+
wbapi_codegen-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
6
|
+
wbapi_codegen-0.1.0.dist-info/entry_points.txt,sha256=hvuWVkTiDPpvJ9W-azeRaVAk_uycxqiDcuO2jgRGYjw,62
|
|
7
|
+
wbapi_codegen-0.1.0.dist-info/RECORD,,
|