ssc_codegen 0.37.2__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.2 → ssc_codegen-0.38.0}/PKG-INFO +3 -2
  2. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/README.md +2 -1
  3. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/pyproject.toml +1 -1
  4. {ssc_codegen-0.37.2 → 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.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/module.py +5 -0
  7. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/contexts.py +3 -1
  8. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/expressions.py +70 -1
  9. ssc_codegen-0.38.0/ssc_codegen/core/extensions.py +144 -0
  10. ssc_codegen-0.38.0/ssc_codegen/core/imports.py +544 -0
  11. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/linter.py +430 -10
  12. ssc_codegen-0.38.0/ssc_codegen/core/module_handler.py +147 -0
  13. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/reader.py +7 -4
  14. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/struct_parser.py +10 -0
  15. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/type_checking.py +17 -1
  16. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/generation/builder.py +37 -0
  17. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/generation/runtime.py +44 -1
  18. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/main.py +29 -9
  19. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/visitor.py +42 -1
  20. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/visitor.py +50 -6
  21. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/visitor.py +61 -8
  22. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/traversal/utils.py +11 -0
  23. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/traversal/walker.py +2 -0
  24. ssc_codegen-0.37.2/ssc_codegen/core/module_handler.py +0 -281
  25. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/.gitignore +0 -0
  26. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/LICENSE +0 -0
  27. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/__init__.py +0 -0
  28. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/_logging.py +0 -0
  29. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/array.py +0 -0
  30. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/base.py +0 -0
  31. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/cast.py +0 -0
  32. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/control.py +0 -0
  33. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/extract.py +0 -0
  34. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/function.py +0 -0
  35. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/helpers.py +0 -0
  36. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/jsondef.py +0 -0
  37. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/predicate_containers.py +0 -0
  38. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/predicate_ops.py +0 -0
  39. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/regex.py +0 -0
  40. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/rest.py +0 -0
  41. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/selectors.py +0 -0
  42. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/string.py +0 -0
  43. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/struct.py +0 -0
  44. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/typedef.py +0 -0
  45. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/ast/types.py +0 -0
  46. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/__init__.py +0 -0
  47. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/format.py +0 -0
  48. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/predicates.py +0 -0
  49. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/core/rest_artifacts.py +0 -0
  50. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/exceptions.py +0 -0
  51. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/explore.py +0 -0
  52. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/generation/__init__.py +0 -0
  53. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/health.py +0 -0
  54. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/naming.py +0 -0
  55. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/parsers/__init__.py +0 -0
  56. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/parsers/curl.py +0 -0
  57. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/parsers/http.py +0 -0
  58. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/regex_utils.py +0 -0
  59. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/request_spec.py +0 -0
  60. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/__init__.py +0 -0
  61. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/__init__.py +0 -0
  62. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/http_libs/__init__.py +0 -0
  63. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/http_libs/base.py +0 -0
  64. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/http_libs/nethttp.py +0 -0
  65. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/literals.py +0 -0
  66. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/regex.py +0 -0
  67. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/rest.py +0 -0
  68. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/golang/runtime.py +0 -0
  69. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/__init__.py +0 -0
  70. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/http_libs/__init__.py +0 -0
  71. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/http_libs/axios.py +0 -0
  72. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/http_libs/base.py +0 -0
  73. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/http_libs/fetch.py +0 -0
  74. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/javascript/rest.py +0 -0
  75. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/profile.py +0 -0
  76. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/__init__.py +0 -0
  77. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/__init__.py +0 -0
  78. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/base.py +0 -0
  79. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/bs4.py +0 -0
  80. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/lxml.py +0 -0
  81. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/parsel.py +0 -0
  82. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/html_libs/slax.py +0 -0
  83. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/__init__.py +0 -0
  84. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/aiohttp.py +0 -0
  85. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/base.py +0 -0
  86. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/httpx.py +0 -0
  87. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/http_libs/requests.py +0 -0
  88. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/python/rest.py +0 -0
  89. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/resolver.py +0 -0
  90. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/targets/spec.py +0 -0
  91. {ssc_codegen-0.37.2 → ssc_codegen-0.38.0}/ssc_codegen/traversal/__init__.py +0 -0
  92. {ssc_codegen-0.37.2 → 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.2
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
 
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.37.2"
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"
@@ -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
@@ -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,6 +49,7 @@ 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
53
54
  node_source_paths: dict[int, Path] = field(default_factory=dict)
54
55
 
@@ -58,6 +59,7 @@ class ParseContext:
58
59
  | set(self.children_defines)
59
60
  | set(self.structs)
60
61
  | set(self.json_defs)
62
+ | set(self.extensions)
61
63
  )
62
64
 
63
65
 
@@ -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.
@@ -0,0 +1,144 @@
1
+ """Parsing and type resolution for user-defined pipeline extensions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from kdlquery import KdlNode
6
+
7
+ from ssc_codegen.ast import (
8
+ ExtensionDef,
9
+ ExtensionHelper,
10
+ ExtensionImport,
11
+ ExtensionTarget,
12
+ ExtensionType,
13
+ Module,
14
+ TypeInfo,
15
+ VariableType,
16
+ )
17
+ from ssc_codegen.core.contexts import LintContext, ParseContext
18
+ from ssc_codegen.exceptions import BuildTimeError
19
+
20
+ _TYPE_NAMES: dict[str, VariableType] = {
21
+ "bool": VariableType.BOOL,
22
+ "doc": VariableType.DOCUMENT,
23
+ "float": VariableType.FLOAT,
24
+ "int": VariableType.INT,
25
+ "str": VariableType.STRING,
26
+ }
27
+
28
+
29
+ def parse_extension_type(value) -> ExtensionType:
30
+ name = str(value.value)
31
+ is_optional = name.endswith("?")
32
+ if is_optional:
33
+ name = name[:-1]
34
+ is_array = value.type_annotation == "(array)"
35
+ if name == "T":
36
+ if is_array or is_optional:
37
+ raise BuildTimeError("generic type T cannot have modifiers")
38
+ return ExtensionType(generic="T")
39
+ base = _TYPE_NAMES.get(name)
40
+ if base is None:
41
+ raise BuildTimeError(f"unknown extension signature type: {name}")
42
+ return ExtensionType(base=base, is_array=is_array, is_optional=is_optional)
43
+
44
+
45
+ def resolve_extension_call(
46
+ definition: ExtensionDef, input_type: TypeInfo
47
+ ) -> TypeInfo:
48
+ if not definition.accept.accepts(input_type):
49
+ return TypeInfo(base=VariableType.AUTO)
50
+ return definition.resolve_return(input_type)
51
+
52
+
53
+ def _parse_import(node: KdlNode) -> ExtensionImport:
54
+ return ExtensionImport(
55
+ value=str(node.args[0].value),
56
+ alias=str(node.get_prop("alias") or ""),
57
+ )
58
+
59
+
60
+ def _parse_helper(node: KdlNode) -> ExtensionHelper:
61
+ name = str(node.args[0].value)
62
+ imports = tuple(
63
+ _parse_import(child)
64
+ for child in node.children
65
+ if child.name == "import" and child.args
66
+ )
67
+ source_node = next(
68
+ (child for child in node.children if child.name == "source"), None
69
+ )
70
+ source = (
71
+ str(source_node.args[0].value)
72
+ if source_node is not None and source_node.args
73
+ else ""
74
+ )
75
+ return ExtensionHelper(name=name, source=source, imports=imports)
76
+
77
+
78
+ def _parse_target(node: KdlNode) -> ExtensionTarget:
79
+ imports = tuple(
80
+ _parse_import(child)
81
+ for child in node.children
82
+ if child.name == "import" and child.args
83
+ )
84
+ helpers = tuple(
85
+ _parse_helper(child)
86
+ for child in node.children
87
+ if child.name == "helper" and child.args
88
+ )
89
+ emit_node = next(
90
+ (child for child in node.children if child.name == "emit"), None
91
+ )
92
+ emit = (
93
+ str(emit_node.args[0].value)
94
+ if emit_node is not None and emit_node.args
95
+ else ""
96
+ )
97
+ return ExtensionTarget(
98
+ language=node.name,
99
+ emit=emit,
100
+ imports=imports,
101
+ helpers=helpers,
102
+ )
103
+
104
+
105
+ def handle_extension(
106
+ node: KdlNode,
107
+ module: Module,
108
+ ctx: ParseContext,
109
+ lint: LintContext,
110
+ ) -> list[ExtensionDef]:
111
+ if not node.args:
112
+ raise BuildTimeError("'extension' requires a namespace")
113
+ namespace = str(node.args[0].value)
114
+ definitions: list[ExtensionDef] = []
115
+ for operation in node.children:
116
+ sig = next(
117
+ (child for child in operation.children if child.name == "sig"),
118
+ None,
119
+ )
120
+ if sig is None or len(sig.args) != 2:
121
+ continue
122
+ definition = ExtensionDef(
123
+ namespace=namespace,
124
+ name=operation.name,
125
+ accept=parse_extension_type(sig.args[0]),
126
+ ret=parse_extension_type(sig.args[1]),
127
+ targets={
128
+ child.name: _parse_target(child)
129
+ for child in operation.children
130
+ if child.name in ("py", "js", "go")
131
+ },
132
+ )
133
+ qualified_name = definition.qualified_name
134
+ if qualified_name in ctx.extensions:
135
+ lint.error(
136
+ operation,
137
+ message=f"duplicate extension operation '{qualified_name}'",
138
+ code="E402",
139
+ )
140
+ continue
141
+ ctx.extensions[qualified_name] = definition
142
+ module.extensions[qualified_name] = definition
143
+ definitions.append(definition)
144
+ return definitions