ssc_codegen 0.9.1__tar.gz → 0.9.3__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 (53) hide show
  1. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/PKG-INFO +5 -2
  2. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/README.md +4 -1
  3. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/pyproject.toml +1 -1
  4. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/__init__.py +1 -1
  5. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/nodes_selectors.py +2 -2
  6. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/cli/main.py +2 -2
  7. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/go_goquery.py +2 -1
  8. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/js_pure.py +18 -4
  9. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/py_base.py +2 -3
  10. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/py_bs4.py +16 -6
  11. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/py_parsel.py +16 -4
  12. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/py_selectolax.py +16 -4
  13. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/document.py +11 -3
  14. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/pseudo_selectors.py +10 -4
  15. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/.gitignore +0 -0
  16. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/LICENSE +0 -0
  17. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/_compat.py +0 -0
  18. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/__init__.py +0 -0
  19. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/base.py +0 -0
  20. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/nodes_array.py +0 -0
  21. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/nodes_cast.py +0 -0
  22. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/nodes_core.py +0 -0
  23. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/nodes_filter.py +0 -0
  24. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/nodes_string.py +0 -0
  25. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_/nodes_validate.py +0 -0
  26. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_build/__init__.py +0 -0
  27. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_build/main.py +0 -0
  28. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/ast_build/utils.py +0 -0
  29. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/cli/__init__.py +0 -0
  30. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/cli/cli_callbacks.py +0 -0
  31. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/cli/cli_utils.py +0 -0
  32. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/cli/code_callbacks.py +0 -0
  33. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/cli/consts.py +0 -0
  34. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/cli/runtime_parse_runners.py +0 -0
  35. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/compiler.py +0 -0
  36. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/consts.py +0 -0
  37. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/__init__.py +0 -0
  38. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/base.py +0 -0
  39. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/helpers.py +0 -0
  40. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/templates/__init__.py +0 -0
  41. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/templates/go_goquery.py +0 -0
  42. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/converters/templates/js_pure.py +0 -0
  43. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/document_utlis.py +0 -0
  44. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/json_struct.py +0 -0
  45. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/json_to_scc.py +0 -0
  46. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/logs.py +0 -0
  47. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/schema.py +0 -0
  48. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/selector_utils.py +0 -0
  49. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/static_checker/__init__.py +0 -0
  50. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/static_checker/base.py +0 -0
  51. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/static_checker/callbacks.py +0 -0
  52. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/str_utils.py +0 -0
  53. {ssc_codegen-0.9.1 → ssc_codegen-0.9.3}/ssc_codegen/tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssc_codegen
3
- Version: 0.9.1
3
+ Version: 0.9.3
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
@@ -45,6 +45,9 @@ Project solving next problems:
45
45
  - generate types or type annotations if possible and documentation
46
46
  - simplify code support
47
47
  - portability to other languages and HTML parser backends
