diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index aa98c92..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,57 +0,0 @@ -## `TODO` - -The pack is considered feature-complete. -No other features planned (but I have nothing against improvements: feel free to [Pull Request](../../pulls)). - -To do some fancy stuff with dictionaries, specialized node packs are recommended instead. - -# v1.1.2 - -- `[fix]` [False-positive `Input-dict isn't a dict` type-check error on older python versions (3.10)](https://github.com/Lex-DRL/ComfyUI-StringConstructor/issues/4) - -# v1.1.1 - -- Shorten tooltip for `safe_format` toggle. - -# v1.1.0 - -- `✨New feature` Safe-formatting mode for `String Formatter`: - - When an invalid pattern included in the formatted template - instead of throwing an error, leaves this part of the template as-is. Useful for templates with curly braces in them intended to stay: JSON/CSS. - - The entire implementation of `String Formatter` node is fully reworked because of it. - -# v1.0.4 - -- All the secondary dict-related nodes are moved to a subcategory. -- Default node name shortened: `Format-Dict` → just `Dict`. -- `✨New node` - `Extract String from Dict` - to be actually self-sufficient for the common use cases. - -# v1.0.3 - -- [fix] Nodes made compatible with built-in preview nodes. -- Some internal refactoring/cleanup. - -# v1.0.2 - -- `✨New node` - `Validate Format-Dict` - -# v1.0.1 - -- `✨New feature` ❗ Recursive formatting. -- Tiny tweaks to tooltips/descriptions. - -# v1.0.0 - -The minimally-complete version. - -New node: -- `Add ANY to Format-Dict (Advanced)` - -The Format-Dict data type name changed from `STR_DICT` to just `DICT` - for potential compatibility with other nodes. - -# v0.0.1 - -MVP nodes: -- `Add String to Format-Dict` -- `Format-Dict from Text` -- `Format-Dict Preview` -- `String Formatter` diff --git a/README.md b/README.md index 12e75c3..6c47f0d 100644 --- a/README.md +++ b/README.md @@ -2,204 +2,65 @@ > "Simple is better than complex." _— Zen of Python_ -# `String Constructor` (Text-Formatting) nodes -... for ComfyUI. +
-[🔄 Updates ChangeLog](CHANGELOG.md) +# `🗂️ Dict Tools` for ComfyUI + +*Essential nodes to use dictionaries in ComfyUI: for smart prompt-formatting, general organization (passing a single connection instead of spaghetti), or anything else.*
+
### TL;DR -There's already a plenty of string-formatting nodes for ComfyUI. But this node pack takes a different approach: -- Build your dictionary of available text chunks once. Pass it further as a single line (bus/pipe design). -- Easily reuse these sub-strings to build as many variations of a prompt as needed. -- It's especially handy for regional prompting (aka area composition). -- [❗ Recursive formatting support](#recursive-formatting-) -- ... -- PROFIT! +- In python, there's a built-in "dictionary" (`dict`) data type. +- It's both simple and powerful: it's a single container that lets you put many pieces of data into it, **and still access them individually** - by their unique names aka keys. For example, you can put there: + - `seed` (int) + - `denoise` value (float) + - `text` prompt (string) + - ... and even have multiple versions of them for different stages of workflow - as long as each value has its own unique key. +- Even though ComfyUI actively uses dicts under the hood, there's no built-in `DICT` data type exposed to users, nor there are any nodes to build/modify/use dicts. +- This node pack aims to fix that. +- I **highly** recommend using it together with my [String Constructor/Formatter](https://github.com/Lex-DRL/ComfyUI-StringConstructor) node and with [Basic Data Handling](https://github.com/StableLlama/ComfyUI-basic_data_handling) pack. -![screenshot1](img/screenshot1.png) +## What for? -## The main `String Formatter` node +In short: +- Advanced text formatting; +- Utilizing the well-known "bus" workflow to reduce spaghetti, while letting **you** decide what data is contained inside the bus - not the node author deciding for you; +- Any other uses for dicts - your choice! -It expects one input with a full "library" (dictionary) of all the named strings you might need, and it lets you use them in a prompt - any of them (or none at all), in any order, anywhere inside text template - using [python's string formatting syntax](https://docs.python.org/3/library/string.html#format-examples)... **Don't panic!** All the unfamiliar programming terms are explained below. +Now, specifics. -But the main point is, you don't need to fiddle around connecting a bunch of wires every time you'd like to build another prompt, compiled from the same parts. There's just a single node, single input _(with all the available text pieces contained within it)_, single text field - and that's it. +### Formatting -> [!NOTE] -> Unlike many other _(giant uber-mega)_ node packs, this one strictly adheres to the modular philosophy: it's minimal and self-sufficient. It's designed to only do string formatting and nothing else. +Originally, all these nodes were just a part of my [String Constructor pack](https://github.com/Lex-DRL/ComfyUI-StringConstructor) as "supporting" nodes. So they let you prepare a dictionary with various text chunks, and then build the actual prompt from them, easily reusing the same descriptions across the workflow. Like this: -### Dictionary +![example-graph-with-constructor](img/example-graph-with-constructor.png) -For those unfamiliar, dictionary is a special datatype, which can contain a bunch of individual values (as many as you need), each of which accessible with its own **unique** name _(key)_. For our purposes, it can be understood as a simple "search & replace" table. So, you prepare a bunch of distinct parts of the prompt, you name them and then you can reference any of them individually. +### Bus connection -For example, in the screenshot shown above, this is the dictionary we get in the end (as the `Preview` node confirms): +... but it quickly became apparent, that dicts are good not only for that - they're perfect as general-purpose "wrappers" over big sets of **any** data, no matter what is its type. So: +- You put all your seeds/prompts/denoise values/whatever (even models) into a single dictionary. +- You pass it as a **single** connection across the graph. + - Yes, if you wish, also using any other spaghetti-reducing nodes - like `Set`/`Get` nodes from [KJNodes pack](https://github.com/kijai/ComfyUI-KJNodes) or any nodes from [UE pack](https://github.com/chrisgoringe/cg-use-everywhere). +- When needed, you extract these values back from the dict and connect them as usual. No special bus-understanding nodes required. -| Chunk Name (key) | Chunk Text (value) | -|------------------|--------------------------------------------| -| `model_prefix` |
score_9, score_8_up, score_7_up
| -| `char1_short` |
1boy, blond, short hair
| -| `char1_long` |
1boy, smiling, blue eyes, blond, short hair,[NEW LINE HERE]
wearing a leather jacket, sitting on a bike
| +Also, dicts are designed to be easily updated, so you could initially build one "base" dict, another "override" dict, and somewhere down the workflow you apply the latter - getting a third dict which preserves all the original values from the first one, but takes the updated values from the second one. -And then, we can do **this** with the same input dictionary: +This "base-then-override" approach lets you build your own templates containing entire **sets** of values, applied all at once with a single node when you need it. If you're familiar with Automatic1111 UI, then it's basically a "style preset" concept, but much, much, **MUCH** more flexible and powerful. -![screenshot2](img/screenshot2.png) +## Examples 🚧🏗️ -> [!NOTE] -> The `BREAK` keyword shown here isn't natively supported by Comfy's `CLIP Text Encode`. But you can use [`CLIPTextEncode with BREAK syntax`](https://github.com/dfl/comfyui-clip-with-break) or [`CLIP Text Encode++` from smZNodes](https://github.com/shiimizu/ComfyUI_smZNodes) (if you want to fully switch from Comfy's to A1111's way of encoding text). +(Section under construction) +## Implementation details for programmers -### String-Formatting syntax +The exact datatype the nodes in this pack output is actually `frozendict` (not native `dict`) - to prevent accidentally mutating the same dict by multiple nodes. -The way you "paste" values from the dictionary into your text template is called "string formatting". Its syntax is very simple: -- You just put your `{key_name}` inside curly braces (no spaces between) - and voila! When the prompt template gets formatted, this pattern will be replaced with the actual sub-string from the dict. - - For your convenience, the string-formatting node itself can show the actual output. -- Also keep in mind, that while naming your sub-strings, you must follow the same restrictions python imposes on any variable names: only ASCII letters, digits and underscore are allowed + the name can't start with a digit. So: - - ✅ `valid_name`, `_other_valid_name_`, `YetAnother_ValidName___`, `name4`. - - ❌ `wrong name with spaces`, `wrong-name.with:punctuation`, `4name`. - - Don't worry, the node pack will warn you if you type an invalid name. +## Versioning scheme -### Recursive formatting +I adopted a custom versioning scheme, which looks like a regular SemVer, but is slightly different: +- First number is internal node-API version. +- Second is major update - when it bumps, it contains breaking changes. +- Last is a minor update. -It's quite simple in principle yet the most powerful feature of the pack. - -When formatting the string, you can let chunks reference each other, which unlocks immense possibilities - like building entire **HIERARCHIES** of descriptions with various granularity (tailored for different resolutions) or even [conditional string formatting](#dynamic-pattern-aka-conditional-formatting) for the most advanced users. - -- You can design your dict to be intended for updating down the line. Change some keys → get a different prompt with the same template (for example, a more detailed description of a character). -- Don't forget that with recursive formatting, [template itself could be a part of the dictionary](#pattern-as-part-of-the-dictionary), too! - -![recursive_screenshot_v2](img/recursive_screenshot_v2.png) - -> [!WARNING] -> Remember that with great power comes great responsibility! -> -> With a sloppy use, you can create chunks that cross-reference each other in an infinite loop. The node will error out after reaching a high level of recursion (about 1k), so you're safe. But still, you've been warned! - -## Helper nodes for Dictionaries - -At this point it should be clear that most of the work would be done around preparing the dictionary to use. - -The pack provides some utility nodes to build such dict: -- `Dict from Text` - **this node would be enough 99% of the time**. It parses a single wall of text and splits it into chunks at empty lines. The first line in each chunk is extracted as key, the rest of the chunk _(including any number of the following new lines, as long as they have some text)_ is the actual text of this chunk. -- `Add String to Dict` - similar, but adds only one entry. Useful when you need a value or a key of the dictionary entry to come as input connection from somewhere else. -- `Add ANY to Dict` similar, but for advanced formatting. It allows you to add not only a string, but literally anything (float, int, etc). The key still must follow the same restrictions. -- Any of these nodes can take another dictionary as input - then they output the extended/updated dict. -- `Extract String from Dict` - the opposite to `Add String to Dict`: extracts a single element. With these two nodes, you can extract a single string, modify it, and update the dict with the new version. Technically, the main `String Formatter` node can "extract" string, too - but this one is more compact. -- `Validate Dict` - a node that ensures that all the keys in the dictionary are named properly. Useful if you build the dictionary with nodes from other packs (see below) and want to ensure that everything is fine - before passing the dictionary down the line. - -These bundled nodes should be enough to start your journey. If you need to do a more advanced stuff with dictionaries, you can look into other node packs specializing on exactly that. Good candidates are: -- [Basic Data Handling](https://github.com/StableLlama/ComfyUI-basic_data_handling) -- [Eugene's Nodes](https://github.com/JEONG-JIWOO/ComfyUI_Eugene_Nodes) -- [WAS Node Suite](https://github.com/ltdrdata/was-node-suite-comfyui). Note: this is a mega-pack, over-bloated to my taste. It has nodes to work with dictionaries, but also **A TON** of other unrelated stuff... and it's known to have dependencies conflicting with other custom nodes. - -## Helper nodes for Preview - -To debug the dictionary you build, there's a `Preview Dict` node. - -You might also look into the built-in `Preview Any` node. - -Alternatively, [Crystools](https://github.com/crystian/ComfyUI-Crystools) pack has a `🪛 Show any to JSON` node _(I highly recommend it)_, which produces a more readable output. Crystools pack isn't as bloated as WAS' one, but still, it's a multipurpose mega-pack, too. - -## Advanced Topics - -### Pattern as part of the dictionary - -With [Recursive formatting](#recursive-formatting-), nothing stops you from putting the template itself into the dictionary, too. You can even have both your prompts there (positive/negative), and you unpack them with a couple of `String Formatter` nodes _(with their only text being something like `{pos_template}` / `{neg_template}`)_, just before KSampler. - -This single possibility can **drastically** change your approach to workflow creation. Since building a prompt for a specific KSampler becomes effectively free (with a properly configured dictionary) and string-formatting/text-encoding is almost free, too - you no longer need passing spaghetti of your pre-encoded conditionings all across your workflow. Instead, you just build them in-place by simply setting a couple of "special" keys to the desired toggle-like values, and pass only your all-in-one dictionary itself. - -#### A negative prompt example - -![neg_recursive_example](img/neg_recursive_example.png) - -Try it yourself: - -``` -bad_quality -(worst quality:1.2), (low quality:1.2), (normal quality:1.2), lowres - -bad_anatomy_short -bad anatomy - -bad_anatomy_extra -ugly, unnatural body, error - -bad_anatomy_long -{bad_anatomy_short}, {bad_anatomy_extra} - -bad_hands_short -bad hands - -bad_hands_extra -extra finger, missing fingers - -bad_hands_long -{bad_hands_short}, {bad_anatomy_long}, {bad_hands_extra} - -bad_eyes_short -imperfect eyes, skewed eyes - -bad_face_short -{bad_eyes_short}, unnatural face - -bad_face_long -{bad_face_short}, {bad_anatomy_long} - -bad_limbs_short -extra limb, missing limbs - -bad_human_long -{bad_anatomy_short}, {bad_hands_short}, {bad_face_short}, {bad_anatomy_extra}, {bad_limbs_short} - -watermark -signature, watermarks - -neg_common -{bad_quality}, {watermark}, {bad_human_long} -``` - -### When you need curly braces themselves - -#### Safe mode `✨New in v1.1.0` - -The simplest solution - just enable `safe_format` toggle. It changes the node behavior: whenever formatter encounters any `{text inside curly braces}` that cannot be formatted _(for any reason: the dict misses this key, or it's an outright invalid pattern)_, it doesn't throw an error and instead simply leaves this part of template as-is. - -It's extremely useful for code-like templates (e.g., containing JSON or CSS). - -However, even for proper `{patterns}` referencing proper dict keys, the manual escaping (see below) also applies. - -> [!NOTE] -> Keep in mind that any spaces inside braces immediately make this pattern invalid. -> -> Thus, if you want to have something like: `{{my_key}}` _(so, `{my_key}` to be replaced + it's wrapped into another set of braces which you want to have in the output)_, the easiest way to do it without escaping is simply enabling safe mode and adding a couple of spaces inside the braces you want to stay. So: `{ {my_key} }` - the innermost part will be replaced with the value of `my_key` item from the dict, while the outer braces will stay. - -#### Manual escaping - double braces - -If you want to explicitly control which braces perform formatting and which don't - to have the literal curly-brace characters inside the prompt **after** formatting, you need to [escape](https://docs.python.org/3/library/string.html#format-string-syntax) them: whenever you need one, you type it twice (`'{{'` or `'}}'`). Then, after formatting, it will turn to `'{'` or `'}'`, respectively. - -Keep in mind though, that with escaping approach and [Recursive formatting](#recursive-formatting-), any `{{text}}` will become `{text}` after very first iteration, and thus on the next one, it still will be treated as a placeholder to put a value of the `text` key into. - -However, this might be exactly what you want for... - -### Dynamic pattern aka conditional formatting - -In other words, you build a prompt, where **keys themselves** are compiled from pieces. For example: -- Your main text template has one of the following patterns somewhere inside it: - - {{character_`{active_char}`}} - if safe mode is off; - - {character_`{active_char}`} - if safe mode is on. -- You also have a string named `active_char` in your dict, which you simply set to a number. -- Also-also, you have strings named `character_1`, `character_2`, etc. -- Then, with recursive formatting, depending on just a **single** value you set the `active_char` element to, the following happens: - - At the first iteration, `{{` in the beginning and `}}` in the end are treated as escaped ones, so they're simply replaced to `{` and `}`. - - The middle `{active_char}` part, however, is treated as a real pattern to replace. - - Thus, after the first iteration, `{{character_{active_char}}}` becomes `{character_1}`... - - ... and in the next iteration, the actual value of `character_1` string is placed here instead. - -Clearly, to take advantage of this approach, you need to build your dictionary very cautiously and precisely. But if you do so, you effectively get a way of building entire **logic trees**, represented as data (not as code), all self-contained inside a single dictionary, but still letting you generate a **completely** different prompt by changing values of one or two such "switcher" strings. - -At this point, your imagination is your only limit. - -### Implementation details for programmers - -Internally in the main `String Formatter` node, just a built-in `str.format_map()` is called with keyword arguments from the passed Format-Dict, which is literally just a dict _(and it's expected to have only string keys)_. - -So any "complex" formatting patterns are available, too (like `{float_value:.3f}`). `Add ANY to Format-Dict` node is there for exactly that. +## [TODO.md](TODO.md) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e03c33f --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +# `TODO` + +- Auto-parse built-in types in `DictFromText`. +- Add/extract nodes for all the built-in types in Comfy. +- Bulk key-rename nodes: add/remove prefix, replace. +- Support non-strings as keys. diff --git a/__init__.py b/__init__.py index 5fb9844..d04d282 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 .node_dict_from_text import * +from .nodes_dict import * -NODE_CLASS_MAPPINGS: _t.Dict[str, type] = { - 'StringConstructorDictAddAny': StringConstructorDictAddAny, - 'StringConstructorDictAddString': StringConstructorDictAddString, - 'StringConstructorDictExtractString': StringConstructorDictExtractString, - 'StringConstructorDictFromText': StringConstructorDictFromText, - 'StringConstructorDictPreview': StringConstructorDictPreview, - 'StringConstructorFormatter': StringConstructorFormatter, - 'StringConstructorValidateKeys': StringConstructorValidateKeys, -} -NODE_DISPLAY_NAME_MAPPINGS: _t.Dict[str, str] = { - 'StringConstructorDictAddAny': "Add ANY to Dict", - 'StringConstructorDictAddString': "Add String to Dict", - 'StringConstructorDictExtractString': "Extract String from Dict", - 'StringConstructorDictFromText': "Dict from Text", - 'StringConstructorDictPreview': "Preview Dict", - 'StringConstructorFormatter': "String Formatter", - 'StringConstructorValidateKeys': "Validate Dict", -} +class DictToolsExtension(_ComfyExtension): + async def get_node_list(self) -> list[type[_io.ComfyNode]]: + return [ + DictFromText, DictFromTextOld1, + DictAddAny, DictAddString, DictExtractString, + DictAddAnyOld1, DictAddStringOld1, DictExtractStringOld1 + ] -__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] +async def comfy_entrypoint() -> _ComfyExtension: + return DictToolsExtension() diff --git a/__meta.py b/__meta.py new file mode 100644 index 0000000..5b8b6c2 --- /dev/null +++ b/__meta.py @@ -0,0 +1,8 @@ +# encoding: utf-8 +""" +Metadata-related module. +""" + +category: str = "🗂️ Dict Tools" +category_old: str = f"{category}/_old" +pack_id_suffix: str = " [Dict Tools]" 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/funcs_common.py b/_dict_funcs.py similarity index 84% rename from funcs_common.py rename to _dict_funcs.py index 6c5ec7c..92876eb 100644 --- a/funcs_common.py +++ b/_dict_funcs.py @@ -1,6 +1,6 @@ # encoding: utf-8 """ -Code shared between various `funcs_*` modules. +Shared code to deal with dicts. """ import typing as _t @@ -9,20 +9,7 @@ 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) +from .__typing import T as _T, T2 as _T2 _re_valid_key_match = _re.compile("[a-zA-Z_][a-zA-Z_0-9]*$").match diff --git a/_io_custom.py b/_io_custom.py new file mode 100644 index 0000000..9dd9acd --- /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) 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/_meta.py b/_meta.py deleted file mode 100644 index 0714032..0000000 --- a/_meta.py +++ /dev/null @@ -1,7 +0,0 @@ -# encoding: utf-8 -""" -Metadata-related module. -""" - -category: str = "String Constructor" -category_dict: str = f"{category}/Format-Dict" 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/img/screenshot1.png b/img/example-graph-with-constructor.png similarity index 100% rename from img/screenshot1.png rename to img/example-graph-with-constructor.png diff --git a/img/neg_recursive_example.png b/img/neg_recursive_example.png deleted file mode 100644 index 2a63d3a..0000000 Binary files a/img/neg_recursive_example.png and /dev/null differ diff --git a/img/recursive_screenshot_v2.png b/img/recursive_screenshot_v2.png deleted file mode 100644 index 56085cb..0000000 Binary files a/img/recursive_screenshot_v2.png and /dev/null differ diff --git a/img/recursive_toggle.png b/img/recursive_toggle.png deleted file mode 100644 index 0c631e9..0000000 Binary files a/img/recursive_toggle.png and /dev/null differ diff --git a/img/screenshot2.png b/img/screenshot2.png deleted file mode 100644 index 4c6f38e..0000000 Binary files a/img/screenshot2.png and /dev/null differ 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 index 1d7681c..0c83048 100644 --- a/node_dict_from_text.py +++ b/node_dict_from_text.py @@ -1,21 +1,26 @@ # encoding: utf-8 -""" -""" - -import typing as _t +"""Code for the ``Dict from Text`` node.""" from inspect import cleandoc as _cleandoc -from frozendict import frozendict as _frozendict, 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 ._dict_funcs import _new_updated_dict +from ._io_custom import ( + _BaseNode, + _DICT_INPUT_OPTIONAL, _DICT_OUTPUT, + _input_override, _schema_old_name +) from .docstring_formatter import format_docstring as _format_docstring -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 +_dict = dict + +# ---------------------------------------------------------- def _return_line_raw(line_raw: str, line_stripped:str) -> str: return line_raw @@ -26,7 +31,7 @@ def _return_line_stripped(line_raw: str, line_stripped:str) -> str: def _parsed_kv_pairs_gen(multiline_str: str, strip_lines=True): - """ + """Parse the "dict text" into sequence of key-value pairs: Given a multiline string, extract keywords (first non-empty line in each chunk) and their substrings (all the following non-empty lines). """ @@ -62,68 +67,93 @@ def _parsed_kv_pairs_gen(multiline_str: str, strip_lines=True): 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: +class DictFromText(_BaseNode): """ 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__)) + _schema = _io.Schema( + node_id=f'DictFromText{_pack_id}', + display_name='Dict from Text', + category=_category, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.Boolean.Input( + 'cleanup', + tooltip="When enabled, each line in each sub-string is stripped from any spaces at its start and end.", + default=True, + label_on='leading/trailing spaces', + label_off='no', + ), + _io.String.Input( + 'dict_text', display_name='dict-items text', + tooltip=( + "Sub-string names followed by their text. Different sub-string chunks are separated by empty lines. Example:\n\n" + "char1_short\n1boy, blond, short hair\n\n" + "char1_long\n1boy, smiling, blue eyes, blond, short hair,\nwearing a leather jacket, sitting on a bike" + ), + multiline=True, + ), + _io.Boolean.Input( + 'show_status', + tooltip="Show detected string names on the node itself?", + default=False, + label_on='detected keys', + label_off='no', + ), - 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() + _DICT_INPUT_OPTIONAL, + ], + outputs=[_DICT_OUTPUT], + # hidden=[_io.Hidden.unique_id], + is_output_node=True, # Just to show the status message even if not connected to anything + ) @classmethod - def 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)} + def execute(cls, + cleanup: bool, dict_text: str, show_status: bool = False, + dict: _O[_FormatDict] = None + ) -> _io.NodeOutput: + parsed_items = _parsed_kv_pairs_gen(dict_text, strip_lines=cleanup) + new_dict = {k: v for k, v in parsed_items} if not new_dict: - if dict is None: - dict = _dict() - return (dict, ) # No need to create another dict instance if we add nothing + # No need to create another dict instance if we add nothing: + out_dict = _dict() if dict is None else dict + out_ui = '' if show_status else None + return _io.NodeOutput(out_dict, ui=out_ui) out_dict = _new_updated_dict(dict, new_dict) - # These two ↑↓ must be in this specific order: `_new_updated_dict()` also checks both dicts - if show_status and unique_id: + # These two ↑↓ must be in this specific order: + # `_new_updated_dict()` also checks both dicts and might throw an error + out_ui = None + if show_status: status_text = ','.join(new_dict.keys()) if new_dict else '' - _show_text_on_node(status_text, unique_id) + out_ui = _ui.PreviewText(status_text) - return (out_dict, ) + return _io.NodeOutput(out_dict, ui=out_ui) + +# ========================================================== +# Deprecated node with old ID (for backwards compatibility) + +class DictFromTextOld1(_BaseNode): + # noinspection PyProtectedMember + _schema = _schema_old_name( + DictFromText._schema, + 'StringConstructorDictFromText', + inputs=[ + _input_override(x, id='strings') if x.id == 'dict_text' else x + for x in DictFromText._schema.inputs + ], + ) + + @classmethod + def execute(cls, + cleanup: bool, strings: str, show_status: bool = False, + dict: _O[_FormatDict] = None + ) -> _io.NodeOutput: + return DictFromText.execute( + cleanup=cleanup, dict_text=strings, show_status=show_status, + dict=dict + ) diff --git a/node_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 deleted file mode 100644 index 83c3d2f..0000000 --- a/node_formatter.py +++ /dev/null @@ -1,253 +0,0 @@ -# encoding: utf-8 -""" -Code for ``StringConstructorFormatter`` node. -""" - -import typing as _t - -from dataclasses import dataclass as _dataclass, field as _field -from inspect import cleandoc as _cleandoc -import re as _re -import sys as _sys - -from frozendict import deepfreeze as _deepfreeze - -from comfy.comfy_types.node_typing import IO as _IO - -from . 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 - - -_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: - """ - 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 - - @staticmethod - def main( - template: str, - recursive_format: bool = False, - safe_format: bool = True, - show_status: bool = False, - dict: _t.Dict[str, _t.Any] = None, #actually, required - but it's here to keep the declared params order - unique_id: str = None - ) -> _t.Tuple[str]: - formatter = _Formatter( - format_dict=dict, - recursive=recursive_format, safe=safe_format, - show_status=show_status, unique_node_id=unique_id, - ) - out_text = formatter(template) - return (out_text, ) 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/nodes_dict.py b/nodes_dict.py new file mode 100644 index 0000000..590f12c --- /dev/null +++ b/nodes_dict.py @@ -0,0 +1,231 @@ +# encoding: utf-8 +"""The main module for simple dict nodes.""" + +from inspect import cleandoc as _cleandoc + +from frozendict import frozendict as _frozendict + +from comfy_api.latest import io as _io, ui as _ui + +from .__meta import ( + category as _category, + pack_id_suffix as _pack_id +) +from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict +from ._dict_funcs import _new_dict_with_updated_key +from ._io_custom import ( + _BaseNode, + _DICT_INPUT_OPTIONAL, _DICT_OUTPUT, + _input_override, _schema_old_name +) +from .docstring_formatter import format_docstring as _format_docstring + +_dict = dict + +# ---------------------------------------------------------- + +class DictAddAny(_BaseNode): + """Add/update any-type item to a Format-Dict.""" + _schema = _io.Schema( + node_id=f'DictAddAny{_pack_id}', + display_name='Add ANY to Dict', + category=_category, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.String.Input( + 'name', + tooltip=( + "Name (key) of the item inserted into the dict. " + "It must comprise only of latin letters, digits and underscores + it can't start with a digit." + ), + ), + + _DICT_INPUT_OPTIONAL, + _io.AnyType.Input( + 'value', + optional=True, + tooltip="The actual any-type item to add into the dict." + ), + ], + outputs=[_DICT_OUTPUT], + ) + + @classmethod + def execute(cls, + name: str, dict: _O[_FormatDict] = None, value: _A = None + ) -> _io.NodeOutput: + """Update/append an item of any type to the dict.""" + if value is None: + if dict is None: + dict = _frozendict() + # No need to create another dict instance if we add nothing: + result = dict + else: + result = _new_dict_with_updated_key(dict, name, value) + return _io.NodeOutput(result) + +# ---------------------------------------------------------- + +class DictAddString(_BaseNode): + """Add/update a string to a Format-Dict.""" + _schema = _io.Schema( + node_id=f'DictAddString{_pack_id}', + display_name='Add String to Dict', + category=_category, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.String.Input( + 'name', + tooltip=( + "Name (key) of the string inserted into the dict. " + "It must comprise only of latin letters, digits and underscores + it can't start with a digit." + ), + ), + _io.Boolean.Input( + 'cleanup', + tooltip=( + "When enabled, each line in the sub-string is stripped " + "from any spaces at its start and end." + ), + default=True, + label_on='leading/trailing spaces', + label_off='no', + ), + _io.String.Input( + 'string', + tooltip="The actual string to add into the dict.", + multiline=True, + ), + + _DICT_INPUT_OPTIONAL, + ], + outputs=[_DICT_OUTPUT], + ) + + @classmethod + def execute(cls, + name: str, cleanup: bool, string: str, + dict: _O[_FormatDict] = None, + ) -> _io.NodeOutput: + """Update/append a string to the dict.""" + string = '' if string is None else str(string) + if cleanup: + string = '\n'.join( + x.strip() for x in string.splitlines() + ) + result= _new_dict_with_updated_key(dict, name, string) + return _io.NodeOutput(result) + +# ---------------------------------------------------------- + +class DictExtractString(_BaseNode): + """Extract a single string from a Format-Dict.""" + _schema = _io.Schema( + node_id=f'DictExtractString{_pack_id}', + display_name='Extract String from Dict', + category=_category, + description=_format_docstring(_cleandoc(__doc__)), + inputs=[ + _io.Boolean.Input( + 'cleanup_key', display_name='cleanup', + tooltip=( + "Automatically remove leading/trailing spaces and newlines from the key." + ), + default=True, + label_on='leading/trailing spaces', + label_off='no', + ), + _io.String.Input( + 'key', + tooltip=( + "Key (unique name) of a string to extract from dictionary.\n" + "If the element with such key isn't a string, it will be turned to one.\n" + "If no such key exists in the dict, an empty string returned." + ), + ), + _io.Boolean.Input( + 'show_status', + tooltip="Show the extracted string on the node itself?", + default=False, + label_on='value', + label_off='no', + ), + + _DICT_INPUT_OPTIONAL, + ], + outputs=[_io.String.Output('string')], + # hidden=[_io.Hidden.unique_id], + ) + + @classmethod + def execute(cls, + cleanup_key: bool, key: str, show_status: bool = False, + dict: _O[_FormatDict] = None + ) -> _io.NodeOutput: + """Extract a single string from the Format-Dict.""" + if cleanup_key: + for line in key.split(): + line = line.strip() # just in case \r is left + if line: + key = line + break + + # The passed dict might not actually be a dict but ANY mapping, + # which doesn't have a `get()` method. + # So direct key access is better: + string: str = '' + # noinspection PyBroadException + try: + string = dict[key] + except Exception: + pass + + if string is None: + string = '' + elif not isinstance(string, str): + string = repr(string) + + return _io.NodeOutput( + string, + ui=_ui.PreviewText(string) if show_status else None + ) + + +# ========================================================== +# Deprecated nodes with old IDs (for backwards compatibility) + + +class DictAddAnyOld1(DictAddAny): + _schema = _schema_old_name( + DictAddAny._schema, + 'StringConstructorDictAddAny' + ) + + +class DictAddStringOld1(DictAddString): + _schema = _schema_old_name( + DictAddString._schema, + 'StringConstructorDictAddString' + ) + + +class DictExtractStringOld1(_BaseNode): + # noinspection PyProtectedMember + _schema = _schema_old_name( + DictExtractString._schema, + 'StringConstructorDictExtractString', + inputs=[ + _input_override(x, id='name') if x.id == 'key' else x + for x in DictExtractString._schema.inputs + if x.id != 'cleanup_key' + ], + ) + + @classmethod + def execute(cls, + name: str, show_status: bool = False, + dict: _O[_FormatDict] = None + ) -> _io.NodeOutput: + return DictExtractString.execute( + cleanup_key=False, key=name, show_status=show_status, dict=dict + ) diff --git a/pyproject.toml b/pyproject.toml index 771309e..0dfe0c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [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)." +name = "dict_tools" +version = "3.1.0" +description = "Essential nodes to use dictionaries in ComfyUI: for smart prompt-formatting, general organization (passing a single connection instead of spaghetti), or anything else." license = {file = "LICENSE.md"} readme = "README.md" authors = [ @@ -15,13 +15,13 @@ classifiers = [ ] [project.urls] -Repository = "https://github.com/Lex-DRL/ComfyUI-StringConstructor" -"Feedback/Bugs/Feature Requests" = "https://github.com/Lex-DRL/ComfyUI-StringConstructor/issues" +Repository = "https://github.com/Lex-DRL/ComfyUI-DictTools" +"Feedback/Bugs/Feature Requests" = "https://github.com/Lex-DRL/ComfyUI-DictTools/issues" # Used by Comfy Registry https://comfyregistry.org [tool.comfy] PublisherId = "lex-drl" -DisplayName = "String Constructor (Text-Formatting)" +DisplayName = "🗂️ Dict Tools" # 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