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 diff --git a/__init__.py b/__init__.py index 5fb9844..be47061 100644 --- a/__init__.py +++ b/__init__.py @@ -2,33 +2,18 @@ """ """ -import typing as _t +from comfy_api.latest import ComfyExtension as _ComfyExtension, io as _io -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 ._formatter_class import Formatter +from .node_formatter import * +from .node_validate 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 [ + StringFormatter, StringFormatterOld1, + ValidateKeys, ValidateKeysOld1, + ] -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] +async def comfy_entrypoint() -> _ComfyExtension: + return StringConstructorExtension() diff --git a/_meta.py b/__meta.py similarity index 51% rename from _meta.py rename to __meta.py index 0714032..a290eaf 100644 --- a/_meta.py +++ b/__meta.py @@ -4,4 +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/__typing.py b/__typing.py new file mode 100644 index 0000000..dec7f96 --- /dev/null +++ b/__typing.py @@ -0,0 +1,10 @@ +# encoding: utf-8 +""" +""" + +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/_dict.py b/_dict.py new file mode 100644 index 0000000..3b8bec9 --- /dev/null +++ b/_dict.py @@ -0,0 +1,73 @@ +# encoding: utf-8 +""" +Shared code to deal with dicts. +""" + +import typing as _t + +import re as _re + +from frozendict import frozendict as _frozendict + +from .__typing import T as _T + + +_re_valid_key_match = _re.compile("[a-zA-Z_][a-zA-Z_0-9]*$").match +_re_starting_digits_match = _re.compile("[0-9]+").match + + +def _validate_key(key: str, errors_dict: _t.Dict[_t.Any, str]) -> _t.Union[str, None]: + """Verify that the given string can be a key. Otherwise, append a message to `errors_dict`.""" + if not isinstance(key, str): + errors_dict[key] = f"Not a valid key (string name): {key!r}" + return None + key = str(key).strip() + if not key: + errors_dict[key] = f"Key (string name) can't be empty. Got: {key!r}" + return None + if not _re_valid_key_match(key): + if _re_starting_digits_match(key): + errors_dict[key] = f"Key (string name) can't start with a digit. Got: {key!r}" + else: + errors_dict[key] = f"Key (string name) must contain only numbers, latin letters and underscores. Got: {key!r}" + return None + return key + + +def _raise_from_errors_dict( + errors_dict: _t.Dict[_t.Any, str], + single_error_format: str = '{}', + multi_errors_format: str = "Invalid keys (string names): {}", +): + if not errors_dict: + return + + if len(errors_dict) == 1: + msg = single_error_format.format(next(iter(errors_dict.values()))) + else: + msg = multi_errors_format.format(', '.join(repr(x) for x in errors_dict.keys())) + raise KeyError(msg) + + +def _verify_input_dict(input_dict: _t.Dict[str, _T] = None, error_if_none=False): + """ + Verify input dict to have only valid keys. Raises errors if invalid ones found. + """ + if input_dict is None: + if error_if_none: + raise TypeError("No input-dict") + return + + # In py3.10, frozendict isn't a dict, but is a `typing.Mapping`. + # So, this many types to check against: + if not isinstance(input_dict, (dict, _frozendict, _t.Mapping)): + raise TypeError(f"Input-dict isn't a dict. Got: {input_dict!r}") + + errors_dict: _t.Dict[_t.Any, str] = dict() + for key in input_dict.keys(): + _validate_key(key, errors_dict) + + _raise_from_errors_dict( + errors_dict, + "Invalid input-dict: {}", "Invalid keys (string names) in input-dict: {}" + ) 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/_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/enums.py b/enums.py deleted file mode 100644 index a04c4ab..0000000 --- a/enums.py +++ /dev/null @@ -1,39 +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 - - -T = _t.TypeVar('T') -T2 = _t.TypeVar('T2') - - -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/funcs_common.py b/funcs_common.py deleted file mode 100644 index 6c5ec7c..0000000 --- a/funcs_common.py +++ /dev/null @@ -1,132 +0,0 @@ -# encoding: utf-8 -""" -Code shared between various `funcs_*` modules. -""" - -import typing as _t - -import re as _re - -from frozendict import deepfreeze as _deepfreeze, frozendict as _frozendict - -from server import PromptServer as _PromptServer - -from .enums 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) - - -_re_valid_key_match = _re.compile("[a-zA-Z_][a-zA-Z_0-9]*$").match -_re_starting_digits_match = _re.compile("[0-9]+").match - - -def _validate_key(key: str, errors_dict: _t.Dict[_t.Any, str]) -> _t.Union[str, None]: - """Verify that the given string can be a key. Otherwise, append a message to `errors_dict`.""" - if not isinstance(key, str): - errors_dict[key] = f"Not a valid key (string name): {key!r}" - return None - key = str(key).strip() - if not key: - errors_dict[key] = f"Key (string name) can't be empty. Got: {key!r}" - return None - if not _re_valid_key_match(key): - if _re_starting_digits_match(key): - errors_dict[key] = f"Key (string name) can't start with a digit. Got: {key!r}" - else: - errors_dict[key] = f"Key (string name) must contain only numbers, latin letters and underscores. Got: {key!r}" - return None - return key - - -def _raise_from_errors_dict( - errors_dict: _t.Dict[_t.Any, str], - single_error_format: str = '{}', - multi_errors_format: str = "Invalid keys (string names): {}", -): - if not errors_dict: - return - - if len(errors_dict) == 1: - msg = single_error_format.format(next(iter(errors_dict.values()))) - else: - msg = multi_errors_format.format(', '.join(repr(x) for x in errors_dict.keys())) - raise KeyError(msg) - - -def _verify_input_dict(input_dict: _t.Dict[str, _T] = None, error_if_none=False): - """ - Verify input dict to have only valid keys. Raises errors if invalid ones found. - """ - if input_dict is None: - if error_if_none: - raise TypeError("No input-dict") - return - - # In py3.10, frozendict isn't a dict, but is a `typing.Mapping`. - # So, this many types to check against: - if not isinstance(input_dict, (dict, _frozendict, _t.Mapping)): - raise TypeError(f"Input-dict isn't a dict. Got: {input_dict!r}") - - errors_dict: _t.Dict[_t.Any, str] = dict() - for key in input_dict.keys(): - _validate_key(key, errors_dict) - - _raise_from_errors_dict( - 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_add_any.py b/node_dict_add_any.py deleted file mode 100644 index 61fbd2f..0000000 --- a/node_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 . import _meta -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 - - -_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 = _meta.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/node_dict_add_string.py b/node_dict_add_string.py deleted file mode 100644 index 0cf2fbd..0000000 --- a/node_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 . import _meta -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: -_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 = _meta.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/node_dict_from_text.py b/node_dict_from_text.py deleted file mode 100644 index 1d7681c..0000000 --- a/node_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 . import _meta -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 - - -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 = _meta.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/node_dict_key_extract.py b/node_dict_key_extract.py deleted file mode 100644 index 2e099c9..0000000 --- a/node_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 . import _meta -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({ - '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 = _meta.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/node_dict_preview.py b/node_dict_preview.py deleted file mode 100644 index beacc28..0000000 --- a/node_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 . import _meta -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: - """ - 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 = _meta.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/node_formatter.py b/node_formatter.py index 83c3d2f..f7e13ea 100644 --- a/node_formatter.py +++ b/node_formatter.py @@ -1,253 +1,110 @@ # encoding: utf-8 -""" -Code for ``StringConstructorFormatter`` node. -""" +"""Code for the main 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_api.latest import io as _io, ui as _ui -from comfy.comfy_types.node_typing import IO as _IO - -from . import _meta +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 -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 - -__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({ - '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: +class StringFormatter(_BaseNode): """ Construct the formatted string from template and format-dictionary. """ - NODE_NAME = 'StringConstructorFormatter' - CATEGORY = _meta.category - DESCRIPTION = _format_docstring(_cleandoc(__doc__)) + _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', + ), - OUTPUT_NODE = True - - FUNCTION = 'main' - RETURN_TYPES = (_IO.STRING, ) - RETURN_NAMES = ('string', ) - # OUTPUT_TOOLTIPS = tuple() + _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 INPUT_TYPES(cls): - return _input_types - - @staticmethod - def main( + def execute(cls, 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]: + dict: _O[_FormatDict] = None, + ) -> _io.NodeOutput: formatter = _Formatter( - format_dict=dict, - recursive=recursive_format, safe=safe_format, - show_status=show_status, unique_node_id=unique_id, + format_dict=dict, recursive=recursive_format, safe=safe_format, ) - out_text = formatter(template) - return (out_text, ) + 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' + ) diff --git a/node_validate.py b/node_validate.py new file mode 100644 index 0000000..cba9a7f --- /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 as _category, + 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, + 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' + ) diff --git a/node_validate_keys.py b/node_validate_keys.py deleted file mode 100644 index 7ada38a..0000000 --- a/node_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 . import _meta -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({ - '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 = _meta.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, ) 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