From e13024cd2cd83838a2386e248612ec10b916abe9 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:14:18 -0300 Subject: [PATCH 01/15] `_meta` -> `__meta` --- _meta.py => __meta.py | 0 node_dict_add_any.py | 4 ++-- node_dict_add_string.py | 4 ++-- node_dict_from_text.py | 4 ++-- node_dict_key_extract.py | 4 ++-- node_dict_preview.py | 4 ++-- node_formatter.py | 4 ++-- node_validate_keys.py | 4 ++-- 8 files changed, 14 insertions(+), 14 deletions(-) rename _meta.py => __meta.py (100%) diff --git a/_meta.py b/__meta.py similarity index 100% rename from _meta.py rename to __meta.py diff --git a/node_dict_add_any.py b/node_dict_add_any.py index 61fbd2f..f6351ab 100644 --- a/node_dict_add_any.py +++ b/node_dict_add_any.py @@ -10,7 +10,7 @@ from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO -from . import _meta +from .__meta import category_dict from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes from .funcs_common import _new_dict_with_updated_key @@ -40,7 +40,7 @@ _input_types = _deepfreeze({ class StringConstructorDictAddAny: """Add/update a non-string item to the Format-Dict - to do some advanced formatting.""" NODE_NAME = 'StringConstructorDictAddAny' - CATEGORY = _meta.category_dict + CATEGORY = category_dict DESCRIPTION = _format_docstring(_cleandoc(__doc__)) FUNCTION = 'main' diff --git a/node_dict_add_string.py b/node_dict_add_string.py index 0cf2fbd..9a45d5c 100644 --- a/node_dict_add_string.py +++ b/node_dict_add_string.py @@ -10,7 +10,7 @@ from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO -from . import _meta +from .__meta import category_dict from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes from .funcs_common import _new_dict_with_updated_key, _T @@ -40,7 +40,7 @@ _input_types = _deepfreeze({ class StringConstructorDictAddString: """Add/update a string to the Format-Dict.""" NODE_NAME = 'StringConstructorDictAddString' - CATEGORY = _meta.category_dict + CATEGORY = category_dict DESCRIPTION = _format_docstring(_cleandoc(__doc__)) FUNCTION = 'main' diff --git a/node_dict_from_text.py b/node_dict_from_text.py index 1d7681c..eafe0d8 100644 --- a/node_dict_from_text.py +++ b/node_dict_from_text.py @@ -10,7 +10,7 @@ from frozendict import frozendict as _frozendict, deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO -from . import _meta +from .__meta import category from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes from .funcs_common import _show_text_on_node, _new_updated_dict, _T @@ -94,7 +94,7 @@ class StringConstructorDictFromText: Build a dict of named sub-strings to be used later in string formatting (text construction). """ NODE_NAME = 'StringConstructorDictFromText' - CATEGORY = _meta.category + CATEGORY = category DESCRIPTION = _format_docstring(_cleandoc(__doc__)) OUTPUT_NODE = True # Just to show the status message even if not connected to anything diff --git a/node_dict_key_extract.py b/node_dict_key_extract.py index 2e099c9..b53f604 100644 --- a/node_dict_key_extract.py +++ b/node_dict_key_extract.py @@ -10,7 +10,7 @@ from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO -from . import _meta +from .__meta import category_dict from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes from .funcs_common import _show_text_on_node, _new_dict_with_updated_key, _T @@ -39,7 +39,7 @@ _input_types = _deepfreeze({ class StringConstructorDictExtractString: """Extract a single string from the Format-Dict.""" NODE_NAME = 'StringConstructorDictExtractString' - CATEGORY = _meta.category_dict + CATEGORY = category_dict DESCRIPTION = _format_docstring(_cleandoc(__doc__)) FUNCTION = 'main' diff --git a/node_dict_preview.py b/node_dict_preview.py index beacc28..e342099 100644 --- a/node_dict_preview.py +++ b/node_dict_preview.py @@ -9,7 +9,7 @@ from pprint import pformat as _pformat from frozendict import deepfreeze as _deepfreeze, frozendict as _frozendict -from . import _meta +from .__meta import category_dict from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes from .funcs_common import _show_text_on_node, _verify_input_dict_into_new @@ -69,7 +69,7 @@ class StringConstructorDictPreview: Show the contents of a Format-Dict. """ NODE_NAME = 'StringConstructorDictPreview' - CATEGORY = _meta.category_dict + CATEGORY = category_dict DESCRIPTION = _format_docstring(_cleandoc(__doc__)) OUTPUT_NODE = True diff --git a/node_formatter.py b/node_formatter.py index 83c3d2f..fc67818 100644 --- a/node_formatter.py +++ b/node_formatter.py @@ -14,7 +14,7 @@ from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO -from . import _meta +from .__meta import category from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes from .funcs_common import _show_text_on_node, _verify_input_dict @@ -221,7 +221,7 @@ class StringConstructorFormatter: Construct the formatted string from template and format-dictionary. """ NODE_NAME = 'StringConstructorFormatter' - CATEGORY = _meta.category + CATEGORY = category DESCRIPTION = _format_docstring(_cleandoc(__doc__)) OUTPUT_NODE = True diff --git a/node_validate_keys.py b/node_validate_keys.py index 7ada38a..8f8d51d 100644 --- a/node_validate_keys.py +++ b/node_validate_keys.py @@ -8,7 +8,7 @@ from inspect import cleandoc as _cleandoc from frozendict import deepfreeze as _deepfreeze -from . import _meta +from .__meta import category_dict from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes from .funcs_common import _verify_input_dict, _show_text_on_node, _T @@ -31,7 +31,7 @@ class StringConstructorValidateKeys: but it might be handy to verify the dict right after it's created. """ NODE_NAME = 'StringConstructorValidateKeys' - CATEGORY = _meta.category_dict + CATEGORY = category_dict DESCRIPTION = _format_docstring(_cleandoc(__doc__)) OUTPUT_NODE = True From 7f018e27b11352cbf3057341c4129c495a4bc575 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:31:48 -0300 Subject: [PATCH 02/15] `__typing` --- __typing.py | 8 ++++++++ enums.py | 8 +++----- funcs_common.py | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 __typing.py diff --git a/__typing.py b/__typing.py new file mode 100644 index 0000000..5ae9d6b --- /dev/null +++ b/__typing.py @@ -0,0 +1,8 @@ +# encoding: utf-8 +""" +""" + +from typing import TypeVar as _TypeVar + +T = _TypeVar('T') +T2 = _TypeVar('T2') diff --git a/enums.py b/enums.py index a04c4ab..bdcceeb 100644 --- a/enums.py +++ b/enums.py @@ -10,9 +10,7 @@ try: except ImportError: from comfy.comfy_types.node_typing import StrEnum as _StrEnum - -T = _t.TypeVar('T') -T2 = _t.TypeVar('T2') +from .__typing import T as _T class __BaseEnum(_StrEnum): @@ -26,7 +24,7 @@ class DataTypes(__BaseEnum): DICT = 'DICT' @staticmethod - def __custom_input_type_dict(_dict_args: _t.Tuple[_t.Dict[str, T], ...], kwargs: _t.Dict[str, T]) -> _t.Dict[str, T]: + def __custom_input_type_dict(_dict_args: _t.Tuple[_t.Dict[str, _T], ...], kwargs: _t.Dict[str, _T]) -> _t.Dict[str, _T]: res_dict: _t.Dict[str, _t.Any] = {"forceInput": True} for d in _dict_args: if d is not None: @@ -35,5 +33,5 @@ class DataTypes(__BaseEnum): return res_dict @classmethod - def input_dict(cls, *_dicts: _t.Dict[str, T], **kwargs: T) -> _t.Tuple[_t.Union['DataTypes', str], _t.Dict[str, T]]: + def input_dict(cls, *_dicts: _t.Dict[str, _T], **kwargs: _T) -> _t.Tuple[_t.Union['DataTypes', str], _t.Dict[str, _T]]: return str(cls.DICT), cls.__custom_input_type_dict(_dicts, kwargs) diff --git a/funcs_common.py b/funcs_common.py index 6c5ec7c..042ffd1 100644 --- a/funcs_common.py +++ b/funcs_common.py @@ -11,7 +11,7 @@ from frozendict import deepfreeze as _deepfreeze, frozendict as _frozendict from server import PromptServer as _PromptServer -from .enums import T as _T, T2 as _T2 +from .__typing import T as _T, T2 as _T2 def _show_text_on_node(text: str = None, unique_id: str = None): From f908500d8cb9b32966a2ac0e6d30a6ea5dcd5e2a Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:27:15 -0300 Subject: [PATCH 03/15] `funcs_common` -> `_dict` --- funcs_common.py => _dict.py | 2 +- node_dict_add_any.py | 2 +- node_dict_add_string.py | 2 +- node_dict_from_text.py | 2 +- node_dict_key_extract.py | 2 +- node_dict_preview.py | 2 +- node_formatter.py | 2 +- node_validate_keys.py | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) rename funcs_common.py => _dict.py (98%) diff --git a/funcs_common.py b/_dict.py similarity index 98% rename from funcs_common.py rename to _dict.py index 042ffd1..8b70e61 100644 --- a/funcs_common.py +++ b/_dict.py @@ -1,6 +1,6 @@ # encoding: utf-8 """ -Code shared between various `funcs_*` modules. +Shared code to deal with dicts. """ import typing as _t diff --git a/node_dict_add_any.py b/node_dict_add_any.py index f6351ab..ddb20b2 100644 --- a/node_dict_add_any.py +++ b/node_dict_add_any.py @@ -11,9 +11,9 @@ from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO from .__meta import category_dict +from ._dict import _new_dict_with_updated_key from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .funcs_common import _new_dict_with_updated_key from .node_dict_add_string import _input_types as _input_types_str diff --git a/node_dict_add_string.py b/node_dict_add_string.py index 9a45d5c..fecfe05 100644 --- a/node_dict_add_string.py +++ b/node_dict_add_string.py @@ -11,9 +11,9 @@ from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO from .__meta import category_dict +from ._dict import _new_dict_with_updated_key, _T from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .funcs_common import _new_dict_with_updated_key, _T # A tiny optimization by reusing the same immutable dict: diff --git a/node_dict_from_text.py b/node_dict_from_text.py index eafe0d8..2a03777 100644 --- a/node_dict_from_text.py +++ b/node_dict_from_text.py @@ -11,9 +11,9 @@ from frozendict import frozendict as _frozendict, deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO from .__meta import category +from ._dict import _show_text_on_node, _new_updated_dict, _T from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .funcs_common import _show_text_on_node, _new_updated_dict, _T from .node_dict_add_string import _input_types as _input_types_str diff --git a/node_dict_key_extract.py b/node_dict_key_extract.py index b53f604..78d2ac3 100644 --- a/node_dict_key_extract.py +++ b/node_dict_key_extract.py @@ -11,9 +11,9 @@ from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO from .__meta import category_dict +from ._dict import _show_text_on_node, _new_dict_with_updated_key, _T from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .funcs_common import _show_text_on_node, _new_dict_with_updated_key, _T _input_types = _deepfreeze({ diff --git a/node_dict_preview.py b/node_dict_preview.py index e342099..75bf1e6 100644 --- a/node_dict_preview.py +++ b/node_dict_preview.py @@ -10,9 +10,9 @@ from pprint import pformat as _pformat from frozendict import deepfreeze as _deepfreeze, frozendict as _frozendict from .__meta import category_dict +from ._dict import _show_text_on_node, _verify_input_dict_into_new from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .funcs_common import _show_text_on_node, _verify_input_dict_into_new def _to_regular_dict_recursive_copy(input_dict: dict = None) -> dict: diff --git a/node_formatter.py b/node_formatter.py index fc67818..5578b1c 100644 --- a/node_formatter.py +++ b/node_formatter.py @@ -15,9 +15,9 @@ from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO from .__meta import category +from ._dict import _show_text_on_node, _verify_input_dict from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .funcs_common import _show_text_on_node, _verify_input_dict _RECURSION_LIMIT = max(int(_sys.getrecursionlimit()), 1) # You can externally monkey-patch it... but if it blows up, your fault πŸ€·πŸ»β€β™‚οΈsingle diff --git a/node_validate_keys.py b/node_validate_keys.py index 8f8d51d..9fe9708 100644 --- a/node_validate_keys.py +++ b/node_validate_keys.py @@ -9,9 +9,9 @@ from inspect import cleandoc as _cleandoc from frozendict import deepfreeze as _deepfreeze from .__meta import category_dict +from ._dict import _verify_input_dict, _show_text_on_node, _T from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .funcs_common import _verify_input_dict, _show_text_on_node, _T _input_types = _deepfreeze({ From 1c0e1f74557252ac58b73b8069ccf05a94718de6 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:06:11 -0300 Subject: [PATCH 04/15] ren: `old_*` node modules --- __init__.py | 14 +++++++------- node_dict_add_any.py => old_dict_add_any.py | 2 +- node_dict_add_string.py => old_dict_add_string.py | 0 node_dict_from_text.py => old_dict_from_text.py | 2 +- ..._dict_key_extract.py => old_dict_key_extract.py | 0 node_dict_preview.py => old_dict_preview.py | 0 node_formatter.py => old_formatter.py | 0 node_validate_keys.py => old_validate_keys.py | 0 8 files changed, 9 insertions(+), 9 deletions(-) rename node_dict_add_any.py => old_dict_add_any.py (96%) rename node_dict_add_string.py => old_dict_add_string.py (100%) rename node_dict_from_text.py => old_dict_from_text.py (98%) rename node_dict_key_extract.py => old_dict_key_extract.py (100%) rename node_dict_preview.py => old_dict_preview.py (100%) rename node_formatter.py => old_formatter.py (100%) rename node_validate_keys.py => old_validate_keys.py (100%) diff --git a/__init__.py b/__init__.py index 5fb9844..57beb3b 100644 --- a/__init__.py +++ b/__init__.py @@ -4,13 +4,13 @@ import typing as _t -from .node_dict_add_any import StringConstructorDictAddAny -from .node_dict_add_string import StringConstructorDictAddString -from .node_dict_key_extract import StringConstructorDictExtractString -from .node_dict_from_text import StringConstructorDictFromText -from .node_dict_preview import StringConstructorDictPreview -from .node_formatter import StringConstructorFormatter -from .node_validate_keys import StringConstructorValidateKeys +from .old_dict_add_any import StringConstructorDictAddAny +from .old_dict_add_string import StringConstructorDictAddString +from .old_dict_key_extract import StringConstructorDictExtractString +from .old_dict_from_text import StringConstructorDictFromText +from .old_dict_preview import StringConstructorDictPreview +from .old_formatter import StringConstructorFormatter +from .old_validate_keys import StringConstructorValidateKeys NODE_CLASS_MAPPINGS: _t.Dict[str, type] = { 'StringConstructorDictAddAny': StringConstructorDictAddAny, diff --git a/node_dict_add_any.py b/old_dict_add_any.py similarity index 96% rename from node_dict_add_any.py rename to old_dict_add_any.py index ddb20b2..3500f6e 100644 --- a/node_dict_add_any.py +++ b/old_dict_add_any.py @@ -14,7 +14,7 @@ from .__meta import category_dict from ._dict import _new_dict_with_updated_key from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .node_dict_add_string import _input_types as _input_types_str +from .old_dict_add_string import _input_types as _input_types_str _dict = dict diff --git a/node_dict_add_string.py b/old_dict_add_string.py similarity index 100% rename from node_dict_add_string.py rename to old_dict_add_string.py diff --git a/node_dict_from_text.py b/old_dict_from_text.py similarity index 98% rename from node_dict_from_text.py rename to old_dict_from_text.py index 2a03777..aa3de2b 100644 --- a/node_dict_from_text.py +++ b/old_dict_from_text.py @@ -14,7 +14,7 @@ from .__meta import category from ._dict import _show_text_on_node, _new_updated_dict, _T from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes -from .node_dict_add_string import _input_types as _input_types_str +from .old_dict_add_string import _input_types as _input_types_str def _return_line_raw(line_raw: str, line_stripped:str) -> str: diff --git a/node_dict_key_extract.py b/old_dict_key_extract.py similarity index 100% rename from node_dict_key_extract.py rename to old_dict_key_extract.py diff --git a/node_dict_preview.py b/old_dict_preview.py similarity index 100% rename from node_dict_preview.py rename to old_dict_preview.py diff --git a/node_formatter.py b/old_formatter.py similarity index 100% rename from node_formatter.py rename to old_formatter.py diff --git a/node_validate_keys.py b/old_validate_keys.py similarity index 100% rename from node_validate_keys.py rename to old_validate_keys.py From 331cf9f1665213b65660bb362db885b2c7b698b0 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:28:40 -0300 Subject: [PATCH 05/15] `nodes_dict`: `DictAddAny` --- __meta.py | 2 ++ __typing.py | 4 ++- _io_custom.py | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++ nodes_dict.py | 76 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 _io_custom.py create mode 100644 nodes_dict.py diff --git a/__meta.py b/__meta.py index 0714032..eac65e6 100644 --- a/__meta.py +++ b/__meta.py @@ -5,3 +5,5 @@ Metadata-related module. category: str = "String Constructor" category_dict: str = f"{category}/Format-Dict" +category_old: str = f"{category}/_old" +pack_id_suffix: str = " [String Constructor]" diff --git a/__typing.py b/__typing.py index 5ae9d6b..dec7f96 100644 --- a/__typing.py +++ b/__typing.py @@ -2,7 +2,9 @@ """ """ -from typing import TypeVar as _TypeVar +import typing as _t +from typing import Any as _A, Optional as _O, Union as _U, TypeVar as _TypeVar T = _TypeVar('T') T2 = _TypeVar('T2') +FormatDict = _TypeVar('FormatDict', bound=_U[_t.Dict[str, _A], _t.Mapping[str, _A]]) diff --git a/_io_custom.py b/_io_custom.py new file mode 100644 index 0000000..f3f9bf6 --- /dev/null +++ b/_io_custom.py @@ -0,0 +1,85 @@ +# encoding: utf-8 +""" +""" + +from dataclasses import fields as __dataclass_fields + +from comfy_api.latest import io as _io + +from .__meta import category_old as _category_old +from .__typing import _t, T as _T + + +_DICT = _io.Custom("DICT") +_DICT_INPUT_OPTIONAL = _DICT.Input( + 'dict', + optional=True, + tooltip="An (optional) Format-Dictionary to work with." +) +_DICT_OUTPUT = _DICT.Output( + 'DICT', + display_name='dict' +) + +_T_Input = _t.TypeVar('T_Input', bound=_io.Input) + + +def __dataclass_fields_dict(obj) -> _t.Dict[str, _t.Any]: + """Return all the field values from a dataclass - as dict. No deep-copy.""" + field_names = (field.name for field in __dataclass_fields(obj)) + return { + attr: getattr(obj, attr) + for attr in field_names + } + + +def _dataclass_override(obj: _T, **overrides) -> _T: + """Copy a dataclass instance with optional overrides.""" + obj_dict = __dataclass_fields_dict(obj) + obj_dict.update(overrides) + obj_type = type(obj) + return obj_type(**obj_dict) + + +def _input_override(input_obj: _T_Input, **overrides) -> _T_Input: + """Copy an ``io.Input`` instance with optional overrides.""" + new_id = input_obj.id + if 'id' in overrides: + new_id = overrides.pop('id') + + input_args = input_obj.as_dict() + if 'id' in input_args: + del input_args['id'] + input_args.update(overrides) + + input_type: _t.Type[_T_Input] = type(input_obj) + return input_type(new_id, **input_args) + + +def _schema_old_name(schema: _io.Schema, node_id: str, **overrides): + """Build a deprecated schema for old node-ID.""" + schema_dict = __dataclass_fields_dict(schema) + if 'node_id' in schema_dict: + del schema_dict['node_id'] + name_old = schema_dict.pop('display_name') + + schema_dict.update(dict( + category=_category_old, + display_name=f"{name_old} [OLD]", + is_deprecated=True, + is_dev_only=True, + )) + schema_dict.update(overrides) + return _io.Schema(node_id, **schema_dict) + + +# noinspection PyAbstractClass +class _BaseNode(_io.ComfyNode): + """Shared base class for all the nodes in pack. + Stores schema in internal class attribute. + """ + _schema: _io.Schema = None + + @classmethod + def define_schema(cls) -> _io.Schema: + return cls._schema diff --git a/nodes_dict.py b/nodes_dict.py new file mode 100644 index 0000000..066349b --- /dev/null +++ b/nodes_dict.py @@ -0,0 +1,76 @@ +# encoding: utf-8 +"""Service nodes for dict preparation.""" + +from inspect import cleandoc as _cleandoc + +from frozendict import frozendict as _frozendict + +from comfy_api.latest import io as _io, ui as _ui + +from .__meta import ( + category_dict as _category_dict, + pack_id_suffix as _pack_id +) +from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict +from ._dict import _new_dict_with_updated_key +from ._io_custom import ( + _BaseNode, + _DICT_INPUT_OPTIONAL, _DICT_OUTPUT, + _input_override, _schema_old_name +) +from .docstring_formatter import format_docstring as _format_docstring + +_dict = dict + +# ---------------------------------------------------------- + +class DictAddAny(_BaseNode): + """Add/update any-type item to a Format-Dict.""" + _schema = _io.Schema( + node_id=f'DictAddAny{_pack_id}', + display_name='Add ANY to Dict', + category=_category_dict, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.String.Input( + 'name', + tooltip=( + "Name (key) of the item inserted into the dict. " + "It must comprise only of latin letters, digits and underscores + it can't start with a digit." + ), + ), + + _DICT_INPUT_OPTIONAL, + _io.AnyType.Input( + 'value', + optional=True, + tooltip="The actual any-type item to add into the dict." + ), + ], + outputs=[_DICT_OUTPUT], + ) + + @classmethod + def execute(cls, + name: str, dict: _O[_FormatDict] = None, value: _A = None + ) -> _io.NodeOutput: + """Update/append an item of any type to the dict.""" + if value is None: + if dict is None: + dict = _frozendict() + # No need to create another dict instance if we add nothing: + result = dict + else: + result = _new_dict_with_updated_key(dict, name, value) + return _io.NodeOutput(result) + + +# ========================================================== +# Deprecated nodes with old IDs (for backwards compatibility) + + +class DictAddAnyOld1(DictAddAny): + _schema = _schema_old_name( + DictAddAny._schema, + 'StringConstructorDictAddAny' + ) From 644ffe0b0016e9eca7707c7239905360b72f4faa Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:52:26 -0300 Subject: [PATCH 06/15] + `DictAddString` --- nodes_dict.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/nodes_dict.py b/nodes_dict.py index 066349b..655a91b 100644 --- a/nodes_dict.py +++ b/nodes_dict.py @@ -64,6 +64,58 @@ class DictAddAny(_BaseNode): result = _new_dict_with_updated_key(dict, name, value) return _io.NodeOutput(result) +# ---------------------------------------------------------- + +class DictAddString(_BaseNode): + """Add/update a string to a Format-Dict.""" + _schema = _io.Schema( + node_id=f'DictAddString{_pack_id}', + display_name='Add String to Dict', + category=_category_dict, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.String.Input( + 'name', + tooltip=( + "Name (key) of the string inserted into the dict. " + "It must comprise only of latin letters, digits and underscores + it can't start with a digit." + ), + ), + _io.Boolean.Input( + 'cleanup', + tooltip=( + "When enabled, each line in the sub-string is stripped " + "from any spaces at its start and end." + ), + default=True, + label_on='leading/trailing spaces', + label_off='no', + ), + _io.String.Input( + 'string', + tooltip="The actual string to add into the dict.", + multiline=True, + ), + + _DICT_INPUT_OPTIONAL, + ], + outputs=[_DICT_OUTPUT], + ) + + @classmethod + def execute(cls, + name: str, cleanup: bool, string: str, + dict: _O[_FormatDict] = None, + ) -> _io.NodeOutput: + """Update/append a string to the dict.""" + string = '' if string is None else str(string) + if cleanup: + string = '\n'.join( + x.strip() for x in string.splitlines() + ) + result= _new_dict_with_updated_key(dict, name, string) + return _io.NodeOutput(result) + # ========================================================== # Deprecated nodes with old IDs (for backwards compatibility) @@ -74,3 +126,10 @@ class DictAddAnyOld1(DictAddAny): DictAddAny._schema, 'StringConstructorDictAddAny' ) + + +class DictAddStringOld1(DictAddString): + _schema = _schema_old_name( + DictAddString._schema, + 'StringConstructorDictAddString' + ) From 40f2702734ce2a73a5a47a4af76beaf54b2f6f28 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:52:42 -0300 Subject: [PATCH 07/15] + `DictExtractString` --- nodes_dict.py | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/nodes_dict.py b/nodes_dict.py index 655a91b..77b9e64 100644 --- a/nodes_dict.py +++ b/nodes_dict.py @@ -116,6 +116,80 @@ class DictAddString(_BaseNode): result= _new_dict_with_updated_key(dict, name, string) return _io.NodeOutput(result) +# ---------------------------------------------------------- + +class DictExtractString(_BaseNode): + """Extract a single string from a Format-Dict.""" + _schema = _io.Schema( + node_id=f'DictExtractString{_pack_id}', + display_name='Extract String from Dict', + category=_category_dict, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.Boolean.Input( + 'cleanup_key', display_name='cleanup', + tooltip=( + "Automatically remove leading/trailing spaces and newlines from the key." + ), + default=True, + label_on='leading/trailing spaces', + label_off='no', + ), + _io.String.Input( + 'key', + tooltip=( + "Key (unique name) of a string to extract from dictionary.\n" + "If the element with such key isn't a string, it will be turned to one.\n" + "If no such key exists in the dict, an empty string returned." + ), + ), + _io.Boolean.Input( + 'show_status', + tooltip="Show the extracted string on the node itself?", + default=False, + label_on='value', + label_off='no', + ), + + _DICT_INPUT_OPTIONAL, + ], + outputs=[_io.String.Output('string')], + # hidden=[_io.Hidden.unique_id], + ) + + @classmethod + def execute(cls, + cleanup_key: bool, key: str, show_status: bool = False, + dict: _O[_FormatDict] = None + ) -> _io.NodeOutput: + """Extract a single string from the Format-Dict.""" + if cleanup_key: + for line in key.split(): + line = line.strip() # just in case \r is left + if line: + key = line + break + + # The passed dict might not actually be a dict but ANY mapping, + # which doesn't have a `get()` method. + # So direct key access is better: + string: str = '' + # noinspection PyBroadException + try: + string = dict[key] + except Exception: + pass + + if string is None: + string = '' + elif not isinstance(string, str): + string = repr(string) + + return _io.NodeOutput( + string, + ui=_ui.PreviewText(string) if show_status else None + ) + # ========================================================== # Deprecated nodes with old IDs (for backwards compatibility) @@ -133,3 +207,25 @@ class DictAddStringOld1(DictAddString): DictAddString._schema, 'StringConstructorDictAddString' ) + + +class DictExtractStringOld1(_BaseNode): + # noinspection PyProtectedMember + _schema = _schema_old_name( + DictExtractString._schema, + 'StringConstructorDictExtractString', + inputs=[ + _input_override(x, id='name') if x.id == 'key' else x + for x in DictExtractString._schema.inputs + if x.id != 'cleanup_key' + ], + ) + + @classmethod + def execute(cls, + name: str, show_status: bool = False, + dict: _O[_FormatDict] = None + ) -> _io.NodeOutput: + return DictExtractString.execute( + cleanup_key=False, key=name, show_status=show_status, dict=dict + ) From 390aa738bc0514dcac6f86efcb19c37afe3c0603 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:53:25 -0300 Subject: [PATCH 08/15] `DictFromText` --- node_dict_from_text.py | 159 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 node_dict_from_text.py diff --git a/node_dict_from_text.py b/node_dict_from_text.py new file mode 100644 index 0000000..1212fb4 --- /dev/null +++ b/node_dict_from_text.py @@ -0,0 +1,159 @@ +# encoding: utf-8 +"""Code for the ``Dict from Text`` node.""" + +from inspect import cleandoc as _cleandoc + +from comfy_api.latest import io as _io, ui as _ui + +from .__meta import ( + category as _category, + pack_id_suffix as _pack_id +) +from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict +from ._dict import _new_updated_dict +from ._io_custom import ( + _BaseNode, + _DICT_INPUT_OPTIONAL, _DICT_OUTPUT, + _input_override, _schema_old_name +) +from .docstring_formatter import format_docstring as _format_docstring + +_dict = dict + +# ---------------------------------------------------------- + +def _return_line_raw(line_raw: str, line_stripped:str) -> str: + return line_raw + + +def _return_line_stripped(line_raw: str, line_stripped:str) -> str: + return line_stripped + + +def _parsed_kv_pairs_gen(multiline_str: str, strip_lines=True): + """Parse the "dict text" into sequence of key-value pairs: + Given a multiline string, extract keywords (first non-empty line in each chunk) + and their substrings (all the following non-empty lines). + """ + if not multiline_str: + return + multiline_str = str(multiline_str) + if not multiline_str.strip(): + return + + appended_line_f = _return_line_stripped if strip_lines else _return_line_raw + cur_chunk: _t.List[str] = list() + + def dump_chunk(): + cur_chunk_iter = iter(cur_chunk) # It's more backwards-compatible than `a, *b = x` + chunk_key = next(cur_chunk_iter) + cur_chunk_lines = list(cur_chunk_iter) + # If there are no actual lines, join() would return an empty string: + return chunk_key.strip(), '\n'.join(cur_chunk_lines) + + for line in multiline_str.splitlines(): + line_stripped: str = line.strip() + if line_stripped: + cur_chunk.append(appended_line_f(line, line_stripped)) + continue + + assert not line_stripped + if cur_chunk: + yield dump_chunk() + cur_chunk = list() + + # We simply skip all the empty lines entirely + + if cur_chunk: + yield dump_chunk() + +# ---------------------------------------------------------- + +class DictFromText(_BaseNode): + """ + Build a dict of named sub-strings to be used later in string formatting (text construction). + """ + _schema = _io.Schema( + node_id=f'DictFromText{_pack_id}', + display_name='Dict from Text', + category=_category, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.Boolean.Input( + 'cleanup', + tooltip="When enabled, each line in each sub-string is stripped from any spaces at its start and end.", + default=True, + label_on='leading/trailing spaces', + label_off='no', + ), + _io.String.Input( + 'dict_text', display_name='dict-items text', + tooltip=( + "Sub-string names followed by their text. Different sub-string chunks are separated by empty lines. Example:\n\n" + "char1_short\n1boy, blond, short hair\n\n" + "char1_long\n1boy, smiling, blue eyes, blond, short hair,\nwearing a leather jacket, sitting on a bike" + ), + multiline=True, + ), + _io.Boolean.Input( + 'show_status', + tooltip="Show detected string names on the node itself?", + default=False, + label_on='detected keys', + label_off='no', + ), + + _DICT_INPUT_OPTIONAL, + ], + outputs=[_DICT_OUTPUT], + # hidden=[_io.Hidden.unique_id], + is_output_node=True, # Just to show the status message even if not connected to anything + ) + + @classmethod + def execute(cls, + cleanup: bool, dict_text: str, show_status: bool = False, + dict: _O[_FormatDict] = None + ) -> _io.NodeOutput: + parsed_items = _parsed_kv_pairs_gen(dict_text, strip_lines=cleanup) + new_dict = {k: v for k, v in parsed_items} + + if not new_dict: + # No need to create another dict instance if we add nothing: + out_dict = _dict() if dict is None else dict + out_ui = '' if show_status else None + return _io.NodeOutput(out_dict, ui=out_ui) + + out_dict = _new_updated_dict(dict, new_dict) + # These two ↑↓ must be in this specific order: + # `_new_updated_dict()` also checks both dicts and might throw an error + out_ui = None + if show_status: + status_text = ','.join(new_dict.keys()) if new_dict else '' + out_ui = _ui.PreviewText(status_text) + + return _io.NodeOutput(out_dict, ui=out_ui) + +# ========================================================== +# Deprecated node with old ID (for backwards compatibility) + +class DictFromTextOld1(_BaseNode): + # noinspection PyProtectedMember + _schema = _schema_old_name( + DictFromText._schema, + 'StringConstructorDictFromText', + inputs=[ + _input_override(x, id='strings') if x.id == 'dict_text' else x + for x in DictFromText._schema.inputs + ], + ) + + @classmethod + def execute(cls, + cleanup: bool, strings: str, show_status: bool = False, + dict: _O[_FormatDict] = None + ) -> _io.NodeOutput: + return DictFromText.execute( + cleanup=cleanup, dict_text=strings, show_status=show_status, + dict=dict + ) From a59ea00074946a575ab90d64d5a0e7d42676cd64 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:55:29 -0300 Subject: [PATCH 09/15] `ValidateKeys` --- node_validate.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 node_validate.py diff --git a/node_validate.py b/node_validate.py new file mode 100644 index 0000000..ce3a333 --- /dev/null +++ b/node_validate.py @@ -0,0 +1,58 @@ +# encoding: utf-8 +"""Code for the dict-validator node.""" + +from inspect import cleandoc as _cleandoc + +from comfy_api.latest import io as _io + +from .__meta import ( + category_dict as _category_dict, + pack_id_suffix as _pack_id +) +from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict +from ._dict import _verify_input_dict +from ._io_custom import ( + _BaseNode, + _DICT_INPUT_OPTIONAL, _DICT_OUTPUT, + _schema_old_name +) +from .docstring_formatter import format_docstring as _format_docstring + +# ---------------------------------------------------------- + +class ValidateKeys(_BaseNode): + """ + Verify the dict to have string-formatting-compatible keys. No need to add it before "String Formatter" node, + but it might be handy to verify the dict in the very beginning of the graph - right after the keys are set. + """ + _schema = _io.Schema( + node_id=f'ValidateKeys{_pack_id}', + display_name='Validate Dict', + category=_category_dict, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[_DICT_INPUT_OPTIONAL], + outputs=[_DICT_OUTPUT], + # hidden=[_io.Hidden.unique_id], + is_output_node=True, + ) + + @classmethod + def execute(cls, dict: _O[_FormatDict] = None) -> _io.NodeOutput: + # try: + # _verify_input_dict(dict, error_if_none=True) + # except Exception: + # _show_text_on_node('❌', unique_id) + # raise + # return (Null, ) + # _show_text_on_node('βœ…', unique_id) + _verify_input_dict(dict, error_if_none=True) + return _io.NodeOutput(dict) + +# ========================================================== +# Deprecated node with old ID (for backwards compatibility) + +class ValidateKeysOld1(ValidateKeys): + _schema = _schema_old_name( + ValidateKeys._schema, + 'StringConstructorValidateKeys' + ) From 201edd0498480e9a69779a55489aa96e77449560 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:59:20 -0300 Subject: [PATCH 10/15] `Formatter` func-class - extracted to `_formatter_class` --- __init__.py | 1 + _formatter_class.py | 168 +++++++++++++++++++++++++++++++++++++++++++ old_formatter.py | 172 ++------------------------------------------ 3 files changed, 173 insertions(+), 168 deletions(-) create mode 100644 _formatter_class.py diff --git a/__init__.py b/__init__.py index 57beb3b..306326b 100644 --- a/__init__.py +++ b/__init__.py @@ -4,6 +4,7 @@ import typing as _t +from ._formatter_class import Formatter from .old_dict_add_any import StringConstructorDictAddAny from .old_dict_add_string import StringConstructorDictAddString from .old_dict_key_extract import StringConstructorDictExtractString diff --git a/_formatter_class.py b/_formatter_class.py new file mode 100644 index 0000000..89106b5 --- /dev/null +++ b/_formatter_class.py @@ -0,0 +1,168 @@ +# encoding: utf-8 +""" +The main class which actually does all the job for Formatter-node. +""" + +from dataclasses import dataclass as _dataclass, field as _field +import re as _re +import sys as _sys + +from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict +from ._dict import _verify_input_dict + + +_RECURSION_LIMIT = max(int(_sys.getrecursionlimit()), 1) # You can externally monkey-patch it... but if it blows up, your fault πŸ€·πŸ»β€β™‚οΈsingle + +__dataclass_slots_args = dict() if _sys.version_info < (3, 10) else dict(slots=True) + +_re_formatting_keyword_match = _re.compile( # Pre-compiled regex match to extract ``{keyword}`` patterns + r'(?P.*?)' + r'(?P\{+)' + r'(?P[^{}]+)' + r'(?P\}+)' + r'(?P[^{}].*)?$', + # flags=_re.DOTALL | _re.IGNORECASE, + flags = _re.DOTALL, # We need dot to match new lines, too +).match + + +@_dataclass(**__dataclass_slots_args) +class Formatter: + """ + A callable (function-like) class, which does the actual formatting, while respecting all the options. + + It's made as a class to split the formatting into two stages: + - First, the instance is properly initialized with the shared arguments (the methods to call are conditionally assigned depending on options); + - Then, the actual instance is treated as a function - it needs to be called with the formatted string as the only argument. + + It's done this way to avoid extra conditions in the loop + to organize the convoluted mess of intertwined functions + into a more readable code. + """ + format_dict: _O[_FormatDict] = None + + recursive: bool = False + safe: bool = True + + __format_single: _t.Callable[[str], str] = _field(init=False, repr=False, compare=False, default=lambda x: x) + _format: _t.Callable[[str], str] = _field(init=False, repr=False, compare=False, default=lambda x: x) + + def __post_init__(self): # called by dataclass init + if self.format_dict is None: + self.format_dict = dict() + + format_dict = self.format_dict + _verify_input_dict(format_dict) + self.__format_single = self.__format_single_safe if self.safe else self.__format_single_unsafe + + if format_dict: + self._format = self.__format_recursive if self.recursive else self.__format_single + else: + self._format = self.__dummy_return_intact + + @staticmethod + def __dummy_return_intact(template: str) -> str: + return template + + def __format_single_unsafe(self, template: str): + return template.format_map(self.format_dict) + + def __format_single_safe_parts_gen(self, template: str) -> _t.Generator[str, None, None]: + """ + EAFP: https://docs.python.org/3/glossary.html#term-EAFP + + Instead of pre-escaping the whole template + (which would require basically re-implementing the entire format-parsing logic), + let's extract individual formatted pieces, actually try formatting them one by one, + and return anything that cannot be formatted as-is - without any processing at all. + + This generator returns such pieces - formatted or intact. + """ + format_dict = self.format_dict + to_piece_template = '{{{}}}'.format + + suffix: str = template + while suffix: + match = _re_formatting_keyword_match(suffix) + if not match: + break + + prefix = match.group('prefix') + open_brackets = match.group('open_brackets') + inside_brackets = match.group('inside_brackets') + closed_brackets = match.group('closed_brackets') + suffix = match.group('suffix') + + if prefix: + yield prefix + + piece_template = to_piece_template(inside_brackets) + # noinspection PyBroadException + try: + formatted_piece = piece_template.format_map(format_dict) + except Exception: + # If, for ANY reason, we're unable to format the piece, return the template piece intact: + yield open_brackets + yield inside_brackets + yield closed_brackets + continue + + # The key is found. Treat the piece as the actual formatting pattern. + # Formatting "eats" one set of brackets either way: + open_brackets = open_brackets[:-1] + closed_brackets = closed_brackets[:-1] + + # Now, even though we've succeeded, the template might've been pre-escaped. + if open_brackets and closed_brackets: + # The keyword is already pre-escaped. Return it intact: + formatted_piece = inside_brackets + + yield open_brackets + yield formatted_piece + yield closed_brackets + + if suffix: + yield suffix + + def __format_single_safe(self, template: str) -> str: + """ + Format the pattern (single iteration of formatting) in the safe mode. + Safe mode preserves any unknown ``{text patterns}`` inside curly brackets if they cannot be formatted. + Correctly handles any formatting patterns natively supported by python + (even the most fancy ones, involving ':', '!', attribute or index access, etc.). + + Useful when JSON/CSS-like code is in the formatted template. + """ + return ''.join(self.__format_single_safe_parts_gen(template)) + + def __format_recursive(self, template: str) -> str: + """ + It's not actually recursive - because, you know, any recursion could be turned into iteration, + and good boys do that. 😊 + """ + assert isinstance(_RECURSION_LIMIT, int) and _RECURSION_LIMIT > 0 + + format_single_func = self.__format_single + + prev: str = '' + new: str = template + if prev == new: + return new + + for i in range(_RECURSION_LIMIT): + prev = new + new = format_single_func(new) + if prev == new: + return new + + msg = ( + f"Recursion limit ({_RECURSION_LIMIT}) reached on attempt to format a string: {template!r}\n" + f"Last two formatting attempts:\n{prev!r}\n{new!r}" + ) + raise RecursionError(msg) + # noinspection PyUnreachableCode + return '' # just to be extra-safe, if RecursionError is treated as warning + + def __call__(self, template: str) -> str: + if not isinstance(template, str): + raise TypeError(f"Not a string: {template!r}") + return self._format(template) if template else '' diff --git a/old_formatter.py b/old_formatter.py index 5578b1c..5ae0261 100644 --- a/old_formatter.py +++ b/old_formatter.py @@ -5,183 +5,18 @@ Code for ``StringConstructorFormatter`` node. import typing as _t -from dataclasses import dataclass as _dataclass, field as _field from inspect import cleandoc as _cleandoc -import re as _re -import sys as _sys from frozendict import deepfreeze as _deepfreeze from comfy.comfy_types.node_typing import IO as _IO from .__meta import category -from ._dict import _show_text_on_node, _verify_input_dict +from ._dict import _show_text_on_node +from ._formatter_class import Formatter as _Formatter from .docstring_formatter import format_docstring as _format_docstring from .enums import DataTypes as _DataTypes - -_RECURSION_LIMIT = max(int(_sys.getrecursionlimit()), 1) # You can externally monkey-patch it... but if it blows up, your fault πŸ€·πŸ»β€β™‚οΈsingle - -__dataclass_slots_args = dict() if _sys.version_info < (3, 10) else dict(slots=True) - -_re_formatting_keyword_match = _re.compile( # Pre-compiled regex match to extract ``{keyword}`` patterns - r'(?P.*?)' - r'(?P\{+)' - r'(?P[^{}]+)' - r'(?P\}+)' - r'(?P[^{}].*)?$', - # flags=_re.DOTALL | _re.IGNORECASE, - flags = _re.DOTALL, # We need dot to match new lines, too -).match - - -@_dataclass(**__dataclass_slots_args) -class _Formatter: - """ - A callable (function-like) class, which does the actual formatting, while respecting all the options. - - It's made as a class to split the formatting into two stages: - - First, the instance is properly initialized with the shared arguments (the methods to call are conditionally assigned depending on options); - - Then, the actual instance is treated as a function - it needs to be called with the formatted string as the only argument. - - It's done this way to avoid extra conditions in the loop + to organize the convoluted mess of intertwined functions - into a more readable code. - """ - format_dict: _t.Optional[_t.Dict[str, _t.Any]] = None - - recursive: bool = False - safe: bool = True - - show_status: bool = True - unique_node_id: str = None - - __format_single: _t.Callable[[str], str] = _field(init=False, repr=False, compare=False, default=lambda x: x) - _format: _t.Callable[[str], str] = _field(init=False, repr=False, compare=False, default=lambda x: x) - - def __post_init__(self): # called by dataclass init - if self.format_dict is None: - self.format_dict = dict() - - format_dict = self.format_dict - _verify_input_dict(format_dict) - self.__format_single = self.__format_single_safe if self.safe else self.__format_single_unsafe - - if format_dict: - self._format = self.__format_recursive if self.recursive else self.__format_single - else: - self._format = self.__dummy_return_intact - - @staticmethod - def __dummy_return_intact(template: str) -> str: - return template - - def __format_single_unsafe(self, template: str): - return template.format_map(self.format_dict) - - def __format_single_safe_parts_gen(self, template: str) -> _t.Generator[str, None, None]: - """ - EAFP: https://docs.python.org/3/glossary.html#term-EAFP - - Instead of pre-escaping the whole template - (which would require basically re-implementing the entire format-parsing logic), - let's extract individual formatted pieces, actually try formatting them one by one, - and return anything that cannot be formatted as-is - without any processing at all. - - This generator returns such pieces - formatted or intact. - """ - format_dict = self.format_dict - to_piece_template = '{{{}}}'.format - - suffix: str = template - while suffix: - match = _re_formatting_keyword_match(suffix) - if not match: - break - - prefix = match.group('prefix') - open_brackets = match.group('open_brackets') - inside_brackets = match.group('inside_brackets') - closed_brackets = match.group('closed_brackets') - suffix = match.group('suffix') - - if prefix: - yield prefix - - piece_template = to_piece_template(inside_brackets) - # noinspection PyBroadException - try: - formatted_piece = piece_template.format_map(format_dict) - except Exception: - # If, for ANY reason, we're unable to format the piece, return the template piece intact: - yield open_brackets - yield inside_brackets - yield closed_brackets - continue - - # The key is found. Treat the piece as the actual formatting pattern. - # Formatting "eats" one set of brackets either way: - open_brackets = open_brackets[:-1] - closed_brackets = closed_brackets[:-1] - - # Now, even though we've succeeded, the template might've been pre-escaped. - if open_brackets and closed_brackets: - # The keyword is already pre-escaped. Return it intact: - formatted_piece = inside_brackets - - yield open_brackets - yield formatted_piece - yield closed_brackets - - if suffix: - yield suffix - - def __format_single_safe(self, template: str) -> str: - """ - Format the pattern (single iteration of formatting) in the safe mode. - Safe mode preserves any unknown ``{text patterns}`` inside curly brackets if they cannot be formatted. - Correctly handles any formatting patterns natively supported by python - (even the most fancy ones, involving ':', '!', attribute or index access, etc.). - - Useful when JSON/CSS-like code is in the formatted template. - """ - return ''.join(self.__format_single_safe_parts_gen(template)) - - def __format_recursive(self, template: str) -> str: - """ - It's not actually recursive - because, you know, any recursion could be turned into iteration, - and good boys do that. 😊 - """ - assert isinstance(_RECURSION_LIMIT, int) and _RECURSION_LIMIT > 0 - - format_single_func = self.__format_single - - prev: str = '' - new: str = template - for i in range(_RECURSION_LIMIT): - if prev == new: - return new - prev = new - new = format_single_func(new) - - msg = ( - f"Recursion limit ({_RECURSION_LIMIT}) reached on attempt to format a string: {template!r}\n" - f"Last two formatting attempts:\n{prev!r}\n{new!r}" - ) - if self.show_status and self.unique_node_id: - _show_text_on_node(msg, self.unique_node_id) - raise RecursionError(msg) - # noinspection PyUnreachableCode - return '' # just to be extra-safe, if RecursionError is treated as warning - - def __call__(self, template: str) -> str: - if not isinstance(template, str): - raise TypeError(f"Not a string: {template!r}") - - out_text = self._format(template) if template else '' - if self.show_status and self.unique_node_id: - _show_text_on_node(out_text, self.unique_node_id) - return out_text - # -------------------------------------- _input_types = _deepfreeze({ @@ -247,7 +82,8 @@ class StringConstructorFormatter: formatter = _Formatter( format_dict=dict, recursive=recursive_format, safe=safe_format, - show_status=show_status, unique_node_id=unique_id, ) out_text = formatter(template) + if show_status and unique_id: + _show_text_on_node(out_text, unique_id) return (out_text, ) From c71f426c751ab0a32898e1022578d82fb7f85489 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:45:47 -0300 Subject: [PATCH 11/15] `StringFormatter` --- node_formatter.py | 110 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 node_formatter.py diff --git a/node_formatter.py b/node_formatter.py new file mode 100644 index 0000000..f7e13ea --- /dev/null +++ b/node_formatter.py @@ -0,0 +1,110 @@ +# encoding: utf-8 +"""Code for the main node.""" + +from inspect import cleandoc as _cleandoc + +from comfy_api.latest import io as _io, ui as _ui + +from .__meta import ( + category as _category, + pack_id_suffix as _pack_id +) +from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict +from ._formatter_class import Formatter as _Formatter +from ._io_custom import ( + _BaseNode, + _DICT_INPUT_OPTIONAL, + _input_override, _schema_old_name +) +from .docstring_formatter import format_docstring as _format_docstring + +# ---------------------------------------------------------- + +class StringFormatter(_BaseNode): + """ + Construct the formatted string from template and format-dictionary. + """ + _schema = _io.Schema( + node_id=f'StringFormatter{_pack_id}', + display_name='String Formatter', + category=_category, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.String.Input( + 'template', + tooltip=( + "Type the text template. " + "To reference named substrings from format-dictionary, use this syntax: {substring_name}. For example:\n\n" + "score_9, score_8_up, score_7_up, {char1_short}, standing next to {char2_short},\n" + "{char1_long}\n{char2_long}" + ), + multiline=True, + ), + _io.Boolean.Input( + 'recursive_format', + tooltip=( + "Do recursive format - i.e., allow the chunks from the dictionary to reference other chunks." + ), + default=False, + label_on='❗ yes', + label_off='no', + ), + _io.Boolean.Input( + 'safe_format', + tooltip=( + "If template contains an invalid {text pattern} which can't be formatted - leave it as-is " + "(instead of throwing an error).\n" + "Safe mode is recommended for templates with JSON, CSS, or other literal curly brackets." + ), + default=True, + label_on='yes', + label_off='no', + ), + _io.Boolean.Input( + 'show_status', + tooltip=( + "Show the final string constructed from the text-template and format-dictionary?" + ), + default=True, + label_on='formatted string', + label_off='no', + ), + + _input_override( + _DICT_INPUT_OPTIONAL, + tooltip=( + "The dictionary to take named sub-strings from. It could be left unconnected, if the pattern doesn't reference " + "any sub-strings - then, this node acts exactly the same as a regular string-primitive node." + ), + ), + ], + outputs=[_io.String.Output('string')], + # hidden=[_io.Hidden.unique_id], + is_output_node=True, + ) + + @classmethod + def execute(cls, + template: str, + recursive_format: bool = False, + safe_format: bool = True, + show_status: bool = False, + dict: _O[_FormatDict] = None, + ) -> _io.NodeOutput: + formatter = _Formatter( + format_dict=dict, recursive=recursive_format, safe=safe_format, + ) + string = formatter(template) + return _io.NodeOutput( + string, + ui=_ui.PreviewText(string) if show_status else None + ) + +# ========================================================== +# Deprecated node with old ID (for backwards compatibility) + +class StringFormatterOld1(StringFormatter): + _schema = _schema_old_name( + StringFormatter._schema, + 'StringConstructorFormatter' + ) From e61c02f94fbe5c54854d8c4380bb418d68f5342c Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:35:32 -0300 Subject: [PATCH 12/15] =====> switch from v1 to v3 nodes in `__init__` --- __init__.py | 43 +++++--------- enums.py | 37 ------------ old_dict_add_any.py | 65 -------------------- old_dict_add_string.py | 67 --------------------- old_dict_from_text.py | 129 ---------------------------------------- old_dict_key_extract.py | 74 ----------------------- old_dict_preview.py | 96 ------------------------------ old_formatter.py | 89 --------------------------- old_validate_keys.py | 61 ------------------- 9 files changed, 16 insertions(+), 645 deletions(-) delete mode 100644 enums.py delete mode 100644 old_dict_add_any.py delete mode 100644 old_dict_add_string.py delete mode 100644 old_dict_from_text.py delete mode 100644 old_dict_key_extract.py delete mode 100644 old_dict_preview.py delete mode 100644 old_formatter.py delete mode 100644 old_validate_keys.py diff --git a/__init__.py b/__init__.py index 306326b..9242c64 100644 --- a/__init__.py +++ b/__init__.py @@ -2,34 +2,23 @@ """ """ -import typing as _t +from comfy_api.latest import ComfyExtension as _ComfyExtension, io as _io from ._formatter_class import Formatter -from .old_dict_add_any import StringConstructorDictAddAny -from .old_dict_add_string import StringConstructorDictAddString -from .old_dict_key_extract import StringConstructorDictExtractString -from .old_dict_from_text import StringConstructorDictFromText -from .old_dict_preview import StringConstructorDictPreview -from .old_formatter import StringConstructorFormatter -from .old_validate_keys import StringConstructorValidateKeys +from .node_dict_from_text import * +from .node_formatter import * +from .node_validate import * +from .nodes_dict import * -NODE_CLASS_MAPPINGS: _t.Dict[str, type] = { - 'StringConstructorDictAddAny': StringConstructorDictAddAny, - 'StringConstructorDictAddString': StringConstructorDictAddString, - 'StringConstructorDictExtractString': StringConstructorDictExtractString, - 'StringConstructorDictFromText': StringConstructorDictFromText, - 'StringConstructorDictPreview': StringConstructorDictPreview, - 'StringConstructorFormatter': StringConstructorFormatter, - 'StringConstructorValidateKeys': StringConstructorValidateKeys, -} -NODE_DISPLAY_NAME_MAPPINGS: _t.Dict[str, str] = { - 'StringConstructorDictAddAny': "Add ANY to Dict", - 'StringConstructorDictAddString': "Add String to Dict", - 'StringConstructorDictExtractString': "Extract String from Dict", - 'StringConstructorDictFromText': "Dict from Text", - 'StringConstructorDictPreview': "Preview Dict", - 'StringConstructorFormatter': "String Formatter", - 'StringConstructorValidateKeys': "Validate Dict", -} +class StringConstructorExtension(_ComfyExtension): + async def get_node_list(self) -> list[type[_io.ComfyNode]]: + return [ + DictFromText, DictFromTextOld1, + StringFormatter, StringFormatterOld1, + ValidateKeys, ValidateKeysOld1, + DictAddAny, DictAddString, DictExtractString, + DictAddAnyOld1, DictAddStringOld1, DictExtractStringOld1 + ] -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] +async def comfy_entrypoint() -> _ComfyExtension: + return StringConstructorExtension() diff --git a/enums.py b/enums.py deleted file mode 100644 index bdcceeb..0000000 --- a/enums.py +++ /dev/null @@ -1,37 +0,0 @@ -# encoding: utf-8 -""" -Internal enums. -""" - -import typing as _t - -try: - from enum import StrEnum as _StrEnum -except ImportError: - from comfy.comfy_types.node_typing import StrEnum as _StrEnum - -from .__typing import T as _T - - -class __BaseEnum(_StrEnum): - @classmethod - def all_values(cls) -> _t.Tuple[str, ...]: - return tuple(x.value for x in cls) - - -class DataTypes(__BaseEnum): - """Additional data-types defined byt the node pack.""" - DICT = 'DICT' - - @staticmethod - def __custom_input_type_dict(_dict_args: _t.Tuple[_t.Dict[str, _T], ...], kwargs: _t.Dict[str, _T]) -> _t.Dict[str, _T]: - res_dict: _t.Dict[str, _t.Any] = {"forceInput": True} - for d in _dict_args: - if d is not None: - res_dict.update(d) - res_dict.update(kwargs) - return res_dict - - @classmethod - def input_dict(cls, *_dicts: _t.Dict[str, _T], **kwargs: _T) -> _t.Tuple[_t.Union['DataTypes', str], _t.Dict[str, _T]]: - return str(cls.DICT), cls.__custom_input_type_dict(_dicts, kwargs) diff --git a/old_dict_add_any.py b/old_dict_add_any.py deleted file mode 100644 index 3500f6e..0000000 --- a/old_dict_add_any.py +++ /dev/null @@ -1,65 +0,0 @@ -# encoding: utf-8 -""" -""" - -import typing as _t - -from inspect import cleandoc as _cleandoc - -from frozendict import deepfreeze as _deepfreeze - -from comfy.comfy_types.node_typing import IO as _IO - -from .__meta import category_dict -from ._dict import _new_dict_with_updated_key -from .docstring_formatter import format_docstring as _format_docstring -from .enums import DataTypes as _DataTypes -from .old_dict_add_string import _input_types as _input_types_str - - -_dict = dict - -# A tiny optimization by reusing the same immutable dict: -_input_types = _deepfreeze({ - 'required': { - 'name': (_IO.STRING, {'tooltip': ( - "Name (key) of the non-string item inserted into the dict. " - "It must comprise only of latin letters, digits and underscores + it can't start with a digit." - )}), - }, - 'optional': { - 'dict': _input_types_str['optional']['dict'], - 'value': (_IO.ANY, {"forceInput": True, 'tooltip': "The actual non-string item to add into the dict."}), - }, - # 'hidden': { - # 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node - # }, -}) - - -class StringConstructorDictAddAny: - """Add/update a non-string item to the Format-Dict - to do some advanced formatting.""" - NODE_NAME = 'StringConstructorDictAddAny' - CATEGORY = category_dict - DESCRIPTION = _format_docstring(_cleandoc(__doc__)) - - FUNCTION = 'main' - RETURN_TYPES = (str(_DataTypes.DICT), ) - RETURN_NAMES = (_DataTypes.DICT.lower(), ) - # OUTPUT_TOOLTIPS = tuple() - - @classmethod - def INPUT_TYPES(cls): - return _input_types - - @staticmethod - def main( - name: str, dict: _t.Dict[str, _t.Any] = None, value: _t.Any = None, - # unique_id: str = None, - ) -> _t.Tuple[_t.Dict[str, _t.Any]]: - """Update/append an item of any type to the dict.""" - if value is None: - if dict is None: - dict = _dict() - return (dict, ) # No need to create another dict instance if we add nothing - return (_new_dict_with_updated_key(dict, name, value), ) diff --git a/old_dict_add_string.py b/old_dict_add_string.py deleted file mode 100644 index fecfe05..0000000 --- a/old_dict_add_string.py +++ /dev/null @@ -1,67 +0,0 @@ -# encoding: utf-8 -""" -""" - -import typing as _t - -from inspect import cleandoc as _cleandoc - -from frozendict import deepfreeze as _deepfreeze - -from comfy.comfy_types.node_typing import IO as _IO - -from .__meta import category_dict -from ._dict import _new_dict_with_updated_key, _T -from .docstring_formatter import format_docstring as _format_docstring -from .enums import DataTypes as _DataTypes - - -# A tiny optimization by reusing the same immutable dict: -_input_types = _deepfreeze({ - 'required': { - 'name': (_IO.STRING, {'tooltip': ( - "Name (key) of the substring inserted into the dict. " - "It must comprise only of latin letters, digits and underscores + it can't start with a digit." - )}), - 'cleanup': (_IO.BOOLEAN, {'default': True, 'label_on': 'leading/trailing spaces', 'label_off': 'no', 'tooltip': ( - "When enabled, each line in the sub-string is stripped from any spaces at its start and end." - )}), - 'string': (_IO.STRING, {'multiline': True, 'tooltip': "The actual sub-string to add into the dict."}), - }, - 'optional': { - 'dict': _DataTypes.input_dict(tooltip="An optional Format-Dictionary to extend/update."), - }, - # 'hidden': { - # 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node - # }, -}) - - -class StringConstructorDictAddString: - """Add/update a string to the Format-Dict.""" - NODE_NAME = 'StringConstructorDictAddString' - CATEGORY = category_dict - DESCRIPTION = _format_docstring(_cleandoc(__doc__)) - - FUNCTION = 'main' - RETURN_TYPES = (str(_DataTypes.DICT), ) - RETURN_NAMES = (_DataTypes.DICT.lower(), ) - # OUTPUT_TOOLTIPS = tuple() - - @classmethod - def INPUT_TYPES(cls): - return _input_types - - @staticmethod - def main( - name: str, cleanup: bool, string: str, - dict: _t.Dict[str, _T] = None, - # unique_id: str = None, - ) -> _t.Tuple[_t.Dict[str, _t.Union[_T, str]]]: - """Update/append a string to the dict.""" - string = '' if string is None else str(string) - if cleanup: - string = '\n'.join( - x.strip() for x in string.splitlines() - ) - return (_new_dict_with_updated_key(dict, name, string), ) diff --git a/old_dict_from_text.py b/old_dict_from_text.py deleted file mode 100644 index aa3de2b..0000000 --- a/old_dict_from_text.py +++ /dev/null @@ -1,129 +0,0 @@ -# encoding: utf-8 -""" -""" - -import typing as _t - -from inspect import cleandoc as _cleandoc - -from frozendict import frozendict as _frozendict, deepfreeze as _deepfreeze - -from comfy.comfy_types.node_typing import IO as _IO - -from .__meta import category -from ._dict import _show_text_on_node, _new_updated_dict, _T -from .docstring_formatter import format_docstring as _format_docstring -from .enums import DataTypes as _DataTypes -from .old_dict_add_string import _input_types as _input_types_str - - -def _return_line_raw(line_raw: str, line_stripped:str) -> str: - return line_raw - - -def _return_line_stripped(line_raw: str, line_stripped:str) -> str: - return line_stripped - - -def _parsed_kv_pairs_gen(multiline_str: str, strip_lines=True): - """ - Given a multiline string, extract keywords (first non-empty line in each chunk) - and their substrings (all the following non-empty lines). - """ - if not multiline_str: - return - multiline_str = str(multiline_str) - if not multiline_str.strip(): - return - - appended_line_f = _return_line_stripped if strip_lines else _return_line_raw - cur_chunk: _t.List[str] = list() - - def dump_chunk(): - cur_chunk_iter = iter(cur_chunk) # It's more backwards-compatible than `a, *b = x` - chunk_key = next(cur_chunk_iter) - cur_chunk_lines = list(cur_chunk_iter) - # If there are no actual lines, join() would return an empty string: - return chunk_key.strip(), '\n'.join(cur_chunk_lines) - - for line in multiline_str.splitlines(): - line_stripped: str = line.strip() - if line_stripped: - cur_chunk.append(appended_line_f(line, line_stripped)) - continue - - assert not line_stripped - if cur_chunk: - yield dump_chunk() - cur_chunk = list() - - # We simply skip all the empty lines entirely - - if cur_chunk: - yield dump_chunk() - -# -------------------------------------- - -_dict = dict - -_input_types = _deepfreeze({ - 'required': { - 'cleanup': (_IO.BOOLEAN, {'default': True, 'label_on': 'leading/trailing spaces', 'label_off': 'no', 'tooltip': ( - "When enabled, each line in each sub-string is stripped from any spaces at its start and end." - )}), - 'strings': (_IO.STRING, {'multiline': True, 'tooltip': ( - "Sub-string names followed by their text. Different sub-string chunks are separated by empty lines. Example:\n\n" - "char1_short\n1boy, blond, short hair\n\n" - "char1_long\n1boy, smiling, blue eyes, blond, short hair,\nwearing a leather jacket, sitting on a bike" - )}), - 'show_status': (_IO.BOOLEAN, {'default': False, 'label_on': 'detected names', 'label_off': 'no', 'tooltip': ( - "Show detected string names on the node itself?" - )}), - }, - 'optional': { - 'dict': _input_types_str['optional']['dict'], - }, - 'hidden': { - 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node - }, -}) - - -class StringConstructorDictFromText: - """ - Build a dict of named sub-strings to be used later in string formatting (text construction). - """ - NODE_NAME = 'StringConstructorDictFromText' - CATEGORY = category - DESCRIPTION = _format_docstring(_cleandoc(__doc__)) - - OUTPUT_NODE = True # Just to show the status message even if not connected to anything - - FUNCTION = 'main' - RETURN_TYPES = (str(_DataTypes.DICT), ) - RETURN_NAMES = (_DataTypes.DICT.lower(), ) - # OUTPUT_TOOLTIPS = tuple() - - @classmethod - def INPUT_TYPES(cls): - return _input_types - - @staticmethod - def main( - cleanup: bool, strings: str, show_status: bool = True, - dict: _t.Dict[str, _T] = None, unique_id: str = None, - ) -> _t.Tuple[_t.Dict[str, _t.Union[_T, str]]]: - new_dict = {k: v for k, v in _parsed_kv_pairs_gen(strings, strip_lines=cleanup)} - - if not new_dict: - if dict is None: - dict = _dict() - return (dict, ) # No need to create another dict instance if we add nothing - - out_dict = _new_updated_dict(dict, new_dict) - # These two ↑↓ must be in this specific order: `_new_updated_dict()` also checks both dicts - if show_status and unique_id: - status_text = ','.join(new_dict.keys()) if new_dict else '' - _show_text_on_node(status_text, unique_id) - - return (out_dict, ) diff --git a/old_dict_key_extract.py b/old_dict_key_extract.py deleted file mode 100644 index 78d2ac3..0000000 --- a/old_dict_key_extract.py +++ /dev/null @@ -1,74 +0,0 @@ -# encoding: utf-8 -""" -""" - -import typing as _t - -from inspect import cleandoc as _cleandoc - -from frozendict import deepfreeze as _deepfreeze - -from comfy.comfy_types.node_typing import IO as _IO - -from .__meta import category_dict -from ._dict import _show_text_on_node, _new_dict_with_updated_key, _T -from .docstring_formatter import format_docstring as _format_docstring -from .enums import DataTypes as _DataTypes - - -_input_types = _deepfreeze({ - 'required': { - 'name': (_IO.STRING, {'tooltip': ( - "Name (key) of a string to extract from dictionary.\n" - "If the element with such key isn't a string, it will be turned to one.\n" - "If no such key exists in the dict, an empty string returned." - )}), - 'show_status': (_IO.BOOLEAN, {'default': False, 'label_on': 'value', 'label_off': 'no', 'tooltip': ( - "Show the extracted string on the node itself?" - )}), - }, - 'optional': { - 'dict': _DataTypes.input_dict(tooltip="The dictionary to extract the element from."), - }, - 'hidden': { - 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node - }, -}) - - -class StringConstructorDictExtractString: - """Extract a single string from the Format-Dict.""" - NODE_NAME = 'StringConstructorDictExtractString' - CATEGORY = category_dict - DESCRIPTION = _format_docstring(_cleandoc(__doc__)) - - FUNCTION = 'main' - RETURN_TYPES = (_IO.STRING, ) - RETURN_NAMES = ('string', ) - # OUTPUT_TOOLTIPS = tuple() - - @classmethod - def INPUT_TYPES(cls): - return _input_types - - @staticmethod - def main( - name: str, show_status: bool = False, dict: _t.Dict[str, _T] = None, - unique_id: str = None, - ) -> _t.Tuple[str]: - """Extract a single string from the Format-Dict.""" - # noinspection PyBroadException - try: - string: str = dict.get(name, '') - except Exception: - string = '' - if string is None: - string = '' - - if not isinstance(string, str): - string = repr(string) - - if show_status and unique_id: - _show_text_on_node(string, unique_id) - - return (string, ) diff --git a/old_dict_preview.py b/old_dict_preview.py deleted file mode 100644 index 75bf1e6..0000000 --- a/old_dict_preview.py +++ /dev/null @@ -1,96 +0,0 @@ -# encoding: utf-8 -""" -""" - -import typing as _t - -from inspect import cleandoc as _cleandoc -from pprint import pformat as _pformat - -from frozendict import deepfreeze as _deepfreeze, frozendict as _frozendict - -from .__meta import category_dict -from ._dict import _show_text_on_node, _verify_input_dict_into_new -from .docstring_formatter import format_docstring as _format_docstring -from .enums import DataTypes as _DataTypes - - -def _to_regular_dict_recursive_copy(input_dict: dict = None) -> dict: - """ - To simplify preview, turns frozen dicts back to regular ones. - Copies the dict in process and does so recursively, but not truly deep: if value is another dict, - it will be processed, too. But dicts within other objects (like lists) won't be found. - """ - if not input_dict: - return dict() - return { - k: ( - _to_regular_dict_recursive_copy(v) - # In py3.10, frozendict isn't a dict, but is a `typing.Mapping`. - # So, this many types to check against: - if isinstance(v, (dict, _frozendict, _t.Mapping)) - else v - ) - for k, v in input_dict.items() - } - - -def _preview_single_entry(key: str, value) -> str: - if isinstance(value, str): - return f"{key}:\n{value}" - value_repr = _pformat(value) - return f"{key}:\n{value_repr}" - - -def _preview_message(input_dict: dict = None): - input_dict = _to_regular_dict_recursive_copy(_verify_input_dict_into_new(input_dict)) - parts: _t.List[str] = [ - _preview_single_entry(k, v) for k, v in input_dict.items() - ] - return '\n\n'.join(parts) - -# -------------------------------------- - -_dict = dict - -_input_types = _deepfreeze({ - 'required': {}, - 'optional': { - 'dict': _DataTypes.input_dict(tooltip="A Format-Dictionary."), - }, - 'hidden': { - 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node - }, -}) - - -class StringConstructorDictPreview: - """ - Show the contents of a Format-Dict. - """ - NODE_NAME = 'StringConstructorDictPreview' - CATEGORY = category_dict - DESCRIPTION = _format_docstring(_cleandoc(__doc__)) - - OUTPUT_NODE = True - - FUNCTION = 'main' - RETURN_TYPES = (str(_DataTypes.DICT), ) - RETURN_NAMES = (_DataTypes.DICT.lower(), ) - # OUTPUT_TOOLTIPS = tuple() - - @classmethod - def INPUT_TYPES(cls): - return _input_types - - @staticmethod - def main( - dict: _t.Dict[str, _t.Any] = None, - unique_id: str = None, - ): - if dict is None: - dict = _dict() - if unique_id: - status_text = _preview_message(dict) - _show_text_on_node(status_text, unique_id) - return (dict, ) diff --git a/old_formatter.py b/old_formatter.py deleted file mode 100644 index 5ae0261..0000000 --- a/old_formatter.py +++ /dev/null @@ -1,89 +0,0 @@ -# encoding: utf-8 -""" -Code for ``StringConstructorFormatter`` node. -""" - -import typing as _t - -from inspect import cleandoc as _cleandoc - -from frozendict import deepfreeze as _deepfreeze - -from comfy.comfy_types.node_typing import IO as _IO - -from .__meta import category -from ._dict import _show_text_on_node -from ._formatter_class import Formatter as _Formatter -from .docstring_formatter import format_docstring as _format_docstring -from .enums import DataTypes as _DataTypes - -# -------------------------------------- - -_input_types = _deepfreeze({ - 'required': { - 'template': (_IO.STRING, {'multiline': True, 'tooltip': ( - "Type the text template. " - "To reference named substrings from format-dictionary, use this syntax: {substring_name}. For example:\n\n" - "score_9, score_8_up, score_7_up, {char1_short}, standing next to {char2_short},\n" - "{char1_long}\n{char2_long}" - )}), - 'recursive_format': (_IO.BOOLEAN, {'default': False, 'label_on': '❗ yes', 'label_off': 'no', 'tooltip': ( - "Do recursive format - i.e., allow the chunks from the dictionary to reference other chunks." - )}), - 'safe_format': (_IO.BOOLEAN, {'default': True, 'label_on': 'yes', 'label_off': 'no', 'tooltip': ( - "If template contains an invalid {text pattern} which can't be formatted - leave it as-is " - "(instead of throwing an error).\n" - "Safe mode is recommended for templates with JSON, CSS, or other literal curly brackets." - )}), - 'show_status': (_IO.BOOLEAN, {'default': True, 'label_on': 'formatted string', 'label_off': 'no', 'tooltip': ( - "Show the final string constructed from the text-template and format-dictionary?" - )}), - }, - 'optional': { - 'dict': _DataTypes.input_dict(tooltip=( # It's not actually optional, but is here since there's no dict-widget - "The dictionary to take named sub-strings from. It could be left unconnected, if the pattern doesn't reference " - "any sub-strings - then, this node acts exactly the same as a regular string-primitive node." - )), - }, - 'hidden': { - 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node - }, -}) - - -class StringConstructorFormatter: - """ - Construct the formatted string from template and format-dictionary. - """ - NODE_NAME = 'StringConstructorFormatter' - CATEGORY = category - DESCRIPTION = _format_docstring(_cleandoc(__doc__)) - - OUTPUT_NODE = True - - FUNCTION = 'main' - RETURN_TYPES = (_IO.STRING, ) - RETURN_NAMES = ('string', ) - # OUTPUT_TOOLTIPS = tuple() - - @classmethod - def INPUT_TYPES(cls): - return _input_types - - @staticmethod - def main( - template: str, - recursive_format: bool = False, - safe_format: bool = True, - show_status: bool = False, - dict: _t.Dict[str, _t.Any] = None, #actually, required - but it's here to keep the declared params order - unique_id: str = None - ) -> _t.Tuple[str]: - formatter = _Formatter( - format_dict=dict, - recursive=recursive_format, safe=safe_format, - ) - out_text = formatter(template) - if show_status and unique_id: - _show_text_on_node(out_text, unique_id) - return (out_text, ) diff --git a/old_validate_keys.py b/old_validate_keys.py deleted file mode 100644 index 9fe9708..0000000 --- a/old_validate_keys.py +++ /dev/null @@ -1,61 +0,0 @@ -# encoding: utf-8 -""" -""" - -import typing as _t - -from inspect import cleandoc as _cleandoc - -from frozendict import deepfreeze as _deepfreeze - -from .__meta import category_dict -from ._dict import _verify_input_dict, _show_text_on_node, _T -from .docstring_formatter import format_docstring as _format_docstring -from .enums import DataTypes as _DataTypes - - -_input_types = _deepfreeze({ - 'required': { - 'dict': _DataTypes.input_dict(), - }, - # 'optional': {}, - # 'hidden': { - # 'unique_id': 'UNIQUE_ID', # used for text display at the bottom of the node - # }, -}) - - -class StringConstructorValidateKeys: - """ - Verify the dict to have string-formatting-compatible keys. No need to add it before "String Formatter" node, - but it might be handy to verify the dict right after it's created. - """ - NODE_NAME = 'StringConstructorValidateKeys' - CATEGORY = category_dict - DESCRIPTION = _format_docstring(_cleandoc(__doc__)) - - OUTPUT_NODE = True - - FUNCTION = 'main' - RETURN_TYPES = (str(_DataTypes.DICT), ) - RETURN_NAMES = (_DataTypes.DICT.lower(), ) - # OUTPUT_TOOLTIPS = tuple() - - @classmethod - def INPUT_TYPES(cls): - return _input_types - - @staticmethod - def main( - dict: _t.Dict[str, _T], - # unique_id: str = None, - ) -> _t.Tuple[_t.Dict[str, _T]]: - # try: - # _verify_input_dict(dict, error_if_none=True) - # except Exception: - # _show_text_on_node('❌', unique_id) - # raise - # return (Null, ) - # _show_text_on_node('βœ…', unique_id) - _verify_input_dict(dict, error_if_none=True) - return (dict, ) From e3a72a762a62811e91deacdd26a11c7838a1fa20 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:54:08 -0300 Subject: [PATCH 13/15] del nodes moved to `Dict Tools` --- __init__.py | 5 - __meta.py | 1 - _dict.py | 63 +---------- node_dict_from_text.py | 159 ---------------------------- node_validate.py | 4 +- nodes_dict.py | 231 ----------------------------------------- 6 files changed, 4 insertions(+), 459 deletions(-) delete mode 100644 node_dict_from_text.py delete mode 100644 nodes_dict.py diff --git a/__init__.py b/__init__.py index 9242c64..be47061 100644 --- a/__init__.py +++ b/__init__.py @@ -5,19 +5,14 @@ from comfy_api.latest import ComfyExtension as _ComfyExtension, io as _io from ._formatter_class import Formatter -from .node_dict_from_text import * from .node_formatter import * from .node_validate import * -from .nodes_dict import * class StringConstructorExtension(_ComfyExtension): async def get_node_list(self) -> list[type[_io.ComfyNode]]: return [ - DictFromText, DictFromTextOld1, StringFormatter, StringFormatterOld1, ValidateKeys, ValidateKeysOld1, - DictAddAny, DictAddString, DictExtractString, - DictAddAnyOld1, DictAddStringOld1, DictExtractStringOld1 ] async def comfy_entrypoint() -> _ComfyExtension: diff --git a/__meta.py b/__meta.py index eac65e6..a290eaf 100644 --- a/__meta.py +++ b/__meta.py @@ -4,6 +4,5 @@ Metadata-related module. """ category: str = "String Constructor" -category_dict: str = f"{category}/Format-Dict" category_old: str = f"{category}/_old" pack_id_suffix: str = " [String Constructor]" diff --git a/_dict.py b/_dict.py index 8b70e61..3b8bec9 100644 --- a/_dict.py +++ b/_dict.py @@ -7,22 +7,9 @@ import typing as _t import re as _re -from frozendict import deepfreeze as _deepfreeze, frozendict as _frozendict +from frozendict import frozendict as _frozendict -from server import PromptServer as _PromptServer - -from .__typing import T as _T, T2 as _T2 - - -def _show_text_on_node(text: str = None, unique_id: str = None): - if not text: - # TODO: Planned for the future - currently, there's no point removing the text since it's box is shown anyway - # An odd workaround since `send_progress_text()` doesn't want to update text when '' passed - text = '' - # print(f"{unique_id} text: {text!r}") - - # Snatched from: https://github.com/comfyanonymous/ComfyUI/blob/27870ec3c30e56be9707d89a120eb7f0e2836be1/comfy_extras/nodes_images.py#L581-L582 - _PromptServer.instance.send_progress_text(text, unique_id) +from .__typing import T as _T _re_valid_key_match = _re.compile("[a-zA-Z_][a-zA-Z_0-9]*$").match @@ -84,49 +71,3 @@ def _verify_input_dict(input_dict: _t.Dict[str, _T] = None, error_if_none=False) errors_dict, "Invalid input-dict: {}", "Invalid keys (string names) in input-dict: {}" ) - - -def _verify_input_dict_into_new(input_dict: _t.Dict[str, _T] = None) -> _t.Dict[str, _T]: - """ - Verify input dict to have only valid keys. Raises errors if invalid ones found. - Always returns a new dict instance (shallow copy if non-empty dict passed). - """ - if input_dict is None: - return dict() - _verify_input_dict(input_dict) - return dict(input_dict) - - -def _new_updated_dict( - input_dict: _t.Union[_t.Dict[str, _T], None], *updating_dicts: _t.Dict[str, _T2], - sort=True, frozen=True, validate_new_keys=True -) -> _t.Dict[str, _t.Union[_T, _T2]]: - """Return a new dict being a union of the input one and the new (updating) ones.""" - out_dict: _t.Dict[str, _t.Union[_T, _T2]] = _verify_input_dict_into_new(input_dict) - - if validate_new_keys: - errors_dict: _t.Dict[_t.Any, str] = dict() - for upd_d in updating_dicts: - upd_d = dict(upd_d) - for key in list(upd_d.keys()): - val = upd_d.pop(key) - key = _validate_key(key, errors_dict) - upd_d[key] = val - out_dict.update(upd_d) - _raise_from_errors_dict(errors_dict) - else: - for upd_d in updating_dicts: - out_dict.update(upd_d) - - if sort: - out_dict = {k: v for k, v in sorted(out_dict.items())} # rely on implied ordered dicts in newer py3 versions - return _deepfreeze(out_dict) if frozen else out_dict - - -def _new_dict_with_updated_key( - input_dict: _t.Union[_t.Dict[str, _T], None], key: str, value: _T2, sort=True, frozen=True, validate_key=True -) -> _t.Dict[str, _t.Union[_T, _T2]]: - """Return a new dict being a union of the input one and the new (updating) key/value.""" - return _new_updated_dict( - input_dict, {key: value}, sort=sort, frozen=frozen, validate_new_keys=validate_key - ) diff --git a/node_dict_from_text.py b/node_dict_from_text.py deleted file mode 100644 index 1212fb4..0000000 --- a/node_dict_from_text.py +++ /dev/null @@ -1,159 +0,0 @@ -# encoding: utf-8 -"""Code for the ``Dict from Text`` node.""" - -from inspect import cleandoc as _cleandoc - -from comfy_api.latest import io as _io, ui as _ui - -from .__meta import ( - category as _category, - pack_id_suffix as _pack_id -) -from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict -from ._dict import _new_updated_dict -from ._io_custom import ( - _BaseNode, - _DICT_INPUT_OPTIONAL, _DICT_OUTPUT, - _input_override, _schema_old_name -) -from .docstring_formatter import format_docstring as _format_docstring - -_dict = dict - -# ---------------------------------------------------------- - -def _return_line_raw(line_raw: str, line_stripped:str) -> str: - return line_raw - - -def _return_line_stripped(line_raw: str, line_stripped:str) -> str: - return line_stripped - - -def _parsed_kv_pairs_gen(multiline_str: str, strip_lines=True): - """Parse the "dict text" into sequence of key-value pairs: - Given a multiline string, extract keywords (first non-empty line in each chunk) - and their substrings (all the following non-empty lines). - """ - if not multiline_str: - return - multiline_str = str(multiline_str) - if not multiline_str.strip(): - return - - appended_line_f = _return_line_stripped if strip_lines else _return_line_raw - cur_chunk: _t.List[str] = list() - - def dump_chunk(): - cur_chunk_iter = iter(cur_chunk) # It's more backwards-compatible than `a, *b = x` - chunk_key = next(cur_chunk_iter) - cur_chunk_lines = list(cur_chunk_iter) - # If there are no actual lines, join() would return an empty string: - return chunk_key.strip(), '\n'.join(cur_chunk_lines) - - for line in multiline_str.splitlines(): - line_stripped: str = line.strip() - if line_stripped: - cur_chunk.append(appended_line_f(line, line_stripped)) - continue - - assert not line_stripped - if cur_chunk: - yield dump_chunk() - cur_chunk = list() - - # We simply skip all the empty lines entirely - - if cur_chunk: - yield dump_chunk() - -# ---------------------------------------------------------- - -class DictFromText(_BaseNode): - """ - Build a dict of named sub-strings to be used later in string formatting (text construction). - """ - _schema = _io.Schema( - node_id=f'DictFromText{_pack_id}', - display_name='Dict from Text', - category=_category, - description=_format_docstring(_cleandoc(__doc__)), - inputs=[ - _io.Boolean.Input( - 'cleanup', - tooltip="When enabled, each line in each sub-string is stripped from any spaces at its start and end.", - default=True, - label_on='leading/trailing spaces', - label_off='no', - ), - _io.String.Input( - 'dict_text', display_name='dict-items text', - tooltip=( - "Sub-string names followed by their text. Different sub-string chunks are separated by empty lines. Example:\n\n" - "char1_short\n1boy, blond, short hair\n\n" - "char1_long\n1boy, smiling, blue eyes, blond, short hair,\nwearing a leather jacket, sitting on a bike" - ), - multiline=True, - ), - _io.Boolean.Input( - 'show_status', - tooltip="Show detected string names on the node itself?", - default=False, - label_on='detected keys', - label_off='no', - ), - - _DICT_INPUT_OPTIONAL, - ], - outputs=[_DICT_OUTPUT], - # hidden=[_io.Hidden.unique_id], - is_output_node=True, # Just to show the status message even if not connected to anything - ) - - @classmethod - def execute(cls, - cleanup: bool, dict_text: str, show_status: bool = False, - dict: _O[_FormatDict] = None - ) -> _io.NodeOutput: - parsed_items = _parsed_kv_pairs_gen(dict_text, strip_lines=cleanup) - new_dict = {k: v for k, v in parsed_items} - - if not new_dict: - # No need to create another dict instance if we add nothing: - out_dict = _dict() if dict is None else dict - out_ui = '' if show_status else None - return _io.NodeOutput(out_dict, ui=out_ui) - - out_dict = _new_updated_dict(dict, new_dict) - # These two ↑↓ must be in this specific order: - # `_new_updated_dict()` also checks both dicts and might throw an error - out_ui = None - if show_status: - status_text = ','.join(new_dict.keys()) if new_dict else '' - out_ui = _ui.PreviewText(status_text) - - return _io.NodeOutput(out_dict, ui=out_ui) - -# ========================================================== -# Deprecated node with old ID (for backwards compatibility) - -class DictFromTextOld1(_BaseNode): - # noinspection PyProtectedMember - _schema = _schema_old_name( - DictFromText._schema, - 'StringConstructorDictFromText', - inputs=[ - _input_override(x, id='strings') if x.id == 'dict_text' else x - for x in DictFromText._schema.inputs - ], - ) - - @classmethod - def execute(cls, - cleanup: bool, strings: str, show_status: bool = False, - dict: _O[_FormatDict] = None - ) -> _io.NodeOutput: - return DictFromText.execute( - cleanup=cleanup, dict_text=strings, show_status=show_status, - dict=dict - ) diff --git a/node_validate.py b/node_validate.py index ce3a333..cba9a7f 100644 --- a/node_validate.py +++ b/node_validate.py @@ -6,7 +6,7 @@ from inspect import cleandoc as _cleandoc from comfy_api.latest import io as _io from .__meta import ( - category_dict as _category_dict, + category as _category, pack_id_suffix as _pack_id ) from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict @@ -28,7 +28,7 @@ class ValidateKeys(_BaseNode): _schema = _io.Schema( node_id=f'ValidateKeys{_pack_id}', display_name='Validate Dict', - category=_category_dict, + category=_category, description=_format_docstring(_cleandoc(__doc__)), inputs=[_DICT_INPUT_OPTIONAL], outputs=[_DICT_OUTPUT], diff --git a/nodes_dict.py b/nodes_dict.py deleted file mode 100644 index 77b9e64..0000000 --- a/nodes_dict.py +++ /dev/null @@ -1,231 +0,0 @@ -# encoding: utf-8 -"""Service nodes for dict preparation.""" - -from inspect import cleandoc as _cleandoc - -from frozendict import frozendict as _frozendict - -from comfy_api.latest import io as _io, ui as _ui - -from .__meta import ( - category_dict as _category_dict, - pack_id_suffix as _pack_id -) -from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict -from ._dict import _new_dict_with_updated_key -from ._io_custom import ( - _BaseNode, - _DICT_INPUT_OPTIONAL, _DICT_OUTPUT, - _input_override, _schema_old_name -) -from .docstring_formatter import format_docstring as _format_docstring - -_dict = dict - -# ---------------------------------------------------------- - -class DictAddAny(_BaseNode): - """Add/update any-type item to a Format-Dict.""" - _schema = _io.Schema( - node_id=f'DictAddAny{_pack_id}', - display_name='Add ANY to Dict', - category=_category_dict, - description=_format_docstring(_cleandoc(__doc__)), - inputs=[ - _io.String.Input( - 'name', - tooltip=( - "Name (key) of the item inserted into the dict. " - "It must comprise only of latin letters, digits and underscores + it can't start with a digit." - ), - ), - - _DICT_INPUT_OPTIONAL, - _io.AnyType.Input( - 'value', - optional=True, - tooltip="The actual any-type item to add into the dict." - ), - ], - outputs=[_DICT_OUTPUT], - ) - - @classmethod - def execute(cls, - name: str, dict: _O[_FormatDict] = None, value: _A = None - ) -> _io.NodeOutput: - """Update/append an item of any type to the dict.""" - if value is None: - if dict is None: - dict = _frozendict() - # No need to create another dict instance if we add nothing: - result = dict - else: - result = _new_dict_with_updated_key(dict, name, value) - return _io.NodeOutput(result) - -# ---------------------------------------------------------- - -class DictAddString(_BaseNode): - """Add/update a string to a Format-Dict.""" - _schema = _io.Schema( - node_id=f'DictAddString{_pack_id}', - display_name='Add String to Dict', - category=_category_dict, - description=_format_docstring(_cleandoc(__doc__)), - inputs=[ - _io.String.Input( - 'name', - tooltip=( - "Name (key) of the string inserted into the dict. " - "It must comprise only of latin letters, digits and underscores + it can't start with a digit." - ), - ), - _io.Boolean.Input( - 'cleanup', - tooltip=( - "When enabled, each line in the sub-string is stripped " - "from any spaces at its start and end." - ), - default=True, - label_on='leading/trailing spaces', - label_off='no', - ), - _io.String.Input( - 'string', - tooltip="The actual string to add into the dict.", - multiline=True, - ), - - _DICT_INPUT_OPTIONAL, - ], - outputs=[_DICT_OUTPUT], - ) - - @classmethod - def execute(cls, - name: str, cleanup: bool, string: str, - dict: _O[_FormatDict] = None, - ) -> _io.NodeOutput: - """Update/append a string to the dict.""" - string = '' if string is None else str(string) - if cleanup: - string = '\n'.join( - x.strip() for x in string.splitlines() - ) - result= _new_dict_with_updated_key(dict, name, string) - return _io.NodeOutput(result) - -# ---------------------------------------------------------- - -class DictExtractString(_BaseNode): - """Extract a single string from a Format-Dict.""" - _schema = _io.Schema( - node_id=f'DictExtractString{_pack_id}', - display_name='Extract String from Dict', - category=_category_dict, - description=_format_docstring(_cleandoc(__doc__)), - inputs=[ - _io.Boolean.Input( - 'cleanup_key', display_name='cleanup', - tooltip=( - "Automatically remove leading/trailing spaces and newlines from the key." - ), - default=True, - label_on='leading/trailing spaces', - label_off='no', - ), - _io.String.Input( - 'key', - tooltip=( - "Key (unique name) of a string to extract from dictionary.\n" - "If the element with such key isn't a string, it will be turned to one.\n" - "If no such key exists in the dict, an empty string returned." - ), - ), - _io.Boolean.Input( - 'show_status', - tooltip="Show the extracted string on the node itself?", - default=False, - label_on='value', - label_off='no', - ), - - _DICT_INPUT_OPTIONAL, - ], - outputs=[_io.String.Output('string')], - # hidden=[_io.Hidden.unique_id], - ) - - @classmethod - def execute(cls, - cleanup_key: bool, key: str, show_status: bool = False, - dict: _O[_FormatDict] = None - ) -> _io.NodeOutput: - """Extract a single string from the Format-Dict.""" - if cleanup_key: - for line in key.split(): - line = line.strip() # just in case \r is left - if line: - key = line - break - - # The passed dict might not actually be a dict but ANY mapping, - # which doesn't have a `get()` method. - # So direct key access is better: - string: str = '' - # noinspection PyBroadException - try: - string = dict[key] - except Exception: - pass - - if string is None: - string = '' - elif not isinstance(string, str): - string = repr(string) - - return _io.NodeOutput( - string, - ui=_ui.PreviewText(string) if show_status else None - ) - - -# ========================================================== -# Deprecated nodes with old IDs (for backwards compatibility) - - -class DictAddAnyOld1(DictAddAny): - _schema = _schema_old_name( - DictAddAny._schema, - 'StringConstructorDictAddAny' - ) - - -class DictAddStringOld1(DictAddString): - _schema = _schema_old_name( - DictAddString._schema, - 'StringConstructorDictAddString' - ) - - -class DictExtractStringOld1(_BaseNode): - # noinspection PyProtectedMember - _schema = _schema_old_name( - DictExtractString._schema, - 'StringConstructorDictExtractString', - inputs=[ - _input_override(x, id='name') if x.id == 'key' else x - for x in DictExtractString._schema.inputs - if x.id != 'cleanup_key' - ], - ) - - @classmethod - def execute(cls, - name: str, show_status: bool = False, - dict: _O[_FormatDict] = None - ) -> _io.NodeOutput: - return DictExtractString.execute( - cleanup_key=False, key=name, show_status=show_status, dict=dict - ) From b711caf7761235274b578527c3d909f7496e2fa2 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 21 Apr 2026 01:30:25 -0300 Subject: [PATCH 14/15] `pyproject.toml` --- pyproject.toml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 771309e..4b2e6a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,14 @@ [project] name = "string-constructor" -version = "1.1.2" -description = "String formatting (compiling text from pieces) made for humans:\nβ€’ Turn parts of prompt into a dictionary - to share them all across the entire workflow as a single connection.\nβ€’ Compose your prompt in-place from these snippets and regular text - with just one text field.\nβ€’ Easily update the dictionary down the line - get different prompts from the same template.\nβ€’ Reference text chunks within each other to build complex hierarchies of self-updating prompts.\nβ€’ A true time-saver for regional prompting (aka area composition)." +version = "3.1.2" +description = """ +String formatting (compiling text from pieces) made for humans: +β€’ Turn parts of prompt into a dictionary - to share them all across the entire workflow as a single connection. +β€’ Compose your prompt in-place from these snippets and regular text - with just one text field. +β€’ Easily update the dictionary down the line - get different prompts from the same template. +β€’ Reference text chunks within each other to build complex hierarchies of self-updating prompts. +β€’ A true time-saver for regional prompting (aka area composition). +""" license = {file = "LICENSE.md"} readme = "README.md" authors = [ @@ -24,4 +31,4 @@ PublisherId = "lex-drl" DisplayName = "String Constructor (Text-Formatting)" # Icon = "https://raw.githubusercontent.com/username/repo/main/icon.png" # Banner = "https://raw.githubusercontent.com/username/repo/main/banner.png" -# requires-comfyui = ">=0.3.51" # Node schema v3 released # TODO +requires-comfyui = ">=0.18.0" # Tested with somewhat-stable Node schema v3 From 0c8f5e0809577d871ae852f1c1a13046e37c35e8 Mon Sep 17 00:00:00 2001 From: "Lex Darlog (DRL)" <3897975+Lex-DRL@users.noreply.github.com> Date: Tue, 21 Apr 2026 03:42:42 -0300 Subject: [PATCH 15/15] README --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 12e75c3..fa5e936 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,13 @@ # `String Constructor` (Text-Formatting) nodes ... for ComfyUI. -[πŸ”„ Updates ChangeLog](CHANGELOG.md) +> [!WARNING] +> Starting with `v3.x`: +> - I adopt a new versioning scheme, where: +> - First number is internal node-API version. +> - Second is major update - when it bumps, it contains breaking changes. +> - Last is a minor update. +> - **ALL** the dictionary-building nodes are extracted into their own node pack: [πŸ—‚οΈ Dict Tools](https://github.com/Lex-DRL/ComfyUI-DictTools). ### TL;DR