48
+ - designed for SSR (server-side-render) pages parsers.
49
+ For REST-API, GRAPHQL endpoints use tools like [mitmproxy2swagger](https://github.com/alufers/mitmproxy2swagger)
50
+
48
51
 
49
52
  ## Support converters
50
53
 
@@ -56,7 +59,7 @@ Current support converters
56
59
  | ... | parsel | Y | Y | N | ... | ... |
57
60
  | ... | selectolax (lexbor) | N | Y | N | ... | ... |
58
61
  | js (ES6) | pure (firefox/chrome/nodejs) | Y | Y | Y | Array, Map** | prettier |
59
- | go (1.10+) | goquery | N | Y | N | struct(+json anchors), array, map | gofmt |
62
+ | go (1.10+) | goquery (WIP) | N | Y | N | struct(+json anchors), array, map | gofmt |
60
63
 
61
64
  - **CSS3** means support next selectors:
62
65
  - basic: (`tag`, `.class`, `#id`, `tag1,tag2`)
@@ -11,6 +11,9 @@ Project solving next problems:
11
11
  - generate types or type annotations if possible and documentation
12
12
  - simplify code support
13
13
  - portability to other languages and HTML parser backends
14
+ - designed for SSR (server-side-render) pages parsers.
15
+ For REST-API, GRAPHQL endpoints use tools like [mitmproxy2swagger](https://github.com/alufers/mitmproxy2swagger)
16
+
14
17
 
15
18
  ## Support converters
16
19
 
@@ -22,7 +25,7 @@ Current support converters
22
25
  | ... | parsel | Y | Y | N | ... | ... |
23
26
  | ... | selectolax (lexbor) | N | Y | N | ... | ... |
24
27
  | js (ES6) | pure (firefox/chrome/nodejs) | Y | Y | Y | Array, Map** | prettier |
25
- | go (1.10+) | goquery | N | Y | N | struct(+json anchors), array, map | gofmt |
28
+ | go (1.10+) | goquery (WIP) | N | Y | N | struct(+json anchors), array, map | gofmt |
26
29
 
27
30
  - **CSS3** means support next selectors:
28
31
  - basic: (`tag`, `.class`, `#id`, `tag1,tag2`)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.9.1"
3
+ version = "0.9.3"
4
4
  description = "Python-dsl code converter to html parser for web scraping "
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -7,7 +7,7 @@ from ssc_codegen.schema import ItemSchema, DictSchema, ListSchema, FlatListSchem
7
7
 
8
8
  setup_logger()
9
9
 
10
- VERSION = "0.9.1"
10
+ VERSION = "0.9.3"
11
11
 
12
12
 
13
13
  class __MISSING(object):
@@ -36,8 +36,8 @@ class ExprXpathAll(BaseAstNode[KW_EXPR_SELECTOR_LIKE, ARGS_EXPR_SELECTOR_LIKE]):
36
36
  ret_type: VariableType = VariableType.LIST_DOCUMENT
37
37
 
38
38
 
39
- KW_EXPR_ATTR = TypedDict("KW_EXPR_ATTR", {"key": str})
40
- ARGS_EXPR_ATTR = tuple[str]
39
+ KW_EXPR_ATTR = TypedDict("KW_EXPR_ATTR", {"key": tuple[str, ...]})
40
+ ARGS_EXPR_ATTR = tuple[tuple[str, ...]]
41
41
 
42
42
 
43
43
  @dataclass(kw_only=True)
@@ -216,8 +216,8 @@ def gen_js(
216
216
  converter = import_converter(f"js_{lib.value}")
217
217
  if fmt:
218
218
  commands: list[str] = [
219
- 'prettier --write --tab-width 4 --single-quote --trailing-comma all --semi {}',
220
- ]
219
+ "prettier --write --tab-width 4 --single-quote --trailing-comma all --semi {}",
220
+ ]
221
221
  fmt_cmd = create_fmt_cmd(ssc_files, prefix, suffix, out, commands)
222
222
  else:
223
223
  fmt_cmd = []
@@ -1264,4 +1264,5 @@ def pre_list_str_rm_prefix_and_suffix(
1264
1264
  # TODO impl ExprHasAttr, ExprListHasAttr
1265
1265
  # TODO: impl Filter Expr
1266
1266
  # TODO: impl ExprListUnique
1267
- # TODO: inpt ExprStringMapReplace, ExprListStringMapReplace
1267
+ # TODO: impl ExprStringMapReplace, ExprListStringMapReplace
1268
+ # TODO: impl ExprAttr, ExprListAttr for multiple extract keys
@@ -175,6 +175,12 @@ def make_js_docstring(value: str) -> str:
175
175
  return docstr_start + docstr_parts + docstr_end
176
176
 
177
177
 
178
+ def py_sequence_to_js_array(values: tuple[str, ...] | list[str]) -> str:
179
+ """note: value should be wrapper to"""
180
+ val_arr = str(values)
181
+ return "[" + val_arr[1:-1] + "]"
182
+
183
+
178
184
  @CONVERTER(Docstring.kind)
179
185
  def pre_docstring(node: Docstring) -> str:
180
186
  value = node.kwargs["value"]
@@ -631,15 +637,23 @@ def pre_xpath_all(node: ExprXpathAll) -> str:
631
637
  @CONVERTER(ExprGetHtmlAttr.kind)
632
638
  def pre_html_attr(node: ExprGetHtmlAttr) -> str:
633
639
  prv, nxt = prev_next_var(node)
634
- key = node.kwargs["key"]
635
- return f"let {nxt} = {prv}.getAttribute({key!r});"
640
+ keys = node.kwargs["key"]
641
+ if len(keys) == 1:
642
+ key = keys[0]
643
+ return f"let {nxt} = {prv}.getAttribute({key!r});"
644
+ js_keys = py_sequence_to_js_array(keys)
645
+ return f"let {nxt} = {js_keys}.map(k => {prv}?.getAttribute(k)).filter(Boolean); "
636
646
 
637
647
 
638
648
  @CONVERTER(ExprGetHtmlAttrAll.kind)
639
649
  def pre_html_attr_all(node: ExprGetHtmlAttrAll) -> str:
640
650
  prv, nxt = prev_next_var(node)
641
- key = node.kwargs["key"]
642
- return f"let {nxt} = {prv}.map(e => e.getAttribute({key!r}));"
651
+ keys = node.kwargs["key"]
652
+ if len(keys) == 1:
653
+ key = keys[0]
654
+ return f"let {nxt} = {prv}.map(e => e.getAttribute({key!r})); "
655
+ js_keys = py_sequence_to_js_array(keys)
656
+ return f"let {nxt} = Array.from({prv}).flatMap(e => {js_keys}.map(k => e.getAttribute(k)).filter(Boolean)); "
643
657
 
644
658
 
645
659
  @CONVERTER(ExprGetHtmlText.kind)
@@ -491,7 +491,7 @@ def post_start_parse(node: StartParseMethod) -> str:
491
491
  code += f"{name!r}: self._parse_{name}(self._document),"
492
492
  code += "}"
493
493
  case StructType.ACC_LIST:
494
- code += INDENT_METHOD_BODY + "return list(dict.fromkeys("
494
+ code += INDENT_METHOD_BODY + "return list(set("
495
495
  methods = []
496
496
  for expr in node.body:
497
497
  name = expr.kwargs["name"]
@@ -1100,8 +1100,7 @@ def pre_list_unique(node: ExprListUnique) -> str:
1100
1100
  INDENT_DEFAULT_BODY if have_default_expr(node) else INDENT_METHOD_BODY
1101
1101
  )
1102
1102
  prv, nxt = prev_next_var(node)
1103
- # use `dict.fromkeys()` instead `set()` for guarantees insertion order (py 3.7+) reason
1104
- return f"{indent}{nxt} = list(dict.fromkeys({prv}))"
1103
+ return f"{indent}{nxt} = list(set({prv}))"
1105
1104
 
1106
1105
 
1107
1106
  def pre_str_map_replace(node: ExprStringMapReplace) -> str:
@@ -104,11 +104,14 @@ def pre_html_attr(node: ExprGetHtmlAttr) -> str:
104
104
  INDENT_DEFAULT_BODY if have_default_expr(node) else INDENT_METHOD_BODY
105
105
  )
106
106
  prv, nxt = prev_next_var(node)
107
- key = node.kwargs["key"]
108
- # as default, bs4 return list of attrs, set string for API consistence
107
+ keys = node.kwargs["key"]
108
+ # bs4 maybe return list of attrs, set string for API consistence
109
+ if len(keys) == 1:
110
+ key = keys[0]
111
+ return indent + f"{nxt} = ' '.join({prv}.get_attribute_list({key!r}))"
109
112
  return (
110
113
  indent
111
- + f"{nxt} = {prv}[{key!r}] if isinstance({prv}[{key!r}], str) else ' '.join({prv}[{key!r}])"
114
+ + f"{nxt} = [' '.join({prv}.get_attribute_list(k)) for k in {keys} if {prv}.get(k)]"
112
115
  )
113
116
 
114
117
 
@@ -118,11 +121,18 @@ def pre_html_attr_all(node: ExprGetHtmlAttrAll) -> str:
118
121
  INDENT_DEFAULT_BODY if have_default_expr(node) else INDENT_METHOD_BODY
119
122
  )
