ssc_codegen 0.19.4__tar.gz → 0.20.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/PKG-INFO +2 -1
  2. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/pyproject.toml +2 -1
  3. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/converters/py_bs4.py +19 -336
  4. ssc_codegen-0.20.0/ssc_codegen/converters/py_helpers.py +361 -0
  5. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/converters/py_lxml.py +4 -3
  6. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/converters/py_parsel.py +3 -2
  7. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/converters/py_slax.py +3 -2
  8. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/main.py +77 -0
  9. ssc_codegen-0.20.0/ssc_codegen/openapi/__init__.py +87 -0
  10. ssc_codegen-0.20.0/ssc_codegen/openapi/converter.py +684 -0
  11. ssc_codegen-0.20.0/ssc_codegen/openapi/emitter.py +128 -0
  12. ssc_codegen-0.20.0/ssc_codegen/openapi/parser.py +143 -0
  13. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/parsers/spec.py +3 -1
  14. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/.gitignore +0 -0
  15. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/LICENSE +0 -0
  16. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/README.md +0 -0
  17. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/__init__.py +0 -0
  18. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/_logging.py +0 -0
  19. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/__init__.py +0 -0
  20. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/array.py +0 -0
  21. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/base.py +0 -0
  22. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/cast.py +0 -0
  23. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/control.py +0 -0
  24. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/extract.py +0 -0
  25. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/helpers.py +0 -0
  26. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/jsondef.py +0 -0
  27. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/module.py +0 -0
  28. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/predicate_containers.py +0 -0
  29. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/predicate_ops.py +0 -0
  30. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/regex.py +0 -0
  31. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/selectors.py +0 -0
  32. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/string.py +0 -0
  33. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/struct.py +0 -0
  34. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/transform.py +0 -0
  35. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/typedef.py +0 -0
  36. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/ast/types.py +0 -0
  37. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/converters/base.py +0 -0
  38. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/converters/go_goquery.py +0 -0
  39. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/converters/helpers.py +0 -0
  40. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/converters/js_pure.py +0 -0
  41. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/document_utils.py +0 -0
  42. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/exceptions.py +0 -0
  43. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/health.py +0 -0
  44. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/kdl/__init__.py +0 -0
  45. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/kdl/parser.py +0 -0
  46. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/__init__.py +0 -0
  47. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/_kdl_lang.py +0 -0
  48. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/base.py +0 -0
  49. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/errors.py +0 -0
  50. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/format_errors.py +0 -0
  51. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/metadata.py +0 -0
  52. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/navigation.py +0 -0
  53. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/path.py +0 -0
  54. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/rule_keywords.py +0 -0
  55. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/rules.py +0 -0
  56. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/rules_struct.py +0 -0
  57. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/type_rules.py +0 -0
  58. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/linter/types.py +0 -0
  59. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/parser.py +0 -0
  60. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/parsers/__init__.py +0 -0
  61. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/parsers/curl.py +0 -0
  62. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/parsers/http.py +0 -0
  63. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/pseudo_selectors.py +0 -0
  64. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/regex_utils.py +0 -0
  65. {ssc_codegen-0.19.4 → ssc_codegen-0.20.0}/ssc_codegen/selector_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssc_codegen
3
- Version: 0.19.4
3
+ Version: 0.20.0
4
4
  Summary: Python-dsl code converter to html parser for web scraping
5
5
  Project-URL: Documentation, https://github.com/vypivshiy/selector_schema_codegen#readme
6
6
  Project-URL: Issues, https://github.com/vypivshiy/selector_schema_codegen/issues
@@ -24,6 +24,7 @@ Requires-Dist: click<8.2.0
24
24
  Requires-Dist: colorama>=0.4.6; sys_platform == 'win32'
25
25
  Requires-Dist: cssselect>=1.2.0
26
26
  Requires-Dist: lxml>=5.3.0
27
+ Requires-Dist: pyyaml>=6.0
27
28
  Requires-Dist: soupsieve>=2.6
28
29
  Requires-Dist: typer>=0.15.1
29
30
  Requires-Dist: typing-extensions; python_version < '3.11'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.19.4"
3
+ version = "0.20.0"
4
4
  description = "Python-dsl code converter to html parser for web scraping "
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -15,6 +15,7 @@ dependencies = [
15
15
  # https://github.com/fastapi/typer/discussions/1215
16
16
  # https://github.com/fastapi/typer/pull/1145
17
17
  "click<8.2.0",
18
+ "pyyaml>=6.0",
18
19
  ]
