"""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// 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)