ssc_codegen 0.37.1__tar.gz → 0.38.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 (92) hide show
  1. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/PKG-INFO +10 -4
  2. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/README.md +12 -6
  3. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/pyproject.toml +1 -11
  4. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/__init__.py +18 -2
  5. ssc_codegen-0.38.0/ssc_codegen/ast/extension.py +80 -0
  6. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/module.py +5 -0
  7. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/struct.py +21 -15
  8. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/typedef.py +2 -1
  9. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/contexts.py +35 -23
  10. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/expressions.py +84 -1
  11. ssc_codegen-0.38.0/ssc_codegen/core/extensions.py +144 -0
  12. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/format.py +29 -5
  13. ssc_codegen-0.38.0/ssc_codegen/core/imports.py +544 -0
  14. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/linter.py +804 -24
  15. ssc_codegen-0.38.0/ssc_codegen/core/module_handler.py +147 -0
  16. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/reader.py +54 -17
  17. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/struct_parser.py +20 -1
  18. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/type_checking.py +17 -1
  19. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/generation/builder.py +37 -0
  20. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/generation/runtime.py +44 -1
  21. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/main.py +135 -12
  22. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/parsers/curl.py +20 -9
  23. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/parsers/http.py +17 -6
  24. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/request_spec.py +84 -3
  25. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/rest.py +106 -54
  26. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/runtime.py +11 -0
  27. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/visitor.py +109 -9
  28. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/rest.py +48 -14
  29. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/visitor.py +52 -7
  30. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/rest.py +14 -49
  31. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/visitor.py +65 -21
  32. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/traversal/utils.py +11 -0
  33. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/traversal/walker.py +2 -0
  34. ssc_codegen-0.37.1/ssc_codegen/core/module_handler.py +0 -240
  35. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/.gitignore +0 -0
  36. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/LICENSE +0 -0
  37. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/__init__.py +0 -0
  38. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/_logging.py +0 -0
  39. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/array.py +0 -0
  40. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/base.py +0 -0
  41. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/cast.py +0 -0
  42. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/control.py +0 -0
  43. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/extract.py +0 -0
  44. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/function.py +0 -0
  45. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/helpers.py +0 -0
  46. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/jsondef.py +0 -0
  47. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/predicate_containers.py +0 -0
  48. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/predicate_ops.py +0 -0
  49. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/regex.py +0 -0
  50. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/rest.py +0 -0
  51. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/selectors.py +0 -0
  52. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/string.py +0 -0
  53. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/ast/types.py +0 -0
  54. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/__init__.py +0 -0
  55. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/predicates.py +0 -0
  56. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/core/rest_artifacts.py +0 -0
  57. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/exceptions.py +0 -0
  58. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/explore.py +0 -0
  59. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/generation/__init__.py +0 -0
  60. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/health.py +0 -0
  61. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/naming.py +0 -0
  62. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/parsers/__init__.py +0 -0
  63. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/regex_utils.py +0 -0
  64. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/__init__.py +0 -0
  65. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/__init__.py +0 -0
  66. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/http_libs/__init__.py +0 -0
  67. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/http_libs/base.py +0 -0
  68. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/http_libs/nethttp.py +0 -0
  69. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/literals.py +0 -0
  70. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/regex.py +0 -0
  71. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/__init__.py +0 -0
  72. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/http_libs/__init__.py +0 -0
  73. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/http_libs/axios.py +0 -0
  74. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/http_libs/base.py +0 -0
  75. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/http_libs/fetch.py +0 -0
  76. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/profile.py +0 -0
  77. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/__init__.py +0 -0
  78. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/__init__.py +0 -0
  79. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/base.py +0 -0
  80. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/bs4.py +0 -0
  81. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/lxml.py +0 -0
  82. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/parsel.py +0 -0
  83. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/slax.py +0 -0
  84. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/__init__.py +0 -0
  85. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/aiohttp.py +0 -0
  86. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/base.py +0 -0
  87. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/httpx.py +0 -0
  88. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/requests.py +0 -0
  89. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/resolver.py +0 -0
  90. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/targets/spec.py +0 -0
  91. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/traversal/__init__.py +0 -0
  92. {ssc_codegen-0.37.1 → ssc_codegen-0.38.0}/ssc_codegen/traversal/context.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ssc_codegen