19
20
 
20
21
  classifiers = [
@@ -1,4 +1,5 @@
1
1
  from ssc_codegen.converters.base import ConverterContext, BaseConverter
2
+ from ssc_codegen.converters import py_helpers
2
3
 
3
4
  # types
4
5
  from ssc_codegen.ast import VariableType, StructType
@@ -22,14 +23,12 @@ from ssc_codegen.parsers import (
22
23
  from ssc_codegen.ast import (
23
24
  Docstring,
24
25
  Imports,
25
- Module,
26
26
  Utilities,
27
27
  JsonDef,
28
28
  JsonDefField,
29
29
  TypeDef,
30
30
  TypeDefField,
31
31
  Struct,
32
- PlaceholderSpec,
33
32
  )
34
33
 
35
34
  # struct layer
@@ -177,28 +176,6 @@ def pre_docstring(node: Docstring, _: ConverterContext):
177
176
  ]
178
177
 
179
178
 
180
- def _module_has_rest(node) -> bool:
181
- """True if the given node's Module has any `struct type=rest`."""
182
- module = node
183
- while module is not None and not isinstance(module, Module):
184
- module = getattr(module, "parent", None)
185
- if module is None:
186
- return False
187
- return any(
188
- isinstance(n, Struct) and n.is_rest for n in getattr(module, "body", [])
189
- )
190
-
191
-
192
- def rest_imports(node) -> list[str]:
193
- """Extra imports required when the module has any `struct type=rest`."""
194
- if not _module_has_rest(node):
195
- return []
196
- return [
197
- "from dataclasses import dataclass, field",
198
- "from typing import Generic, Literal, Mapping, TypeVar",
199
- ]
200
-
201
-
202
179
  @PY_BASE_CONVERTER(Imports)
203
180
  def pre_imports(node: Imports, _: ConverterContext):
204
181
  base_imports = [
@@ -208,7 +185,7 @@ def pre_imports(node: Imports, _: ConverterContext):
208
185
  "from typing import TypedDict, Optional, Any, List, Dict, Union",
209
186
  "from html import unescape as _html_unescape",
210
187
  ]
211
- base_imports.extend(rest_imports(node))
188
+ base_imports.extend(py_helpers.rest_imports(node))
212
189
 
213
190
  # Get transform imports for Python (already collected during parsing)
214
191
  transform_imports = sorted(node.transform_imports.get("py", set()))
@@ -216,16 +193,6 @@ def pre_imports(node: Imports, _: ConverterContext):
216
193
  return base_imports + transform_imports
217
194
 
218
195
 
219
- def http_client_import(ctx: ConverterContext) -> list[str]:
220
- """Return the import line(s) for the chosen HTTP client, or [] if none."""
221
- client = ctx.meta.get("http_client", "")
222
- if client == "requests":
223
- return ["import requests"]
224
- if client == "httpx":
225
- return ["import httpx"]
226
- return []
227
-
228
-
229
196
  # hook for add extra dependencies
230
197
  @PY_BASE_CONVERTER.post(Imports)
231
198
  def post_imports(node: Imports, ctx: ConverterContext):
@@ -233,7 +200,7 @@ def post_imports(node: Imports, ctx: ConverterContext):
233
200
  "from bs4 import BeautifulSoup, ResultSet, Tag",
234
201
  "BS4_FEATURES = 'lxml'",
235
202
  ]
236
- lines.extend(http_client_import(ctx))
203
+ lines.extend(py_helpers.http_client_import(ctx))
237
204
  return lines
238
205
 
239
206
 
@@ -285,55 +252,10 @@ def pre_utilities(node: Utilities, _: ConverterContext):
285
252
  "UNMATCHED_TABLE_ROW = _UnmatchedTableRow()",
286
253
  "\n\n",
287
254
  ]
288
- lines.extend(rest_utilities(node))
255
+ lines.extend(py_helpers.rest_utilities(node))
289
256
  return lines
290
257
 
291
258
 
