Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
"""Low-level payload decoding and brace-tree parsing for 1C SQL metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import copy
|
||||
import re
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
GUID_RE = re.compile(
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
||||
)
|
||||
|
||||
BraceNode = dict[str, Any]
|
||||
|
||||
|
||||
def try_decompress(data: bytes) -> tuple[bytes, str]:
|
||||
"""Try known 1C payload compression envelopes."""
|
||||
|
||||
attempts = (
|
||||
("raw_deflate", lambda value: zlib.decompress(value, -15)),
|
||||
("zlib", zlib.decompress),
|
||||
("gzip", gzip.decompress),
|
||||
)
|
||||
for name, func in attempts:
|
||||
try:
|
||||
return func(data), name
|
||||
except Exception:
|
||||
pass
|
||||
return data, "none"
|
||||
|
||||
|
||||
def compress_payload(data: bytes, compression: str) -> bytes:
|
||||
"""Compress payload bytes using a known 1C storage envelope."""
|
||||
|
||||
if compression == "raw_deflate":
|
||||
compressor = zlib.compressobj(level=6, wbits=-15)
|
||||
return compressor.compress(data) + compressor.flush()
|
||||
if compression == "zlib":
|
||||
return zlib.compress(data)
|
||||
if compression == "gzip":
|
||||
return gzip.compress(data)
|
||||
if compression == "none":
|
||||
return data
|
||||
raise ValueError(f"unsupported compression: {compression}")
|
||||
|
||||
|
||||
def try_decode(data: bytes) -> tuple[str | None, str | None]:
|
||||
"""Decode payload text, preferring explicit BOM over heuristic scoring."""
|
||||
|
||||
if data.startswith(b"\xef\xbb\xbf"):
|
||||
try:
|
||||
return data.decode("utf-8-sig"), "utf-8-sig"
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
candidates = ("utf-8-sig", "utf-8", "utf-16-le", "utf-16-be", "cp1251")
|
||||
best: tuple[str | None, str | None, int] = (None, None, -1)
|
||||
for encoding in candidates:
|
||||
try:
|
||||
text = data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
sample = text[:20000]
|
||||
printable = sum(1 for char in sample if char.isprintable() or char in "\r\n\t")
|
||||
cyrillic = sum(1 for char in sample if "\u0400" <= char <= "\u04ff")
|
||||
cjk = sum(1 for char in sample if "\u4e00" <= char <= "\u9fff")
|
||||
replacement = sample.count("\ufffd")
|
||||
controls = sum(1 for char in sample if ord(char) < 32 and char not in "\r\n\t\x00")
|
||||
score = printable + cyrillic * 4 - sample.count("\x00") * 10 - cjk * 12 - replacement * 20 - controls * 5
|
||||
if score > best[2]:
|
||||
best = (text, encoding, score)
|
||||
return best[0], best[1]
|
||||
|
||||
|
||||
def encode_text(text: str, encoding: str | None) -> bytes:
|
||||
if not encoding:
|
||||
raise ValueError("encoding is required")
|
||||
return text.encode(encoding)
|
||||
|
||||
|
||||
def decode_payload_lossless(data: bytes) -> dict[str, Any]:
|
||||
"""Decode payload without stripping text bytes so it can be encoded back."""
|
||||
|
||||
payload, compression = try_decompress(data)
|
||||
text, encoding = try_decode(payload)
|
||||
return {
|
||||
"payload": payload,
|
||||
"compression": compression,
|
||||
"text": text,
|
||||
"encoding": encoding,
|
||||
"raw_bytes": len(data),
|
||||
"payload_bytes": len(payload),
|
||||
}
|
||||
|
||||
|
||||
def encode_payload_lossless(decoded: dict[str, Any], *, text: str | None = None, payload: bytes | None = None) -> bytes:
|
||||
"""Encode a decoded payload using its original compression/encoding metadata."""
|
||||
|
||||
compression = str(decoded.get("compression") or "none")
|
||||
if payload is None:
|
||||
if text is None:
|
||||
text = decoded.get("text")
|
||||
payload = encode_text(str(text), decoded.get("encoding")) if text is not None else decoded.get("payload")
|
||||
if not isinstance(payload, (bytes, bytearray)):
|
||||
raise ValueError("payload bytes or text are required")
|
||||
return compress_payload(bytes(payload), compression)
|
||||
|
||||
|
||||
def payload_to_text(data: bytes) -> dict[str, Any]:
|
||||
"""Decode compressed SQL BinaryData to text and generic metadata."""
|
||||
|
||||
payload, compression = try_decompress(data)
|
||||
text, encoding = try_decode(payload)
|
||||
return {
|
||||
"payload": payload,
|
||||
"compression": compression,
|
||||
"text": text.replace("\x00", "").replace("\ufeff", "") if text is not None else None,
|
||||
"encoding": encoding,
|
||||
"raw_bytes": len(data),
|
||||
"payload_bytes": len(payload),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Token:
|
||||
kind: str
|
||||
value: str
|
||||
pos: int
|
||||
end: int
|
||||
|
||||
|
||||
class Lexer:
|
||||
def __init__(self, text: str) -> None:
|
||||
self.text = text
|
||||
self.pos = 0
|
||||
|
||||
def tokens(self) -> list[Token]:
|
||||
result: list[Token] = []
|
||||
while self.pos < len(self.text):
|
||||
char = self.text[self.pos]
|
||||
if char.isspace():
|
||||
self.pos += 1
|
||||
continue
|
||||
if char in "{},:":
|
||||
result.append(Token(char, char, self.pos, self.pos + 1))
|
||||
self.pos += 1
|
||||
continue
|
||||
if char == '"':
|
||||
result.append(self._string())
|
||||
continue
|
||||
result.append(self._atom())
|
||||
result.append(Token("EOF", "", self.pos, self.pos))
|
||||
return result
|
||||
|
||||
def _string(self) -> Token:
|
||||
start = self.pos
|
||||
self.pos += 1
|
||||
chars: list[str] = []
|
||||
while self.pos < len(self.text):
|
||||
char = self.text[self.pos]
|
||||
self.pos += 1
|
||||
if char == '"':
|
||||
if self.pos < len(self.text) and self.text[self.pos] == '"':
|
||||
chars.append('"')
|
||||
self.pos += 1
|
||||
continue
|
||||
break
|
||||
chars.append(char)
|
||||
return Token("string", "".join(chars), start, self.pos)
|
||||
|
||||
def _atom(self) -> Token:
|
||||
start = self.pos
|
||||
while self.pos < len(self.text):
|
||||
char = self.text[self.pos]
|
||||
if char.isspace() or char in "{},:":
|
||||
break
|
||||
self.pos += 1
|
||||
return Token("atom", self.text[start : self.pos], start, self.pos)
|
||||
|
||||
|
||||
class Parser:
|
||||
def __init__(self, tokens: list[Token]) -> None:
|
||||
self.tokens = tokens
|
||||
self.index = 0
|
||||
|
||||
def parse(self) -> Any:
|
||||
values = []
|
||||
while not self._peek("EOF"):
|
||||
if self._peek(","):
|
||||
self.index += 1
|
||||
continue
|
||||
values.append(self._value())
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
return {"type": "sequence", "items": values}
|
||||
|
||||
def _value(self) -> Any:
|
||||
if self._peek("{"):
|
||||
return self._list()
|
||||
token = self._next()
|
||||
if token.kind == "string":
|
||||
return {"type": "string", "value": token.value, "pos": token.pos, "end": token.end}
|
||||
if token.kind == "atom":
|
||||
return {"type": "atom", "value": token.value, "pos": token.pos, "end": token.end}
|
||||
return {"type": "token", "kind": token.kind, "value": token.value, "pos": token.pos, "end": token.end}
|
||||
|
||||
def _list(self) -> Any:
|
||||
start = self._next()
|
||||
items = []
|
||||
while not self._peek("EOF") and not self._peek("}"):
|
||||
if self._peek(","):
|
||||
self.index += 1
|
||||
continue
|
||||
items.append(self._value())
|
||||
end = items[-1].get("end", items[-1].get("pos", start.end)) if items and isinstance(items[-1], dict) else start.end
|
||||
if self._peek("}"):
|
||||
end = self.tokens[self.index].end
|
||||
self.index += 1
|
||||
return {"type": "list", "pos": start.pos, "end": end, "items": items}
|
||||
|
||||
def _peek(self, kind: str) -> bool:
|
||||
return self.tokens[self.index].kind == kind
|
||||
|
||||
def _next(self) -> Token:
|
||||
token = self.tokens[self.index]
|
||||
self.index += 1
|
||||
return token
|
||||
|
||||
|
||||
def parse_brace_text(text: str) -> Any:
|
||||
"""Parse brace text into a generic tree without semantic interpretation."""
|
||||
|
||||
clean = text.replace("\x00", "").replace("\ufeff", "")
|
||||
first_brace = clean.find("{")
|
||||
if first_brace > 0:
|
||||
clean = clean[first_brace:]
|
||||
return Parser(Lexer(clean).tokens()).parse()
|
||||
|
||||
|
||||
def quote_string(value: str) -> str:
|
||||
return '"' + value.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def patch_brace_text_path(text: str, path: str, value: Any, *, node_type: str = "auto") -> tuple[str, dict[str, Any]]:
|
||||
"""Patch one scalar token in-place without canonicalizing the whole brace tree."""
|
||||
|
||||
if "\x00" in text:
|
||||
raise ValueError("byte-preserving patch does not support NUL-stripped payload text")
|
||||
offset = text.find("{")
|
||||
if offset < 0:
|
||||
raise ValueError("brace text payload is required")
|
||||
source = text[offset:]
|
||||
tree = Parser(Lexer(source).tokens()).parse()
|
||||
old = get_tree_path(tree, path)
|
||||
if not isinstance(old, dict) or old.get("type") not in {"atom", "string"}:
|
||||
raise ValueError("byte-preserving patch supports scalar atom/string nodes only")
|
||||
replacement_type = node_type
|
||||
if replacement_type == "auto":
|
||||
replacement_type = str(old.get("type") or "string")
|
||||
replacement = scalar_node(value, replacement_type)
|
||||
if replacement.get("type") == "string":
|
||||
rendered = quote_string(str(replacement.get("value") or ""))
|
||||
elif replacement.get("type") == "atom":
|
||||
rendered = str(replacement.get("value") or "")
|
||||
else:
|
||||
raise ValueError("byte-preserving patch replacement must be atom or string")
|
||||
start = offset + int(old.get("pos"))
|
||||
end = offset + int(old.get("end"))
|
||||
if start < 0 or end < start or end > len(text):
|
||||
raise ValueError("invalid scalar token span")
|
||||
patched = text[:start] + rendered + text[end:]
|
||||
return patched, {
|
||||
"path": path,
|
||||
"old": scalar(old),
|
||||
"new": str(replacement.get("value") or ""),
|
||||
"old_node_type": old.get("type"),
|
||||
"new_node_type": replacement.get("type"),
|
||||
"span": [start, end],
|
||||
}
|
||||
|
||||
|
||||
def swap_brace_text_paths(text: str, path_a: str, path_b: str) -> tuple[str, dict[str, Any]]:
|
||||
"""Swap two parsed brace-tree node text spans without canonicalizing the tree."""
|
||||
|
||||
if "\x00" in text:
|
||||
raise ValueError("byte-preserving swap does not support NUL-stripped payload text")
|
||||
offset = text.find("{")
|
||||
if offset < 0:
|
||||
raise ValueError("brace text payload is required")
|
||||
if path_a == path_b:
|
||||
raise ValueError("swap paths must be different")
|
||||
parts_a = tree_path_parts(path_a)
|
||||
parts_b = tree_path_parts(path_b)
|
||||
if parts_a == parts_b[: len(parts_a)] or parts_b == parts_a[: len(parts_b)]:
|
||||
raise ValueError("swap paths must not be ancestor/descendant")
|
||||
source = text[offset:]
|
||||
tree = Parser(Lexer(source).tokens()).parse()
|
||||
node_a = get_tree_path(tree, path_a)
|
||||
node_b = get_tree_path(tree, path_b)
|
||||
if not isinstance(node_a, dict) or not isinstance(node_b, dict):
|
||||
raise ValueError("swap paths must resolve to parsed nodes")
|
||||
if "pos" not in node_a or "end" not in node_a or "pos" not in node_b or "end" not in node_b:
|
||||
raise ValueError("swap nodes must have text spans")
|
||||
span_a = [offset + int(node_a["pos"]), offset + int(node_a["end"])]
|
||||
span_b = [offset + int(node_b["pos"]), offset + int(node_b["end"])]
|
||||
if span_a[0] >= span_a[1] or span_b[0] >= span_b[1]:
|
||||
raise ValueError("swap node span is empty")
|
||||
if not (span_a[1] <= span_b[0] or span_b[1] <= span_a[0]):
|
||||
raise ValueError("swap node spans overlap")
|
||||
first_span, second_span = (span_a, span_b) if span_a[0] < span_b[0] else (span_b, span_a)
|
||||
first_text = text[first_span[0] : first_span[1]]
|
||||
second_text = text[second_span[0] : second_span[1]]
|
||||
swapped = text[: first_span[0]] + second_text + text[first_span[1] : second_span[0]] + first_text + text[second_span[1] :]
|
||||
return swapped, {
|
||||
"path_a": path_a,
|
||||
"path_b": path_b,
|
||||
"span_a": span_a,
|
||||
"span_b": span_b,
|
||||
"node_a_type": node_a.get("type"),
|
||||
"node_b_type": node_b.get("type"),
|
||||
}
|
||||
|
||||
|
||||
def append_brace_text_child(text: str, parent_path: str, child_node: Any) -> tuple[str, dict[str, Any]]:
|
||||
"""Append one child node to a parsed brace-tree container without reformatting the rest."""
|
||||
|
||||
if "\x00" in text:
|
||||
raise ValueError("byte-preserving append does not support NUL-stripped payload text")
|
||||
offset = text.find("{")
|
||||
if offset < 0:
|
||||
raise ValueError("brace text payload is required")
|
||||
source = text[offset:]
|
||||
tree = Parser(Lexer(source).tokens()).parse()
|
||||
parent = get_tree_path(tree, parent_path)
|
||||
if not isinstance(parent, dict) or parent.get("type") not in {"list", "sequence"}:
|
||||
raise ValueError("append parent must be a list or sequence node")
|
||||
if "end" not in parent:
|
||||
raise ValueError("append parent span is unavailable")
|
||||
items = parent.get("items") if isinstance(parent.get("items"), list) else []
|
||||
insertion = offset + int(parent["end"]) - 1
|
||||
if insertion < 0 or insertion > len(text) or text[insertion] != "}":
|
||||
raise ValueError("append insertion point is not a closing brace")
|
||||
rendered_child = serialize_brace_tree(child_node)
|
||||
inserted = ("," if items else "") + rendered_child
|
||||
count_updated = False
|
||||
count_before = None
|
||||
count_after = None
|
||||
count_span = None
|
||||
original_insertion = insertion
|
||||
prefix = text[:original_insertion]
|
||||
if len(items) >= 2:
|
||||
count_node = items[1]
|
||||
count_text = scalar(count_node)
|
||||
try:
|
||||
declared_count = int(count_text) if count_text is not None else None
|
||||
except ValueError:
|
||||
declared_count = None
|
||||
actual_record_count = len(items) - 2
|
||||
if declared_count is not None and declared_count == actual_record_count and isinstance(count_node, dict) and "pos" in count_node and "end" in count_node:
|
||||
count_start = offset + int(count_node["pos"])
|
||||
count_end = offset + int(count_node["end"])
|
||||
if 0 <= count_start < count_end <= len(prefix):
|
||||
count_before = declared_count
|
||||
count_after = declared_count + 1
|
||||
count_span = [count_start, count_end]
|
||||
prefix = prefix[:count_start] + str(count_after) + prefix[count_end:]
|
||||
count_updated = True
|
||||
insertion = len(prefix)
|
||||
patched = prefix + inserted + text[original_insertion:]
|
||||
return patched, {
|
||||
"parent_path": parent_path,
|
||||
"inserted_index": len(items),
|
||||
"span": [insertion, insertion],
|
||||
"inserted_bytes": len(inserted.encode("utf-8")),
|
||||
"count_updated": count_updated,
|
||||
**({"count_before": count_before, "count_after": count_after, "count_span": count_span} if count_updated else {}),
|
||||
}
|
||||
|
||||
|
||||
def serialize_brace_tree(node: Any) -> str:
|
||||
"""Serialize parsed brace tree to canonical 1C brace text."""
|
||||
|
||||
if isinstance(node, dict):
|
||||
node_type = node.get("type")
|
||||
if node_type == "list":
|
||||
return "{" + ",".join(serialize_brace_tree(item) for item in (node.get("items") or [])) + "}"
|
||||
if node_type == "sequence":
|
||||
return ",".join(serialize_brace_tree(item) for item in (node.get("items") or []))
|
||||
if node_type == "string":
|
||||
return quote_string(str(node.get("value") or ""))
|
||||
if node_type == "atom":
|
||||
return str(node.get("value") or "")
|
||||
if node_type == "token":
|
||||
return str(node.get("value") or "")
|
||||
if isinstance(node, str):
|
||||
return quote_string(node)
|
||||
if node is None:
|
||||
return ""
|
||||
return str(node)
|
||||
|
||||
|
||||
def tree_path_parts(path: str) -> list[int]:
|
||||
if not str(path or "").strip():
|
||||
raise ValueError("path is required")
|
||||
parts: list[int] = []
|
||||
for part in str(path).split("."):
|
||||
if not part.isdigit():
|
||||
raise ValueError(f"path segment is not a non-negative integer: {part}")
|
||||
parts.append(int(part))
|
||||
return parts
|
||||
|
||||
|
||||
def get_tree_path(tree: Any, path: str) -> Any:
|
||||
node = tree
|
||||
for index in tree_path_parts(path):
|
||||
if not isinstance(node, dict) or node.get("type") not in {"list", "sequence"}:
|
||||
raise ValueError(f"path enters a non-container node at segment {index}")
|
||||
items = node.get("items") or []
|
||||
if index >= len(items):
|
||||
raise IndexError(f"path segment {index} is outside node with {len(items)} items")
|
||||
node = items[index]
|
||||
return node
|
||||
|
||||
|
||||
def inspect_tree_node(node: Any, *, depth: int = 0, max_depth: int = 2, max_children: int = 8) -> dict[str, Any]:
|
||||
if not isinstance(node, dict):
|
||||
return {"type": type(node).__name__, "repr": repr(node)[:200]}
|
||||
node_type = str(node.get("type") or "")
|
||||
result: dict[str, Any] = {"type": node_type}
|
||||
for key in ("value", "pos", "end", "kind"):
|
||||
if key in node:
|
||||
result[key] = node.get(key)
|
||||
items = node.get("items") if isinstance(node.get("items"), list) else None
|
||||
if items is not None:
|
||||
result["items"] = len(items)
|
||||
if depth < max_depth:
|
||||
result["children"] = [
|
||||
inspect_tree_node(child, depth=depth + 1, max_depth=max_depth, max_children=max_children)
|
||||
for child in items[:max_children]
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def inspect_brace_text_path(text: str, path: str, *, max_depth: int = 2, max_children: int = 8) -> dict[str, Any]:
|
||||
if "\x00" in text:
|
||||
raise ValueError("byte-preserving probe does not support NUL-stripped payload text")
|
||||
offset = text.find("{")
|
||||
if offset < 0:
|
||||
raise ValueError("brace text payload is required")
|
||||
source = text[offset:]
|
||||
tree = Parser(Lexer(source).tokens()).parse()
|
||||
node = get_tree_path(tree, path)
|
||||
result = {"path": path, "node": inspect_tree_node(node, max_depth=max_depth, max_children=max_children)}
|
||||
if isinstance(node, dict) and "pos" in node:
|
||||
result["span"] = [offset + int(node.get("pos")), offset + int(node.get("end", node.get("pos")))]
|
||||
return result
|
||||
|
||||
|
||||
def scalar_node(value: Any, node_type: str = "auto") -> dict[str, Any]:
|
||||
if isinstance(value, dict) and value.get("type") in {"atom", "string", "list", "sequence", "token"}:
|
||||
return value
|
||||
if node_type not in {"auto", "atom", "string"}:
|
||||
raise ValueError("node_type must be auto, atom, or string")
|
||||
if node_type == "atom":
|
||||
return {"type": "atom", "value": str(value)}
|
||||
if node_type == "string":
|
||||
return {"type": "string", "value": str(value)}
|
||||
if isinstance(value, bool):
|
||||
return {"type": "atom", "value": "true" if value else "false"}
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return {"type": "atom", "value": str(value)}
|
||||
return {"type": "string", "value": str(value)}
|
||||
|
||||
|
||||
def set_tree_path(tree: Any, path: str, value: Any, *, node_type: str = "auto") -> Any:
|
||||
"""Return a deep-copied tree with one node replaced by path."""
|
||||
|
||||
parts = tree_path_parts(path)
|
||||
result = copy.deepcopy(tree)
|
||||
parent = result
|
||||
for index in parts[:-1]:
|
||||
if not isinstance(parent, dict) or parent.get("type") not in {"list", "sequence"}:
|
||||
raise ValueError(f"path enters a non-container node at segment {index}")
|
||||
items = parent.get("items") or []
|
||||
if index >= len(items):
|
||||
raise IndexError(f"path segment {index} is outside node with {len(items)} items")
|
||||
parent = items[index]
|
||||
if not isinstance(parent, dict) or parent.get("type") not in {"list", "sequence"}:
|
||||
raise ValueError("path parent is not a container")
|
||||
items = parent.get("items") or []
|
||||
last = parts[-1]
|
||||
if last >= len(items):
|
||||
raise IndexError(f"path segment {last} is outside node with {len(items)} items")
|
||||
old = items[last]
|
||||
replacement_type = node_type
|
||||
if replacement_type == "auto" and isinstance(old, dict) and old.get("type") in {"atom", "string"}:
|
||||
replacement_type = str(old.get("type"))
|
||||
items[last] = scalar_node(value, replacement_type)
|
||||
return result
|
||||
|
||||
|
||||
def encode_brace_tree(tree: Any, decoded: dict[str, Any]) -> bytes:
|
||||
"""Encode a modified brace tree with canonical formatting."""
|
||||
|
||||
return encode_payload_lossless(decoded, text=serialize_brace_tree(tree))
|
||||
|
||||
|
||||
def parse_payload_file(path: Path) -> dict[str, Any]:
|
||||
decoded = payload_to_text(path.read_bytes())
|
||||
text = decoded.get("text")
|
||||
decoded["tree"] = parse_brace_text(text) if text and "{" in text else None
|
||||
return decoded
|
||||
|
||||
|
||||
def scalar(node: Any) -> str:
|
||||
if isinstance(node, dict) and node.get("type") in {"atom", "string"}:
|
||||
return str(node.get("value") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def collect_strings(value: Any, limit: int = 200) -> list[str]:
|
||||
result: list[str] = []
|
||||
|
||||
def walk(node: Any) -> None:
|
||||
if len(result) >= limit:
|
||||
return
|
||||
if isinstance(node, dict) and node.get("type") == "string":
|
||||
text = str(node.get("value") or "")
|
||||
if text:
|
||||
result.append(text)
|
||||
return
|
||||
if isinstance(node, dict):
|
||||
for child in node.get("items") or []:
|
||||
walk(child)
|
||||
|
||||
walk(value)
|
||||
return result
|
||||
|
||||
|
||||
def root_signature(tree: Any) -> dict[str, Any]:
|
||||
if not (isinstance(tree, dict) and tree.get("type") == "list"):
|
||||
return {"root_type": tree.get("type") if isinstance(tree, dict) else None}
|
||||
items = tree.get("items") or []
|
||||
return {
|
||||
"root_type": "list",
|
||||
"root_len": len(items),
|
||||
"root_marker": scalar(items[0]) if items else "",
|
||||
}
|
||||
Reference in New Issue
Block a user