diff --git a/CHANGELOG.md b/CHANGELOG.md index 0094eea..f90a535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,4 @@ # v0.0.1 + +MVP nodes: +- `Format-Dict from Text` diff --git a/__init__.py b/__init__.py index e60a055..839dda5 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,13 @@ import typing as _t -NODE_CLASS_MAPPINGS: _t.Dict[str, type] = dict() -NODE_DISPLAY_NAME_MAPPINGS: _t.Dict[str, str] = dict() +from .node_dict_from_text import StringConstructorDictFromText -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] +NODE_CLASS_MAPPINGS: _t.Dict[str, type] = { + 'StringConstructorDictFromText': StringConstructorDictFromText, +} +NODE_DISPLAY_NAME_MAPPINGS: _t.Dict[str, str] = { + 'StringConstructorDictFromText': "Format-Dict from Text" +} + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/funcs_dict_from_text.py b/funcs_dict_from_text.py new file mode 100644 index 0000000..0a22aba --- /dev/null +++ b/funcs_dict_from_text.py @@ -0,0 +1,81 @@ +# encoding: utf-8 +""" +The actual code (MVC pattern) for `StringConstructorDictFromText` node. +""" + +import typing as _t + +from frozendict import frozendict as _frozendict, deepfreeze as _deepfreeze + +from .enums import T as _T +from .funcs_common import _show_text_on_node, _raise_from_errors_dict, _validate_key, _verify_input_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): + """ + 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() + + +def parse_dict_from_text( + multiline_str: str, strip_lines=True, + show: bool = True, in_dict: _t.Dict[str, _T] = None, unique_id: str = None +) -> _t.Tuple[_t.Dict[str, _T]]: + out_dict: _t.Dict[str, _T] = _verify_input_dict(in_dict) + + errors_dict: _t.Dict[_t.Any, str] = dict() + new_dict: _t.Dict[str, str] = { + _k: _v for _k, _v in ( + (_validate_key(k, errors_dict), v) + for k, v in _parsed_kv_pairs_gen(multiline_str, strip_lines=strip_lines) + ) if _k is not None + } + _raise_from_errors_dict(errors_dict) + + out_dict.update(new_dict) + + if show and unique_id: + status_text = ','.join(new_dict.keys()) if new_dict else '' + _show_text_on_node(status_text, unique_id) + + return (_deepfreeze(out_dict), ) diff --git a/node_dict_from_text.py b/node_dict_from_text.py new file mode 100644 index 0000000..b35bab7 --- /dev/null +++ b/node_dict_from_text.py @@ -0,0 +1,69 @@ +# 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_dict_from_text import parse_dict_from_text as _parse_dict_from_text + + +# A tiny optimization by reusing the same immutable dict: +_input_types = _deepfreeze({ + 'required': { + 'cleanup': (_IO.BOOLEAN, {'default': True, 'label_on': 'extra 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,\nsitting on a motorcycle, wearing a leather jacket" + )}), + 'show_status': (_IO.BOOLEAN, {'default': False, 'label_on': 'found names', 'label_off': 'no', 'tooltip': ( + "Show detected string names on the node itself?" + )}), + }, + 'optional': { + 'str_dict': _DataTypes.input_str_dict(tooltip=( + "An optional input dictionary. If it's connected, it will be expanded/updated with new strings." + )), + }, + '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 = (_DataTypes.STR_DICT, ) + RETURN_NAMES = (_DataTypes.STR_DICT.lower(), ) + # OUTPUT_TOOLTIPS = tuple() + + @classmethod + def INPUT_TYPES(cls): + return _input_types + + def main( + self, cleanup: bool, strings: str, show_status: bool, + str_dict: _t.Dict[str, _t.Any] = None, + unique_id: str = None, + ): + return _parse_dict_from_text(strings, strip_lines=cleanup, show=show_status, in_dict=str_dict, unique_id=unique_id)