292
- def rest_utilities(node) -> list[str]:
293
- """Ok/Err/UnknownErr/TransportErr + _parse_response block; empty if no REST."""
294
- if not _module_has_rest(node):
295
- return []
296
- return [
297
- "_T = TypeVar('_T')",
298
- "_E = TypeVar('_E')",
299
- "\n",
300
- "@dataclass(frozen=True)",
301
- "class Ok(Generic[_T]):",
302
- " status: int = 0",
303
- " headers: Mapping[str, str] = field(default_factory=dict)",
304
- " value: _T = None # type: ignore[assignment]",
305
- " is_ok: Literal[True] = True",
306
- "\n",
307
- "@dataclass(frozen=True)",
308
- "class Err(Generic[_E]):",
309
- " status: int = 0",
310
- " headers: Mapping[str, str] = field(default_factory=dict)",
311
- " value: _E = None # type: ignore[assignment]",
312
- " is_ok: Literal[False] = False",
313
- "\n",
314
- "@dataclass(frozen=True)",
315
- "class UnknownErr(Err[Any]):",
316
- " pass",
317
- "\n",
318
- "@dataclass(frozen=True)",
319
- "class TransportErr(Err[None]):",
320
- " status: Literal[0] = 0",
321
- " cause: str = ''",
322
- " value: None = None",
323
- " headers: Mapping[str, str] = field(default_factory=dict)",
324
- "\n\n",
325
- "def _parse_response(_resp):",
326
- " _status = _resp.status_code",
327
- " _headers = {k.lower(): v for k, v in _resp.headers.items()}",
328
- " try:",
329
- " _body = _resp.json()",
330
- " except Exception:",
331
- " _body = None",
332
- " return _status, _headers, _body",
333
- "\n\n",
334
- ]
335
-
336
-
337
259
  @PY_BASE_CONVERTER(JsonDef, post_callback="})")
338
260
  def pre_json_struct(node: JsonDef, _: ConverterContext):
339
261
  name = to_pascal_case(node.name)
@@ -394,108 +316,6 @@ def pre_typedef_field(node: TypeDefField, ctx: ConverterContext):
394
316
  return [f"{ctx.indent}{name}: {type_}"]
395
317
 
396
318
 
397
- def _err_subclass_name(struct_name: str, err: ErrorResponse) -> str:
398
- """Naming: `<Struct>Err<Status>[<FieldPascal>]`."""
399
- base = f"{to_pascal_case(struct_name)}Err{err.status}"
400
- if err.discriminator_field:
401
- base += to_pascal_case(err.discriminator_field)
402
- return base
403
-
404
-
405
- def _err_value_type(err: ErrorResponse, struct: Struct) -> str:
406
- """Return the typed value annotation for an Err subclass."""
407
- schema = err.schema_name
408
- if not schema:
409
- return "Any"
410
- type_name = f"{to_pascal_case(schema)}Json"
411
- module = struct.parent
412
- if module is not None:
413
- for n in module.body:
414
- if isinstance(n, JsonDef) and n.name == schema and n.is_array:
415
- return f"List[{type_name}]"
416
- return type_name
417
-
418
-
419
- def _rest_err_union_type(struct: Struct) -> str:
420
- """Return the typed union of all Err variants for _dispatch_err sig."""
421
- variants: list[str] = []
422
- seen: set[str] = set()
423
- for err in struct.errors:
424
- cls_name = _err_subclass_name(struct.name, err)
425
- if cls_name not in seen:
426
- seen.add(cls_name)
427
- variants.append(cls_name)
428
- return "Union[" + ", ".join([*variants, "UnknownErr", "None"]) + "]"
429
-
430
-
431
- def _emit_dispatch_err_py(node: Struct, ctx: ConverterContext) -> list[str]:
432
- """Emit the `_dispatch_err` @staticmethod lines inside a REST class.
433
-
434
- ctx arrives from StructDocstring — already at class-body depth.
435
- """
436
- i1 = ctx.indent # class-body level
437
- i2 = i1 + ctx.indent_char # method body
438
- i3 = i2 + ctx.indent_char # nested (if ...:)
439
- i4 = i3 + ctx.indent_char
440
-
441
- errors = node.errors
442
- status_errors = [e for e in errors if e.discriminator_field is None]
443
- field_errors = [e for e in errors if e.discriminator_field is not None]
444
- union_type = _rest_err_union_type(node)
445
-
446
- lines: list[str] = [
447
- f"{i1}@staticmethod",
448
- f"{i1}def _dispatch_err("
449
- f"_status: int, _headers: Mapping[str, str], _body: Any"
450
- f") -> {union_type}:",
451
- f"{i2}if 200 <= _status < 300:",
452
- ]
453
-
454
- # 2xx field discriminators check first, inside the 2xx branch
455
- if field_errors:
456
- lines.append(f"{i3}if isinstance(_body, dict):")
457
- emitted_field_branch = False
458
- for err in field_errors:
459
- if 200 <= err.status < 300:
460
- cls_name = _err_subclass_name(node.name, err)
461
- lines.append(
462
- f"{i4}if _status == {err.status} "
463
- f"and {err.discriminator_field!r} in _body:"
464
- )
465
- lines.append(
466
- f"{i4}{ctx.indent_char}return {cls_name}("
467
- f"headers=_headers, value=_body)"
468
- )
469
- emitted_field_branch = True
470
- if not emitted_field_branch:
471
- # no 2xx-field errors → drop the isinstance guard
472
- lines.pop()
473
- lines.append(f"{i3}return None")
474
-
475
- # non-2xx status routing
476
- for err in status_errors:
477
- cls_name = _err_subclass_name(node.name, err)
478
- lines.append(f"{i2}if _status == {err.status}:")
479
- lines.append(f"{i3}return {cls_name}(headers=_headers, value=_body)")
480
- # non-2xx field discriminators (unusual but supported)
481
- for err in field_errors:
482
- if not (200 <= err.status < 300):
483
- cls_name = _err_subclass_name(node.name, err)
484
- lines.append(
485
- f"{i2}if _status == {err.status} "
486
- f"and isinstance(_body, dict) "
487
- f"and {err.discriminator_field!r} in _body:"
488
- )
489
- lines.append(
490
- f"{i3}return {cls_name}(headers=_headers, value=_body)"
491
- )
492
-
493
- lines.append(
494
- f"{i2}return UnknownErr(status=_status, headers=_headers, value=_body)"
495
- )
496
- return lines
497
-
498
-
499
319
  @PY_BASE_CONVERTER(Struct)
