ssc_codegen 0.32.3__tar.gz → 0.33.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 (87) hide show
  1. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/PKG-INFO +11 -8
  2. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/README.md +10 -7
  3. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/pyproject.toml +1 -1
  4. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/types.py +1 -0
  5. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/expressions.py +66 -35
  6. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/linter.py +8 -5
  7. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/module_handler.py +1 -0
  8. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/struct_parser.py +82 -5
  9. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/health.py +16 -0
  10. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/visitor.py +51 -11
  11. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/javascript/visitor.py +37 -8
  12. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/rest.py +2 -2
  13. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/visitor.py +41 -13
  14. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/traversal/utils.py +13 -0
  15. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/.gitignore +0 -0
  16. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/LICENSE +0 -0
  17. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/__init__.py +0 -0
  18. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/_logging.py +0 -0
  19. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/__init__.py +0 -0
  20. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/array.py +0 -0
  21. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/base.py +0 -0
  22. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/cast.py +0 -0
  23. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/control.py +0 -0
  24. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/extract.py +0 -0
  25. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/helpers.py +0 -0
  26. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/jsondef.py +0 -0
  27. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/module.py +0 -0
  28. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/predicate_containers.py +0 -0
  29. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/predicate_ops.py +0 -0
  30. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/regex.py +0 -0
  31. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/rest.py +0 -0
  32. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/selectors.py +0 -0
  33. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/string.py +0 -0
  34. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/struct.py +0 -0
  35. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/ast/typedef.py +0 -0
  36. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/__init__.py +0 -0
  37. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/contexts.py +0 -0
  38. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/format.py +0 -0
  39. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/predicates.py +0 -0
  40. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/reader.py +0 -0
  41. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/rest_artifacts.py +0 -0
  42. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/core/type_checking.py +0 -0
  43. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/exceptions.py +0 -0
  44. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/explore.py +0 -0
  45. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/generation/__init__.py +0 -0
  46. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/generation/builder.py +0 -0
  47. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/generation/runtime.py +0 -0
  48. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/main.py +0 -0
  49. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/naming.py +0 -0
  50. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/parsers/__init__.py +0 -0
  51. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/parsers/curl.py +0 -0
  52. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/parsers/http.py +0 -0
  53. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/regex_utils.py +0 -0
  54. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/request_spec.py +0 -0
  55. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/__init__.py +0 -0
  56. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/__init__.py +0 -0
  57. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/http_libs/__init__.py +0 -0
  58. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/http_libs/base.py +0 -0
  59. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/http_libs/nethttp.py +0 -0
  60. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/literals.py +0 -0
  61. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/regex.py +0 -0
  62. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/rest.py +0 -0
  63. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/golang/runtime.py +0 -0
  64. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/javascript/__init__.py +0 -0
  65. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/javascript/http_libs/__init__.py +0 -0
  66. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/javascript/http_libs/axios.py +0 -0
  67. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/javascript/http_libs/base.py +0 -0
  68. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/javascript/http_libs/fetch.py +0 -0
  69. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/javascript/rest.py +0 -0
  70. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/profile.py +0 -0
  71. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/__init__.py +0 -0
  72. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/html_libs/__init__.py +0 -0
  73. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/html_libs/base.py +0 -0
  74. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/html_libs/bs4.py +0 -0
  75. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/html_libs/lxml.py +0 -0
  76. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/html_libs/parsel.py +0 -0
  77. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/html_libs/slax.py +0 -0
  78. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/http_libs/__init__.py +0 -0
  79. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/http_libs/aiohttp.py +0 -0
  80. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/http_libs/base.py +0 -0
  81. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/http_libs/httpx.py +0 -0
  82. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/python/http_libs/requests.py +0 -0
  83. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/resolver.py +0 -0
  84. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/targets/spec.py +0 -0
  85. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/traversal/__init__.py +0 -0
  86. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/traversal/context.py +0 -0
  87. {ssc_codegen-0.32.3 → ssc_codegen-0.33.0}/ssc_codegen/traversal/walker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssc_codegen
3
- Version: 0.32.3
3
+ Version: 0.33.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
@@ -69,7 +69,7 @@ uv tool install ssc_codegen
69
69
  Generate Python parser:
70
70
 
71
71
  ```bash
72
- ssc-gen generate books.kdl -l python -L bs4 -o ./output
72
+ ssc-gen generate python books.kdl -L bs4 -o ./output
73
73
  ```
74
74
 
75
75
  ## Usage
@@ -78,22 +78,25 @@ ssc-gen generate books.kdl -l python -L bs4 -o ./output
78
78
 
79
79
  ```bash
80
80
  # single file (Python + bs4)
81
- ssc-gen generate schema.kdl -l python -L bs4 -o ./output
81
+ ssc-gen generate python schema.kdl -L bs4 -o ./output
82
82
 
83
83
  # all .kdl files in a directory (JavaScript)
84
- ssc-gen generate examples/ -l js -o ./output
84
+ ssc-gen generate js examples/ -o ./output
85
+
86
+ # Go (goquery + net/http)
87
+ ssc-gen generate go schema.kdl -o ./output
85
88
 
86
89
  # with custom package name
87
- ssc-gen generate schema.kdl -l python -L bs4 -o ./parsers --package scraper
90
+ ssc-gen generate python schema.kdl -L bs4 -o ./parsers --package scraper
88
91
 
89
92
  # with @request support (REST/HTTP codegen)
90
- ssc-gen generate schema.kdl -l python -L bs4 -o ./out --http-client httpx
93
+ ssc-gen generate python schema.kdl -L bs4 -o ./out --http-client httpx
91
94
 
92
95
  # extract helper functions into a separate runtime module
93
- ssc-gen generate schema.kdl -l python -L bs4 -o ./out -R
96
+ ssc-gen generate python schema.kdl -L bs4 -o ./out -R
94
97
  ```
95
98
 
