ssc_codegen 0.21.0__tar.gz → 0.22.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 (66) hide show
  1. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/PKG-INFO +1 -1
  2. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/pyproject.toml +1 -1
  3. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/__init__.py +2 -1
  4. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/jsondef.py +11 -5
  5. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/struct.py +20 -5
  6. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/base.py +2 -0
  7. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/go_goquery.py +13 -0
  8. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/js_pure.py +58 -8
  9. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/py_bs4.py +21 -0
  10. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/py_helpers.py +41 -9
  11. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/rules_struct.py +1 -0
  12. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/parser.py +71 -11
  13. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/repl.py +12 -3
  14. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/.gitignore +0 -0
  15. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/LICENSE +0 -0
  16. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/README.md +0 -0
  17. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/__init__.py +0 -0
  18. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/_logging.py +0 -0
  19. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/array.py +0 -0
  20. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/base.py +0 -0
  21. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/cast.py +0 -0
  22. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/control.py +0 -0
  23. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/extract.py +0 -0
  24. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/helpers.py +0 -0
  25. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/module.py +0 -0
  26. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/predicate_containers.py +0 -0
  27. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/predicate_ops.py +0 -0
  28. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/regex.py +0 -0
  29. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/selectors.py +0 -0
  30. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/string.py +0 -0
  31. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/transform.py +0 -0
  32. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/typedef.py +0 -0
  33. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/ast/types.py +0 -0
  34. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/helpers.py +0 -0
  35. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/py_lxml.py +0 -0
  36. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/py_parsel.py +0 -0
  37. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/converters/py_slax.py +0 -0
  38. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/document_utils.py +0 -0
  39. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/exceptions.py +0 -0
  40. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/health.py +0 -0
  41. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/kdl/__init__.py +0 -0
  42. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/kdl/parser.py +0 -0
  43. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/__init__.py +0 -0
  44. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/_kdl_lang.py +0 -0
  45. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/base.py +0 -0
  46. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/errors.py +0 -0
  47. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/format_errors.py +0 -0
  48. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/metadata.py +0 -0
  49. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/navigation.py +0 -0
  50. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/path.py +0 -0
  51. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/rule_keywords.py +0 -0
  52. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/rules.py +0 -0
  53. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/type_rules.py +0 -0
  54. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/linter/types.py +0 -0
  55. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/main.py +0 -0
  56. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/openapi/__init__.py +0 -0
  57. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/openapi/converter.py +0 -0
  58. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/openapi/emitter.py +0 -0
  59. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/openapi/parser.py +0 -0
  60. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/parsers/__init__.py +0 -0
  61. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/parsers/curl.py +0 -0
  62. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/parsers/http.py +0 -0
  63. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/parsers/spec.py +0 -0
  64. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/pseudo_selectors.py +0 -0
  65. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/regex_utils.py +0 -0
  66. {ssc_codegen-0.21.0 → ssc_codegen-0.22.0}/ssc_codegen/selector_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssc_codegen
3
- Version: 0.21.0
3
+ Version: 0.22.0
4
4
  Summary: Python-dsl code converter to html parser for web scraping
5
5
  Project-URL: Documentation, https://github.com/vypivshiy/selector_schema_codegen#readme
6
6
  Project-URL: Issues, https://github.com/vypivshiy/selector_schema_codegen/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.21.0"