500
320
  def pre_struct(node: Struct, ctx: ConverterContext):
501
321
  name = to_pascal_case(node.name)
@@ -503,15 +323,19 @@ def pre_struct(node: Struct, ctx: ConverterContext):
503
323
  if node.is_rest:
504
324
  seen: set[str] = set()
505
325
  for err in node.errors:
506
- cls_name = _err_subclass_name(node.name, err)
326
+ cls_name = py_helpers._err_subclass_name(node.name, err)
507
327
  if cls_name in seen:
508
328
  continue
509
329
  seen.add(cls_name)
510
- value_type = _err_value_type(err, node)
330
+ value_type = py_helpers._err_value_type(err, node)
511
331
  lines.append("@dataclass(frozen=True)")
512
332
  lines.append(f"class {cls_name}(Err[{value_type}]):")
513
333
  lines.append(f" status: Literal[{err.status}] = {err.status}")
514
334
  lines.append("")
335
+ alias_lines = py_helpers._emit_result_aliases(node)
336
+ if alias_lines:
337
+ lines.extend(alias_lines)
338
+ lines.append("")
515
339
  lines.append(f"class {name}:")
516
340
  return lines
517
341
 
@@ -523,7 +347,7 @@ def post_struct_docstring(node: StructDocstring, ctx: ConverterContext):
523
347
  parent = node.parent
524
348
  if not isinstance(parent, Struct) or not parent.is_rest:
525
349
  return None
526
- return _emit_dispatch_err_py(parent, ctx)
350
+ return py_helpers._emit_dispatch_err_py(parent, ctx)
527
351
 
528
352
 
529
353
  @PY_BASE_CONVERTER(StructDocstring)
@@ -1614,147 +1438,6 @@ def pre_expr_pred_re_any(node: PredReAny, ctx: ConverterContext):
1614
1438
  return ctx.indent + f"and {cond}"
1615
1439
 
1616
1440
 