96
- Languages (`--lang / -l`): `python`, `js`.
99
+ Languages: `generate python`, `generate js`, `generate go`.
97
100
  HTML libraries (`--lib / -L`, Python only): `bs4` (default), `lxml`, `parsel`, `slax`.
98
101
 
99
102
  ### Lint schemas
@@ -37,7 +37,7 @@ uv tool install ssc_codegen
37
37
  Generate Python parser:
38
38
 
39
39
  ```bash
40
- ssc-gen generate books.kdl -l python -L bs4 -o ./output
40
+ ssc-gen generate python books.kdl -L bs4 -o ./output
41
41
  ```
42
42
 
43
43
  ## Usage
@@ -46,22 +46,25 @@ ssc-gen generate books.kdl -l python -L bs4 -o ./output
46
46
 
47
47
  ```bash
48
48
  # single file (Python + bs4)
49
- ssc-gen generate schema.kdl -l python -L bs4 -o ./output
49
+ ssc-gen generate python schema.kdl -L bs4 -o ./output
50
50
 
51
51
  # all .kdl files in a directory (JavaScript)
52
- ssc-gen generate examples/ -l js -o ./output
52
+ ssc-gen generate js examples/ -o ./output
53
+
54
+ # Go (goquery + net/http)
55
+ ssc-gen generate go schema.kdl -o ./output
53
56
 
54
57
  # with custom package name
55
- ssc-gen generate schema.kdl -l python -L bs4 -o ./parsers --package scraper
58
+ ssc-gen generate python schema.kdl -L bs4 -o ./parsers --package scraper
56
59
 
57
60
  # with @request support (REST/HTTP codegen)
58
- ssc-gen generate schema.kdl -l python -L bs4 -o ./out --http-client httpx
61
+ ssc-gen generate python schema.kdl -L bs4 -o ./out --http-client httpx
59
62
 
60
63
  # extract helper functions into a separate runtime module
61
- ssc-gen generate schema.kdl -l python -L bs4 -o ./out -R
64
+ ssc-gen generate python schema.kdl -L bs4 -o ./out -R
62
65
  ```
63
66
 
64
- Languages (`--lang / -l`): `python`, `js`.
67
+ Languages: `generate python`, `generate js`, `generate go`.
65
68
  HTML libraries (`--lib / -L`, Python only): `bs4` (default), `lxml`, `parsel`, `slax`.
66
69
 
67
70
  ### Lint schemas
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.32.3"
3
+ version = "0.33.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"
@@ -49,3 +49,4 @@ class StructType(IntEnum):
49
49
  TABLE = auto()
50
50
  FLAT = auto()
51
51
  REST = auto()
52
+ RAW = auto()
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import ast as _py_ast
6
6
  import re as _re
7
7
  from collections.abc import Sequence
8
+ from types import SimpleNamespace
8
9
  from typing import Any, Callable, TypeAlias
9
10
 
