ssc_codegen 0.33.0__tar.gz → 0.35.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 (88) hide show
  1. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/PKG-INFO +1 -1
  2. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/pyproject.toml +1 -1
  3. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/__init__.py +4 -0
  4. ssc_codegen-0.35.0/ssc_codegen/ast/function.py +28 -0
  5. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/expressions.py +4 -2
  6. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/linter.py +65 -1
  7. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/module_handler.py +30 -1
  8. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/reader.py +6 -0
  9. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/struct_parser.py +42 -0
  10. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/rest.py +27 -19
  11. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/runtime.py +14 -0
  12. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/visitor.py +30 -3
  13. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/javascript/rest.py +50 -23
  14. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/javascript/visitor.py +26 -0
  15. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/rest.py +58 -37
  16. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/visitor.py +26 -0
  17. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/traversal/utils.py +9 -1
  18. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/traversal/walker.py +4 -0
  19. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/.gitignore +0 -0
  20. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/LICENSE +0 -0
  21. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/README.md +0 -0
  22. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/__init__.py +0 -0
  23. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/_logging.py +0 -0
  24. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/array.py +0 -0
  25. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/base.py +0 -0
  26. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/cast.py +0 -0
  27. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/control.py +0 -0
  28. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/extract.py +0 -0
  29. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/helpers.py +0 -0
  30. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/jsondef.py +0 -0
  31. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/module.py +0 -0
  32. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/predicate_containers.py +0 -0
  33. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/predicate_ops.py +0 -0
  34. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/regex.py +0 -0
  35. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/rest.py +0 -0
  36. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/selectors.py +0 -0
  37. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/string.py +0 -0
  38. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/struct.py +0 -0
  39. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/typedef.py +0 -0
  40. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/ast/types.py +0 -0
  41. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/__init__.py +0 -0
  42. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/contexts.py +0 -0
  43. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/format.py +0 -0
  44. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/predicates.py +0 -0
  45. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/rest_artifacts.py +0 -0
  46. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/core/type_checking.py +0 -0
  47. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/exceptions.py +0 -0
  48. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/explore.py +0 -0
  49. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/generation/__init__.py +0 -0
  50. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/generation/builder.py +0 -0
  51. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/generation/runtime.py +0 -0
  52. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/health.py +0 -0
  53. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/main.py +0 -0
  54. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/naming.py +0 -0
  55. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/parsers/__init__.py +0 -0
  56. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/parsers/curl.py +0 -0
  57. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/parsers/http.py +0 -0
  58. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/regex_utils.py +0 -0
  59. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/request_spec.py +0 -0
  60. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/__init__.py +0 -0
  61. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/__init__.py +0 -0
  62. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/http_libs/__init__.py +0 -0
  63. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/http_libs/base.py +0 -0
  64. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/http_libs/nethttp.py +0 -0
  65. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/literals.py +0 -0
  66. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/golang/regex.py +0 -0
  67. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/javascript/__init__.py +0 -0
  68. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/javascript/http_libs/__init__.py +0 -0
  69. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/javascript/http_libs/axios.py +0 -0
  70. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/javascript/http_libs/base.py +0 -0
  71. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/javascript/http_libs/fetch.py +0 -0
  72. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/profile.py +0 -0
  73. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/__init__.py +0 -0
  74. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/html_libs/__init__.py +0 -0
  75. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/html_libs/base.py +0 -0
  76. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/html_libs/bs4.py +0 -0
  77. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/html_libs/lxml.py +0 -0
  78. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/html_libs/parsel.py +0 -0
  79. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/html_libs/slax.py +0 -0
  80. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/http_libs/__init__.py +0 -0
  81. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/http_libs/aiohttp.py +0 -0
  82. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/http_libs/base.py +0 -0
  83. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/http_libs/httpx.py +0 -0
  84. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/python/http_libs/requests.py +0 -0
  85. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/resolver.py +0 -0
  86. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/targets/spec.py +0 -0
  87. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/traversal/__init__.py +0 -0
  88. {ssc_codegen-0.33.0 → ssc_codegen-0.35.0}/ssc_codegen/traversal/context.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssc_codegen
3
- Version: 0.33.0
3
+ Version: 0.35.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.33.0"
3
+ version = "0.35.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"
@@ -20,6 +20,8 @@ from .module import (
20
20
  Utilities,
21
21
  )
22
22
 
23
+ from .function import FunctionDef
24
+
23
25
  from .typedef import TypeDef, TypeDefField
24
26
 
25
27
  from .jsondef import JsonDef, JsonDefField
