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,6 @@
1
+ """wbapi-async code generator."""
2
+
3
+ from .codegen import run
4
+
5
+
6
+ __all__ = ("run",)
@@ -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()
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ wbapi-codegen = wbapi_codegen.__main__:main