1617
- def _build_request_method(
1618
- *,
1619
- name: str,
1620
- is_async: bool,
1621
- client_type: str,
1622
- struct_name: str,
1623
- call_args: list[str],
1624
- ph_params: str,
1625
- pre_lines: list[str],
1626
- response_path: str,
1627
- response_join: str,
1628
- i1: str,
1629
- i2: str,
1630
- i3: str,
1631
- ) -> list[str]:
1632
- async_kw = "async " if is_async else ""
1633
- await_kw = "await " if is_async else ""
1634
- lines: list[str] = [
1635
- f"{i1}@classmethod",
1636
- f'{i1}{async_kw}def {name}(cls, client: {client_type}{ph_params}) -> "{struct_name}":',
1637
- ]
1638
- lines.extend(pre_lines)
1639
- lines.append(f"{i2}_resp = {await_kw}client.request(")
1640
- lines.extend(f"{i3}{a}" for a in call_args)
1641
- lines.append(f"{i2})")
1642
- lines.append(f"{i2}_resp.raise_for_status()")
1643
- if response_path:
1644
- accessor = "".join(f"[{p!r}]" for p in response_path.split("."))
1645
- lines.append(f"{i2}_data = _resp.json()")
1646
- if response_join:
1647
- lines.append(f"{i2}_body = {response_join!r}.join(_data{accessor})")
1648
- else:
1649
- lines.append(f"{i2}_body = _data{accessor}")
1650
- else:
1651
- lines.append(f"{i2}_body = _resp.text")
1652
- lines.append(f"{i2}return cls(_body)")
1653
- return lines
1654
-
1655
-
1656
- _PY_PRIM_ANNO = {"str": "str", "int": "int", "float": "float", "bool": "bool"}
1657
-
1658
-
1659
- def _ph_to_py_annotation(ph: PlaceholderSpec) -> tuple[str, str]:
1660
- """Return (annotation, default_suffix). Suffix is '' or ' = None'."""
1661
- anno = _PY_PRIM_ANNO[ph.type_name]
1662
- if ph.is_array:
1663
- anno = f"List[{anno}]"
1664
- if ph.is_optional:
1665
- return f"Optional[{anno}]", " = None"
1666
- return anno, ""
1667
-
1668
-
1669
- def _render_signature_params(placeholders: list[PlaceholderSpec]) -> str:
1670
- """Build keyword-only parameters clause: ', *, a: int, b: Optional[str] = None'."""
1671
- if not placeholders:
1672
- return ""
1673
- required = [p for p in placeholders if not p.is_optional]
1674
- optional = [p for p in placeholders if p.is_optional]
1675
- parts: list[str] = []
1676
- for ph in required + optional:
1677
- anno, default = _ph_to_py_annotation(ph)
1678
- parts.append(f"{ph.name}: {anno}{default}")
1679
- return ", *, " + ", ".join(parts)
1680
-
1681
-
1682
- def _resolve_ok_payload_type(node: RequestConfig) -> str:
1683
- """Return the Python type annotation for the successful payload."""
1684
- if not node.response_schema:
1685
- return "None"
1686
- struct = node.parent
1687
- module = struct.parent if struct is not None else None
1688
- schema_type = f"{to_pascal_case(node.response_schema)}Json"
1689
- if module is not None:
1690
- for n in module.body:
1691
- if isinstance(n, JsonDef) and n.name == node.response_schema:
1692
- if n.is_array:
1693
- return f"List[{schema_type}]"
1694
- break
1695
- return schema_type
1696
-
1697
-
1698
- def _resolve_response_type(node: RequestConfig) -> str:
1699
- """Return the full Result-union annotation for a REST method."""
1700
- payload = _resolve_ok_payload_type(node)
1701
- struct = node.parent
1702
- err_variants: list[str] = []
1703
- seen: set[str] = set()
1704
- if isinstance(struct, Struct):
1705
- for err in struct.errors:
1706
- cls_name = _err_subclass_name(struct.name, err)
1707
- if cls_name not in seen:
1708
- seen.add(cls_name)
1709
- err_variants.append(cls_name)
1710
- parts = [f"Ok[{payload}]", *err_variants, "UnknownErr", "TransportErr"]
1711
- return "Union[" + ", ".join(parts) + "]"
1712
-
1713
-
1714
- def _build_rest_method(
1715
- *,
1716
- node: RequestConfig,
1717
- name: str,
1718
- is_async: bool,
1719
- client_type: str,
1720
- call_args: list[str],
1721
- ph_params: str,
1722
- pre_lines: list[str],
1723
- ret_type: str,
1724
- transport_exc: str,
1725
- i1: str,
1726
- i2: str,
1727
- i3: str,
1728
- i4: str,
1729
- ) -> list[str]:
1730
- async_kw = "async " if is_async else ""
1731
- await_kw = "await " if is_async else ""
1732
-
1733
- lines: list[str] = [
1734
- f"{i1}@classmethod",
1735
- f"{i1}{async_kw}def {name}(cls, client: {client_type}"
1736
- f"{ph_params}) -> {ret_type}:",
1737
- ]
1738
- if node.doc:
1739
- lines.append(f'{i2}"""{node.doc}"""')
1740
- lines.extend(pre_lines)
1741
- lines.append(f"{i2}try:")
1742
- lines.append(f"{i3}_resp = {await_kw}client.request(")
1743
- lines.extend(f"{i4}{a}" for a in call_args)
1744
- lines.append(f"{i3})")
1745
- lines.append(f"{i2}except {transport_exc} as _exc:")
1746
- lines.append(f"{i3}return TransportErr(cause=repr(_exc))")
1747
- lines.append(f"{i2}_status, _headers, _body = _parse_response(_resp)")
1748
- lines.append(f"{i2}_err = cls._dispatch_err(_status, _headers, _body)")
1749
- lines.append(f"{i2}if _err is not None:")
1750
- lines.append(f"{i3}return _err")
1751
- ok_value = "_body" if node.response_schema else "None"
1752
- lines.append(
1753
- f"{i2}return Ok(status=_status, headers=_headers, value={ok_value})"
1754
- )
1755
- return lines
1756
-
1757
-
1758
1441
  @PY_BASE_CONVERTER(RequestConfig)