120
123
  prv, nxt = prev_next_var(node)
121
- key = node.kwargs["key"]
122
- # as default, bs4 return list of attrs, set string for API consistence
124
+ keys = node.kwargs["key"]
125
+ # as default, bs4 maybe return list of attrs, set string for API consistence
126
+ if len(keys) == 1:
127
+ key = keys[0]
128
+ return (
129
+ indent
130
+ + f"{nxt} = [' '.join(e.get_attribute_list({key!r})) for e in {prv} if e.get({key!r})]"
131
+ )
132
+ # [tag.get(attr) for tag in tags for attr in ('src', 'href') if tag.has_attr(attr)]
123
133
  return (
124
134
  indent
125
- + f"{nxt} = [e[{key!r}] if isinstance(e[{key!r}], str) else ' '.join(e[{key!r}]) for e in {prv}]"
135
+ + f"{nxt} = [' '.join(e.get_attribute_list(k)) for e in {prv} for k in {keys} if e.get(k)]"
126
136
  )
127
137
 
128
138
 
@@ -124,8 +124,14 @@ def pre_html_attr(node: ExprGetHtmlAttr) -> str:
124
124
  INDENT_DEFAULT_BODY if have_default_expr(node) else INDENT_METHOD_BODY
125
125
  )
126
126
  prv, nxt = prev_next_var(node)
