diff --git a/.github/workflows/publish_action.yml b/.github/workflows/publish_action.yml new file mode 100644 index 0000000..4081728 --- /dev/null +++ b/.github/workflows/publish_action.yml @@ -0,0 +1,27 @@ +name: 📦 Publish to Comfy registry +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "pyproject.toml" + +permissions: + issues: write + +jobs: + publish-node: + name: Publish Custom Node to registry + runs-on: ubuntu-latest + # Just to be nice - to prevent unwanted auto-runs on forks made solely for a PR: + if: ${{ github.repository_owner == 'Lex-DRL' }} + steps: + - name: ♻️ Check out code + uses: actions/checkout@v4 + - name: 📦 Publish Custom Node + uses: Comfy-Org/publish-node-action@main + with: + # Add your own personal access token to your Github Repository secrets and reference it here. + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} + # personal_access_token: {% raw %}${{ secrets.REGISTRY_ACCESS_TOKEN }}{% endraw %} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d0b238 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# v0.0.1 + +MVP nodes: +- `Add String to Format-Dict` +- `Format-Dict from Text` +- `Format-Dict Preview` +- `String Formatter` + +⚠️ Active `TODO:` +- Recursive formatting. +- Append non-string to Format-Dict. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..0927556 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,157 @@ +### GNU LESSER GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This version of the GNU Lesser General Public License incorporates the +terms and conditions of version 3 of the GNU General Public License, +supplemented by the additional permissions listed below. + +#### 0. Additional Definitions. + +As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the +GNU General Public License. + +"The Library" refers to a covered work governed by this License, other +than an Application or a Combined Work as defined below. + +An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + +A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + +The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + +The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + +#### 1. Exception to Section 3 of the GNU GPL. + +You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + +#### 2. Conveying Modified Versions. + +If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + +- a) under this License, provided that you make a good faith effort + to ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or +- b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + +#### 3. Object Code Incorporating Material from Library Header Files. + +The object code form of an Application may incorporate material from a +header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + +- a) Give prominent notice with each copy of the object code that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the object code with a copy of the GNU GPL and this + license document. + +#### 4. Combined Works. + +You may convey a Combined Work under terms of your choice that, taken +together, effectively do not restrict modification of the portions of +the Library contained in the Combined Work and reverse engineering for +debugging such modifications, if you also do each of the following: + +- a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. +- b) Accompany the Combined Work with a copy of the GNU GPL and this + license document. +- c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. +- d) Do one of the following: + - 0) Convey the Minimal Corresponding Source under the terms of + this License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + - 1) Use a suitable shared library mechanism for linking with + the Library. A suitable mechanism is one that (a) uses at run + time a copy of the Library already present on the user's + computer system, and (b) will operate properly with a modified + version of the Library that is interface-compatible with the + Linked Version. +- e) Provide Installation Information, but only if you would + otherwise be required to provide such information under section 6 + of the GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the Application + with a modified version of the Linked Version. (If you use option + 4d0, the Installation Information must accompany the Minimal + Corresponding Source and Corresponding Application Code. If you + use option 4d1, you must provide the Installation Information in + the manner specified by section 6 of the GNU GPL for conveying + Corresponding Source.) + +#### 5. Combined Libraries. + +You may place library facilities that are a work based on the Library +side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + +- a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities, conveyed under the terms of this License. +- b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + +#### 6. Revised Versions of the GNU Lesser General Public License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +as you received it specifies that a certain numbered version of the +GNU Lesser General Public License "or any later version" applies to +it, you have the option of following the terms and conditions either +of that published version or of any later version published by the +Free Software Foundation. If the Library as you received it does not +specify a version number of the GNU Lesser General Public License, you +may choose any version of the GNU Lesser General Public License ever +published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5263b7 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +> "Do one thing and do it well." _— Peter H. Salus / Doug McIlroy, [core Unix principle](https://en.wikipedia.org/wiki/Unix_philosophy)_ + +> "Simple is better than complex." _— Zen of Python_ + +# `String Constructor` (Text-Formatting) nodes + +There's already a plenty of string-formatting nodes for ComfyUI. But this node pack takes a different approach: +- First, you prepare a dictionary of named sub-strings (chunks of text). +- Then, right before `CLIP Text Encode`, you construct a final prompt from these parts, using python's [string formatting syntax](https://docs.python.org/3/library/string.html#format-examples)... **Don't panic!** You don't need to be a programmer - just look below for a quick introduction. +- Unlike many other _(giant uber-mega)_ node packs, this one strictly adheres to the modular philosophy: it's minimal and self-sufficient. + +[🔄 Updates ChangeLog](CHANGELOG.md) + +![image](img/screenshot1.png) + +## Syntax + +For those who are unfamiliar with python's string formatting, the rules are very simple: +- You need to name (set tags/**keys** for) your sub-strings the same way python variables are named: only ASCII letters, digits and underscore are allowed + the name can't start with a digit. So: + - ✅ `valid_name`, `other_valid_name`, `YetAnother_ValidName`, `name3`. + - ❌ `wrong name with spaces`, `wrong-name.with:punctuation`, `3name`. +- Then, you simply put your `{substring_name}` inside curly braces (no spaces between) in the formatting template - and voila! This pattern will be replaced with the actual sub-string. + +> [!NOTE] +> For advanced users: +> Internally, a built-in `str.format()` is called with keyword arguments from `str_dict`, which is literally just a dict... though, an immutable one. +> So any formatting patterns are available (like `{float_value:.3f}`). However, currently there are no nodes to append non-string values (planned, TODO). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..83000be --- /dev/null +++ b/__init__.py @@ -0,0 +1,25 @@ +# encoding: utf-8 +""" +""" + +import typing as _t + +from .node_dict_add_string import StringConstructorDictAddString +from .node_dict_from_text import StringConstructorDictFromText +from .node_dict_preview import StringConstructorDictPreview +from .node_formatter import StringConstructorFormatter + +NODE_CLASS_MAPPINGS: _t.Dict[str, type] = { + 'StringConstructorDictAddString': StringConstructorDictAddString, + 'StringConstructorDictFromText': StringConstructorDictFromText, + 'StringConstructorDictPreview': StringConstructorDictPreview, + 'StringConstructorFormatter': StringConstructorFormatter, +} +NODE_DISPLAY_NAME_MAPPINGS: _t.Dict[str, str] = { + 'StringConstructorDictAddString': "Add String to Format-Dict", + 'StringConstructorDictFromText': "Format-Dict from Text", + 'StringConstructorDictPreview': "Preview Format-Dict", + 'StringConstructorFormatter': "String Formatter", +} + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/_meta.py b/_meta.py new file mode 100644 index 0000000..f716a0e --- /dev/null +++ b/_meta.py @@ -0,0 +1,6 @@ +# encoding: utf-8 +""" +Metadata-related module. +""" + +category: str = "String Constructor" diff --git a/docstring_formatter.py b/docstring_formatter.py new file mode 100644 index 0000000..b8a7ede --- /dev/null +++ b/docstring_formatter.py @@ -0,0 +1,146 @@ +# encoding: utf-8 +""" +""" + +import typing as _t + +from inspect import getdoc as _getdoc +import re as _re + + +_re_indent_match = _re.compile("(\t*)( +)(\t*)(.*?)$").match +_re_tab_indent_match = _re.compile("(\t+)(.*?)$").match +_re_list_line_match = _re.compile( + "(\s*)(" + "[-*•]+" + "|" + "[a-zA-Z]\s*[.)]" + "|" + "[0-9+]\s*[.)]" + ")\s+" +).match + + +def _recover_tab_indents(line: str, tab_size: int): + """Turn indenting spaces back to tabs using regexp. Half-tab indents are rounded.""" + assert bool(line) and isinstance(line, str) + + n_tabs = 0.0 + + match = _re_indent_match(line) + while match: + pre_tabs, spaces, post_tabs, line = match.groups() + n_tabs_from_spaces = float(len(spaces)) / tab_size + 0.00001 + n_post_tabs = len(post_tabs) + if n_post_tabs > 0: + # There are tabs after spaces. Don't preserve the fractional spaces-indent, truncate it: + n_tabs_from_spaces = int(n_tabs_from_spaces) + n_tabs += len(pre_tabs) + n_tabs_from_spaces + n_post_tabs + match = _re_indent_match(line) + + if n_tabs < 0.5: + return line + + tabs_prefix = '\t' * int(n_tabs + 0.50001) + return f"{tabs_prefix}{line}" + + +def _join_paragraph_and_format_tabs(paragraph: _t.List[str], tab_size: int): + """ + Given "continuous" paragraph (i.e., with no empty newlines between chunks), recover tabs for each chunk + and join them together into a single actual line. + Works as a generator to account for blocks with different indents - to make each its own line. + """ + pending_indent = 0 + pending_chunks: _t.List[str] = list() + + def join_pending_chunks() -> str: + return "{}{}".format('\t' * pending_indent, ' '.join(pending_chunks)) + + for chunk in paragraph: + chunk = _recover_tab_indents(chunk, tab_size) + + cur_indent = 0 + match = _re_tab_indent_match(chunk) + if match: + tab_indent, chunk = match.groups() # We've detected indent. Now, get rid of it. + cur_indent = len(tab_indent) + + match_list_line = _re_list_line_match(chunk) + # In case of a bulleted/numbered list, we'll need to start a new block, too. + if cur_indent == pending_indent and not match_list_line: + pending_chunks.append(chunk) + continue + + # Indent mismatch or a list line: + # we're either ended one block or entered another. Either way, the previous block ends. + if pending_chunks: + yield join_pending_chunks() + pending_chunks = list() + assert not pending_chunks + pending_chunks.append(chunk) + pending_indent = cur_indent + + if pending_chunks: + yield join_pending_chunks() + + +def _formatted_paragraphs_gen(doc: str, tab_size: int): + """ + Generator, which splits docstring into lines and transforms them into an actual printable output: + - From each bulk of empty lines, the first one is skipped... + - ... thus, non-empty lines are joined into continuous paragraphs. + - Recover tabs in the beginning oh lines (``inspect.cleandoc()`` converts them into spaces). + """ + if not doc: + return + doc = str(doc) + if not doc.strip(): + return + + tab_size = max(int(tab_size), 1) + + cur_paragraph: _t.List[str] = list() + + for line in doc.splitlines(): + line: str = line.rstrip() + if line: + cur_paragraph.append(line) + continue + + assert not line + if cur_paragraph: + for block in _join_paragraph_and_format_tabs(cur_paragraph, tab_size): + yield block + cur_paragraph = list() + # Just skip the current empty line entirely - do nothing with it. + continue + + # We're in a chain of empty lines, and we've already skipped the first one. Preserve the remaining ones: + yield '' + + # Return the last paragraph post-loop: + if cur_paragraph: + for block in _join_paragraph_and_format_tabs(cur_paragraph, tab_size): + yield block + + +def format_docstring(doc: str, tab_size: int = 8) -> str: + """ + Turn a pre-cleaned-up docstring (with tabs as spaces and newlines mid-sentence) + into an actually printable output: + - mid-paragraph new lines are replaced with spaces... + - ... while still keeping indented blocks separate. + + Remember to pass a pre-cleaned-up docstring - i.e.: ``format_docstring(inspect.cleandoc(__doc__))`` + """ + return '\n'.join(_formatted_paragraphs_gen(doc, tab_size)) + + +def format_object_docstring(_obj, tab_size: int = 8) -> str: + """Find the object's docstring and format it with ``format_docstring()``""" + doc = _getdoc(_obj) + if not doc: + return '' + # noinspection PyArgumentList + return format_docstring(doc, tab_size=tab_size) diff --git a/enums.py b/enums.py new file mode 100644 index 0000000..8bbbc83 --- /dev/null +++ b/enums.py @@ -0,0 +1,38 @@ +# 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') + + +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.""" + STR_DICT = 'STR_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_str_dict(cls, *_dicts: _t.Dict[str, T], **kwargs: T) -> _t.Tuple[_t.Union['DataTypes', str], _t.Dict[str, T]]: + return cls.STR_DICT, cls.__custom_input_type_dict(_dicts, kwargs) diff --git a/funcs_common.py b/funcs_common.py new file mode 100644 index 0000000..c05db3e --- /dev/null +++ b/funcs_common.py @@ -0,0 +1,81 @@ +# encoding: utf-8 +""" +Code shared between various `funcs_*` modules. +""" + +import typing as _t + +import re as _re + +from server import PromptServer as _PromptServer + +from .enums import T as _T + + +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):\n{}", +): + 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('\n'.join(repr(x) for x in errors_dict.keys())) + raise KeyError(msg) + + +def _verify_input_dict(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() + if not isinstance(input_dict, dict): + 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:\n{}", "Invalid keys (string names) in input-dict:\n{}" + ) + return dict(input_dict) diff --git a/funcs_dict_add_string.py b/funcs_dict_add_string.py new file mode 100644 index 0000000..da61249 --- /dev/null +++ b/funcs_dict_add_string.py @@ -0,0 +1,29 @@ +# encoding: utf-8 +""" +The actual code (MVC pattern) for `StringConstructorDictString` node. +""" + +import typing as _t + +from frozendict import deepfreeze as _deepfreeze + +from .enums import T as _T +from .funcs_common import _raise_from_errors_dict, _validate_key, _verify_input_dict + +def set_dict_string( + name: str, value: str, strip_lines=True, in_dict: _t.Dict[str, _T] = None, + # unique_id: str = None, +): + """Update/append a string to the dict.""" + out_dict: _t.Dict[str, _T] = _verify_input_dict(in_dict) + errors_dict: _t.Dict[_t.Any, str] = dict() + name = _validate_key(name, errors_dict) + _raise_from_errors_dict(errors_dict) + value = str(value) + if strip_lines: + value = '\n'.join( + x.strip() for x in value.splitlines() + ) + new_dict: _t.Dict[str, str] = {name: value} + out_dict.update(new_dict) + return (_deepfreeze(out_dict),) 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/funcs_dict_preview.py b/funcs_dict_preview.py new file mode 100644 index 0000000..0285211 --- /dev/null +++ b/funcs_dict_preview.py @@ -0,0 +1,47 @@ +# encoding: utf-8 +""" +The actual code (MVC pattern) for `StringConstructorDictPreview` node. +""" + +import typing as _t + +from pprint import pformat as _pformat + +from .funcs_common import _show_text_on_node, _verify_input_dict + + +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) if isinstance(v, dict) 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(input_dict)) + parts: _t.List[str] = [ + _preview_single_entry(k, v) for k, v in input_dict.items() + ] + return '\n\n'.join(parts) + + +def preview_str_dict(input_dict: dict, unique_id: str = None): + if not unique_id: + return (input_dict, ) + status_text = _preview_message(input_dict) + _show_text_on_node(status_text, unique_id) + return (input_dict, ) diff --git a/funcs_formatter.py b/funcs_formatter.py new file mode 100644 index 0000000..433c635 --- /dev/null +++ b/funcs_formatter.py @@ -0,0 +1,24 @@ +# encoding: utf-8 +""" +The actual code (MVC pattern) for `StringConstructorFormatter` node. +""" + +import typing as _t + +from .enums import T as _T +from .funcs_common import _show_text_on_node, _verify_input_dict + + +def formatter(template: str, format_dict: _t.Dict[str, _T] = None, show: bool = True, unique_id: str = None) -> _t.Tuple[str]: + format_dict = _verify_input_dict(format_dict) + if not isinstance(template, str): + raise TypeError(f"Not a string: {template!r}") + out_text = template.format_map(format_dict) + print(template) + print(format_dict) + print(out_text) + + if show and unique_id: + _show_text_on_node(out_text, unique_id) + + return (out_text, ) diff --git a/img/screenshot1.png b/img/screenshot1.png new file mode 100644 index 0000000..169d12e Binary files /dev/null and b/img/screenshot1.png differ diff --git a/node_dict_add_string.py b/node_dict_add_string.py new file mode 100644 index 0000000..189c7ff --- /dev/null +++ b/node_dict_add_string.py @@ -0,0 +1,65 @@ +# 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_dict_add_string import set_dict_string as _set_dict_string + + +# A tiny optimization by reusing the same immutable dict: +_input_types = _deepfreeze({ + 'required': { + 'name': (_IO.STRING, {'tooltip': ( + "Name 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': 'extra 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 set into the dict."}), + }, + 'optional': { + 'str_dict': _DataTypes.input_str_dict(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." + )), + }, + # '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 + 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, name: str, cleanup: bool, string: str, + str_dict: _t.Dict[str, _t.Any] = None, + # unique_id: str = None, + ): + return _set_dict_string(name, string, strip_lines=cleanup, in_dict=str_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) diff --git a/node_dict_preview.py b/node_dict_preview.py new file mode 100644 index 0000000..64a4b17 --- /dev/null +++ b/node_dict_preview.py @@ -0,0 +1,52 @@ +# 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_dict_preview import preview_str_dict as _preview_str_dict + + +# A tiny optimization by reusing the same immutable dict: +_input_types = _deepfreeze({ + 'required': { + 'str_dict': _DataTypes.input_str_dict(tooltip="A dictionary of named substrings."), + }, + # 'optional': {}, + '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 + DESCRIPTION = _format_docstring(_cleandoc(__doc__)) + + OUTPUT_NODE = True + + 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, str_dict: _t.Dict[str, _t.Any], + unique_id: str = None, + ): + return _preview_str_dict(str_dict, unique_id=unique_id) diff --git a/node_formatter.py b/node_formatter.py new file mode 100644 index 0000000..c80fed2 --- /dev/null +++ b/node_formatter.py @@ -0,0 +1,68 @@ +# 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_formatter import formatter as _formatter + + +# A tiny optimization by reusing the same immutable dict: +_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}" + )}), + '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': { + 'str_dict': _DataTypes.input_str_dict(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." + )), + }, + '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 = _meta.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 + + def main( + self, template: str, show_status: bool, + str_dict: _t.Dict[str, _t.Any] = None, + unique_id: str = None, + ): + return _formatter(template, str_dict, show=show_status, unique_id=unique_id) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..52972d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "string-constructor" +description = "Build your dictionary of named strings (text chunks), then easily compile different prompt variations from them - with simple string formatting.\nUnlike string-formatting nodes from other popular packs, this one is designed for specifying your sub-strings once, then dragging the entire \"library\" of them across the entire workflow (like a bus), updating it in-place as necessary. This is especially handy for regional prompting aka area composition." +version = "0.0.1" +authors = [ + {name = "Lex Darlog"} +] +license = {file = "LICENSE.md"} +readme = "README.md" +dependencies = ["frozendict"] + +[project.urls] +Repository = "https://github.com/Lex-DRL/ComfyUI-BestResolution" +# Used by Comfy Registry https://comfyregistry.org + +[tool.comfy] +PublisherId = "lex-drl" +DisplayName = "String Constructor (Text-Formatting)" +Icon = "" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..302328f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +frozendict