10
11
  from ssc_codegen.ast import (
@@ -206,7 +207,7 @@ def resolve_index_types(
206
207
  the element base with is_array=False.
207
208
  """
208
209
  if parent.body:
209
- prev = parent.body[-1]
210
+ prev = _prev_type_info(parent)
210
211
  ret_ti = TypeInfo(base=prev.ret_type_info.base)
211
212
  return prev.ret_type_info, ret_ti, prev.is_array
212
213
  auto = TypeInfo(base=VariableType.AUTO)
@@ -420,18 +421,26 @@ def parse_expressions(
420
421
  raise BuildTimeError(f"Unknown expression: {node.name}")
421
422
  lint.push(node.name)
422
423
  lint_pipeline_op(node, lint)
423
- # Guard: ops in _REQUIRES_PREV access parent.body[-1] unconditionally
424
- # in their handlers. Emit a diagnostic and skip the handler when the
425
- # pipeline is empty so far, instead of crashing with IndexError.
424
+ # Guard: ops in _REQUIRES_PREV access parent.body[-1] in their
425
+ # handlers. Emit a diagnostic and skip when the pipeline is empty —
426
+ # UNLESS the parent starts with STRING (RAW struct fields), where
427
+ # string ops are valid as the first operation.
426
428
  if not parent.body and node.name in _REQUIRES_PREV:
427
- lint.error(
428
- node,
429
- message=(f"'{node.name}' requires a preceding operation"),
430
- code="E100",
431
- hint="add a producer first, e.g. `css '.x'; text`",
432
- )
433
- lint.pop()
434
- continue
429
+ parent_accept = getattr(parent, "accept_type_info", None)
430
+ if (
431
+ parent_accept is not None
432
+ and parent_accept.base == VariableType.STRING
433
+ ):
434
+ pass # RAW struct field — string ops OK as first node
435
+ else:
436
+ lint.error(
437
+ node,
438
+ message=(f"'{node.name}' requires a preceding operation"),
439
+ code="E100",
440
+ hint="add a producer first, e.g. `css '.x'; text`",
441
+ )
442
+ lint.pop()
443
+ continue
435
444
  # Guard: handlers reading node.args[i] without a bounds check would
436
445
  # crash with `tuple index out of range` when the user omits args.
437
446
  required_args = _MIN_ARGS.get(node.name)
@@ -504,6 +513,28 @@ _ExpressionHandler = Callable[
504
513
  _EXPRESSION_HANDLERS: dict[str, _ExpressionHandler] = {}
505
514
 
506
515
 
516
+ def _prev_type_info(
517
+ parent: FieldLikeNode,
518
+ ) -> SimpleNamespace:
519
+ """Return a namespace with ``ret_type_info`` and ``is_array`` for the
520
+ pipeline cursor.
521
+
522
+ When the pipeline is non-empty, uses the last node's ret type.
523
+ When empty (first op), falls back to the parent's accept type —
524
+ this lets RAW struct fields start directly with string ops
525
+ (re/split/fmt/etc.) since their document is already a STRING.
526
+ """
527
+ if parent.body:
528
+ prev = parent.body[-1]
529
+ return SimpleNamespace(
530
+ ret_type_info=prev.ret_type_info, is_array=prev.is_array
531
+ )
532
+ return SimpleNamespace(
533
+ ret_type_info=parent.accept_type_info,
534
+ is_array=parent.accept_type_info.is_array,
535
+ )
536
+
537
+
507
538
  # Ops whose handlers access ``parent.body[-1]`` unconditionally and therefore
508
539
  # require a preceding node. Used by ``parse_expressions`` to emit a clean
509
540
  # E100 diagnostic instead of crashing with IndexError on an empty pipeline.
@@ -659,7 +690,7 @@ def _expr_text(
659
690
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
660
691
  ):
661
692
  if parent.body:
662
- prev = parent.body[-1]
693
+ prev = _prev_type_info(parent)
663
694
  accept_ti = prev.ret_type_info
664
695
  is_arr = prev.is_array
665
696
  else:
@@ -678,7 +709,7 @@ def _expr_raw(
678
709
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
679
710
  ):
680
711
  if parent.body:
681
- prev = parent.body[-1]
712
+ prev = _prev_type_info(parent)
682
713
  accept_ti = prev.ret_type_info
683
714
  is_arr = prev.is_array
684
715
  else:
@@ -697,7 +728,7 @@ def _expr_attr(
697
728
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
698
729
  ):
699
730
  if parent.body:
700
- prev = parent.body[-1]
731
+ prev = _prev_type_info(parent)
701
732
  accept_ti = prev.ret_type_info
702
733
  is_arr = prev.is_array
703
734
  else:
@@ -727,7 +758,7 @@ def _expr_trim(
727
758
  if node.args
728
759
  else ""
729
760
  )
730
- prev = parent.body[-1]
761
+ prev = _prev_type_info(parent)
731
762
  return Trim(
732
763
  parent=parent,
733
764
  accept_type_info=prev.ret_type_info,
@@ -749,7 +780,7 @@ def _expr_ltrim(
749
780
  if node.args
750
781
  else ""
751
782
  )
752
- prev = parent.body[-1]
783
+ prev = _prev_type_info(parent)
753
784
  return Ltrim(
754
785
  parent=parent,
755
786
  accept_type_info=prev.ret_type_info,
@@ -771,7 +802,7 @@ def _expr_rtrim(
771
802
  if node.args
772
803
  else ""
773
804
  )
774
- prev = parent.body[-1]
805
+ prev = _prev_type_info(parent)
775
806
  return Rtrim(
776
807
  parent=parent,
777
808
  accept_type_info=prev.ret_type_info,
@@ -785,7 +816,7 @@ def _expr_rtrim(
785
816
  def _expr_norm_space(
786
817
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
787
818
  ):
788
- prev = parent.body[-1]
819
+ prev = _prev_type_info(parent)
789
820
  return NormalizeSpace(
790
821
  parent=parent,
791
822
  accept_type_info=prev.ret_type_info,
@@ -799,7 +830,7 @@ def _expr_rm_prefix(
799
830
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
800
831
  ):
801
832
  substr = ctx.property_defines.get(node.args[0].value, node.args[0].value)
802
- prev = parent.body[-1]
833
+ prev = _prev_type_info(parent)
803
834
  return RmPrefix(
804
835
  parent=parent,
805
836
  accept_type_info=prev.ret_type_info,
@@ -814,7 +845,7 @@ def _expr_rm_suffix(
814
845
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
815
846
  ):
816
847
  substr = ctx.property_defines.get(node.args[0].value, node.args[0].value)
817
- prev = parent.body[-1]
848
+ prev = _prev_type_info(parent)
818
849
  return RmSuffix(
819
850
  parent=parent,
820
851
  accept_type_info=prev.ret_type_info,
@@ -829,7 +860,7 @@ def _expr_rm_prefix_suffix(
829
860
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
830
861
  ):
831
862
  substr = ctx.property_defines.get(node.args[0].value, node.args[0].value)
832
- prev = parent.body[-1]
863
+ prev = _prev_type_info(parent)
833
864
  return RmPrefixSuffix(
834
865
  parent=parent,
835
866
  accept_type_info=prev.ret_type_info,
@@ -844,7 +875,7 @@ def _expr_fmt(
844
875
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
845
876
  ):
846
877
  tmpl = ctx.property_defines.get(node.args[0].value, node.args[0].value)
847
- prev = parent.body[-1]
878
+ prev = _prev_type_info(parent)
848
879
  return Fmt(
849
880
  parent=parent,
850
881
  accept_type_info=prev.ret_type_info,
@@ -858,7 +889,7 @@ def _expr_fmt(
858
889
  def _expr_repl(
859
890
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
860
891
  ):
861
- prev = parent.body[-1]
892
+ prev = _prev_type_info(parent)
862
893
  if node.children:
863
894
  items = {
864
895
  str(child.name): str(child.args[0].value) for child in node.children
@@ -890,7 +921,7 @@ def _expr_repl(
890
921
  def _expr_lower(
891
922
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
892
923
  ):
893
- prev = parent.body[-1]
924
+ prev = _prev_type_info(parent)
894
925
  return Lower(
895
926
  parent=parent,
896
927
  accept_type_info=prev.ret_type_info,
@@ -903,7 +934,7 @@ def _expr_lower(
903
934
  def _expr_upper(
904
935
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
905
936
  ):
906
- prev = parent.body[-1]
937
+ prev = _prev_type_info(parent)
907
938
  return Upper(
908
939
  parent=parent,
909
940
  accept_type_info=prev.ret_type_info,
@@ -936,7 +967,7 @@ def _expr_join(
936
967
  def _expr_unescape(
937
968
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
938
969
  ):
939
- prev = parent.body[-1]
970
+ prev = _prev_type_info(parent)
940
971
  return Unescape(
941
972
  parent=parent,
942
973
  accept_type_info=prev.ret_type_info,
@@ -954,7 +985,7 @@ def _expr_re(
954
985
  ):
955
986
  raw = ctx.property_defines.get(node.args[0].value, node.args[0].value)
956
987
  pattern = normalize_regex_pattern(raw)
957
- prev = parent.body[-1]
988
+ prev = _prev_type_info(parent)
958
989
  return Re(
959
990
  parent=parent,
960
991
  pattern=pattern,
@@ -978,7 +1009,7 @@ def _expr_re_all(
978
1009
  def _expr_re_sub(
979
1010
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
980
1011
  ):
981
- prev = parent.body[-1]
1012
+ prev = _prev_type_info(parent)
982
1013
  raw = ctx.property_defines.get(node.args[0].value, node.args[0].value)
983
1014
  pattern = normalize_regex_pattern(raw)
984
1015
  repl = cast(
@@ -1045,7 +1076,7 @@ def _expr_slice(
1045
1076
  ):
1046
1077
  start, end = int(node.args[0].value), int(node.args[1].value)
1047
1078
  if parent.body:
1048
- prev = parent.body[-1]
1079
+ prev = _prev_type_info(parent)
1049
1080
  return Slice(
1050
1081
  parent=parent,
1051
1082
  start=start,
@@ -1079,7 +1110,7 @@ def _expr_unique(
1079
1110
  def _expr_to_int(
1080
1111
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
1081
1112
  ):
1082
- prev = parent.body[-1]
1113
+ prev = _prev_type_info(parent)
1083
1114
  return ToInt(
1084
1115
  parent=parent,
1085
1116
  accept_type_info=prev.ret_type_info,
@@ -1092,7 +1123,7 @@ def _expr_to_int(
1092
1123
  def _expr_to_float(
1093
1124
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
1094
1125
  ):
1095
- prev = parent.body[-1]
1126
+ prev = _prev_type_info(parent)
1096
1127
  return ToFloat(
1097
1128
  parent=parent,
1098
1129
  accept_type_info=prev.ret_type_info,
@@ -1108,7 +1139,7 @@ def _expr_to_bool(
1108
1139
  # Guard against empty parent.body is enforced centrally in
1109
1140
  # parse_expressions via _REQUIRES_PREV — handler is reached only when a
1110
1141
  # preceding node exists.
1111
- prev = parent.body[-1]
1142
+ prev = _prev_type_info(parent)
1112
1143
  return ToBool(parent=parent, accept_type_info=prev.ret_type_info)
1113
1144
 
1114
1145
 
@@ -1164,7 +1195,7 @@ def _expr_fallback(
1164
1195
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
1165
1196
  ):
1166
1197
  value = [] if not node.args else node.args[0].value
1167
- prev = parent.body[-1]
1198
+ prev = _prev_type_info(parent)
1168
1199
  prev_ti = prev.ret_type_info
1169
1200
  fb = Fallback(
1170
1201
  parent=parent,
@@ -1189,7 +1220,7 @@ def _expr_filter(
1189
1220
  ret_type_info=TypeInfo(base=VariableType.DOCUMENT, is_array=True),
1190
1221
  is_array=True,
1191
1222
  )
1192
- prev = parent.body[-1]
1223
+ prev = _prev_type_info(parent)
1193
1224
  return Filter(
1194
1225
  parent=parent,
1195
1226
  accept_type_info=prev.ret_type_info,
@@ -49,7 +49,7 @@ def _node_args(node: KdlNode) -> list[str]:
49
49
 
50
50
 
51
51
  _VALID_STRUCT_TYPES = frozenset(
52
- {"item", "list", "dict", "table", "flat", "rest"}
52
+ {"item", "list", "dict", "table", "flat", "rest", "raw"}
53
53
  )
54
54
 
55
55
  _REQUIRED_RESERVED: dict[str, frozenset[str]] = {
@@ -59,15 +59,18 @@ _REQUIRED_RESERVED: dict[str, frozenset[str]] = {
59
59
  "table": frozenset({"@table", "@rows", "@match", "@value"}),
60
60
  "flat": frozenset(),
61
61
  "rest": frozenset({"@request"}),
62
+ "raw": frozenset(),
62
63
  }
63
64
 
64
65
  _RESERVED_ALLOWED: dict[str, frozenset[str] | None] = {
65
66
  "@request": None,
66
67
  "@doc": None,
67
- "@pre-validate": frozenset({"item", "list", "dict", "table", "flat"}),
68
- "@check": frozenset({"item", "list", "dict", "table", "flat"}),
69
- "@init": frozenset({"item", "list", "dict", "table", "flat"}),
70
- "@split-doc": frozenset({"list", "dict"}),
68
+ "@pre-validate": frozenset(
69
+ {"item", "list", "dict", "table", "flat", "raw"}
70
+ ),
71
+ "@check": frozenset({"item", "list", "dict", "table", "flat", "raw"}),
72
+ "@init": frozenset({"item", "list", "dict", "table", "flat", "raw"}),
73
+ "@split-doc": frozenset({"list", "dict", "raw"}),
71
74
  "@key": frozenset({"dict"}),
72
75
  "@value": frozenset({"dict", "table"}),
73
76
  "@table": frozenset({"table"}),
@@ -42,6 +42,7 @@ def handle_struct(
42
42
  "dict": StructType.DICT,
43
43
  "table": StructType.TABLE,
44
44
  "rest": StructType.REST,
45
+ "raw": StructType.RAW,
45
46
  }
46
47
  st = struct_type_map.get(type_)
47
48
  if st is None:
@@ -6,7 +6,11 @@ from collections.abc import Sequence
6
6
  from typing import Any
7
7
 
8
8
  from ssc_codegen.ast import (
9
+ Attr,
9
10
  CheckMethod,
11
+ CssRemove,
12
+ CssSelect,
13
+ CssSelectAll,
10
14
  ErrorResponse,
11
15
  Field,
12
16
  InitField,
@@ -18,6 +22,7 @@ from ssc_codegen.ast import (
18
22
  MethodRest,
19
23
  Node,
20
24
  PreValidate,
25
+ Raw,
21
26
  SplitDoc,
22
27
  StartParse,
23
28
  StructBase,
@@ -27,9 +32,13 @@ from ssc_codegen.ast import (
27
32
  TableConfig,
28
33
  TableMatchKey,
29
34
  TableRows,
35
+ Text,
30
36
  TypeInfo,
31
37
  Value,
32
38
  VariableType,
39
+ XpathRemove,
40
+ XpathSelect,
41
+ XpathSelectAll,
33
42
  )
34
43
  from ssc_codegen.request_spec import parse_to_http
35
44
  from kdlquery import KdlNode
@@ -38,6 +47,41 @@ from ssc_codegen.core.contexts import LintContext, ParseContext, WalkCtx
38
47
  from ssc_codegen.core.expressions import parse_expressions
39
48
  from ssc_codegen.core.type_checking import check_pipeline_types
40
49
 
50
+ # AST node types forbidden in (raw)struct — they require a DOM document.
51
+ _RAW_FORBIDDEN_OPS = (
52
+ CssSelect,
53
+ CssSelectAll,
54
+ CssRemove,
55
+ XpathSelect,
56
+ XpathSelectAll,
57
+ XpathRemove,
58
+ Text,
59
+ Attr,
60
+ Raw,
61
+ )
62
+
63
+
64
+ def _struct_start_type(struct: StructBase) -> VariableType:
65
+ """Return the pipeline start type for a struct's fields."""
66
+ if isinstance(struct, Struct) and struct.type == StructType.RAW:
67
+ return VariableType.STRING
68
+ return VariableType.DOCUMENT
69
+
70
+
71
+ def _lint_raw_forbidden_ops(expr: Node, lint: LintContext) -> None:
72
+ """Recursively check that no HTML-only ops appear in a RAW struct node."""
73
+ for child in expr.body:
74
+ if isinstance(child, _RAW_FORBIDDEN_OPS):
75
+ lint.error(
76
+ child, # type: ignore[arg-type]
77
+ message=(
78
+ f"HTML operation '{type(child).__name__}' is forbidden in"
79
+ " (raw)struct — the document is a plain string, not a DOM"
80
+ ),
81
+ code="E001",
82
+ )
83
+ _lint_raw_forbidden_ops(child, lint)
84
+
41
85
 
42
86
  def parse_struct(
43
87
  kdl_nodes: Sequence[KdlNode],
@@ -56,7 +100,11 @@ def parse_struct(
56
100
  _parse_init_fields(node.children, parent, ctx, lint)
57
101
  elif node.name == "@pre-validate":
58
102
  expr = PreValidate(parent=parent)
103
+ if isinstance(parent, Struct) and parent.type == StructType.RAW:
104
+ expr.accept_type_info = TypeInfo(base=VariableType.STRING)
59
105
  parse_expressions(node.children, expr, ctx, lint)
106
+ if isinstance(parent, Struct) and parent.type == StructType.RAW:
107
+ _lint_raw_forbidden_ops(expr, lint)
60
108
  parent.body.append(expr)
61
109
  elif node.name == "@check":
62
110
  if not node.args:
@@ -68,11 +116,22 @@ def parse_struct(
68
116
  continue
69
117
  check_name = str(node.args[0].value)
70
118
  expr = CheckMethod(parent=parent, name=check_name)
119
+ if isinstance(parent, Struct) and parent.type == StructType.RAW:
120
+ expr.accept_type_info = TypeInfo(base=VariableType.STRING)
71
121
  parse_expressions(node.children, expr, ctx, lint)
122
+ if isinstance(parent, Struct) and parent.type == StructType.RAW:
123
+ _lint_raw_forbidden_ops(expr, lint)
72
124
  parent.body.append(expr)
73
125
  elif node.name == "@split-doc":
74
126
  expr = SplitDoc(parent=parent)
127
+ if isinstance(parent, Struct) and parent.type == StructType.RAW:
128
+ expr.accept_type_info = TypeInfo(base=VariableType.STRING)
129
+ expr.ret_type_info = TypeInfo(
130
+ base=VariableType.STRING, is_array=True
131
+ )
75
132
  parse_expressions(node.children, expr, ctx, lint)
133
+ if isinstance(parent, Struct) and parent.type == StructType.RAW:
134
+ _lint_raw_forbidden_ops(expr, lint)
76
135
  parent.body.append(expr)
77
136
  elif node.name == "@key":
78
137
  expr = Key(parent=parent)
@@ -218,12 +277,21 @@ def parse_struct(
218
277
  )
219
278
  parent.body.append(err)
220
279
  else:
280
+ is_raw = (
281
+ isinstance(parent, Struct) and parent.type == StructType.RAW
282
+ )
221
283
  if isinstance(parent, Struct) and parent.type == StructType.TABLE:
222
284
  expr = Field(
223
285
  parent=parent,
224
286
  name=node.name,
225
287
  accept_type_info=TypeInfo(base=VariableType.STRING),
226
288
  )
289
+ elif is_raw:
290
+ expr = Field(
291
+ parent=parent,
292
+ name=node.name,
293
+ accept_type_info=TypeInfo(base=VariableType.STRING),
294
+ )
227
295
  else:
228
296
  expr = Field(parent=parent, name=node.name)
229
297
  ops = list(node.children)
@@ -231,8 +299,13 @@ def parse_struct(
231
299
  # Type inference for regular fields
232
300
  if ops and not (len(ops) == 1 and ops[0].name == "nested"):
233
301
  check_pipeline_types(
234
- ops, ctx, lint, start_type=VariableType.DOCUMENT
302
+ ops,
303
+ ctx,
304
+ lint,
305
+ start_type=_struct_start_type(parent),
235
306
  )
307
+ if is_raw:
308
+ _lint_raw_forbidden_ops(expr, lint)
236
309
  parent.body.append(expr)
237
310
 
238
311
  if not isinstance(parent, StructRest):
@@ -321,18 +394,22 @@ def _parse_init_fields(
321
394
  prev_ctx = lint.walk_context
322
395
  lint.walk_context = WalkCtx.INIT_BLOCK
323
396
  init = parent.init
397
+ is_raw = parent.type == StructType.RAW
398
+ start_type = _struct_start_type(parent)
324
399
  for node in kdl_nodes:
325
400
  lint.push(node.name)
326
401
  lint.init_fields.add(node.name)
327
402
  expr = InitField(parent=parent, name=node.name)
403
+ if is_raw:
404
+ expr.accept_type_info = TypeInfo(base=VariableType.STRING)
328
405
  parse_expressions(node.children, expr, ctx, lint)
329
406
  if expr.body:
330
407
  expr.ret_type_info = expr.body[-1].ret_type_info
331
408
  ops = list(node.children)
332
- ret = check_pipeline_types(
333
- ops, ctx, lint, start_type=VariableType.DOCUMENT
334
- )
335
- lint.inferred_define_types[node.name] = (VariableType.DOCUMENT, ret)
409
+ ret = check_pipeline_types(ops, ctx, lint, start_type=start_type)
410
+ lint.inferred_define_types[node.name] = (start_type, ret)
411
+ if is_raw:
412
+ _lint_raw_forbidden_ops(expr, lint)
336
413
  parent.body.append(expr)
337
414
  init.body.append(InitFieldCall(parent=init, name=node.name))
338
415
  lint.pop()
@@ -25,6 +25,8 @@ from ssc_codegen.ast import (
25
25
  TableRows,
26
26
  TableMatchKey,
27
27
  StructBase,
28
+ Struct,
29
+ StructType,
28
30
  Module,
29
31
  )
30
32
  from ssc_codegen.ast.base import Node as AstNode
@@ -285,6 +287,20 @@ def check_struct_health(
285
287
  soup = BeautifulSoup(html, "lxml")
286
288
  result = HealthResult(struct_name=struct.name)
287
289
 
290
+ # RAW structs have no HTML selectors — skip health-check entirely.
291
+ if isinstance(struct, Struct) and struct.type == StructType.RAW:
292
+ result.checks.append(
293
+ SelectorCheck(
294
+ path=struct.name,
295
+ selector_type="raw",
296
+ query="",
297
+ matches=0,
298
+ status="warn",
299
+ message="(raw)struct has no HTML selectors to check",
300
+ )
301
+ )
302
+ return result
303
+
288
304
  # collect selectors + nested refs, then recurse into nested structs
289
305
  visited: set[str] = set()
290
306
  _check_struct_recursive(
@@ -418,6 +418,9 @@ class GoVisitor(BaseWalker):
418
418
  return f"[]{name}Type"
419
419
  case ST.FLAT:
420
420
  return "[]string"
421
+ case ST.RAW:
422
+ has_split = any(isinstance(n, SplitDoc) for n in struct.body)
423
+ return f"[]{name}Type" if has_split else f"{name}Type"
421
424
  case _:
422
425
  return "any"
423
426
 
@@ -516,7 +519,7 @@ class GoVisitor(BaseWalker):
516
519
  return [f"type {name}Type = map[string]{vt}", ""]
517
520
  case ST.TABLE:
518
521
  return [f"type {name}Type = map[string]any", ""]
519
- case ST.ITEM | ST.LIST:
522
+ case ST.ITEM | ST.LIST | ST.RAW:
520
523
  lines = [f"type {name}Type struct {{"]
521
524
  lines.extend(self.walk_children(node, ctx))
522
525
  lines.append("}")
@@ -611,7 +614,9 @@ class GoVisitor(BaseWalker):
611
614
  else f"{ctx.indent}//"
612
615
  )
613
616
  lines.append(f"type {name} struct {{")
614
- lines.append("\tsel *goquery.Selection")
617
+ is_raw = isinstance(node, Struct) and node.type == ST.RAW
618
+ sel_type = "string" if is_raw else "*goquery.Selection"
619
+ lines.append(f"\tsel {sel_type}")
615
620
  init_fields = self._collect_init_fields(node)
616
621
  for cn, gt, _ in init_fields:
617
622
  lines.append(f"\t{cn} {gt}")
@@ -633,18 +638,28 @@ class GoVisitor(BaseWalker):
633
638
  ) -> list[str]:
634
639
  i = ctx.indent
635
640
  i2 = ctx.deeper().indent
636
- lines: list[str] = [
637
- f"func New{name}(input string) (*{name}, error) {{",
638
- f"{i2}doc, err := goquery.NewDocumentFromReader(strings.NewReader(input))",
639
- f"{i2}if err != nil {{",
640
- f"{i2}\treturn nil, err",
641
- f"{i2}}}",
642
- f"{i2}{rcv} := &{name}{{sel: doc.Selection}}",
643
- ]
641
+ is_raw = isinstance(node, Struct) and node.type == ST.RAW
642
+ if is_raw:
643
+ lines: list[str] = [
644
+ f"func New{name}(input string) *{name} {{",
645
+ f"{i2}{rcv} := &{name}{{sel: input}}",
646
+ ]
647
+ else:
648
+ lines = [
649
+ f"func New{name}(input string) (*{name}, error) {{",
650
+ f"{i2}doc, err := goquery.NewDocumentFromReader(strings.NewReader(input))",
651
+ f"{i2}if err != nil {{",
652
+ f"{i2}\treturn nil, err",
653
+ f"{i2}}}",
654
+ f"{i2}{rcv} := &{name}{{sel: doc.Selection}}",
655
+ ]
644
656
  init_fields = self._collect_init_fields(node)
645
657
  if init_fields:
646
658
  lines.append(f"{i2}{rcv}.init()")
647
- lines.append(f"{i2}return {rcv}, nil")
659
+ if is_raw:
660
+ lines.append(f"{i2}return {rcv}")
661
+ else:
662
+ lines.append(f"{i2}return {rcv}, nil")
648
663
  lines.append(f"{i}}}")
649
664
  lines.append("")
650
665
  return lines
@@ -935,6 +950,31 @@ class GoVisitor(BaseWalker):
935
950
  lines.append(f"{i2}}}")
936
951
  else:
937
952
  lines.append(f"{i2}return {name}Type{{}}")
953
+ case ST.RAW:
954
+ if node.use_split_doc:
955
+ lines.append(f"{i2}rows := {rcv}.splitDoc({rcv}.sel)")
956
+ lines.append(
957
+ f"{i2}result := make([]{name}Type, 0, len(rows))"
958
+ )
959
+ lines.append(f"{i2}for _, item := range rows {{")
960
+ lines.append(f"{i3}result = append(result, {name}Type{{")
961
+ for f in node.fields:
962
+ fn = _go_field_name(f.name)
963
+ mn = _go_method_name(f.name)
964
+ lines.append(f"{i4}{fn}: {rcv}.{mn}(item),")
965
+ lines.append(f"{i3}}})")
966
+ lines.append(f"{i2}}}")
967
+ lines.append(f"{i2}return result")
968
+ else:
969
+ if node.fields:
970
+ lines.append(f"{i2}return {name}Type{{")
971
+ for f in node.fields:
972
+ fn = _go_field_name(f.name)
973
+ mn = _go_method_name(f.name)
974
+ lines.append(f"{i3}{fn}: {rcv}.{mn}({rcv}.sel),")
975
+ lines.append(f"{i2}}}")
976
+ else:
977
+ lines.append(f"{i2}return {name}Type{{}}")
938
978
  case ST.LIST:
939
979
  lines.append(f"{i2}rows := {rcv}.splitDoc({rcv}.sel)")
940
980
  lines.append(
@@ -465,14 +465,22 @@ class JsVisitor(BaseWalker):
465
465
  if isinstance(node.parent, StructRest):
466
466
  return []
467
467
  i1, i2, i3 = ctx.indent, ctx.indent * 2, ctx.indent * 3
468
- lines = [
469
- f"{i1}constructor(document) {{",
470
- f"{i2}if (typeof document === 'string') {{",
471
- f"{i3}this._doc = (new DOMParser()).parseFromString(document, 'text/html');",
472
- f"{i2}}} else {{",
473
- f"{i3}this._doc = document;",
474
- f"{i2}}}",
475
- ]
468
+ struct = node.parent
469
+ is_raw = isinstance(struct, Struct) and struct.type == ST.RAW
470
+ if is_raw:
471
+ lines = [
472
+ f"{i1}constructor(document) {{",
473
+ f"{i2}this._doc = document;",
474
+ ]
475
+ else:
476
+ lines = [
477
+ f"{i1}constructor(document) {{",
478
+ f"{i2}if (typeof document === 'string') {{",
479
+ f"{i3}this._doc = (new DOMParser()).parseFromString(document, 'text/html');",
480
+ f"{i2}}} else {{",
481
+ f"{i3}this._doc = document;",
482
+ f"{i2}}}",
483
+ ]
476
484
  lines.extend(self.walk_children(node, ctx))
477
485
  lines.append(f"{i1}}}")
478
486
  return lines
@@ -574,6 +582,9 @@ class JsVisitor(BaseWalker):
574
582
  ret_type = f"Array<{name}Type>"
575
583
  elif st == ST.FLAT:
576
584
  ret_type = "Array<string>"
585
+ elif st == ST.RAW:
586
+ has_split = any(isinstance(n, SplitDoc) for n in struct.body)
587
+ ret_type = f"Array<{name}Type>" if has_split else f"{name}Type"
577
588
  else:
578
589
  ret_type = f"{name}Type"
579
590
  i1, i2, i3 = ctx.indent, ctx.indent * 2, ctx.indent * 3
@@ -642,6 +653,24 @@ class JsVisitor(BaseWalker):
642
653
  )
643
654
  lines.append(f"{i2}}}")
644
655
  lines.append(f"{i2}return _result;")
656
+ elif st == ST.RAW:
657
+ has_split = node.use_split_doc
658
+ if has_split:
659
+ lines.append(
660
+ f"{i2}return Array.from(this._splitDoc(this._doc)).map(i => ({{"
661
+ )
662
+ for f in node.fields:
663
+ n = to_camel_case(f.name)
664
+ lines.append(f"{i3}{n}: this.{_js_method_name(f.name)}(i),")
665
+ lines.append(f"{i2}}}));")
666
+ else:
667
+ lines.append(f"{i2}return {{")
668
+ for f in node.fields:
669
+ n = to_camel_case(f.name)
670
+ lines.append(
671
+ f"{i3}{n}: this.{_js_method_name(f.name)}(this._doc),"
672
+ )
673
+ lines.append(f"{i2}}};")
645
674
  lines.append(f"{i1}}}")
646
675
  return lines
647
676
 
@@ -29,8 +29,8 @@ from ssc_codegen.targets.python.http_libs.base import HttpLibStrategy
29
29
  from ssc_codegen.traversal.context import WalkContext
30
30
  from ssc_codegen.traversal.utils import (
31
31
  dict_needs_builder,
32
+ module_has_html_struct,
32
33
  module_has_rest,
33
- module_is_rest_only,
34
34
  )
35
35
 
36
36
 
@@ -245,7 +245,7 @@ def runtime_export_names(
245
245
  - Module with REST structs: also import REST names.
246
246
  """
247
247
  names: list[str] = list(_RUNTIME_ALWAYS_EXPORT_NAMES)
248
- if not module_is_rest_only(module):
248
+ if module_has_html_struct(module):
249
249
  names.extend(_RUNTIME_HTML_EXPORT_NAMES)
250
250
  if need_fallback:
251
251
  names.append("FALLBACK_HTML_STR")
@@ -125,8 +125,8 @@ from ssc_codegen.ast import (
125
125
  from ssc_codegen.naming import to_pascal_case, to_snake_case
126
126
  from ssc_codegen.traversal.utils import (
127
127
  jsonify_path_to_segments,
128
+ module_has_html_struct,
128
129
  module_has_rest,
129
- module_is_rest_only,
130
130
  module_uses_http,
131
131
  )
132
132
  from ssc_codegen.generation.builder import ModuleBuilder
@@ -340,6 +340,11 @@ class PythonVisitor(BaseWalker):
340
340
  return self.ARRAY_TYPE_FMT.format(f"{name}Type")
341
341
  case ST.FLAT:
342
342
  return self.ARRAY_TYPE_FMT.format(self.TYPES[VT.STRING])
343
+ case ST.RAW:
344
+ has_split = any(isinstance(n, SplitDoc) for n in struct.body)
345
+ if has_split:
346
+ return self.ARRAY_TYPE_FMT.format(f"{name}Type")
347
+ return f"{name}Type"
343
348
  case _:
344
349
  return self.TYPES[VT.STRING]
345
350
 
@@ -350,10 +355,10 @@ class PythonVisitor(BaseWalker):
350
355
  if node.doc:
351
356
  lines.extend(['"""', node.doc, '"""'])
352
357
  has_rest = module_has_rest(node)
353
- is_rest_only = module_is_rest_only(node)
354
358
  uses_http = module_uses_http(node)
359
+ has_html = module_has_html_struct(node)
355
360
  runtime = ctx.meta.get("runtime_module")
356
- if not is_rest_only:
361
+ if has_html:
357
362
  for line in self._dom.parser_imports:
358
363
  self._builder.require_import(line)
359
364
  self._builder.require_import(
@@ -419,8 +424,8 @@ class PythonVisitor(BaseWalker):
419
424
  lines.extend(self._builder.imports)
420
425
  lines.append("")
421
426
  mod = node.parent
422
- is_rest_only = isinstance(mod, Module) and module_is_rest_only(mod)
423
- if not is_rest_only:
427
+ has_html = isinstance(mod, Module) and module_has_html_struct(mod)
428
+ if has_html:
424
429
  lines.extend(
425
430
  [
426
431
  "class UnmatchedTableRow:",
@@ -481,7 +486,7 @@ class PythonVisitor(BaseWalker):
481
486
  return self.walk_children(node, ctx)
482
487
  case ST.FLAT:
483
488
  return [f"{name}Type = List[str]"]
484
- case ST.ITEM | ST.LIST | ST.TABLE:
489
+ case ST.ITEM | ST.LIST | ST.TABLE | ST.RAW:
485
490
  lines = [f'{name}Type = TypedDict("{name}Type", {{']
486
491
  lines.extend(self.walk_children(node, ctx))
487
492
  lines.append("})")
@@ -552,13 +557,21 @@ class PythonVisitor(BaseWalker):
552
557
  ctx.deeper().indent,
553
558
  ctx.deeper().deeper().indent,
554
559
  )
555
- lines = [
556
- f"{i}def __init__(self, document: {self._dom.init_arg_type}):",
557
- f"{i2}if isinstance(document, str):",
558
- f"{i3}self._doc = {self._dom.init_from_str_expr}",
559
- f"{i2}else:",
560
- f"{i3}self._doc = document",
561
- ]
560
+ struct = node.parent
561
+ is_raw = isinstance(struct, Struct) and struct.type == ST.RAW
562
+ if is_raw:
563
+ lines = [
564
+ f"{i}def __init__(self, document: str):",
565
+ f"{i2}self._doc = document",
566
+ ]
567
+ else:
568
+ lines = [
569
+ f"{i}def __init__(self, document: {self._dom.init_arg_type}):",
570
+ f"{i2}if isinstance(document, str):",
571
+ f"{i3}self._doc = {self._dom.init_from_str_expr}",
572
+ f"{i2}else:",
573
+ f"{i3}self._doc = document",
574
+ ]
562
575
  lines.extend(self.walk_children(node, ctx))
563
576
  return lines
564
577
 
@@ -713,6 +726,21 @@ class PythonVisitor(BaseWalker):
713
726
  lines.append(f"{i4}_result[{fn!r}] = _{fn}")
714
727
  lines.append(f"{i4}continue")
715
728
  lines.append(f"{i2}return _result")
729
+ case ST.RAW:
730
+ if node.use_split_doc:
731
+ lines.append(f"{i2}return [{{")
732
+ for field in node.fields:
733
+ fn = to_snake_case(field.name)
734
+ lines.append(f"{i2}{fn!r}: self._parse_{fn}(i),")
735
+ lines.append(f"{i3}}} for i in self._split_doc(self._doc)]")
736
+ else:
737
+ lines.append(f"{i2}return {{")
738
+ for field in node.fields:
739
+ fn = to_snake_case(field.name)
740
+ lines.append(
741
+ f"{i3}{fn!r}: self._parse_{fn}(self._doc),"
742
+ )
743
+ lines.append(f"{i3}}}")
716
744
  return lines
717
745
 
718
746
  # === REST / FETCH (delegate to rest.py) ===
@@ -13,8 +13,10 @@ from ssc_codegen.ast import (
13
13
  PlaceholderSpec,
14
14
  PlaceholderTemplate,
15
15
  PreValidate,
16
+ Struct,
16
17
  StructBase,
17
18
  StructRest,
19
+ StructType,
18
20
  )
19
21
 
20
22
 
@@ -46,6 +48,17 @@ def module_is_rest_only(module: Module) -> bool:
46
48
  return len(structs) == 0 or all(isinstance(s, StructRest) for s in structs)
47
49
 
48
50
 
51
+ 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
+ for n in module.body:
54
+ if isinstance(n, StructRest):
55
+ continue
56
+ if isinstance(n, Struct):
57
+ if n.type != StructType.RAW:
58
+ return True
59
+ return False
60
+
61
+
49
62
  def err_subclass_name(struct_name: str, err: ErrorResponse) -> str:
50
63
  """Deterministic error-subclass name from struct name + error spec."""
51
64
  from ssc_codegen.core.rest_artifacts import (
File without changes
File without changes