Files
1hew-ComfyUI-1hewNodes/utils/_json_path.py
T

683 lines
19 KiB
Python

"""JSON-like text parsing and path queries shared by the text nodes.
The public surface is small on purpose:
data = parse_content(value) # str / dict / list / number / ...
value = query(data, "[0].content") # first match, or a default
values = query_all(data, "[*].content")
parse_content accepts whatever a ComfyUI input hands over: a STRING widget
(plain text, JSON, JSON inside a Markdown fence, a Python literal, relaxed
JSON with single quotes / trailing commas / True|False|None, or JSON with
unquoted keys), an already-parsed dict / list, or any other value, which is
returned unchanged.
query walks that object with a JavaScript-flavoured path:
[0].content list index, then key
[-1].bbox negative index
content bare key == ["content"]
$.data.items[0].zh optional JSONPath-ish root
[0]["content"] quoted bracket key
[0].'content' single quotes work too
data[*].content wildcard: collect every element's content
data.*.content dot form of the wildcard
[*].content.count count / length / size / [#]: how many
[0].bbox.length length of the matched list (4)
Full-width brackets / quotes / commas from a Chinese IME are normalised, so
a path typed as [0].“content” behaves like [0]."content".
This module is a library, not a node: files under nodes/<category>/ must
define exactly one ComfyNode, so shared helpers live in utils/_*.py.
"""
from __future__ import annotations
import ast
import json
import re
from collections.abc import Mapping, Sequence
__all__ = [
"clean_json_text",
"parse_json_text",
"parse_content",
"parse_path",
"query",
"query_all",
"is_count_path",
"format_value",
]
# ---------------------------------------------------------------------------
# JSON-like text parsing
# ---------------------------------------------------------------------------
_FENCE = chr(96) * 3
_PY_LITERAL_VALUE = {"True": "true", "False": "false", "None": "null"}
_PY_LITERAL_RE = re.compile(r"\b(True|False|None)\b")
_TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])")
_KV_PAIR_RE = re.compile(r'"([^"]+)"\s*:\s*"([^"]+)"')
def clean_json_text(text) -> str:
"""Strip Markdown fences and non-JSON noise around the first JSON block."""
text = str(text or "").strip()
if not text:
return ""
if _FENCE in text:
for block in text.split(_FENCE)[1::2]:
body = block.strip()
if body[:4].lower() == "json":
body = body[4:].lstrip()
if body[:1] in "[{":
text = body
break
start = -1
for index, char in enumerate(text):
if char in "[{":
start = index
break
if start == -1:
return text
text = text[start:]
end = -1
for index in range(len(text) - 1, -1, -1):
if text[index] in "]}":
end = index + 1
break
if end != -1:
text = text[:end]
return text
def parse_json_text(value):
"""Parse JSON-like text, returning None when nothing can be parsed.
The fallback chain is, in order: strict JSON, Python literal
(single quotes, True/False/None), relaxed JSON (trailing commas and
Python literals), a hand-written loose parser (unquoted keys, single
quotes inside arrays, bare tokens) and finally a regex scrape of
"key": "value" pairs so a truncated payload still yields a dict.
"""
if value is None:
return None
text = value if isinstance(value, str) else str(value)
# A JSON string that wraps a payload ("{\"a\": 1}") must be decoded
# before the fence/brace trimming below, otherwise the escaped quotes
# would be read as literal characters.
stripped = text.strip()
if stripped[:1] == '"' and stripped[-1:] == '"':
wrapped = _try_json_loads(stripped)
if isinstance(wrapped, str):
return wrapped
text = clean_json_text(text)
if not text:
return None
for candidate in (text, _TRAILING_COMMA_RE.sub(r"\1", text)):
parsed = _try_json_loads(candidate)
if parsed is not None:
return parsed
parsed = _try_literal_eval(candidate)
if parsed is not None:
return parsed
parsed = _try_relaxed_json(candidate)
if parsed is not None:
return parsed
parsed = _try_loose_parse(candidate)
if parsed is not None:
return parsed
pairs = _KV_PAIR_RE.findall(text)
if pairs:
return {key: value for key, value in pairs}
return None
def parse_content(value):
"""Normalise a ComfyUI input value into a queryable Python object.
Strings are parsed as JSON-like text when possible (including JSON that
was itself serialised into a string); every other value -- dict, list,
number, bool, None, tensor -- is returned untouched.
"""
if value is None:
return None
if isinstance(value, (bytes, bytearray)):
value = bytes(value).decode("utf-8", "replace")
if not isinstance(value, str):
return value
parsed = parse_json_text(value)
if parsed is None:
return value
if isinstance(parsed, str):
stripped = parsed.strip()
if stripped[:1] in "[{" and stripped[-1:] in "]}":
inner = parse_json_text(stripped)
if inner is not None and not isinstance(inner, str):
return inner
return parsed
def _try_json_loads(text: str):
try:
return json.loads(text)
except (json.JSONDecodeError, ValueError):
return None
def _try_literal_eval(text: str):
try:
return ast.literal_eval(text)
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError):
return None
def _try_relaxed_json(text: str):
relaxed = _TRAILING_COMMA_RE.sub(r"\1", text)
relaxed = _PY_LITERAL_RE.sub(
lambda match: _PY_LITERAL_VALUE.get(match.group(1), match.group(1)), relaxed
)
if relaxed == text:
return None
try:
return json.loads(relaxed)
except (json.JSONDecodeError, ValueError):
return None
def _try_loose_parse(text: str):
stripped = text.strip()
if not stripped:
return None
start_idx = None
start_char = None
for idx, char in enumerate(stripped):
if char in "[{":
start_idx = idx
start_char = char
break
if start_idx is None or start_char is None:
return None
end_char = "}" if start_char == "{" else "]"
end_idx = stripped.rfind(end_char)
if end_idx == -1 or end_idx <= start_idx:
return None
container = stripped[start_idx : end_idx + 1]
try:
if start_char == "{":
parsed, _ = _loose_parse_dict(container, 0)
return parsed
parsed, _ = _loose_parse_list(container, 0)
return parsed
except Exception:
return None
def _loose_parse_dict(text: str, start: int):
if start >= len(text) or text[start] != "{":
raise ValueError("Expected '{'")
idx = start + 1
result = {}
while idx < len(text):
idx = _skip_ws(text, idx)
if idx >= len(text):
break
if text[idx] == "}":
return result, idx + 1
if text[idx] == ",":
idx += 1
continue
key, idx = _read_key(text, idx)
idx = _skip_ws(text, idx)
if idx < len(text) and text[idx] == ":":
idx += 1
idx = _skip_ws(text, idx)
value, idx = _loose_parse_value(text, idx)
if key:
result[key] = value
idx = _skip_ws(text, idx)
if idx < len(text) and text[idx] == ",":
idx += 1
return result, idx
def _loose_parse_list(text: str, start: int):
if start >= len(text) or text[start] != "[":
raise ValueError("Expected '['")
idx = start + 1
result = []
while idx < len(text):
idx = _skip_ws(text, idx)
if idx >= len(text):
break
if text[idx] == "]":
return result, idx + 1
if text[idx] == ",":
idx += 1
continue
value, idx = _loose_parse_value(text, idx)
result.append(value)
idx = _skip_ws(text, idx)
if idx < len(text) and text[idx] == ",":
idx += 1
return result, idx
def _loose_parse_value(text: str, start: int):
idx = _skip_ws(text, start)
if idx >= len(text):
return None, idx
char = text[idx]
if char in ('"', "'"):
return _read_loose_quoted_string(text, idx)
if char == "{":
return _loose_parse_dict(text, idx)
if char == "[":
return _loose_parse_list(text, idx)
token, idx = _read_token(text, idx)
lowered = token.lower()
if lowered in {"null", "none"}:
return None, idx
if lowered == "true":
return True, idx
if lowered == "false":
return False, idx
if re.fullmatch(r"-?\d+", token):
try:
return int(token), idx
except ValueError:
return token, idx
if re.fullmatch(r"-?\d+\.\d+", token):
try:
return float(token), idx
except ValueError:
return token, idx
return token, idx
def _skip_ws(text: str, idx: int) -> int:
while idx < len(text) and text[idx].isspace():
idx += 1
return idx
def _read_key(text: str, start: int):
idx = start
if idx >= len(text):
return "", idx
char = text[idx]
if char in ('"', "'"):
quote = char
idx += 1
buf = []
while idx < len(text):
current = text[idx]
if current == "\\" and idx + 1 < len(text):
buf.append(text[idx + 1])
idx += 2
continue
if current == quote:
idx += 1
break
buf.append(current)
idx += 1
return "".join(buf).strip(), idx
buf = []
while idx < len(text):
current = text[idx]
if current == ":" or current.isspace() or current in ",}":
break
buf.append(current)
idx += 1
return "".join(buf).strip(), idx
def _read_loose_quoted_string(text: str, start: int):
quote = text[start]
idx = start + 1
buf = []
while idx < len(text):
current = text[idx]
if current == "\\" and idx + 1 < len(text):
buf.append(current)
buf.append(text[idx + 1])
idx += 2
continue
if current == quote:
look = _skip_ws(text, idx + 1)
if look >= len(text) or text[look] in ",}]":
return _decode_loose_string("".join(buf), quote), idx + 1
buf.append(current)
idx += 1
continue
buf.append(current)
idx += 1
return _decode_loose_string("".join(buf), quote), idx
def _decode_loose_string(raw: str, quote: str) -> str:
candidate = f"{quote}{raw}{quote}"
try:
return json.loads(candidate)
except (json.JSONDecodeError, ValueError):
pass
try:
return ast.literal_eval(candidate)
except (ValueError, SyntaxError):
return raw
def _read_token(text: str, start: int):
idx = start
buf = []
while idx < len(text):
current = text[idx]
if current in ",}]":
break
buf.append(current)
idx += 1
return "".join(buf).strip(), idx
# ---------------------------------------------------------------------------
# Path parsing and lookup
# ---------------------------------------------------------------------------
_WILDCARD = object()
_MISSING = object()
_INT_TOKEN_RE = re.compile(r"[+-]?\d+")
_COUNT_TOKENS = {"count", "length", "size", "len"}
class _Key(str):
"""A quoted key: always a literal, never a wildcard or a count operator."""
__slots__ = ()
def _is_count_token(token) -> bool:
return type(token) is str and token.strip().lower() in _COUNT_TOKENS
_FULL_WIDTH = str.maketrans(
{
"\u3010": "[",
"\u3011": "]",
"\u201c": '"',
"\u201d": '"',
"\u2018": "'",
"\u2019": "'",
"\uff0c": ",",
"\uff0e": ".",
"\u3002": ".",
}
)
def parse_path(path) -> list:
"""Tokenise a path such as [0].content into keys / indices / wildcards."""
text = str(path or "").translate(_FULL_WIDTH)
tokens = []
index = 0
length = len(text)
while index < length:
char = text[index]
if char in " \t\r\n":
index += 1
continue
if char in ".$":
index += 1
continue
if char == "[":
token, index = _read_bracket(text, index)
if token is not None:
tokens.append(token)
continue
if char in "\"'":
token, index = _read_quoted(text, index)
if token:
tokens.append(token)
continue
start = index
while index < length and text[index] not in ".[]":
index += 1
raw = text[start:index].strip()
if raw:
tokens.append(_WILDCARD if raw == "*" else raw)
return tokens
def query(content, path, default=None):
"""Return the first value matched by path, or default when nothing matches."""
matches = query_all(content, path)
return matches[0] if matches else default
def is_count_path(path) -> bool:
"""True when the path ends with a count / length / size / [#] operator."""
tokens = parse_path(path)
return bool(tokens) and _is_count_token(tokens[-1])
def query_all(content, path) -> list:
"""Return every value matched by path; an empty path returns the whole content.
A trailing count / length / size / [#] token counts instead of indexing:
the matches of everything before it are counted when there are several,
otherwise the matched value itself is measured (list, dict or string).
An unquoted key that really is named count still wins, so a payload with
a count field keeps working; quote it ("count") to force key access.
"""
data = parse_content(content)
tokens = parse_path(path)
if tokens and _is_count_token(tokens[-1]):
return _count(data, tokens[:-1], tokens[-1])
return _walk(data, tokens)
def _count(data, tokens: list, token) -> list:
subject = _walk(data, tokens)
if len(subject) == 1:
node = subject[0]
if isinstance(node, Mapping) and token in node:
return [node[token]]
if isinstance(node, Mapping):
return [len(node)]
if isinstance(node, Sequence) and not isinstance(
node, (str, bytes, bytearray)
):
return [len(node)]
if isinstance(node, str):
return [len(node)]
return [1]
return [len(subject)]
def _walk(node, tokens: list) -> list:
if not tokens:
return [node]
head, rest = tokens[0], tokens[1:]
if head is _WILDCARD:
matches = []
for child in _iter_children(node):
matches.extend(_walk(child, rest))
return matches
child = _pick(node, head)
if child is _MISSING:
return []
return _walk(child, rest)
def _iter_children(node):
if isinstance(node, Mapping):
return list(node.values())
if isinstance(node, Sequence) and not isinstance(node, (str, bytes, bytearray)):
return list(node)
return []
def _pick(node, token):
if isinstance(node, Mapping):
if token in node:
return node[token]
text = str(token)
if text in node:
return node[text]
return _MISSING
if isinstance(node, Sequence) and not isinstance(node, (str, bytes, bytearray)):
index = _as_index(token)
if index is None:
return _MISSING
try:
return node[index]
except IndexError:
return _MISSING
return _MISSING
def _as_index(token):
if isinstance(token, bool):
return None
if isinstance(token, int):
return token
if isinstance(token, str) and _INT_TOKEN_RE.fullmatch(token.strip()):
try:
return int(token.strip())
except ValueError:
return None
return None
def _read_bracket(text: str, start: int):
index = start + 1
length = len(text)
while index < length and text[index] in " \t\r\n":
index += 1
if index < length and text[index] in "\"'":
token, index = _read_quoted(text, index)
while index < length and text[index] in " \t\r\n":
index += 1
if index < length and text[index] == "]":
index += 1
return (token or None), index
raw_start = index
while index < length and text[index] != "]":
index += 1
raw = text[raw_start:index].strip()
if index < length:
index += 1
if not raw or raw == "*":
return _WILDCARD, index
if raw == "#":
return "count", index
if _INT_TOKEN_RE.fullmatch(raw):
return int(raw), index
return raw, index
def _read_quoted(text: str, start: int):
quote = text[start]
index = start + 1
length = len(text)
buf = []
while index < length:
char = text[index]
if char == "\\" and index + 1 < length:
buf.append(char)
buf.append(text[index + 1])
index += 2
continue
if char == quote:
index += 1
break
buf.append(char)
index += 1
return _Key(_decode_loose_string("".join(buf), quote)), index
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def format_value(value, style: str = "auto") -> str:
"""Render a queried value for a STRING port.
style is one of:
auto -- strings stay raw, containers become compact JSON
json -- always JSON (objects/lists keep their structure)
text -- Python str() of the value
csv -- flat lists joined with ", "
"""
style = str(style or "auto").strip().lower()
if style == "json":
return _to_json(value)
if style == "text":
return _to_text(value)
if style == "csv":
if isinstance(value, (list, tuple)) and value and all(
not isinstance(item, (Mapping, list, tuple)) for item in value
):
return ", ".join(_to_text(item) for item in value)
if isinstance(value, (Mapping, list, tuple)):
return _to_json(value)
return _to_text(value)
if isinstance(value, str):
return value
if isinstance(value, (Mapping, list, tuple)):
return _to_json(value)
return _to_text(value)
def _to_json(value) -> str:
try:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError):
return _to_text(value)
def _to_text(value) -> str:
if value is None:
return ""
if value is True:
return "true"
if value is False:
return "false"
if isinstance(value, float):
if value != value or value in (float("inf"), float("-inf")):
return str(value)
if value.is_integer() and abs(value) < 1e16:
return str(int(value))
return repr(value)
return str(value)