@@ -147,6 +149,8 @@ __all__ = [
147
149
  "CodeEndHook",
148
150
  "Docstring",
149
151
  "Utilities",
152
+ # function
153
+ "FunctionDef",
150
154
  # typedef
151
155
  "TypeDef",
152
156
  "TypeDefField",
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from .base import Node
6
+ from .types import TypeInfo, VariableType
7
+
8
+
9
+ @dataclass
10
+ class FunctionDef(Node):
11
+ """Module-level single-value extraction function.
12
+
13
+ DSL: ``fn <name> { pipeline }`` or ``(raw)fn <name> { pipeline }``.
14
+
15
+ Generates a standalone function instead of a class.
16
+ ``is_raw`` selects the document type: HTML (DOCUMENT) or plain text (STRING).
17
+ ``ret_type_info`` is resolved after pipeline build (AUTO → concrete type).
18
+ """
19
+
20
+ name: str = ""
21
+ is_raw: bool = False
22
+ doc: str = ""
23
+ accept_type_info: TypeInfo = field(
24
+ default_factory=lambda: TypeInfo(base=VariableType.DOCUMENT)
25
+ )
26
+ ret_type_info: TypeInfo = field(
27
+ default_factory=lambda: TypeInfo(base=VariableType.AUTO)
28
+ )
@@ -19,6 +19,7 @@ from ssc_codegen.ast import (
19
19
  Field,
20
20
  Filter,
21
21
  Fmt,
22
+ FunctionDef,
22
23
  Index,
23
24
  InitField,
24
25
  JsonDef,
@@ -104,6 +105,7 @@ FieldLikeNode: TypeAlias = (
104
105
  | Value
105
106
  | Field
106
107
  | InitField
108
+ | FunctionDef
107
109
  )
108
110
 
109
111
 
@@ -480,8 +482,8 @@ def parse_expressions(
480
482
  )
481
483
  parent.ret_type_info = last_ti
482
484
 
483
- # Populate type_info on Field/Value/InitField
484
- if isinstance(parent, (Field, Value, InitField)):
485
+ # Populate type_info on Field/Value/InitField/FunctionDef
486
+ if isinstance(parent, (Field, Value, InitField, FunctionDef)):
485
487
  last_ret = last_ti.base
486
488
  is_arr = (
487
489
  parent.body[-2].is_array if len(parent.body) >= 2 else False
@@ -168,6 +168,7 @@ def lint_module(
168
168
  _lint_defines(doc, source_path, diags, children_defines)
169
169
  _lint_json_defs(doc, source_path, diags, children_defines)
170
170
  _lint_structs(doc, source_path, diags, children_defines)
171
+ _lint_fns(doc, source_path, diags)
171
172
  _lint_nested_topdown(doc, source_path, diags)
172
173
  _lint_json_refs_topdown(doc, source_path, diags, children_defines)
173
174
  return diags
@@ -178,7 +179,7 @@ def _lint_top_level(
178
179
  source_path: str,
179
180
  diags: list[ReadDiagnostic],
180
181
  ) -> None:
181
- for node in doc.select(":root:not(@doc, json, struct, define, import)"):
182
+ for node in doc.select(":root:not(@doc, json, struct, fn, define, import)"):
182
183
  diags.append(
183
184
  _error(
184
185
  node,
@@ -378,6 +379,69 @@ def _lint_structs(
378
379
  _lint_single_struct(node, source_path, diags, children_defines)
379
380
 
380
381
 
382
+ # Directives allowed inside ``fn`` / ``(raw)fn`` body.
383
+ _FN_ALLOWED_RESERVED: frozenset[str] = frozenset({"@doc"})
384
+
385
+
386
+ def _lint_fns(
387
+ doc: KdlDocument,
388
+ source_path: str,
389
+ diags: list[ReadDiagnostic],
390
+ ) -> None:
391
+ """Structural lint for ``fn`` / ``(raw)fn`` directives."""
392
+ for node in doc.select("fn:root"):
393
+ name = _node_arg(node, 0)
394
+ if not name:
395
+ diags.append(
396
+ _error(
397
+ node,
398
+ "'fn' requires a name",
399
+ source_path,
400
+ code="E001",
401
+ hint='example: fn page_title { css "h1" { text } }',
402
+ )
403
+ )
404
+ continue
405
+
406
+ annotation = node.type_annotation
407
+ if annotation and annotation != "(raw)":
408
+ diags.append(
409
+ _error(
410
+ node,
411
+ f"unsupported fn annotation '{annotation}' — only '(raw)' is recognized",
412
+ source_path,
413
+ code="E400",
414
+ hint="use 'fn { ... }' (HTML) or '(raw)fn { ... }' (plain text)",
415
+ )
416
+ )
417
+
418
+ ops = list(node.children)
419
+ if not ops:
420
+ diags.append(
421
+ _error(
422
+ node,
423
+ f"fn '{name}' must contain at least one pipeline operation",
424
+ source_path,
425
+ code="E001",
426
+ hint='example: fn title { css "h1" { text } }',
427
+ )
428
+ )
429
+
430
+ for child in ops:
431
+ if (
432
+ child.name.startswith("@")
433
+ and child.name not in _FN_ALLOWED_RESERVED
434
+ ):
435
+ diags.append(
436
+ _error(
437
+ child,
438
+ f"'{child.name}' is not allowed inside fn — use a struct for init/check/request/etc.",
439
+ source_path,
440
+ code="E203",
441
+ )
442
+ )
443
+
444
+
381
445
  def _lint_nested_topdown(
382
446
  doc: KdlDocument,
383
447
  source_path: str,
@@ -5,12 +5,15 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from ssc_codegen.ast import (
8
+ FunctionDef,
8
9
  JsonDef,
9
10
  Module,
10
11
  StructBase,
11
12
  Struct,
12
13
  StructRest,
13
14
  StructType,
15
+ VariableType,
16
+ TypeInfo,
14
17
  )
15
18
  from ssc_codegen.exceptions import BuildTimeError
16
19
  from kdlquery import KdlNode, parse as kdl_parse
@@ -23,7 +26,11 @@ from ssc_codegen.core.contexts import (
23
26
  ParseContext,
24
27
  )
25
28
  from ssc_codegen.core.expressions import resolve_define_references
26
- from ssc_codegen.core.struct_parser import parse_json_fields, parse_struct
29
+ from ssc_codegen.core.struct_parser import (
30
+ parse_function,
31
+ parse_json_fields,
32
+ parse_struct,
33
+ )
27
34
 
28
35
  _KDL_TEXT_ENCODING = "utf-8-sig"
29
36
 
@@ -65,6 +72,28 @@ def handle_struct(
65
72
  return struct
66
73
 
67
74
 
75
+ def handle_function(
76
+ node: KdlNode, module: Module, ctx: ParseContext, lint: LintContext
77
+ ) -> FunctionDef:
78
+ raw_annotation = node.type_annotation
79
+ is_raw = raw_annotation == "(raw)"
80
+ if not node.args:
81
+ raise BuildTimeError("'fn' requires a name")
82
+ name = str(node.args[0].value)
83
+ fn = FunctionDef(
84
+ parent=module,
85
+ name=name,
86
+ is_raw=is_raw,
87
+ accept_type_info=(
88
+ TypeInfo(base=VariableType.STRING)
89
+ if is_raw
90
+ else TypeInfo(base=VariableType.DOCUMENT)
91
+ ),
92
+ )
93
+ parse_function(node.children, fn, ctx, lint)
94
+ return fn
95
+
96
+
68
97
  def handle_json(
69
98
  node: KdlNode, module: Module, ctx: ParseContext, lint: LintContext
70
99
  ) -> JsonDef:
@@ -16,6 +16,7 @@ from ssc_codegen.core.expressions import typedef_from_struct
16
16
  from ssc_codegen.core.rest_artifacts import rest_artifacts_from_struct
17
17
  from ssc_codegen.core.module_handler import (
18
18
  handle_define,
19
+ handle_function,
19
20
  handle_json,
20
21
  handle_struct,
21
22
  resolve_imports,
@@ -68,6 +69,7 @@ def parse_module(
68
69
  module.source_file = source_path.name if source_path else ""
69
70
  structs: list = []
70
71
  typedefs: list = []
72
+ functions: list = []
71
73
 
72
74
  for node in top_nodes:
73
75
  lint.push(node.name)
@@ -79,6 +81,9 @@ def parse_module(
79
81
  struct = handle_struct(node, module, ctx, lint)
80
82
  typedefs.append(typedef_from_struct(struct, module))
81
83
  structs.append(struct)
84
+ elif node.name == "fn":
85
+ fn = handle_function(node, module, ctx, lint)
86
+ functions.append(fn)
82
87
  elif node.name in ("define", "import"):
83
88
  pass # already handled
84
89
  else:
@@ -95,6 +100,7 @@ def parse_module(
95
100
  if isinstance(struct, StructRest):
96
101
  module.body.extend(rest_artifacts_from_struct(struct, module))
97
102
  module.body.append(struct)
103
+ module.body.extend(functions)
98
104
 
99
105
  # merge lint diagnostics into walker diagnostics
100
106
  diagnostics.extend(lint.diagnostics)
@@ -13,6 +13,7 @@ from ssc_codegen.ast import (
13
13
  CssSelectAll,
14
14
  ErrorResponse,
15
15
  Field,
16
+ FunctionDef,
16
17
  InitField,
17
18
  InitFieldCall,
18
19
  JsonDef,
@@ -313,6 +314,47 @@ def parse_struct(
313
314
  lint.walk_context = prev_ctx
314
315
 
315
316
 
317
+ def parse_function(
318
+ kdl_nodes: Sequence[KdlNode],
319
+ fn: FunctionDef,
320
+ ctx: ParseContext,
321
+ lint: LintContext,
322
+ ) -> None:
323
+ """Parse the body of a ``fn`` / ``(raw)fn`` directive.
324
+
325
+ The body is a flat pipeline (same ops as a Field), optionally preceded
326
+ by ``@doc``. No struct-level directives (@init, @check, @split-doc, etc.)
327
+ are accepted — those are reported by the structural linter.
328
+ """
329
+ prev_ctx = lint.walk_context
330
+ lint.walk_context = WalkCtx.PIPELINE
331
+
332
+ pipeline_nodes: list[KdlNode] = []
333
+ for node in kdl_nodes:
334
+ if node.name == "@doc":
335
+ fn.doc = str(node.args[0].value)
336
+ elif node.name.startswith("@"):
337
+ continue # structural linter already reported E203
338
+ else:
339
+ pipeline_nodes.append(node)
340
+
341
+ ops = pipeline_nodes
342
+ parse_expressions(ops, fn, ctx, lint)
343
+ if ops:
344
+ check_pipeline_types(
345
+ ops,
346
+ ctx,
347
+ lint,
348
+ start_type=VariableType.STRING
349
+ if fn.is_raw
350
+ else VariableType.DOCUMENT,
351
+ )
352
+ if fn.is_raw:
353
+ _lint_raw_forbidden_ops(fn, lint)
354
+
355
+ lint.walk_context = prev_ctx
356
+
357
+
316
358
  def parse_json_fields(
317
359
  nodes: Sequence[KdlNode], parent: JsonDef, ctx: ParseContext
318
360
  ) -> None:
@@ -206,7 +206,7 @@ def emit_method_fetch(
206
206
  i2 = i1 + ctx.indent_char
207
207
 
208
208
  lines: list[str] = [
209
- f"{i1}func {method_name}(ctx context.Context, client {http.client_type}{ph_params}) (*{name}, error) {{",
209
+ f"{i1}func {method_name}(ctx context.Context, client {http.client_type}{ph_params}, opts ...sscReqOpt) (*{name}, error) {{",
210
210
  f"{i2}req, err := http.NewRequestWithContext(ctx, {_go_str(spec.method)}, {url_expr}, nil)",
211
211
  f"{i2}if err != nil {{",
212
212
  f"{i2}\treturn nil, err",
@@ -233,6 +233,15 @@ def emit_method_fetch(
233
233
  f"{i2}req.Body = io.NopCloser(strings.NewReader({body_expr}))"
234
234
  )
235
235
 
236
+ # Apply per-call opts (user headers are additive).
237
+ lines.append(f"{i2}if len(opts) > 0 {{")
238
+ lines.append(f"{i2}\t_opts := &sscReqOpts{{}}")
239
+ lines.append(f"{i2}\tfor _, o := range opts {{ o(_opts) }}")
240
+ lines.append(f"{i2}\tfor _k, _vs := range _opts.Headers {{")
241
+ lines.append(f"{i2}\t\tfor _, _v := range _vs {{ req.Header.Add(_k, _v) }}")
242
+ lines.append(f"{i2}\t}}")
243
+ lines.append(f"{i2}}}")
244
+
236
245
  # Execute.
237
246
  lines.extend(
238
247
  [
@@ -325,29 +334,28 @@ def emit_method_rest(
325
334
 
326
335
  # Function signature.
327
336
  lines.append(
328
- f"{i1}func {method_name}(client {http.client_type}{ph_params}) ({ret_type}, error) {{"
337
+ f"{i1}func {method_name}(client {http.client_type}{ph_params}, opts ...sscReqOpt) ({ret_type}, error) {{"
329
338
  )
330
339
 
331
- # Build opts.
332
- opts_lines = _build_opts(spec, i2)
333
-
334
- # Call sscRestCall — inline if nil, multi-line if struct literal.
335
- # For void responses, use `_` since the body is discarded.
340
+ # Build _opts from DSL, apply per-call user opts, then call sscRestCall.
341
+ opts_lines = _build_opts(spec, i2 + "\t")
336
342
  body_var = "_" if not node.response_schema else "body"
337
- if len(opts_lines) == 1:
338
- lines.append(
339
- f"{i2}{body_var}, err := sscRestCall(client, {matchers_var}, "
340
- f"{_go_str(spec.method)}, {url_expr}, {opts_lines[0].strip()})"
341
- )
343
+
344
+ # Assign DSL opts to _opts variable.
345
+ if len(opts_lines) == 1 and opts_lines[0].strip() == "nil":
346
+ lines.append(f"{i2}_opts := &sscReqOpts{{}}")
342
347
  else:
343
- lines.append(
344
- f"{i2}{body_var}, err := sscRestCall(client, {matchers_var}, "
345
- f"{_go_str(spec.method)}, {url_expr},"
346
- )
347
- lines.append(f"{opts_lines[0].strip()}")
348
- for ol in opts_lines[1:-1]:
348
+ lines.append(f"{i2}_opts := {opts_lines[0].strip()}")
349
+ for ol in opts_lines[1:]:
349
350
  lines.append(ol)
350
- lines.append(f"{opts_lines[-1].strip()})")
351
+
352
+ # Apply per-call opts (functional options).
353
+ lines.append(f"{i2}for _, o := range opts {{ o(_opts) }}")
354
+
355
+ lines.append(
356
+ f"{i2}{body_var}, err := sscRestCall(client, {matchers_var}, "
357
+ f"{_go_str(spec.method)}, {url_expr}, _opts)"
358
+ )
351
359
 
352
360
  # Error propagation.
353
361
  lines.append(f"{i2}if err != nil {{")
@@ -493,6 +493,20 @@ BASE_REST_RUNTIME: list[str] = [
493
493
  "\tBody string",
494
494
  "}",
495
495
  "",
496
+ "// sscReqOpt is a functional option for per-call request overrides.",
497
+ "// Applied after DSL-specified opts; user headers are additive (shallow-merge).",
498
+ "type sscReqOpt func(*sscReqOpts)",
499
+ "",
500
+ "// WithHeader adds a per-call header value (additive — does not replace DSL headers).",
501
+ "func WithHeader(key, val string) sscReqOpt {",
502
+ "\treturn func(o *sscReqOpts) {",
503
+ "\t\tif o.Headers == nil {",
504
+ "\t\t\to.Headers = sscHeaders{}",
505
+ "\t\t}",
506
+ "\t\to.Headers[key] = append(o.Headers[key], val)",
507
+ "\t}",
508
+ "}",
509
+ "",
496
510
  "type sscErrMatcher struct {",
497
511
  "\tStatus int",
498
512
  "\tCheck func([]byte) bool",
@@ -35,6 +35,7 @@ from ssc_codegen.ast import (
35
35
  Field,
36
36
  Filter,
37
37
  Fmt,
38
+ FunctionDef,
38
39
  Init,
39
40
  InitField,
40
41
  InitFieldCall,
@@ -136,8 +137,8 @@ from ssc_codegen.naming import (
136
137
  )
137
138
  from ssc_codegen.traversal.utils import (
138
139
  find_predicate_container,
140
+ module_has_html_struct,
139
141
  module_has_rest,
140
- module_is_rest_only,
141
142
  module_uses_http,
142
143
  )
143
144
  from ssc_codegen.generation.builder import ModuleBuilder
@@ -439,10 +440,9 @@ class GoVisitor(BaseWalker):
439
440
  lines.append(f"// {doc_line}" if doc_line else "//")
440
441
  lines.append("")
441
442
 
442
- is_rest_only = module_is_rest_only(node)
443
443
  uses_http = module_uses_http(node)
444
444
 
445
- if not is_rest_only:
445
+ if module_has_html_struct(node):
446
446
  self._builder.require_import('"github.com/PuerkitoBio/goquery"')
447
447
  self._builder.require_import('"strings"')
448
448
  if uses_http:
@@ -842,6 +842,33 @@ class GoVisitor(BaseWalker):
842
842
  lines.append("")
843
843
  return lines
844
844
 
845
+ def visit_function_def(
846
+ self, node: FunctionDef, ctx: WalkContext
847
+ ) -> list[str]:
848
+ name = to_pascal_case(node.name)
849
+ t_ret = self._resolve_type(node.ret_type_info)
850
+ inner = ctx.deeper()
851
+ lines: list[str] = []
852
+ if node.doc:
853
+ for doc_line in node.doc.splitlines():
854
+ lines.append(f"// {doc_line}")
855
+ lines.append(f"func {name}(document string) {t_ret} {{")
856
+ if node.is_raw:
857
+ lines.append(f"{inner.indent}{inner.var_name} := document")
858
+ else:
859
+ self._builder.require_import('"strings"')
860
+ lines.append(
861
+ f"{inner.indent}doc, err := goquery.NewDocumentFromReader(strings.NewReader(document))"
862
+ )
863
+ lines.append(f"{inner.indent}if err != nil {{")
864
+ lines.append(f"{inner.indent}\tpanic(err)")
865
+ lines.append(f"{inner.indent}}}")
866
+ lines.append(f"{inner.indent}{inner.var_name} := doc.Selection")
867
+ lines.extend(self.walk_children(node, ctx))
868
+ lines.append("}")
869
+ lines.append("")
870
+ return lines
871
+
845
872
  def visit_split_doc(self, node: SplitDoc, ctx: WalkContext) -> list[str]:
846
873
  struct = self._enclosing_struct_name(node)
847
874
  rcv = _receiver(struct)
@@ -390,13 +390,22 @@ def emit_method_rest(
390
390
  t = f"{t}[]"
391
391
  bracket = f"[params.{p.name}]" if p.is_optional else f"params.{p.name}"
392
392
  lines.append(f"{i1} * @param {{{t}}} {bracket}")
393
+ lines.append(f"{i1} * @param {{Object}} [opts] per-call request options (headers, etc.)")
393
394
  lines.append(f"{i1} * @returns {{Promise<{return_union}>}}")
394
395
  lines.append(f"{i1} */")
395
- lines.append(f"{i1}static async {method_name}(client{ph_param}) {{")
396
+ lines.append(f"{i1}static async {method_name}(client{ph_param}, opts = {{}}) {{")
396
397
  lines.extend(pre_lines)
398
+ lines.append(f"{i2}const _kw = {opts_obj};")
399
+ lines.append(f"{i2}for (const [_k, _v] of Object.entries(opts)) {{")
400
+ lines.append(f"{i2} if (typeof _kw[_k] === 'object' && _kw[_k] !== null && typeof _v === 'object' && _v !== null) {{")
401
+ lines.append(f"{i2} _kw[_k] = {{..._kw[_k], ..._v}};")
402
+ lines.append(f"{i2} }} else {{")
403
+ lines.append(f"{i2} _kw[_k] = _v;")
404
+ lines.append(f"{i2} }}")
405
+ lines.append(f"{i2}}}")
397
406
  lines.append(
398
407
  f"{i2}return {fn_name}(client, {matchers_var}, "
399
- f"{spec.method!r}, {url_expr}, {value_fn}, {opts_obj});"
408
+ f"{spec.method!r}, {url_expr}, {value_fn}, _kw);"
400
409
  )
401
410
  lines.append(f"{i1}}}")
402
411
  return lines
@@ -434,9 +443,11 @@ def emit_method_fetch(
434
443
  rl.append(f"{i2}return new {struct_name}(_body);")
435
444
  return rl
436
445
 
437
- lines: list[str] = [f"{i1}static async {method_name}(client{ph_param}) {{"]
446
+ lines: list[str] = [f"{i1}static async {method_name}(client{ph_param}, opts = {{}}) {{"]
438
447
  lines.extend(pre_lines)
439
448
 
449
+ # Build _kw from DSL-specified request options (method/url stay out).
450
+ kw_parts: list[str] = []
440
451
  if http_client == "fetch":
441
452
  if params_expr:
442
453
  url_inner = spec.url.map(
@@ -446,21 +457,30 @@ def emit_method_fetch(
446
457
  url_expr = f"`{url_inner}?${{new URLSearchParams({params_expr})}}`"
447
458
  else:
448
459
  url_expr = render_value(spec.url)
449
- options: list[str] = [f"{i3}method: {spec.method!r},"]
450
460
  if headers_expr:
451
- options.append(f"{i3}headers: {headers_expr},")
461
+ kw_parts.append(f"headers: {headers_expr}")
452
462
  if spec.cookies:
453
463
  cookie_str = "; ".join(
454
464
  f"{k}={v.source}" for k, v in spec.cookies.items()
455
465
  )
456
- options.append(
457
- f"{i3}// cookies: {cookie_str!r} /* set via headers or credentials */"
466
+ kw_parts.append(
467
+ f"// cookies: {cookie_str!r} /* set via headers or credentials */"
458
468
  )
459
469
  if body_expr:
460
- options.append(f"{i3}body: {body_expr},")
461
- lines.append(f"{i2}const _resp = await client({url_expr}, {{")
462
- lines.extend(options)
463
- lines.append(f"{i2}}});")
470
+ kw_parts.append(f"body: {body_expr}")
471
+ kw_obj = "{" + ", ".join(kw_parts) + "}" if kw_parts else "{}"
472
+ lines.append(f"{i2}const _kw = {kw_obj};")
473
+ lines.append(f"{i2}for (const [_k, _v] of Object.entries(opts)) {{")
474
+ lines.append(f"{i2} if (typeof _kw[_k] === 'object' && _kw[_k] !== null && typeof _v === 'object' && _v !== null) {{")
475
+ lines.append(f"{i2} _kw[_k] = {{..._kw[_k], ..._v}};")
476
+ lines.append(f"{i2} }} else {{")
477
+ lines.append(f"{i2} _kw[_k] = _v;")
478
+ lines.append(f"{i2} }}")
479
+ lines.append(f"{i2}}}")
480
+ lines.append(
481
+ f"{i2}const _resp = await client({url_expr},"
482
+ f" {{ method: {spec.method!r}, ..._kw }});"
483
+ )
464
484
  lines.append(
465
485
  f"{i2}if (!_resp.ok) throw new Error(`HTTP ${{_resp.status}}`);"
466
486
  )
@@ -469,23 +489,30 @@ def emit_method_fetch(
469
489
  else:
470
490
  lines.extend(_response_lines("await _resp.text()"))
471
491
  else:
472
- req_props: list[str] = [
473
- f"{i3}method: {spec.method!r},",
474
- f"{i3}url: {render_value(spec.url)},",
475
- ]
492
+ url_expr = render_value(spec.url)
476
493
  if params_expr:
477
- req_props.append(f"{i3}params: {params_expr},")
494
+ kw_parts.append(f"params: {params_expr}")
478
495
  if headers_expr:
479
- req_props.append(f"{i3}headers: {headers_expr},")
496
+ kw_parts.append(f"headers: {headers_expr}")
480
497
  if spec.cookies:
481
- req_props.append(
482
- f"{i3}// cookies: {render_obj(spec.cookies)},",
498
+ kw_parts.append(
499
+ f"// cookies: {render_obj(spec.cookies)},"
483
500
  )
484
501
  if body_expr:
485
- req_props.append(f"{i3}data: {body_expr},")
486
- lines.append(f"{i2}const _resp = await client.request({{")
487
- lines.extend(req_props)
488
- lines.append(f"{i2}}});")
502
+ kw_parts.append(f"data: {body_expr}")
503
+ kw_obj = "{" + ", ".join(kw_parts) + "}" if kw_parts else "{}"
504
+ lines.append(f"{i2}const _kw = {kw_obj};")
505
+ lines.append(f"{i2}for (const [_k, _v] of Object.entries(opts)) {{")
506
+ lines.append(f"{i2} if (typeof _kw[_k] === 'object' && _kw[_k] !== null && typeof _v === 'object' && _v !== null) {{")
507
+ lines.append(f"{i2} _kw[_k] = {{..._kw[_k], ..._v}};")
508
+ lines.append(f"{i2} }} else {{")
509
+ lines.append(f"{i2} _kw[_k] = _v;")
510
+ lines.append(f"{i2} }}")
511
+ lines.append(f"{i2}}}")
512
+ lines.append(
513
+ f"{i2}const _resp = await client.request({{"
514
+ f" method: {spec.method!r}, url: {url_expr}, ..._kw }});"
515
+ )
489
516
  lines.extend(_response_lines("_resp.data"))
490
517
 
491
518
  lines.append(f"{i1}}}")
@@ -31,6 +31,7 @@ from ssc_codegen.ast import (
31
31
  Field,
32
32
  Filter,
33
33
  Fmt,
34
+ FunctionDef,
34
35
  Init,
35
36
  InitField,
36
37
  InitFieldCall,
@@ -528,6 +529,31 @@ class JsVisitor(BaseWalker):
528
529
  lines.append("}")
529
530
  return lines
530
531
 
532
+ def visit_function_def(
533
+ self, node: FunctionDef, ctx: WalkContext
534
+ ) -> list[str]:
535
+ name = to_camel_case(node.name)
536
+ inner = ctx.deeper()
537
+ lines: list[str] = []
538
+ if node.doc:
539
+ lines.extend(_js_docblock(node.doc.splitlines()))
540
+ lines.append(f"function {name}(document) {{")
541
+ if node.is_raw:
542
+ lines.append(f"{inner.indent}let {inner.var_name} = document;")
543
+ else:
544
+ lines.append(f"{inner.indent}let {inner.var_name};")
545
+ lines.append(f"{inner.indent}if (typeof document === 'string') {{")
546
+ lines.append(
547
+ f"{inner.deeper().indent}{inner.var_name} = (new DOMParser()).parseFromString(document, 'text/html');"
548
+ )
549
+ lines.append(f"{inner.indent}}} else {{")
550
+ lines.append(f"{inner.deeper().indent}{inner.var_name} = document;")
551
+ lines.append(f"{inner.indent}}}")
552
+ lines.extend(self.walk_children(node, ctx))
553
+ lines.append("}")
554
+ lines.append("")
555
+ return lines
556
+
531
557
  def visit_split_doc(self, node: SplitDoc, ctx: WalkContext) -> list[str]:
532
558
  lines = [f"{ctx.indent}_splitDoc(v) {{"]
533
559
  lines.extend(self.walk_children(node, ctx))
@@ -296,32 +296,44 @@ def placeholder_args(http: RequestHttp) -> str:
296
296
  # ===========================================================================
297
297
 
298
298
 
299
- def _build_kwargs(
300
- spec: RequestHttp, i2: str, i3: str, include_method_url: bool
299
+ def _build_kw_dict(
300
+ spec: RequestHttp, i2: str, i3: str
301
301
  ) -> tuple[list[str], list[str]]:
302
- """Build (pre_lines, kwargs_lines) for request call.
302
+ """Build (pre_lines, kw_assign_lines) for ``_kw`` dict construction.
303
303
 
304
- include_method_url=True for fetch (client.request(method, url, ...)).
305
- include_method_url=False for rest (ssc_rest_call(client, matchers, method, url, ...)).
304
+ Produces ``_kw["headers"] = {...}`` style assignments so caller kwargs
305
+ can be shallow-merged into the same dict before passing ``**_kw`` to
306
+ ``client.request()`` / ``ssc_rest_call()``.
307
+
308
+ pre_lines: builder helper code (complex dicts needing intermediate vars).
309
+ kw_lines: ``_kw["key"] = value`` assignment lines (at i2 indent).
306
310
  """
307
311
  pre_lines: list[str] = []
308
- kwargs_lines: list[str] = []
309
- if include_method_url:
310
- kwargs_lines.append(f"{i3}{spec.method!r},")
311
- kwargs_lines.append(f"{i3}{render_value(spec.url)},")
312
+ kw_lines: list[str] = []
312
313
  for attr, varname in _DICT_KWARGS:
313
314
  d = getattr(spec, attr)
314
315
  if not d:
315
316
  continue
316
317
  if dict_needs_builder(d):
317
318
  pre_lines.extend(emit_dict_builder(varname, d, i2))
318
- kwargs_lines.append(f"{i3}{attr}={varname},")
319
+ kw_lines.append(f'{i2}_kw["{attr}"] = {varname}')
319
320
  else:
320
- kwargs_lines.append(f"{i3}{attr}={render_dict(d)},")
321
+ kw_lines.append(f'{i2}_kw["{attr}"] = {render_dict(d)}')
321
322
  body_result = render_body(spec)
322
323
  if body_result:
323
- kwargs_lines.append(f"{i3}{body_result[0]}={body_result[1]},")
324
- return pre_lines, kwargs_lines
324
+ kw_lines.append(f'{i2}_kw["{body_result[0]}"] = {body_result[1]}')
325
+ return pre_lines, kw_lines
326
+
327
+
328
+ def _merge_loop_lines(i2: str) -> list[str]:
329
+ """Shallow-merge user kwargs into _kw (dict keys merged key-by-key)."""
330
+ return [
331
+ f"{i2}for _k, _v in kwargs.items():",
332
+ f"{i2} if isinstance(_kw.get(_k), dict) and isinstance(_v, dict):",
333
+ f"{i2} _kw[_k] = {{**_kw[_k], **_v}}",
334
+ f"{i2} else:",
335
+ f"{i2} _kw[_k] = _v",
336
+ ]
325
337
 
326
338
 
327
339
  # ===========================================================================
@@ -343,9 +355,22 @@ def emit_method_fetch(
343
355
  i2 = i1 + ctx.indent_char
344
356
  i3 = i2 + ctx.indent_char
345
357
 
346
- pre_lines, kwargs_lines = _build_kwargs(
347
- spec, i2, i3, include_method_url=True
348
- )
358
+ pre_lines, kw_lines = _build_kw_dict(spec, i2, i3)
359
+
360
+ # kwargs_lines for fetch_body_lines: method, url, **_kw
361
+ fetch_kwargs = [
362
+ f"{i3}{spec.method!r},",
363
+ f"{i3}{render_value(spec.url)},",
364
+ f"{i3}**_kw,",
365
+ ]
366
+
367
+ def _kw_setup() -> list[str]:
368
+ """Lines to build _kw dict + merge user kwargs before the request."""
369
+ setup: list[str] = [f"{i2}_kw: Dict[str, Any] = {{}}"]
370
+ setup.extend(pre_lines)
371
+ setup.extend(kw_lines)
372
+ setup.extend(_merge_loop_lines(i2))
373
+ return setup
349
374
 
350
375
  lines: list[str] = []
351
376
 
@@ -353,14 +378,14 @@ def emit_method_fetch(
353
378
  if http.supports_sync_fetch:
354
379
  lines.append(f"{i1}@classmethod")
355
380
  lines.append(
356
- f'{i1}def fetch{suffix}(cls, client: {http.sync_client_type}{ph_params}) -> "{struct_name}":'
381
+ f'{i1}def fetch{suffix}(cls, client: {http.sync_client_type}{ph_params}, **kwargs: Any) -> "{struct_name}":'
357
382
  )
358
- lines.extend(pre_lines)
383
+ lines.extend(_kw_setup())
359
384
  lines.extend(
360
385
  http.fetch_body_lines(
361
386
  is_async=False,
362
387
  request_call=f"{i2}_resp = client.request(",
363
- kwargs_lines=kwargs_lines,
388
+ kwargs_lines=fetch_kwargs,
364
389
  response_path=node.response_path,
365
390
  response_join=node.response_join,
366
391
  i2=i2,
@@ -372,24 +397,27 @@ def emit_method_fetch(
372
397
  # --- async_fetch ------------------------------------------------------
373
398
  lines.append(f"{i1}@classmethod")
374
399
  lines.append(
375
- f'{i1}async def async_fetch{suffix}(cls, client: {http.async_client_type}{ph_params}) -> "{struct_name}":'
400
+ f'{i1}async def async_fetch{suffix}(cls, client: {http.async_client_type}{ph_params}, **kwargs: Any) -> "{struct_name}":'
376
401
  )
377
402
  if http.async_fetch_delegates_to_sync:
378
403
  # requests: no native async — run the sync fetch in a worker thread
379
404
  # via asyncio.to_thread (non-blocking, yields control to the loop).
380
- to_thread_args = f"client, {ph_kwargs}" if ph_kwargs else "client"
405
+ if ph_kwargs:
406
+ to_thread_args = f"client, {ph_kwargs}, **kwargs"
407
+ else:
408
+ to_thread_args = "client, **kwargs"
381
409
  lines.append(f"{i2}import asyncio")
382
410
  lines.append(
383
411
  f"{i2}return await asyncio.to_thread("
384
412
  f"cls.fetch{suffix}, {to_thread_args})"
385
413
  )
386
414
  else:
387
- lines.extend(pre_lines)
415
+ lines.extend(_kw_setup())
388
416
  lines.extend(
389
417
  http.fetch_body_lines(
390
418
  is_async=True,
391
419
  request_call=f"{i2}_resp = await client.request(",
392
- kwargs_lines=kwargs_lines,
420
+ kwargs_lines=fetch_kwargs,
393
421
  response_path=node.response_path,
394
422
  response_join=node.response_join,
395
423
  i2=i2,
@@ -416,9 +444,7 @@ def emit_method_rest(
416
444
 
417
445
  doc_line = f'{i2}"""{node.doc}"""' if node.doc else None
418
446
 
419
- pre_lines, kwargs_lines = _build_kwargs(
420
- spec, i2, i3, include_method_url=False
421
- )
447
+ pre_lines, kw_lines = _build_kw_dict(spec, i2, i3)
422
448
 
423
449
  value_kwarg: list[str] = []
424
450
  if node.response_path:
@@ -431,36 +457,31 @@ def emit_method_rest(
431
457
  body: list[str] = []
432
458
  if doc_line:
433
459
  body.append(doc_line)
460
+ body.append(f"{i2}_kw: Dict[str, Any] = {{}}")
434
461
  body.extend(pre_lines)
435
- # Wrap in cast(): ssc_rest_call returns Union[Ok[_T], Err] (Err
436
- # base — it cannot know the specific Err subclasses that the
437
- # heterogeneous matchers list will produce at runtime). The
438
- # parser-declared Result alias narrows to the precise union
439
- # (Err400 | UnknownErr | TransportErr | ...). cast() documents
440
- # this intent at the call site without suppressing mypy via
441
- # ``# type: ignore``; the wrapping method's return type is the
442
- # stable monad consumers see.
462
+ body.extend(kw_lines)
463
+ body.extend(_merge_loop_lines(i2))
443
464
  body.append(f"{i2}return cast({ret_type}, {await_kw}{fn_name}(")
444
465
  body.append(
445
466
  f"{i3}client, {matchers_var}, {spec.method!r},"
446
467
  f" {render_value(spec.url)},"
447
468
  )
448
469
  body.extend(value_kwarg)
449
- body.extend(kwargs_lines)
470
+ body.append(f"{i3}**_kw,")
450
471
  body.append(f"{i2}))")
451
472
  return body
452
473
 
453
474
  lines: list[str] = []
454
475
  lines.append(f"{i1}@classmethod")
455
476
  lines.append(
456
- f"{i1}def {method_name}(cls, client: {http.sync_client_type}{ph_params}) -> {ret_type}:"
477
+ f"{i1}def {method_name}(cls, client: {http.sync_client_type}{ph_params}, **kwargs: Any) -> {ret_type}:"
457
478
  )
458
479
  lines.extend(_body("ssc_rest_call", ""))
459
480
  lines.append("")
460
481
 
461
482
  lines.append(f"{i1}@classmethod")
462
483
  lines.append(
463
- f"{i1}async def async_{method_name}(cls, client: {http.async_client_type}{ph_params}) -> {ret_type}:"
484
+ f"{i1}async def async_{method_name}(cls, client: {http.async_client_type}{ph_params}, **kwargs: Any) -> {ret_type}:"
464
485
  )
465
486
  lines.extend(_body("ssc_rest_call_async", "await "))
466
487
  return lines
@@ -28,6 +28,7 @@ from ssc_codegen.ast import (
28
28
  InitFieldCall,
29
29
  InitField,
30
30
  Field,
31
+ FunctionDef,
31
32
  PreValidate,
32
33
  CheckMethod,
33
34
  SplitDoc,
@@ -661,6 +662,31 @@ class PythonVisitor(BaseWalker):
661
662
  lines.extend(self.walk_children(node, ctx))
662
663
  return lines
663
664
 
665
+ def visit_function_def(
666
+ self, node: FunctionDef, ctx: WalkContext
667
+ ) -> list[str]:
668
+ name = to_snake_case(node.name)
669
+ t_ret = self._resolve_type(node.ret_type_info)
670
+ inner = ctx.deeper()
671
+ lines = [f"{ctx.indent}def {name}(document: str) -> {t_ret}:"]
672
+ if node.doc:
673
+ lines.append(f'{inner.indent}"""')
674
+ for doc_line in node.doc.splitlines():
675
+ lines.append(inner.indent + doc_line)
676
+ lines.append(f'{inner.indent}"""')
677
+ if node.is_raw:
678
+ lines.append(f"{inner.indent}{inner.var_name} = document")
679
+ else:
680
+ lines.append(f"{inner.indent}if isinstance(document, str):")
681
+ lines.append(
682
+ f"{inner.deeper().indent}{inner.var_name} = {self._dom.init_from_str_expr}"
683
+ )
684
+ lines.append(f"{inner.indent}else:")
685
+ lines.append(f"{inner.deeper().indent}{inner.var_name} = document")
686
+ lines.extend(self.walk_children(node, ctx))
687
+ lines.append("")
688
+ return lines
689
+
664
690
  def visit_split_doc(self, node: SplitDoc, ctx: WalkContext) -> list[str]:
665
691
  t_arg = self._resolve_type(node.accept_type_info)
666
692
  t_ret = self._resolve_type(node.ret_type_info)
@@ -6,6 +6,7 @@ from ssc_codegen.ast import (
6
6
  Assert,
7
7
  ErrorResponse,
8
8
  Filter,
9
+ FunctionDef,
9
10
  Match,
10
11
  MethodBase,
11
12
  Module,
@@ -49,13 +50,20 @@ def module_is_rest_only(module: Module) -> bool:
49
50
 
50
51
 
51
52
  def module_has_html_struct(module: Module) -> bool:
52
- """True if the module has at least one HTML-parsing struct (not REST, not RAW)."""
53
+ """True if the module has at least one HTML-parsing struct or function.
54
+
55
+ RAW structs, (raw)fn, and REST structs are excluded — they don't need
56
+ an HTML parser backend (bs4/lxml/goquery/DOMParser).
57
+ """
53
58
  for n in module.body:
54
59
  if isinstance(n, StructRest):
55
60
  continue
56
61
  if isinstance(n, Struct):
57
62
  if n.type != StructType.RAW:
58
63
  return True
64
+ if isinstance(n, FunctionDef):
65
+ if not n.is_raw:
66
+ return True
59
67
  return False
60
68
 
61
69
 
@@ -16,6 +16,7 @@ from ssc_codegen.ast import (
16
16
  InitFieldCall,
17
17
  InitField,
18
18
  Field,
19
+ FunctionDef,
19
20
  PreValidate,
20
21
  CheckMethod,
21
22
  SplitDoc,
@@ -115,6 +116,7 @@ _CONTAINER_NODES = (JsonDef, TypeDef, StructBase, Init)
115
116
  # Pipeline: index advances after each node (delegated to walk_pipeline).
116
117
  _PIPELINE_NODES = (
117
118
  Field,
119
+ FunctionDef,
118
120
  InitField,
119
121
  PreValidate,
120
122
  CheckMethod,
@@ -179,6 +181,8 @@ class BaseWalker:
179
181
  MethodFetch: "visit_method_fetch",
180
182
  MethodRest: "visit_method_rest",
181
183
  ErrorResponse: "visit_error_response",
184
+ # function
185
+ FunctionDef: "visit_function_def",
182
186
  # REST result artifacts
183
187
  ResultVariantDef: "visit_result_variant_def",
184
188
  ResultAliasDef: "visit_result_alias_def",
File without changes
File without changes
File without changes