diff --git a/__typing.py b/__typing.py index dec7f96..ea33e48 100644 --- a/__typing.py +++ b/__typing.py @@ -7,4 +7,4 @@ 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]]) +DictMap = _TypeVar('DictMap', bound=_U[_t.Dict[str, _A], _t.Mapping[str, _A]]) diff --git a/_formatter_class.py b/_formatter_class.py index ae757ec..68356c2 100644 --- a/_formatter_class.py +++ b/_formatter_class.py @@ -7,8 +7,8 @@ from dataclasses import dataclass as _dataclass, field as _field import re as _re import sys as _sys -from .__typing import _A, _U, _O, _t, T as _T, FormatDict as _FormatDict -from ._validate_funcs import _verify_input_dict +from .__typing import _A, _U, _O, _t, T as _T, DictMap as _DictMap +from ._validate_funcs import _cleanup_format_dict _RECURSION_LIMIT = max(int(_sys.getrecursionlimit()), 1) # You can externally monkey-patch it... but if it blows up, your fault 🤷🏻‍♂️single @@ -38,7 +38,7 @@ class Formatter: It's done this way to avoid extra conditions in the loop + to organize the convoluted mess of intertwined functions into a more readable code. """ - format_dict: _O[_FormatDict] = None + format_dict: _O[_DictMap] = None recursive: bool = False safe: bool = True @@ -50,8 +50,8 @@ class Formatter: if self.format_dict is None: self.format_dict = dict() - format_dict = self.format_dict - _verify_input_dict(format_dict) + format_dict = _cleanup_format_dict(self.format_dict) + self.format_dict = format_dict self.__format_single = self.__format_single_safe if self.safe else self.__format_single_unsafe if format_dict: diff --git a/_validate_funcs.py b/_validate_funcs.py index 3b8bec9..5296cf8 100644 --- a/_validate_funcs.py +++ b/_validate_funcs.py @@ -1,73 +1,174 @@ # encoding: utf-8 """ -Shared code to deal with dicts. +Shared code to verify the input dict. """ -import typing as _t - import re as _re from frozendict import frozendict as _frozendict -from .__typing import T as _T +try: + from enum import StrEnum as _StrEnum +except ImportError: + from comfy.comfy_types.node_typing import StrEnum as _StrEnum + +from .__typing import _t, T as _T, _A, _O, _U, DictMap as _DictMap -_re_valid_key_match = _re.compile("[a-zA-Z_][a-zA-Z_0-9]*$").match +_re_valid_key_match = _re.compile("[a-zA-Z_][a-zA-Z_0-9]*$", flags=_re.IGNORECASE).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}" +class _KeyErrorType(_StrEnum): + """If a dictionary key fails as an argument for ``str.format()``, this is the specific reason.""" + NOT_STRING = 'Not {a_}string{s}{val}' + EMPTY = 'Empty string{s}{val}' + STARTS_WITH_DIGIT = 'String{s} start{inv_s} with digit{val}' + INVALID_NAME = 'Wrong name{s} (must contain only numbers, latin letters and underscores){val}' + + @classmethod + def all_items(cls) -> _t.Tuple['_KeyErrorType', ...]: + # noinspection PyTypeChecker + return tuple(x for x in cls) + + @staticmethod + def check_key(key: _A) -> _O['_KeyErrorType']: + """ + Verify that the given item can be a keyword-argument name passed to ``str.format()``. + The returned value is the type of key error, if the check fails. ``None`` on success. + """ + if not isinstance(key, str): + return _KeyErrorType.NOT_STRING + if not key: + return _KeyErrorType.EMPTY + if not _re_valid_key_match(key): + if _re_starting_digits_match(key): + return _KeyErrorType.STARTS_WITH_DIGIT + return _KeyErrorType.INVALID_NAME 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}" + + @classmethod + def check_dict_keys(cls, format_dict: _DictMap) -> _t.Dict['_KeyErrorType', list]: + """ + Verify all the keys from the input dictionary to match the criteria of proper argument names for ``str.format()``. + The returned dict contains all the wrong keys, grouped by the error type. + """ + wrong_keys_grouped_by_error_type: _t.Dict[_KeyErrorType, list] = dict() + for key in format_dict.keys(): + error_type = cls.check_key(key) + if error_type is None: + continue + if error_type not in wrong_keys_grouped_by_error_type: + wrong_keys_grouped_by_error_type[error_type] = list() + wrong_keys_grouped_by_error_type[error_type].append(key) + return wrong_keys_grouped_by_error_type + + @classmethod + def format_all_errors( + cls, + wrong_keys_grouped_by_error_type: _t.Dict['_KeyErrorType', _t.Sequence], + where: str = '' + ) -> _O[str]: + """Build error message for all the pre-validated wrong keys.""" + errors_and_is_multi = [ + err_tp.format_error(wrong_keys_grouped_by_error_type[err_tp]) + for err_tp in cls.all_items() + if err_tp in wrong_keys_grouped_by_error_type + ] + if not errors_and_is_multi: + return None + + is_multi_error = len(errors_and_is_multi) > 1 + prefix = "Invalid key{s}{where_sep}{where}".format( + s='s' if is_multi_error or any(x[1] for x in errors_and_is_multi) else '', + where_sep=' ' if where else '', + where=where, + ) + + if not is_multi_error: + # Single error type + error_msg = errors_and_is_multi[0][0] + # We need to turn the fist character to lowercase: + first_char = error_msg[0].lower() + error_msg = error_msg[1:] + error_msg = f"{first_char}{error_msg}" + return f"{prefix} - {error_msg}" + + # Multiple error types + seq = '\n\n'.join(x[0] for x in errors_and_is_multi) + return f"{prefix}:\n\n{seq}" + + def format_error(self, wrong_keys: _t.Sequence) -> _t.Tuple[str, bool]: + """ + Build error message for a single ``_KeyErrorType``. + The second returned value is a boolean: whether multiple wrong keys provided. + """ + n = len(wrong_keys) + if n > 1: + s = 's' + a_ = '' + inv_s = '' + is_multi = True + wrong_keys_str = '\n\t'.join(repr(k) for k in wrong_keys) + wrong_keys_str = f':\n\t{wrong_keys_str}' else: - errors_dict[key] = f"Key (string name) must contain only numbers, latin letters and underscores. Got: {key!r}" - return None - return key + s = '' + a_ = 'a ' + inv_s = 's' + is_multi = False + if n == 1: + wrong_keys_str = f': {wrong_keys[0]!r}' + else: + wrong_keys_str = '' + return self.value.format(a_=a_, s=s, inv_s=inv_s, val=wrong_keys_str), is_multi -def _raise_from_errors_dict( - errors_dict: _t.Dict[_t.Any, str], - single_error_format: str = '{}', - multi_errors_format: str = "Invalid keys (string names): {}", -): - if not errors_dict: - return - - if len(errors_dict) == 1: - msg = single_error_format.format(next(iter(errors_dict.values()))) - else: - msg = multi_errors_format.format(', '.join(repr(x) for x in errors_dict.keys())) - raise KeyError(msg) +# ---------------------------------------------------------- -def _verify_input_dict(input_dict: _t.Dict[str, _T] = None, error_if_none=False): - """ - Verify input dict to have only valid keys. Raises errors if invalid ones found. - """ - if input_dict is None: +def __verify_dict_type(_dict: _O[_DictMap], error_if_none=False) -> _O[_DictMap]: + """Ensure that the given input-dict is at least a dict indeed.""" + if _dict is None: if error_if_none: raise TypeError("No input-dict") - return + return None # In py3.10, frozendict isn't a dict, but is a `typing.Mapping`. # So, this many types to check against: - if not isinstance(input_dict, (dict, _frozendict, _t.Mapping)): - raise TypeError(f"Input-dict isn't a dict. Got: {input_dict!r}") + if not isinstance(_dict, (dict, _frozendict, _t.Mapping)): + raise TypeError(f"Input-dict isn't a dict. Got: {_dict!r}") + return _dict - errors_dict: _t.Dict[_t.Any, str] = dict() - for key in input_dict.keys(): - _validate_key(key, errors_dict) - _raise_from_errors_dict( - errors_dict, - "Invalid input-dict: {}", "Invalid keys (string names) in input-dict: {}" - ) +def _verify_format_dict(format_dict: _DictMap = None, error_if_none=False) -> None: + """ + Verify input dict to have only the keys that match the criteria of proper argument names for ``str.format()``. + Raises error if invalid ones found. + """ + format_dict = __verify_dict_type(format_dict, error_if_none=error_if_none) + if not format_dict: + return + + wrong_keys_grouped_by_error_type = _KeyErrorType.check_dict_keys(format_dict) + error_msg = _KeyErrorType.format_all_errors(wrong_keys_grouped_by_error_type, where='in input-dict') + if not error_msg: + return + raise KeyError(error_msg) + + +def _cleanup_format_dict(format_dict: _DictMap = None, error_if_none=False) -> _t.Dict[str, _A]: + """ + Cleanup input dict by keeping only the keys that match the criteria of proper argument names for ``str.format()``. + Errors are thrown only if the provided dict isn't a dict. + Otherwise, no error is raised, just all the invalid keys are removed from the resulting dict. + """ + format_dict = __verify_dict_type(format_dict, error_if_none=error_if_none) + if not format_dict: + return {} + + error_type_detector = _KeyErrorType.check_key + return { + str(k): v + for k, v in format_dict.items() + if error_type_detector(k) is None + } diff --git a/node_formatter.py b/node_formatter.py index f7e13ea..9e7a16a 100644 --- a/node_formatter.py +++ b/node_formatter.py @@ -9,7 +9,7 @@ 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 .__typing import _A, _U, _O, _t, T as _T, DictMap as _DictMap from ._formatter_class import Formatter as _Formatter from ._io_custom import ( _BaseNode, @@ -89,7 +89,7 @@ class StringFormatter(_BaseNode): recursive_format: bool = False, safe_format: bool = True, show_status: bool = False, - dict: _O[_FormatDict] = None, + dict: _O[_DictMap] = None, ) -> _io.NodeOutput: formatter = _Formatter( format_dict=dict, recursive=recursive_format, safe=safe_format, diff --git a/node_validate.py b/node_validate.py index 6cd7a37..c5d4336 100644 --- a/node_validate.py +++ b/node_validate.py @@ -9,13 +9,13 @@ 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 ._validate_funcs import _verify_input_dict +from .__typing import _A, _U, _O, _t, T as _T, DictMap as _DictMap from ._io_custom import ( _BaseNode, _DICT_INPUT_OPTIONAL, _DICT_OUTPUT, _schema_old_name ) +from ._validate_funcs import _verify_format_dict from .docstring_formatter import format_docstring as _format_docstring # ---------------------------------------------------------- @@ -37,7 +37,7 @@ class ValidateKeys(_BaseNode): ) @classmethod - def execute(cls, dict: _O[_FormatDict] = None) -> _io.NodeOutput: + def execute(cls, dict: _O[_DictMap] = None) -> _io.NodeOutput: # try: # _verify_input_dict(dict, error_if_none=True) # except Exception: @@ -45,7 +45,7 @@ class ValidateKeys(_BaseNode): # raise # return (Null, ) # _show_text_on_node('✅', unique_id) - _verify_input_dict(dict, error_if_none=True) + _verify_format_dict(dict, error_if_none=True) return _io.NodeOutput(dict) # ==========================================================