3
- Version: 0.37.1
3
+ Version: 0.38.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
@@ -42,7 +42,8 @@ Code generator for web scraping parsers. Describe HTML extraction rules in a dec
42
42
 
43
43
  - Declarative DSL based on KDL 2.0 syntax
44
44
  - Static type checking and linting before code generation
45
- - Multiple output targets: Python (bs4, lxml, parsel, selectolax), JavaScript (DOM API)
45
+ - Multiple output targets: Python (bs4, lxml, parsel, selectolax), JavaScript (DOM API), Go (goquery)
46
+ - Typed user extensions with lazy target imports and runtime helpers
46
47
  - Struct types: `item`, `list`, `dict`, `table`, `flat`
47
48
  - LLM-friendly: system prompt + linter loop for AI-assisted schema generation
48
49
 
@@ -86,6 +87,9 @@ ssc-gen generate js examples/ -o ./output
86
87
  # Go (goquery + net/http)
87
88
  ssc-gen generate go schema.kdl -o ./output
88
89
 
90
+ # Go package defaults to main; override explicitly when generating a library
91
+ ssc-gen generate go schema.kdl -o ./output --package scraper
92
+
89
93
  # with custom package name
90
94
  ssc-gen generate python schema.kdl -L bs4 -o ./parsers --package scraper
91
95
 
@@ -116,12 +120,14 @@ ssc-gen check examples/
116
120
 
117
121
  ```bash
118
122
  # from file
119
- ssc-gen run examples/booksToScrape.kdl:MainCatalogue -l python -L bs4 -i page.html
123
+ ssc-gen run examples/booksToScrape.kdl:MainCatalogue -L bs4 -i page.html
120
124
 
121
125
  # from stdin
122
- curl https://books.toscrape.com/ | ssc-gen run examples/booksToScrape.kdl:MainCatalogue -l python -L bs4
126
+ curl https://books.toscrape.com/ | ssc-gen run examples/booksToScrape.kdl:MainCatalogue -L bs4
123
127
  ```
124
128
 
129
+ `ssc-gen run` executes generated Python in-process. Run only trusted schema files.
130
+
125
131
  ### Health check (verify selectors match elements)
126
132
 
127
133
  ```bash
@@ -10,7 +10,8 @@ Code generator for web scraping parsers. Describe HTML extraction rules in a dec
10
10
 
11
11
  - Declarative DSL based on KDL 2.0 syntax
12
12
  - Static type checking and linting before code generation
13
- - Multiple output targets: Python (bs4, lxml, parsel, selectolax), JavaScript (DOM API)
13
+ - Multiple output targets: Python (bs4, lxml, parsel, selectolax), JavaScript (DOM API), Go (goquery)
14
+ - Typed user extensions with lazy target imports and runtime helpers
14
15
  - Struct types: `item`, `list`, `dict`, `table`, `flat`
15
16
  - LLM-friendly: system prompt + linter loop for AI-assisted schema generation
16
17
 
@@ -51,8 +52,11 @@ ssc-gen generate python schema.kdl -L bs4 -o ./output
51
52
  # all .kdl files in a directory (JavaScript)
52
53
  ssc-gen generate js examples/ -o ./output
53
54
 
54
- # Go (goquery + net/http)
55
- ssc-gen generate go schema.kdl -o ./output
55
+ # Go (goquery + net/http)
56
+ ssc-gen generate go schema.kdl -o ./output
57
+
58
+ # Go package defaults to main; override explicitly when generating a library
59
+ ssc-gen generate go schema.kdl -o ./output --package scraper
56
60
 
57
61
  # with custom package name
58
62
  ssc-gen generate python schema.kdl -L bs4 -o ./parsers --package scraper
@@ -84,11 +88,13 @@ ssc-gen check examples/
84
88
 
85
89
  ```bash
86
90
  # from file