127
- key = node.kwargs["key"]
128
- return indent + f"{nxt} = {prv}.attrib[{key!r}]"
127
+ keys = node.kwargs["key"]
128
+ if len(keys) == 1:
129
+ key = keys[0]
130
+ return indent + f"{nxt} = {prv}.attrib[{key!r}]"
131
+ return (
132
+ indent
133
+ + f"{nxt} = [{prv}.attrib[k] for k in {keys} if {prv}.attrib.get(k)]"
134
+ )
129
135
 
130
136
 
131
137
  @CONVERTER(ExprGetHtmlAttrAll.kind)
@@ -134,8 +140,14 @@ def pre_html_attr_all(node: ExprGetHtmlAttrAll) -> str:
134
140
  INDENT_DEFAULT_BODY if have_default_expr(node) else INDENT_METHOD_BODY
135
141
  )
136
142
  prv, nxt = prev_next_var(node)
137
- key = node.kwargs["key"]
138
- return indent + f"{nxt} = [e.attrib[{key!r}] for e in {prv}]"
143
+ keys = node.kwargs["key"]
144
+ if len(keys) == 1:
145
+ key = keys[0]
146
+ return indent + f"{nxt} = [e.attrib[{key!r}] for e in {prv}]"
147
+ return (
148
+ indent
149
+ + f"{nxt} = [e.attrib[k] for e in {prv} for k in {keys} if e.attrib.get(k)]"
150
+ )
139
151
 
140
152
 
141
153
  @CONVERTER(ExprGetHtmlText.kind)
@@ -110,8 +110,14 @@ def pre_html_attr(node: ExprGetHtmlAttr) -> str:
110
110
  INDENT_DEFAULT_BODY if have_default_expr(node) else INDENT_METHOD_BODY
111
111
  )
112
112
  prv, nxt = prev_next_var(node)
113
- key = node.kwargs["key"]
114
- return indent + f"{nxt} = {prv}.attributes[{key!r}]"
113
+ keys = node.kwargs["key"]
114
+ if len(keys) == 1:
115
+ key = keys[0]
116
+ return indent + f"{nxt} = {prv}.attributes[{key!r}]"
117
+ return (
118
+ indent
119
+ + f"{nxt} = [{prv}.attributes[k] for k in {keys} if {prv}.attributes.get(k)]"
120
+ )
115
121
 
116
122
 
117
123
  @CONVERTER(ExprGetHtmlAttrAll.kind)
@@ -120,8 +126,14 @@ def pre_html_attr_all(node: ExprGetHtmlAttrAll) -> str:
120
126
  INDENT_DEFAULT_BODY if have_default_expr(node) else INDENT_METHOD_BODY
121
127
  )
122
128
  prv, nxt = prev_next_var(node)