3
+ version = "0.22.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"
@@ -25,6 +25,7 @@ from .struct import (
25
25
  Struct,
26
26
  StructDocstring,
27
27
  PreValidate,
28
+ CheckMethod,
28
29
  Init,
29
30
  InitField,
30
31
  SplitDoc,
@@ -134,7 +135,7 @@ __all__ = [
134
135
  # jsondef
135
136
  "JsonDef", "JsonDefField",
136
137
  # struct
137
- "Struct", "StructDocstring", "PreValidate",
138
+ "Struct", "StructDocstring", "PreValidate", "CheckMethod",
138
139
  "Init", "InitField", "SplitDoc",
139
140
  "Key", "Value",
140
141
  "TableConfig", "TableRow", "TableMatchKey",
@@ -10,11 +10,13 @@ class JsonDefField(Node):
10
10
  Single field in a JSON mapping definition.
11
11
 
12
12
  type_name — primitive ("str", "int", "float", "bool") or ref name.
13
- is_optional — True when field declared with ? suffix (value | null).
14
- is_array — True when field declared with {} suffix.
13
+ is_optional — True when field declared with ? suffix or @optional arg.
14
+ is_array — True when field declared with (array) prefix.
15
15
  ref_name — set when type_name references another JsonDef.
16
- alias — original JSON key when it differs from name
17
- (e.g. name="context", alias="@context").
16
+ alias — original JSON key when it differs from name.
17
+ skip — field is parsed but excluded from output TypedDict.
18
+ may_miss — field key may be absent from JSON (use .get() instead of []).
19
+ doc — documentation string for the field.
18
20
  """
19
21
 
20
22
  name: str = ""
@@ -23,15 +25,19 @@ class JsonDefField(Node):
23
25
  is_array: bool = False
24
26
  ref_name: str | None = None
25
27
  alias: str = ""
28
+ skip: bool = False
29
+ may_miss: bool = False
30
+ doc: str = ""
26
31
 
27
32
 
28
33
  @dataclass
29
34
  class JsonDef(Node):
30
35
  """
31
36
  JSON mapping definition.
32
- DSL: json Name { ... } / json Name array=#true { ... }
37
+ DSL: json Name { ... } / json Name array=#true { ... } / json Name path="a.b" { ... }
33
38
  body: list[JsonDefField]
34
39
  """
35
40
 
36
41
  name: str = ""
37
42
  is_array: bool = False
43
+ path: str = ""
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
  import re as _re
3
3
  from dataclasses import dataclass, field
4
+ from typing import Any
4
5
  from typing import cast
5
6
 
6
7
  from .base import Node
@@ -119,6 +120,20 @@ class PreValidate(Node):
119
120
  ret: VariableType = field(default=VariableType.DOCUMENT)
120
121
 
121
122
 
123
+ @dataclass
124
+ class CheckMethod(Node):
125
+ """
126
+ Boolean check method on the parsed class.
127
+ DSL: @check <name> { pipeline ... }
128
+ Runs a pipeline on the document and returns True on success, False on failure.
129
+ Called manually by the user before parse().
130
+ """
131
+
132
+ name: str = ""
133
+ accept: VariableType = field(default=VariableType.DOCUMENT)
134
+ ret: VariableType = field(default=VariableType.BOOL)
135
+
136
+
122
137
  @dataclass
123
138
  class Init(Node):
124
139
  """
@@ -276,18 +291,18 @@ class RequestConfig(Node):
276
291
  class ErrorResponse(Node):
277
292
  """
278
293
  Error response mapping for type=rest struct.
279
- DSL: @error <status> [field="<name>"] <JsonSchema>
294
+ DSL: @error <status> <SchemaName> [field=value ...]
280
295
 
281
296
  status: HTTP status code [100..599].
282
297
  schema_name: json schema reference for deserialised error body.
283
- discriminator_field: optional body field name. When set, the error triggers
284
- on 2xx responses where <field> is present in the parsed JSON body
285
- (used for APIs that return 200 + error payload).
298
+ conditions: field=value pairs checked against the parsed JSON body.
299
+ Keys are dot-paths (e.g. "response.success", "data.0.type").
300
+ When non-empty, the error triggers on matching status + all conditions.
286
301
  """
287
302
 
288
303
  status: int = 0
289
304
  schema_name: str = ""
290
- discriminator_field: str | None = None
305
+ conditions: dict[str, Any] = field(default_factory=dict)
291
306
 
292
307
 
293
308
  @dataclass
@@ -9,6 +9,7 @@ from ssc_codegen.ast import LogicNot, LogicAnd, LogicOr
9
9
  from ssc_codegen.ast import JsonDef, TypeDef, Struct, Init
10
10
  from ssc_codegen.ast import (
11
11
  PreValidate,
12
+ CheckMethod,
12
13
  Field,
13
14
  InitField,
14
15
  SplitDoc,
@@ -64,6 +65,7 @@ _PIPELINE_NODES = (
64
65
  Field,
65
66
  InitField,
66
67
  PreValidate,
68
+ CheckMethod,
67
69
  SplitDoc,
68
70
  Key,
69
71
  Value,
@@ -25,6 +25,7 @@ from ssc_codegen.ast import (
25
25
  Init,
26
26
  InitField,
27
27
  PreValidate,
28
+ CheckMethod,
28
29
  SplitDoc,
29
30
  TableConfig,
30
31
  TableMatchKey,
@@ -1011,6 +1012,18 @@ def post_pre_validate(node: PreValidate, _):
1011
1012
  return ["}"]
1012
1013
 
1013
1014
 
1015
+ @GO_GOQUERY_CONVERTER(CheckMethod)
1016
+ def pre_check_method(node: CheckMethod, _):
1017
+ struct_name = to_pascal_case(node.parent.name)
1018
+ recv = to_camel_case(struct_name)
1019
+ return [f"func ({recv} *{struct_name}) {to_camel_case(node.name)}() bool {{", "\tdefer func() { recover() }()"]
1020
+
1021
+
1022
+ @GO_GOQUERY_CONVERTER.post(CheckMethod)
1023
+ def post_check_method(node: CheckMethod, _):
1024
+ return ["\treturn true", "}"]
1025
+
1026
+
1014
1027
  @GO_GOQUERY_CONVERTER(SplitDoc)
1015
1028
  def pre_split_doc(node: SplitDoc, _):
1016
1029
  struct_name = to_pascal_case(node.parent.name)
@@ -51,6 +51,7 @@ from ssc_codegen.ast import (
51
51
  Init,
52
52
  InitField,
53
53
  PreValidate,
54
+ CheckMethod,
54
55
  SplitDoc,
55
56
  TableConfig,
56
57
  TableMatchKey,
@@ -464,11 +465,40 @@ def pre_typedef_field(node: TypeDefField, ctx: ConverterContext):
464
465
 
465
466
  def _js_err_subclass_name(struct_name: str, err) -> str:
466
467
  base = f"{to_pascal_case(struct_name)}Err{err.status}"
467
- if err.discriminator_field:
468
- base += to_pascal_case(err.discriminator_field)
468
+ if err.conditions:
469
+ for key in err.conditions:
470
+ base += to_pascal_case(key.replace(".", "_").replace("-", "_"))
469
471
  return base
470
472
 
471
473
 
474
+ def _js_resolve_path_expr(body_var: str, path: str) -> str:
475
+ """Generate a JS expression navigating a dot-path into a JSON body."""
476
+ parts = path.split(".")
477
+ expr = body_var
478
+ for seg in parts:
479
+ if seg.isdigit():
480
+ expr += f"[{seg}]"
481
+ else:
482
+ expr += f"[{seg!r}]"
483
+ return expr
484
+
485
+
486
+ def _js_condition_check_expr(err) -> str:
487
+ """Build a compound JS condition expression for @error field conditions."""
488
+ parts = []
489
+ for path, value in err.conditions.items():
490
+ lhs = _js_resolve_path_expr("_body", path)
491
+ if isinstance(value, bool):
492
+ parts.append(f"{lhs} === {str(value).lower()}")
493
+ elif value is None:
494
+ parts.append(f"{lhs} === null")
495
+ elif isinstance(value, (int, float)):
496
+ parts.append(f"{lhs} === {value}")
497
+ else:
498
+ parts.append(f"{lhs} === {value!r}")
499
+ return " && ".join(parts)
500
+
501
+
472
502
  def _js_err_value_type(err, struct) -> str:
473
503
  schema = err.schema_name
474
504
  if not schema:
@@ -527,8 +557,8 @@ def _emit_dispatch_err_js(node: Struct, ctx: ConverterContext) -> list[str]:
527
557
  i3 = i2 + ctx.indent_char # nested (if ...)
528
558
 
529
559
  errors = node.errors
530
- status_errors = [e for e in errors if e.discriminator_field is None]
531
- field_errors = [e for e in errors if e.discriminator_field is not None]
560
+ status_errors = [e for e in errors if not e.conditions]
561
+ field_errors = [e for e in errors if e.conditions]
532
562
 
533
563
  lines: list[str] = [
534
564
  f"{i1}static _dispatchErr(_status, _headers, _body) {{",
@@ -536,10 +566,10 @@ def _emit_dispatch_err_js(node: Struct, ctx: ConverterContext) -> list[str]:
536
566
  ]
537
567
  for err in field_errors:
538
568
  if 200 <= err.status < 300:
539
- field = err.discriminator_field
569
+ cond = _js_condition_check_expr(err)
540
570
  lines.append(
541
571
  f"{i3}if (_status === {err.status} && _body "
542
- f"&& _body[{field!r}] !== undefined) {{"
572
+ f"&& _body instanceof Object && {cond}) {{"
543
573
  )
544
574
  lines.append(
545
575
  f"{i3}{ctx.indent_char}return {{ isOk: false, "
@@ -558,10 +588,10 @@ def _emit_dispatch_err_js(node: Struct, ctx: ConverterContext) -> list[str]:
558
588
  lines.append(f"{i2}}}")
559
589
  for err in field_errors:
560
590
  if not (200 <= err.status < 300):
561
- field = err.discriminator_field
591
+ cond = _js_condition_check_expr(err)
562
592
  lines.append(
563
593
  f"{i2}if (_status === {err.status} && _body "
564
- f"&& _body[{field!r}] !== undefined) {{"
594
+ f"&& _body instanceof Object && {cond}) {{"
565
595
  )
566
596
  lines.append(
567
597
  f"{i3}return {{ isOk: false, status: _status, "
@@ -632,6 +662,26 @@ def pre_struct_pre_validate(node: PreValidate, ctx: ConverterContext):
632
662
  ]
633
663
 
634
664
 
665
+ @JS_CONVERTER(CheckMethod)
666
+ def pre_struct_check_method(node: CheckMethod, ctx: ConverterContext):
667
+ method_name = to_camel_case(node.name)
668
+ return [
669
+ f"{ctx.indent}{method_name}() " + "{",
670
+ f"{ctx.indent}{ctx.indent_char}try " + "{",
671
+ ]
672
+
673
+
674
+ @JS_CONVERTER.post(CheckMethod)
675
+ def post_struct_check_method(node: CheckMethod, ctx: ConverterContext):
676
+ return [
677
+ f"{ctx.indent}{ctx.indent_char}{ctx.indent_char}return true;",
678
+ f"{ctx.indent}{ctx.indent_char}" + "} catch (e) {",
679
+ f"{ctx.indent}{ctx.indent_char}{ctx.indent_char}return false;",
680
+ f"{ctx.indent}{ctx.indent_char}" + "}",
681
+ f"{ctx.indent}" + "}",
682
+ ]
683
+
684
+
635
685
  @JS_CONVERTER(SplitDoc, post_callback=lambda _, ctx: ctx.indent + "}")
636
686
  def pre_struct_split_doc(node: SplitDoc, ctx: ConverterContext):
637
687
  return [f"{ctx.indent}_splitDoc(v) " + "{"]
@@ -37,6 +37,7 @@ from ssc_codegen.ast import (
37
37
  Init,
38
38
  InitField,
39
39
  PreValidate,
40
+ CheckMethod,
40
41
  SplitDoc,
41
42
  TableConfig,
42
43
  TableMatchKey,
@@ -537,6 +538,26 @@ def pre_struct_pre_validate(node: PreValidate, ctx: ConverterContext):
537
538
  ]
538
539
 
539
540
 
541
+ @PY_BASE_CONVERTER(CheckMethod)
542
+ def pre_struct_check_method(node: CheckMethod, ctx: ConverterContext):
543
+ from ssc_codegen.converters.helpers import to_snake_case
544
+
545
+ method_name = to_snake_case(node.name)
546
+ return [
547
+ f" def {method_name}(self) -> bool:",
548
+ " try:",
549
+ ]
550
+
551
+
552
+ @PY_BASE_CONVERTER.post(CheckMethod)
553
+ def post_struct_check_method(node: CheckMethod, ctx: ConverterContext):
554
+ return [
555
+ " return True",
556
+ " except Exception:",
557
+ " return False",
558
+ ]
559
+
560
+
540
561
  @PY_BASE_CONVERTER(SplitDoc)
541
562
  def pre_struct_split_doc(node: SplitDoc, ctx: ConverterContext):
542
563
  return [
@@ -99,10 +99,11 @@ def rest_utilities(node) -> list[str]:
99
99
 
100
100
 
101
101
  def _err_subclass_name(struct_name: str, err: ErrorResponse) -> str:
102
- """Naming: `<Struct>Err<Status>[<FieldPascal>]`."""
102
+ """Naming: `<Struct>Err<Status>[<ConditionKeys>]`."""
103
103
  base = f"{to_pascal_case(struct_name)}Err{err.status}"
104
- if err.discriminator_field:
105
- base += to_pascal_case(err.discriminator_field)
104
+ if err.conditions:
105
+ for key in err.conditions:
106
+ base += to_pascal_case(key.replace(".", "_").replace("-", "_"))
106
107
  return base
107
108
 
108
109
 
@@ -132,6 +133,37 @@ def _rest_err_union_type(struct: Struct) -> str:
132
133
  return "Union[" + ", ".join([*variants, "UnknownErr", "None"]) + "]"
133
134
 
134
135
 
136
+ def _resolve_path_expr(body_var: str, path: str) -> str:
137
+ """Generate a Python expression that navigates a dot-path into a JSON body.
138
+
139
+ Segments: digits → list index, else → dict key via .get().
140
+ """
141
+ parts = path.split(".")
142
+ expr = body_var
143
+ for seg in parts:
144
+ if seg.isdigit():
145
+ expr += f"[{seg}]"
146
+ else:
147
+ expr += f".get({seg!r})"
148
+ return expr
149
+
150
+
151
+ def _condition_check_expr(err: ErrorResponse) -> str:
152
+ """Build a compound condition expression for an @error's field conditions."""
153
+ parts = []
154
+ for path, value in err.conditions.items():
155
+ lhs = _resolve_path_expr("_body", path)
156
+ if isinstance(value, bool):
157
+ parts.append(f"{lhs} == {value}")
158
+ elif value is None:
159
+ parts.append(f"{lhs} is None")
160
+ elif isinstance(value, (int, float)):
161
+ parts.append(f"{lhs} == {value}")
162
+ else:
163
+ parts.append(f"{lhs} == {value!r}")
164
+ return " and ".join(parts)
165
+
166
+
135
167
  def _emit_dispatch_err_py(node: Struct, ctx: ConverterContext) -> list[str]:
136
168
  """Emit the `_dispatch_err` @staticmethod lines inside a REST class.
137
169
 
@@ -143,8 +175,8 @@ def _emit_dispatch_err_py(node: Struct, ctx: ConverterContext) -> list[str]:
143
175
  i4 = i3 + ctx.indent_char
144
176
 
145
177
  errors = node.errors
146
- status_errors = [e for e in errors if e.discriminator_field is None]
147
- field_errors = [e for e in errors if e.discriminator_field is not None]
178
+ status_errors = [e for e in errors if not e.conditions]
179
+ field_errors = [e for e in errors if e.conditions]
148
180
  union_type = _rest_err_union_type(node)
149
181
 
150
182
  lines: list[str] = [
@@ -162,9 +194,9 @@ def _emit_dispatch_err_py(node: Struct, ctx: ConverterContext) -> list[str]:
162
194
  for err in field_errors:
163
195
  if 200 <= err.status < 300:
164
196
  cls_name = _err_subclass_name(node.name, err)
197
+ cond = _condition_check_expr(err)
165
198
  lines.append(
166
- f"{i4}if _status == {err.status} "
167
- f"and {err.discriminator_field!r} in _body:"
199
+ f"{i4}if _status == {err.status} and {cond}:"
168
200
  )
169
201
  lines.append(
170
202
  f"{i4}{ctx.indent_char}return {cls_name}("
@@ -185,10 +217,10 @@ def _emit_dispatch_err_py(node: Struct, ctx: ConverterContext) -> list[str]:
185
217
  for err in field_errors:
186
218
  if not (200 <= err.status < 300):
187
219
  cls_name = _err_subclass_name(node.name, err)
220
+ cond = _condition_check_expr(err)
188
221
  lines.append(
189
222
  f"{i2}if _status == {err.status} "
190
- f"and isinstance(_body, dict) "
191
- f"and {err.discriminator_field!r} in _body:"
223
+ f"and isinstance(_body, dict) and {cond}:"
192
224
  )
193
225
  lines.append(
194
226
  f"{i3}return {cls_name}(headers=_headers, value=_body)"
@@ -45,6 +45,7 @@ _RESERVED_ALLOWED: dict[str, frozenset[str] | None] = {
45
45
  "@request": None,
46
46
  "@doc": None,
47
47
  "@pre-validate": frozenset({"item", "list", "dict", "table", "flat"}),
48
+ "@check": frozenset({"item", "list", "dict", "table", "flat"}),
48
49
  "@init": frozenset({"item", "list", "dict", "table", "flat"}),
49
50
  "@split-doc": frozenset({"list", "dict"}), # dict can also use @split-doc
50
51
  "@key": frozenset({"dict"}),
@@ -56,6 +56,7 @@ from ssc_codegen.ast import (
56
56
  # stuct layer
57
57
  from ssc_codegen.ast import (
58
58
  PreValidate,
59
+ CheckMethod,
59
60
  SplitDoc,
60
61
  TableConfig,
61
62
  TableMatchKey,
@@ -461,6 +462,27 @@ class AstParser:
461
462
  ret_type = VariableType.JSON
462
463
  # second argument is optional alias (original JSON key)
463
464
  alias = str(node.args[1]) if len(node.args) > 1 else ""
465
+ # scan remaining args for @modifiers (@optional, @missing, @skip)
466
+ skip = False
467
+ may_miss = False
468
+ for arg in node.args[2:]:
469
+ arg_s = str(arg)
470
+ if arg_s == "@skip":
471
+ skip = True
472
+ elif arg_s == "@missing":
473
+ may_miss = True
474
+ elif arg_s == "@optional":
475
+ is_optional = True
476
+ # if alias was actually an @modifier, correct alias
477
+ if alias.startswith("@"):
478
+ if alias == "@skip":
479
+ skip = True
480
+ elif alias == "@missing":
481
+ may_miss = True
482
+ elif alias == "@optional":
483
+ is_optional = True
484
+ alias = ""
485
+ doc = str(node.properties.get("doc", ""))
464
486
  jf = JsonDefField(
465
487
  parent=parent,
466
488
  ret=ret_type,
@@ -469,15 +491,20 @@ class AstParser:
469
491
  is_array=is_array,
470
492
  ref_name=ref_name,
471
493
  alias=alias,
494
+ skip=skip,
495
+ may_miss=may_miss,
496
+ doc=doc,
472
497
  )
473
498
  logger.debug(
474
- " json field %r: ret=%s, optional=%s, array=%s%s%s",
499
+ " json field %r: ret=%s, optional=%s, array=%s%s%s%s%s",
475
500
  name,
476
501
  ret_type,
477
502
  is_optional,
478
503
  is_array,
479
504
  f", ref={ref_name!r}" if ref_name else "",
480
505
  f", alias={alias!r}" if alias else "",
506
+ ", skip" if skip else "",
507
+ ", miss" if may_miss else "",
481
508
  )
482
509
  parent.body.append(jf)
483
510
 
@@ -777,6 +804,15 @@ class AstParser:
777
804
  expr = PreValidate(parent=parent)
778
805
  self._parse_expressions(node.children, expr)
779
806
  parent.body.append(expr)
807
+ elif node.name == "@check":
808
+ if not node.args:
809
+ raise ParseError(
810
+ "@check requires a name: @check <name> { ... }"
811
+ )
812
+ check_name = str(node.args[0])
813
+ expr = CheckMethod(parent=parent, name=check_name)
814
+ self._parse_expressions(node.children, expr)
815
+ parent.body.append(expr)
780
816
  elif node.name == "@split-doc":
781
817
  expr = SplitDoc(parent=parent)
782
818
  self._parse_expressions(node.children, expr)
@@ -836,7 +872,7 @@ class AstParser:
836
872
  if not node.args:
837
873
  raise ParseError(
838
874
  "@error requires status and schema name: "
839
- '@error <status> [field="..."] <Schema>'
875
+ "@error <status> <Schema> [field=value ...]"
840
876
  )
841
877
  if len(node.args) < 2:
842
878
  raise ParseError(
@@ -852,23 +888,25 @@ class AstParser:
852
888
  schema_name = str(
853
889
  self.ctx.property_defines.get(node.args[1], node.args[1])
854
890
  )
855
- field_prop = node.properties.get("field", None)
856
- if field_prop is not None:
857
- field_prop = str(
858
- self.ctx.property_defines.get(field_prop, field_prop)
891
+ conditions: dict[str, Any] = {}
892
+ for k, v in node.properties.items():
893
+ key = str(
894
+ self.ctx.property_defines.get(k, k)
859
895
  )
896
+ val = self.ctx.property_defines.get(v, v)
897
+ conditions[key] = val
860
898
  err = ErrorResponse(
861
899
  parent=parent,
862
900
  status=status_int,
863
901
  schema_name=schema_name,
864
- discriminator_field=field_prop,
902
+ conditions=conditions,
865
903
  )
866
904
  parent.body.append(err)
867
905
  logger.debug(
868
- " @error: status=%d schema=%r field=%r",
906
+ " @error: status=%d schema=%r conditions=%r",
869
907
  status_int,
870
908
  schema_name,
871
- field_prop,
909
+ conditions,
872
910
  )
873
911
  else:
874
912
  if parent.struct_type == StructType.TABLE:
@@ -1310,6 +1348,24 @@ _FLOAT_RE = _re.compile(
1310
1348
  )
1311
1349
 
1312
1350
 
1351
+ _DEFINE_REF_RE = _re.compile(r"\{\{([A-Za-z][A-Za-z0-9_-]*)\}\}")
1352
+
1353
+
1354
+ def _resolve_define_references(value: str, ctx: ParseContext) -> str:
1355
+ """Replace {{NAME}} in a scalar define value with previously-defined values."""
1356
+
1357
+ def _replacer(m: _re.Match) -> str:
1358
+ name = m.group(1)
1359
+ resolved = ctx.property_defines.get(name)
1360
+ if resolved is None:
1361
+ raise ParseError(
1362
+ f"define references undefined name {name!r}"
1363
+ )
1364
+ return str(resolved)
1365
+
1366
+ return _DEFINE_REF_RE.sub(_replacer, value)
1367
+
1368
+
1313
1369
  # contexts
1314
1370
  @PARSER.register_module_context("define")
1315
1371
  def reg_define(node: KdlNode, ctx: ParseContext):
@@ -1322,7 +1378,10 @@ def reg_define(node: KdlNode, ctx: ParseContext):
1322
1378
  )
1323
1379
  else:
1324
1380
  pair = list(node.properties.items())[0]
1325
- ctx.property_defines[pair[0]] = pair[1]
1381
+ value = pair[1]
1382
+ if isinstance(value, str):
1383
+ value = _resolve_define_references(value, ctx)
1384
+ ctx.property_defines[pair[0]] = value
1326
1385
  logger.debug("define: property %r = %r", pair[0], pair[1])
1327
1386
 
1328
1387
 
@@ -1428,7 +1487,8 @@ def reg_module_struct(node: KdlNode, parent: Module, _: ParseContext):
1428
1487
  def reg_module_json(node: KdlNode, parent: Module, _: ParseContext):
1429
1488
  name = node.args[0]
1430
1489
  is_array = node.properties.get("array", False)
1431
- return JsonDef(parent=parent, name=name, is_array=is_array)
1490
+ path = str(node.properties.get("path", ""))
1491
+ return JsonDef(parent=parent, name=name, is_array=is_array, path=path)
1432
1492
 
1433
1493
 
1434
1494
  # expressions layer
@@ -39,8 +39,9 @@ def _try_prompt_toolkit():
39
39
  Completion,
40
40
  FuzzyWordCompleter,
41
41
  )
42
+ from prompt_toolkit.history import InMemoryHistory
42
43
 
43
- return pt_prompt, Completer, Completion, FuzzyWordCompleter
44
+ return pt_prompt, Completer, Completion, FuzzyWordCompleter, InMemoryHistory
44
45
  except ImportError:
45
46
  return None
46
47
 
@@ -159,6 +160,12 @@ def _make_completer(repl: Repl):
159
160
 
160
161
  return _SsgCompleter()
161
162
 
163
+ def _make_history():
164
+ if _PROMPT_TOOLKIT is None:
165
+ return
166
+ from prompt_toolkit.history import InMemoryHistory
167
+
168
+ return InMemoryHistory()
162
169
 
163
170
  def _get_converter(target: str):
164
171
  mod_path, attr = _CONVERTERS[target].rsplit(":", 1)
@@ -179,7 +186,7 @@ def _fetch_html(url: str) -> str:
179
186
  except ImportError:
180
187
  raise RuntimeError(
181
188
  "httpx is required for URL fetching. "
182
- "Install with: pip install ssc-gen[repl]"
189
+ "Install with: pip install ssc-codegen[repl]"
183
190
  )
184
191
 
185
192
 
@@ -224,14 +231,16 @@ class Repl:
224
231
  self._generated_code: str = ""
225
232
  self._multiline_kdl: list[str] | None = None
226
233
  self._completer = _make_completer(self)
234
+ self._history = _make_history()
227
235
 
228
236
  def _read_line(self, prompt_str: str) -> str:
229
237
  if _PROMPT_TOOLKIT is not None:
230
- pt_prompt, _, _, _ = _PROMPT_TOOLKIT
238
+ pt_prompt, _, _, _, _ = _PROMPT_TOOLKIT
231
239
  return pt_prompt(
232
240
  prompt_str,
233
241
  completer=self._completer,
234
242
  complete_while_typing=True,
243
+ history=self._history
235
244
  )
236
245
  return input(prompt_str)
237
246
 
File without changes
File without changes
File without changes