87
- ssc-gen run examples/booksToScrape.kdl:MainCatalogue -l python -L bs4 -i page.html
91
+ ssc-gen run examples/booksToScrape.kdl:MainCatalogue -L bs4 -i page.html
88
92
 
89
93
  # from stdin
90
- curl https://books.toscrape.com/ | ssc-gen run examples/booksToScrape.kdl:MainCatalogue -l python -L bs4
91
- ```
94
+ curl https://books.toscrape.com/ | ssc-gen run examples/booksToScrape.kdl:MainCatalogue -L bs4
95
+ ```
96
+
97
+ `ssc-gen run` executes generated Python in-process. Run only trusted schema files.
92
98
 
93
99
  ### Health check (verify selectors match elements)
94
100
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.37.1"
3
+ version = "0.38.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"
@@ -78,10 +78,6 @@ exclude = ["__init__.py"]
78
78
  [tool.mypy]
79
79
  python_version = "3.10"
80
80
  show_error_codes = true
81
- exclude = [
82
- "ssc_codegen/ast" # ignore built-in ast and project ast
83
-
84
- ]
85
81
  [[tool.mypy.overrides]]
86
82
  module="tests.*"
87
83
  disallow_untyped_defs = false
@@ -91,12 +87,6 @@ disallow_untyped_defs = false
91
87
  module = ["colorama", "lxml", "lxml.*"]
92
88
  ignore_missing_imports = true
93
89
 
94
- [[tool.mypy.overrides]]
95
- # Large visitor classes with match statements may need relaxed return checks
96
- module = ["ssc_codegen.targets.python.visitor", "ssc_codegen.targets.javascript.visitor"]
97
- disable_error_code = ["return", "return-value"]
98
-
99
-
100
90
  [tool.coverage.report]