1759
1442
  def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1760
1443
  spec = normalize_placeholder_names(
@@ -1770,7 +1453,7 @@ def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1770
1453
 
1771
1454
  is_rest = isinstance(node.parent, Struct) and node.parent.is_rest
1772
1455
  struct_name = to_pascal_case(node.parent.name)
1773
- ph_params = _render_signature_params(spec.placeholders)
1456
+ ph_params = py_helpers._render_signature_params(spec.placeholders)
1774
1457
 
1775
1458
  # Pre-lines: emitted inside the method body before `client.request(...)`
1776
1459
  # to build local _headers/_cookies/_params dicts when dict_needs_builder().
@@ -1798,7 +1481,7 @@ def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1798
1481
  # REST method naming: kebab-case name → snake_case, "" → "fetch"
1799
1482
  method_name = to_snake_case(node.name) if node.name else "fetch"
1800
1483
  async_method_name = f"async_{method_name}"
1801
- ret_type = _resolve_response_type(node)
1484
+ ret_type = py_helpers._resolve_response_type(node)
1802
1485
 
1803
1486
  if http_client == "httpx":
1804
1487
  transport_exc = "httpx.HTTPError"
@@ -1819,7 +1502,7 @@ def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1819
1502
  )
1820
1503
 
1821
1504
  if http_client == "httpx":
1822
- lines = _build_rest_method(
1505
+ lines = py_helpers._build_rest_method(
1823
1506
  name=method_name,
1824
1507
  is_async=False,
1825
1508
  client_type="httpx.Client",
@@ -1827,7 +1510,7 @@ def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1827
1510
  )
1828
1511
  lines.append("")
1829
1512
  lines.extend(
1830
- _build_rest_method(
1513
+ py_helpers._build_rest_method(
1831
1514
  name=async_method_name,
1832
1515
  is_async=True,
1833
1516
  client_type="httpx.AsyncClient",
@@ -1836,7 +1519,7 @@ def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1836
1519
  )
1837
1520
  return lines
1838
1521
 
1839
- return _build_rest_method(
1522
+ return py_helpers._build_rest_method(
1840
1523
  name=method_name,
1841
1524
  is_async=False,
1842
1525
  client_type="requests.Session",
@@ -1861,7 +1544,7 @@ def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1861
1544
  )
1862
1545
 
1863
1546
  if http_client == "httpx":
1864
- lines = _build_request_method(
1547
+ lines = py_helpers._build_request_method(
1865
1548
  name=fetch_name,
1866
1549
  is_async=False,
1867
1550
  client_type="httpx.Client",
@@ -1869,7 +1552,7 @@ def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1869
1552
  )
1870
1553
  lines.append("")
1871
1554
  lines.extend(
1872
- _build_request_method(
1555
+ py_helpers._build_request_method(
1873
1556
  name=async_fetch_name,
1874
1557
  is_async=True,
1875
1558
  client_type="httpx.AsyncClient",
@@ -1879,7 +1562,7 @@ def pre_request_config(node: RequestConfig, ctx: ConverterContext):
1879
1562
  return lines
1880
1563
 
1881
1564
  # requests (default)
1882
- return _build_request_method(
1565
+ return py_helpers._build_request_method(
1883
1566
  name=fetch_name,
1884
1567
  is_async=False,
1885
1568
  client_type="requests.Session",