123
- key = node.kwargs["key"]
124
- return indent + f"{nxt} = [e.attributes[{key!r}] for e in {prv}]"
129
+ keys = node.kwargs["key"]
130
+ if len(keys) == 1:
131
+ key = keys[0]
132
+ return indent + f"{nxt} = [e.attributes[{key!r}] for e in {prv}]"
133
+ return (
134
+ indent
135
+ + f"{nxt} = [e.attributes[k] for e in {prv} for k in {keys} if e.attributes.get(k)]"
136
+ )
125
137
 
126
138
 
127
139
  @CONVERTER(ExprGetHtmlText.kind)
@@ -187,7 +187,7 @@ class HTMLDocument(BaseDocument):
187
187
  case "raw":
188
188
  self.raw()
189
189
  case "attr":
190
- self.attr(action[1]) # type: ignore
190
+ self.attr(*action[1]) # type: ignore
191
191
  case _:
192
192
  assert_never(action[0])
193
193
 
@@ -252,7 +252,7 @@ class HTMLDocument(BaseDocument):
252
252
  self._pseudo_query_action_to_expr(action)
253
253
  return self
254
254
 
255
- def attr(self, key: str) -> Self:
255
+ def attr(self, *key: str) -> Self:
256
256
  """Extract attribute value by name
257
257
 
258
258
  - accept DOCUMENT, return STRING
@@ -266,7 +266,15 @@ class HTMLDocument(BaseDocument):
266
266
  case VariableType.LIST_DOCUMENT:
267
267
  self._add(ExprGetHtmlAttrAll(kwargs={"key": key}))
268
268
  case VariableType.DOCUMENT:
269
- self._add(ExprGetHtmlAttr(kwargs={"key": key}))
269
+ if len(key) == 1:
270
+ self._add(ExprGetHtmlAttr(kwargs={"key": key}))
271
+ else:
272
+ self._add(
273
+ ExprGetHtmlAttr(
274
+ kwargs={"key": key},
275
+ ret_type=VariableType.LIST_STRING,
276
+ )
277
+ )
270
278
  case _:
271
279
  LOGGER.warning(
272
280
  "attr(%s): Expected type(s) %s got %s",
@@ -6,7 +6,7 @@ PseudoAction = Literal["text", "raw", "attr"]
6
6
 
7
7
  def parse_pseudo_css_query(
8
8
  query: str,
9
- ) -> tuple[str, tuple[PseudoAction | None, str | None]]:
9
+ ) -> tuple[str, tuple[PseudoAction | None, str | tuple[str, ...] | None]]:
10
10
  """parse css selector and extract pseudo class action"""
11
11
  query = query.strip()
12
12
 
@@ -17,14 +17,17 @@ def parse_pseudo_css_query(
17
17
  return query[: -len("::raw")], ("raw", None)
18
18
 
19
19
  if match := re.search(r"::attr\(([^)]+)\)$", query):
20
- return query[: match.start()], ("attr", match.group(1))
20
+ return query[: match.start()], (
21
+ "attr",
22
+ tuple([i.strip() for i in match.group(1).split(",")]),
23
+ )
21
24
 
22
25
  return query, (None, None)
23
26
 
24
27
 
25
28
  def parse_pseudo_xpath_query(
26
29
  query: str,
27
- ) -> tuple[str, tuple[PseudoAction | None, str | None]]:
30
+ ) -> tuple[str, tuple[PseudoAction | None, str | tuple[str, ...] | None]]:
28
31
  """parse xpath selector and extract pseudo class action"""
29
32
  query = query.strip()
30
33
 
@@ -35,7 +38,10 @@ def parse_pseudo_xpath_query(
35
38
  return query[: -len("/raw()")], ("raw", None)
36
39
 
37
40
  if match := re.search(r"/@([\w:-]+)$", query):
38
- return query[: match.start()], ("attr", match.group(1))
41
+ return query[: match.start()], (
42
+ "attr",
43
+ tuple([i.strip() for i in match.group(1).split(",")]),
44
+ )
39
45
 
40
46
  return query, (None, None)
41
47
 
File without changes
File without changes