101
91
  exclude_also = [
102
92
  'def __repr__',
@@ -20,7 +20,16 @@ from .module import (
20
20
  Utilities,
21
21
  )
22
22
 
23
- from .function import FunctionDef
23
+ from .function import FunctionDef
24
+
25
+ from .extension import (
26
+ ExtensionCall,
27
+ ExtensionDef,
28
+ ExtensionHelper,
29
+ ExtensionImport,
30
+ ExtensionTarget,
31
+ ExtensionType,
32
+ )
24
33
 
25
34
  from .typedef import TypeDef, TypeDefField
26
35
 
@@ -150,7 +159,14 @@ __all__ = [
150
159
  "Docstring",
151
160
  "Utilities",
152
161
  # function
153
- "FunctionDef",
162
+ "FunctionDef",
163
+ # extensions
164
+ "ExtensionCall",
165
+ "ExtensionDef",
166
+ "ExtensionHelper",
167
+ "ExtensionImport",
168
+ "ExtensionTarget",
169
+ "ExtensionType",
154
170
  # typedef
155
171
  "TypeDef",
156
172
  "TypeDefField",
@@ -0,0 +1,80 @@
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(frozen=True)
10
+ class ExtensionType:
11
+ """Type pattern used by an extension operation signature."""
12
+
13
+ base: VariableType | None = None
14
+ generic: str | None = None
15
+ is_array: bool = False
16
+ is_optional: bool = False
17
+
18
+ def resolve(self, binding: TypeInfo | None = None) -> TypeInfo:
19
+ if self.generic:
20
+ if binding is None:
21
+ return TypeInfo(base=VariableType.AUTO)
22
+ return binding
23
+ return TypeInfo(
24
+ base=self.base or VariableType.AUTO,
25
+ is_array=self.is_array,
26
+ is_optional=self.is_optional,
27
+ )
28
+
29
+ def accepts(self, value: TypeInfo) -> bool:
30
+ if self.generic:
31
+ return True
32
+ return (
33
+ value.base in (self.base, VariableType.AUTO)
34
+ and value.is_array == self.is_array
35
+ and value.is_optional == self.is_optional
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ExtensionImport:
41
+ value: str
42
+ alias: str = ""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ExtensionHelper:
47
+ name: str
48
+ source: str
49
+ imports: tuple[ExtensionImport, ...] = ()
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ExtensionTarget:
54
+ language: str
55
+ emit: str
56
+ imports: tuple[ExtensionImport, ...] = ()
57
+ helpers: tuple[ExtensionHelper, ...] = ()
58
+
59
+
60
+ @dataclass
61
+ class ExtensionDef:
62
+ namespace: str
63
+ name: str
64
+ accept: ExtensionType
65
+ ret: ExtensionType
66
+ targets: dict[str, ExtensionTarget] = field(default_factory=dict)
67
+
68
+ @property
69
+ def qualified_name(self) -> str:
70
+ return f"{self.namespace}.{self.name}"
71
+
72
+ def resolve_return(self, input_type: TypeInfo) -> TypeInfo:
73
+ binding = input_type if self.accept.generic else None
74
+ return self.ret.resolve(binding)
75
+
76
+
77
+ @dataclass
78
+ class ExtensionCall(Node):
79
+ qualified_name: str = ""
80
+ definition: ExtensionDef | None = field(default=None, repr=False)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
  import warnings
3
3
  from dataclasses import dataclass
4
+ from dataclasses import field
4
5
 
5
6
  from .base import Node
6
7
 
@@ -28,6 +29,7 @@ class Module(Node):
28
29
 
29
30
  doc: str = ""
30
31
  source_file: str = ""
32
+ extensions: dict[str, "ExtensionDef"] = field(default_factory=dict)
31
33
 
32
34
  def __post_init__(self):
33
35
  self.body.extend(
@@ -112,3 +114,6 @@ class Utilities(Node):
112
114
  """
113
115
 
114
116
  pass
117
+
118
+
119
+ from .extension import ExtensionDef # noqa: E402
@@ -136,10 +136,16 @@ class PlaceholderSpec:
136
136
  def parse_placeholder(match: "_re.Match[str]") -> PlaceholderSpec:
137
137
  return PlaceholderSpec(
138
138
  name=match.group(1),
139
- type_name=match.group(2) or "str",
139
+ type_name=cast(
140
+ Literal["str", "int", "float", "bool"],
141
+ match.group(2) or "str",
142
+ ),
140
143
  is_array=bool(match.group(3)),
141
144
  is_optional=bool(match.group(4)),
142
- style=match.group(5) or None,
145
+ style=cast(
146
+ Literal["repeat", "csv", "bracket", "pipe", "space"] | None,
147
+ match.group(5) or None,
148
+ ),
143
149
  )
144
150
 
145
151
 
@@ -260,7 +266,7 @@ class RequestHttp(Node):
260
266
 
261
267
  Created once at parse time from ``parse_to_http(raw_payload)``;
262
268
  converters read fields directly instead of re-parsing raw_payload.
263
- String fields (url, headers, cookies, params, body) are ``Template``
269
+ String fields (url, headers, cookies, params, payload) are ``Template``
264
270
  instances: tokenized literal/placeholder parts.
265
271
  """
266
272
 
@@ -270,7 +276,7 @@ class RequestHttp(Node):
270
276
  cookies: dict[str, PlaceholderTemplate] = field(default_factory=dict)
271
277
  params: dict[str, PlaceholderTemplate] = field(default_factory=dict)
272
278
  body_kind: str = "empty" # "empty" | "json" | "form" | "raw"
273
- body: PlaceholderTemplate | dict[str, PlaceholderTemplate] | None = None
279
+ payload: PlaceholderTemplate | dict[str, PlaceholderTemplate] | None = None
274
280
 
275
281
  @property
276
282
  def placeholders(self) -> list[PlaceholderSpec]:
@@ -289,10 +295,10 @@ class RequestHttp(Node):
289
295
  yield self.url
290
296
  for d in (self.headers, self.cookies, self.params):
291
297
  yield from d.values()
292
- if isinstance(self.body, PlaceholderTemplate):
293
- yield self.body
294
- elif isinstance(self.body, dict):
295
- yield from self.body.values()
298
+ if isinstance(self.payload, PlaceholderTemplate):
299
+ yield self.payload
300
+ elif isinstance(self.payload, dict):
301
+ yield from self.payload.values()
296
302
 
297
303
  def with_renamed_placeholders(
298
304
  self, transform: Callable[[str], str]
@@ -312,13 +318,13 @@ class RequestHttp(Node):
312
318
  ) -> dict[str, PlaceholderTemplate]:
313
319
  return {k: v.renamed(mapping) for k, v in d.items()}
314
320
 
315
- new_body: (
321
+ new_payload: (
316
322
  PlaceholderTemplate | dict[str, PlaceholderTemplate] | None
317
- ) = self.body
318
- if isinstance(self.body, PlaceholderTemplate):
319
- new_body = self.body.renamed(mapping)
320
- elif isinstance(self.body, dict):
321
- new_body = _rename_dict(self.body)
323
+ ) = self.payload
324
+ if isinstance(self.payload, PlaceholderTemplate):
325
+ new_payload = self.payload.renamed(mapping)
326
+ elif isinstance(self.payload, dict):
327
+ new_payload = _rename_dict(self.payload)
322
328
 
323
329
  return RequestHttp(
324
330
  method=self.method,
@@ -327,7 +333,7 @@ class RequestHttp(Node):
327
333
  cookies=_rename_dict(self.cookies),
328
334
  params=_rename_dict(self.params),
329
335
  body_kind=self.body_kind,
330
- body=new_body,
336
+ payload=new_payload,
331
337
  )
332
338
 
333
339
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  from dataclasses import dataclass
3
+ from typing import cast
3
4
 
4
5
  from .base import Node
5
6
  from .types import StructType
@@ -29,4 +30,4 @@ class TypeDef(Node):
29
30
 
30
31
  @property
31
32
  def fields(self) -> list[TypeDefField]:
32
- return self.body
33
+ return cast(list[TypeDefField], self.body)
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
6
6
  from enum import Enum, auto
7
7
  from pathlib import Path
8
8
 
9
- from ssc_codegen.ast import StructBase, VariableType
9
+ from ssc_codegen.ast import ExtensionDef, StructBase, VariableType
10
10
  from kdlquery import KdlNode, ReadDiagnostic, Severity
11
11
 
12
12
 
@@ -49,7 +49,9 @@ class ParseContext:
49
49
  children_defines: dict[str, list[KdlNode]] = field(default_factory=dict)
50
50
  structs: dict[str, StructBase] = field(default_factory=dict)
51
51
  json_defs: dict[str, JsonDef] = field(default_factory=dict)
52
+ extensions: dict[str, ExtensionDef] = field(default_factory=dict)
52
53
  source_path: Path | None = None
54
+ node_source_paths: dict[int, Path] = field(default_factory=dict)
53
55
 
54
56
  def all_names(self) -> set[str]:
55
57
  return (
@@ -57,6 +59,7 @@ class ParseContext:
57
59
  | set(self.children_defines)
58
60
  | set(self.structs)
59
61
  | set(self.json_defs)
62
+ | set(self.extensions)
60
63
  )
61
64
 
62
65
 
@@ -77,6 +80,7 @@ class LintContext:
77
80
  )
78
81
  _predicate_depth: int = field(default=0)
79
82
  _predicate_context: str = ""
83
+ node_source_paths: dict[int, Path] = field(default_factory=dict)
80
84
 
81
85
  def error(
82
86
  self,
@@ -88,18 +92,22 @@ class LintContext:
88
92
  label: str | None = None,
89
93
  notes: list[str] | None = None,
90
94
  ) -> None:
91
- self.diagnostics.append(
92
- ReadDiagnostic(
93
- message=message,
94
- severity=Severity.ERROR,
95
- span=node.span,
96
- path=self.path,
97
- hint=hint,
98
- code=code,
99
- label=label,
100
- notes=tuple(notes) if notes else (),
101
- )
95
+ scope = self.path
96
+ source_path = self.node_source_paths.get(id(node))
97
+ diagnostic_notes = tuple(notes) if notes else ()
98
+ if source_path is not None and scope:
99
+ diagnostic_notes = (*diagnostic_notes, f"scope: {scope}")
100
+ diagnostic = ReadDiagnostic(
101
+ message=message,
102
+ severity=Severity.ERROR,
103
+ span=node.span,
104
+ path=str(source_path) if source_path is not None else scope,
105
+ hint=hint,
106
+ code=code,
107
+ label=label,
108
+ notes=diagnostic_notes,
102
109
  )
110
+ self.diagnostics.append(diagnostic)
103
111
 
104
112
  def warning(
105
113
  self,
@@ -111,18 +119,22 @@ class LintContext:
111
119
  label: str | None = None,
112
120
  notes: list[str] | None = None,
113
121
  ) -> None:
114
- self.diagnostics.append(
115
- ReadDiagnostic(
116
- message=message,
117
- severity=Severity.WARNING,
118
- span=node.span,
119
- path=self.path,
120
- hint=hint,
121
- code=code,
122
- label=label,
123
- notes=tuple(notes) if notes else (),
124
- )
122
+ scope = self.path
123
+ source_path = self.node_source_paths.get(id(node))
124
+ diagnostic_notes = tuple(notes) if notes else ()
125
+ if source_path is not None and scope:
126
+ diagnostic_notes = (*diagnostic_notes, f"scope: {scope}")
127
+ diagnostic = ReadDiagnostic(
128
+ message=message,
129
+ severity=Severity.WARNING,
130
+ span=node.span,
131
+ path=str(source_path) if source_path is not None else scope,
132
+ hint=hint,
133
+ code=code,
134
+ label=label,
135
+ notes=diagnostic_notes,
125
136
  )
137
+ self.diagnostics.append(diagnostic)
126
138
 
127
139
  @property
128
140
  def path(self) -> str:
@@ -16,6 +16,7 @@ from ssc_codegen.ast import (
16
16
  CssSelect,
17
17
  CssSelectAll,
18
18
  Fallback,
19
+ ExtensionCall,
19
20
  Field,
20
21
  Filter,
21
22
  Fmt,
@@ -80,6 +81,7 @@ from ssc_codegen.regex_utils import normalize_regex_pattern
80
81
  from typing import cast
81
82
 
82
83
  from ssc_codegen.core.contexts import LintContext, ParseContext, WalkCtx
84
+ from ssc_codegen.core.extensions import resolve_extension_call
83
85
  from ssc_codegen.core.linter import (
84
86
  lint_pipeline_op,
85
87
  lint_validate_css,
@@ -106,6 +108,7 @@ FieldLikeNode: TypeAlias = (
106
108
  | Field
107
109
  | InitField
108
110
  | FunctionDef
111
+ | CheckMethod
109
112
  )
110
113
 
111
114
 
@@ -343,6 +346,9 @@ def _build_expression(
343
346
  f"'self {ref_name}' syntax is no longer supported; use '@{ref_name}' instead"
344
347
  )
345
348
 
349
+ if name.startswith("!"):
350
+ return _expr_extension(node, parent, ctx, lint)
351
+
346
352
  # dispatch
347
353
  handler = _EXPRESSION_HANDLERS.get(name)
348
354
  if handler is None:
@@ -417,6 +423,13 @@ def parse_expressions(
417
423
  raise BuildTimeError(
418
424
  f"'self {ref_name}' syntax is no longer supported; use '@{ref_name}' instead"
419
425
  )
426
+ if node.name.startswith("!"):
427
+ lint.push(node.name)
428
+ lint_pipeline_op(node, lint)
429
+ extension_expr = _expr_extension(node, parent, ctx, lint)
430
+ parent.body.append(extension_expr)
431
+ lint.pop()
432
+ continue
420
433
  handler = _EXPRESSION_HANDLERS.get(node.name)
421
434
  if handler is None:
422
435
  lint_wildcard_op(node, ctx, lint)
@@ -501,7 +514,10 @@ def parse_expressions(
501
514
  break
502
515
  parent.is_array = is_arr
503
516
  parent.ret_type_info = TypeInfo(
504
- base=last_ret, is_array=is_arr, ref=ref
517
+ base=last_ret,
518
+ is_array=is_arr,
519
+ is_optional=last_ti.is_optional,
520
+ ref=ref or last_ti.ref,
505
521
  )
506
522
 
507
523
  lint.walk_context = prev_ctx
@@ -537,6 +553,59 @@ def _prev_type_info(
537
553
  )
538
554
 
539
555
 
556
+ def _expr_extension(
557
+ node: KdlNode,
558
+ parent: FieldLikeNode,
559
+ ctx: ParseContext,
560
+ lint: LintContext,
561
+ ) -> ExtensionCall:
562
+ ref = node.name[1:]
563
+ if node.args or node.properties or node.children:
564
+ lint.error(
565
+ node,
566
+ message=f"custom operation '!{ref}' does not accept entries",
567
+ code="E001",
568
+ )
569
+ definition = ctx.extensions.get(ref)
570
+ prev = _prev_type_info(parent)
571
+ if definition is None:
572
+ lint.error(
573
+ node,
574
+ message=f"unknown extension operation '{ref}'",
575
+ code="E300",
576
+ hint="import its extension namespace and check the operation name",
577
+ )
578
+ return ExtensionCall(
579
+ parent=parent,
580
+ qualified_name=ref,
581
+ accept_type_info=prev.ret_type_info,
582
+ ret_type_info=TypeInfo(base=VariableType.AUTO),
583
+ )
584
+ if not definition.accept.accepts(prev.ret_type_info):
585
+ expected = (
586
+ definition.accept.generic
587
+ or (definition.accept.base or VariableType.AUTO).name
588
+ )
589
+ lint.error(
590
+ node,
591
+ message=(
592
+ f"custom operation '{ref}' does not accept "
593
+ f"{prev.ret_type_info.base.name}; expected {expected}"
594
+ ),
595
+ code="E100",
596
+ )
597
+ ret_type = resolve_extension_call(definition, prev.ret_type_info)
598
+ return ExtensionCall(
599
+ parent=parent,
600
+ qualified_name=ref,
601
+ definition=definition,
602
+ accept_type_info=prev.ret_type_info,
603
+ ret_type_info=ret_type,
604
+ is_array=ret_type.is_array,
605
+ span=node.span,
606
+ )
607
+
608
+
540
609
  # Ops whose handlers access ``parent.body[-1]`` unconditionally and therefore
541
610
  # require a preceding node. Used by ``parse_expressions`` to emit a clean
542
611
  # E100 diagnostic instead of crashing with IndexError on an empty pipeline.
@@ -606,6 +675,9 @@ def _reg_expr(name: str):
606
675
  def _expr_css(
607
676
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
608
677
  ):
678
+ if not node.children and not node.args:
679
+ lint.error(node, message="'css' requires a selector", code="E001")
680
+ return None
609
681
  if node.children:
610
682
  queries = [
611
683
  resolve_selector_child_name(c.name, ctx) for c in node.children
@@ -622,6 +694,9 @@ def _expr_css(
622
694
  def _expr_css_all(
623
695
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
624
696
  ):
697
+ if not node.children and not node.args:
698
+ lint.error(node, message="'css-all' requires a selector", code="E001")
699
+ return None
625
700
  if node.children:
626
701
  queries = [
627
702
  resolve_selector_child_name(c.name, ctx) for c in node.children
@@ -638,6 +713,9 @@ def _expr_css_all(
638
713
  def _expr_xpath(
639
714
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
640
715
  ):
716
+ if not node.children and not node.args:
717
+ lint.error(node, message="'xpath' requires an expression", code="E001")
718
+ return None
641
719
  if node.children:
642
720
  queries = [
643
721
  resolve_selector_child_name(c.name, ctx) for c in node.children
@@ -654,6 +732,11 @@ def _expr_xpath(
654
732
  def _expr_xpath_all(
655
733
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
656
734
  ):
735
+ if not node.children and not node.args:
736
+ lint.error(
737
+ node, message="'xpath-all' requires an expression", code="E001"
738
+ )
739
+ return None
657
740
  if node.children:
658
741
  queries = [
659
742
  resolve_selector_child_name(c.name, ctx) for c in node.children