From 61e6a64affc62212cacf8f65de051628a20d0379 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Thu, 7 May 2026 15:09:56 +0200 Subject: [PATCH 01/18] Unify all error messages & add a new option to allow the space separated `--flag value` format again --- CHANGELOG.md | 8 +++ pyproject.toml | 4 +- src/xulbux/__init__.py | 2 +- src/xulbux/console.py | 130 +++++++++++++++++++++++++------------ src/xulbux/data.py | 18 ++--- src/xulbux/format_codes.py | 4 +- src/xulbux/json.py | 2 +- tests/test_cli.py | 4 +- tests/test_console.py | 112 +++++++++++++++++++++++++++++--- tests/test_data.py | 5 +- tests/test_json.py | 4 +- 11 files changed, 224 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3c609..57e4862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ #
Changelog
+ + +## … `v1.9.8` + +* Unified all error messages throughout the whole library, to always pass the given value if the error is caused by that value being invalid. +* Added a new param `allow_space_value` to `Console.get_args()` and made `flag_value_sep` optional, which allows you to specify whether flags should be able to receive their values with a space in between (*e.g.* `--flag value` instead of just `--flag=value`). + + ## 26.04.2026 `v1.9.7` diff --git a/pyproject.toml b/pyproject.toml index db56544..c9d0703 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,14 +5,16 @@ requires = [ "mypy>=1.19.0", "mypy-extensions>=1.1.0", # TYPES FOR MyPy + "types-setuptools", "types-regex", + "types-toml", "prompt_toolkit>=3.0.41", ] build-backend = "setuptools.build_meta" [project] name = "xulbux" -version = "1.9.7" +version = "1.9.8" description = "A Python library to simplify common programming tasks." readme = "README.md" authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index ed48776..d524c47 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -1,5 +1,5 @@ __package_name__ = "xulbux" -__version__ = "1.9.7" +__version__ = "1.9.8" __description__ = "A Python library to simplify common programming tasks." __status__ = "Production/Stable" diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 0b8eec7..3b055d4 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -272,13 +272,23 @@ class Console(metaclass=_ConsoleMeta): """This class provides methods for logging and other actions within the console.""" @classmethod - def get_args(cls, arg_parse_configs: ArgParseConfigs, /, *, flag_value_sep: str = "=") -> ParsedArgs: + def get_args( + cls, + arg_parse_configs: ArgParseConfigs, + /, + *, + flag_value_sep: Optional[str] = "=", + allow_space_value: bool = True, + ) -> ParsedArgs: """Will search for the specified args in the command-line arguments and return the results as a special `ParsedArgs` object.\n ------------------------------------------------------------------------------------------------- - `arg_parse_configs` - a dictionary where each key is an alias name for the argument and the key's value is the parsing configuration for that argument - - `flag_value_sep` - the character/s used to separate flags from their values\n + - `flag_value_sep` - the character/s used to separate flags from their values; + pass `None` to disable separator-based syntax (e.g. `--flag=value`) entirely + - `allow_space_value` - whether to allow space-separated flag values (e.g. `--flag value`) + in addition to the separator-based syntax; enabled by default\n ------------------------------------------------------------------------------------------------- The `arg_parse_configs` dictionary can have the following structures for each item: 1. Simple set of flags (when no default value is needed): @@ -328,12 +338,16 @@ def get_args(cls, arg_parse_configs: ArgParseConfigs, /, *, flag_value_sep: str ) ``` ------------------------------------------------------------------------------------------------- - NOTE: Flags can ONLY receive values when the separator is present - (e.g. `--flag=value` or `--flag = value`).""" - if not flag_value_sep: - raise ValueError("The 'flag_value_sep' parameter must be a non-empty string.") - - return _ConsoleArgsParseHelper(arg_parse_configs, flag_value_sep)() + NOTE: When `allow_space_value` is `True`, a value that directly follows a flag + (e.g. `--flag value`) is consumed as that flag's value and is not available + as a positional `"after"` argument.""" + if flag_value_sep is not None and not flag_value_sep: + raise ValueError(f"The 'flag_value_sep' parameter must be a non-empty string or None, got {flag_value_sep!r}") + return _ConsoleArgsParseHelper( + arg_parse_configs, + flag_value_sep=flag_value_sep, + allow_space_value=allow_space_value, + )() @classmethod def pause_exit( @@ -402,11 +416,11 @@ def log( The log message can be formatted with special formatting codes. For more detailed information about formatting codes, see `format_codes` module documentation.""" if tab_size < 0: - raise ValueError("The 'tab_size' parameter must be a non-negative integer.") + raise ValueError(f"The 'tab_size' parameter must be a non-negative integer, got {tab_size!r}") if title_px < 0: - raise ValueError("The 'title_px' parameter must be a non-negative integer.") + raise ValueError(f"The 'title_px' parameter must be a non-negative integer, got {title_px!r}") if title_mx < 0: - raise ValueError("The 'title_mx' parameter must be a non-negative integer.") + raise ValueError(f"The 'title_mx' parameter must be a non-negative integer, got {title_mx!r}") title_fg: str = "_c" title = "" if title is None else title.strip().upper() @@ -418,7 +432,7 @@ def log( title_bg_color = Color.to_hexa(title_bg_color) title_fg = str(Color.text_color_for_on_bg(title_bg_color)) else: - raise ValueError("The 'title_bg_color' parameter must be a valid console color, RGBA value, or HEXA value.") + raise ValueError(f"The 'title_bg_color' parameter must be a valid console color, RGBA value, or HEXA value, got {title_bg_color!r}") px, mx = (" " * title_px) if has_title_bg else "", " " * title_mx tab = " " * (tab_size - 1 - ((len(mx) + (title_len := len(title) + 2 * len(px))) % tab_size)) @@ -644,9 +658,9 @@ def log_box_filled( The box content can be formatted with special formatting codes. For more detailed information about formatting codes, see `format_codes` module documentation.""" if w_padding < 0: - raise ValueError("The 'w_padding' parameter must be a non-negative integer.") + raise ValueError(f"The 'w_padding' parameter must be a non-negative integer, got {w_padding!r}") if indent < 0: - raise ValueError("The 'indent' parameter must be a non-negative integer.") + raise ValueError(f"The 'indent' parameter must be a non-negative integer, got {indent!r}") if box_bg_color is not None: if str(box_bg_color).replace(" ", "").lower() in ANSI.COLOR_VARIANTS_MAP: @@ -654,7 +668,7 @@ def log_box_filled( elif Color.is_valid_rgba(box_bg_color) or Color.is_valid_hexa(box_bg_color): box_bg_color = Color.to_hexa(box_bg_color) else: - raise ValueError("The 'box_bg_color' parameter must be a valid console color, RGBA value, or HEXA value.") + raise ValueError(f"The 'box_bg_color' parameter must be a valid console color, RGBA value, or HEXA value, got {box_bg_color!r}") lines, unfmt_lines, max_line_len = cls._prepare_log_box(values, default_color) @@ -734,14 +748,14 @@ def log_box_bordered( 10. horizontal rule 11. right horizontal rule connector""" if w_padding < 0: - raise ValueError("The 'w_padding' parameter must be a non-negative integer.") + raise ValueError(f"The 'w_padding' parameter must be a non-negative integer, got {w_padding!r}") if indent < 0: - raise ValueError("The 'indent' parameter must be a non-negative integer.") + raise ValueError(f"The 'indent' parameter must be a non-negative integer, got {indent!r}") if _border_chars is not None: if len(_border_chars) != 11: raise ValueError(f"The '_border_chars' parameter must contain exactly 11 characters, got {len(_border_chars)}") if not all(len(char) == 1 for char in _border_chars): - raise ValueError("The '_border_chars' parameter must only contain single-character strings.") + raise ValueError(f"The '_border_chars' parameter must only contain single-character strings, got {_border_chars!r}") if Color.is_valid(border_style): border_style = Color.to_hexa(border_style) @@ -938,9 +952,9 @@ def input( if mask_char is not None and len(mask_char) != 1: raise ValueError(f"The 'mask_char' parameter must be a single character, got {mask_char!r}") if min_len is not None and min_len < 0: - raise ValueError("The 'min_len' parameter must be a non-negative integer.") + raise ValueError(f"The 'min_len' parameter must be a non-negative integer, got {min_len!r}") if max_len is not None and max_len < 0: - raise ValueError("The 'max_len' parameter must be a non-negative integer.") + raise ValueError(f"The 'max_len' parameter must be a non-negative integer, got {max_len!r}") helper = _ConsoleInputHelper( mask_char=mask_char, @@ -1109,9 +1123,17 @@ def _multiline_input_submit(event: KeyPressEvent, /) -> None: class _ConsoleArgsParseHelper: """Internal, callable helper class to parse command-line arguments.""" - def __init__(self, arg_parse_configs: ArgParseConfigs, /, flag_value_sep: str): + def __init__( + self, + arg_parse_configs: ArgParseConfigs, + /, + *, + flag_value_sep: Optional[str], + allow_space_value: bool = True, + ): self.arg_parse_configs = arg_parse_configs self.flag_value_sep = flag_value_sep + self.allow_space_value = allow_space_value self.parsed_args: dict[str, ParsedArgData] = {} self.positional_configs: dict[str, str] = {} @@ -1200,7 +1222,7 @@ def find_flag_positions(self) -> None: arg = self.args[i] # CHECK FOR FLAG WITH INLINE SEPARATOR ('--flag=value') - if self.flag_value_sep in arg: + if self.flag_value_sep and self.flag_value_sep in arg: if arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup: if self.first_flag_pos is None: self.first_flag_pos = i @@ -1215,7 +1237,7 @@ def find_flag_positions(self) -> None: self.last_flag_pos = i # CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value') - if i + 1 < self.args_len and self.args[i + 1] == self.flag_value_sep: + if self.flag_value_sep and i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep: if i + 2 < self.args_len: i += 3 # SKIP FLAG, SEPARATOR, AND VALUE continue @@ -1223,6 +1245,13 @@ def find_flag_positions(self) -> None: i += 2 # SKIP FLAG AND SEPARATOR continue + # CHECK FOR SPACE-SEPARATED VALUE ('--flag value') + if self.allow_space_value and i + 1 < self.args_len: + next_arg = self.args[i + 1] + if self._is_flag_value(next_arg): + i += 2 # SKIP FLAG AND ITS SPACE-SEPARATED VALUE + continue + i += 1 def process_positional_args(self) -> None: @@ -1259,19 +1288,23 @@ def _collect_after_arg(self, alias: str, /) -> None: # SKIP THE VALUE AFTER THE LAST FLAG IF IT HAS A SEPARATOR if self.last_flag_pos is not None: # CHECK IF LAST FLAG HAS INLINE VALUE ('--flag=value') - if self.flag_value_sep in self.args[self.last_flag_pos]: + if self.flag_value_sep and self.flag_value_sep in self.args[self.last_flag_pos]: start_pos = self.last_flag_pos + 1 # VALUE IS INLINE, START AFTER THIS POSITION # CHECK IF NEXT TOKEN IS SEPARATOR ('--flag', '=', 'value') - elif start_pos < self.args_len and self.args[start_pos].strip() == self.flag_value_sep: + elif self.flag_value_sep and start_pos < self.args_len and self.args[start_pos].strip() == self.flag_value_sep: if start_pos + 1 < self.args_len: start_pos += 2 # SKIP SEPARATOR AND VALUE else: start_pos += 1 # SKIP SEPARATOR ONLY + # CHECK IF NEXT TOKEN IS SPACE-SEPARATED VALUE ('--flag value') + elif self.allow_space_value and start_pos < self.args_len and self._is_flag_value(self.args[start_pos]): + start_pos += 1 # SKIP SPACE-SEPARATED VALUE # NO SEPARATOR = FLAG HAS NO VALUE = START COLLECTING FROM NEXT POSITION for i in range(start_pos, self.args_len): + arg = self.args[i] # DON'T INCLUDE FLAGS OR SEPARATORS - if (arg := self.args[i]) == self.flag_value_sep: + if self.flag_value_sep and arg == self.flag_value_sep: continue elif self._is_positional_arg(arg): after_args.append(arg) @@ -1282,12 +1315,23 @@ def _collect_after_arg(self, alias: str, /) -> None: def _is_positional_arg(self, arg: str, /, *, allow_separator: bool = True) -> bool: """Check if an argument is positional (not a flag or separator).""" - if self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup: + if self.flag_value_sep and self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup: return True - if arg not in self.arg_lookup and (allow_separator or arg != self.flag_value_sep): + if arg not in self.arg_lookup and (allow_separator or not self.flag_value_sep or arg != self.flag_value_sep): return True return False + def _is_flag_value(self, arg: str, /) -> bool: + """Check if an argument can be treated as a space-separated flag value + (i.e. it is not a known flag, not the separator, and not a `flag=value` token).""" + if arg in self.arg_lookup: + return False + if self.flag_value_sep and arg.strip() == self.flag_value_sep: + return False + if self.flag_value_sep and self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup: + return False + return True + def process_flagged_args(self) -> None: """Process flagged arguments.""" i = 0 @@ -1296,7 +1340,7 @@ def process_flagged_args(self) -> None: arg = self.args[i] # CASE 1: FLAG WITH INLINE SEPARATOR ('--flag=value') - if self.flag_value_sep in arg: + if self.flag_value_sep and self.flag_value_sep in arg: parts = arg.split(self.flag_value_sep, 1) if (potential_flag := (parts := arg.split(self.flag_value_sep, 1))[0].strip()) in self.arg_lookup: @@ -1317,7 +1361,7 @@ def process_flagged_args(self) -> None: self.parsed_args[alias].flag = arg # CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value') - if i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep: + if self.flag_value_sep and i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep: if i + 2 < self.args_len: if (val := self.args[i + 2]) not in self.arg_lookup and val != self.flag_value_sep: self.parsed_args[alias].values = [val] @@ -1325,6 +1369,12 @@ def process_flagged_args(self) -> None: continue i += 2 continue + + # CHECK FOR SPACE-SEPARATED VALUE ('--flag value') + if self.allow_space_value and i + 1 < self.args_len and self._is_flag_value(next_arg := self.args[i + 1]): + self.parsed_args[alias].values = [next_arg] + i += 2 + continue # NO SEPARATOR = JUST A FLAG WITHOUT VALUE i += 1 @@ -1615,13 +1665,13 @@ def set_bar_format( more detailed information about formatting codes, see the `format_codes` module documentation.""" if bar_format is not None: if not any(_PATTERNS.bar.search(part) for part in bar_format): - raise ValueError("The 'bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.") + raise ValueError(f"The 'bar_format' parameter value must contain the '{{bar}}' or '{{b}}' placeholder, got {bar_format!r}") self.bar_format = bar_format if limited_bar_format is not None: if not any(_PATTERNS.bar.search(part) for part in limited_bar_format): - raise ValueError("The 'limited_bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.") + raise ValueError(f"The 'limited_bar_format' parameter value must contain the '{{bar}}' or '{{b}}' placeholder, got {limited_bar_format!r}") self.limited_bar_format = limited_bar_format @@ -1636,9 +1686,9 @@ def set_chars(self, chars: tuple[str, ...], /) -> None: characters create smooth transitions, and the last character represents empty sections. If None, uses default Unicode block characters.""" if len(chars) < 2: - raise ValueError("The 'chars' parameter must contain at least two characters (full and empty).") + raise ValueError(f"The 'chars' parameter must contain at least two characters (full and empty), got {chars!r}") elif not all(len(char) == 1 for char in chars): - raise ValueError("All elements of 'chars' must be single-character strings.") + raise ValueError(f"All elements of 'chars' must be single-character strings, got {chars!r}") self.chars = chars @@ -1658,9 +1708,9 @@ def show_progress(self, current: int, total: int, /, label: Optional[str] = None self._last_update_time = current_time if current < 0: - raise ValueError("The 'current' parameter must be a non-negative integer.") + raise ValueError(f"The 'current' parameter must be a non-negative integer, got {current!r}") if total <= 0: - raise ValueError("The 'total' parameter must be a positive integer.") + raise ValueError(f"The 'total' parameter must be a positive integer, got {total!r}") try: if not self.active: @@ -1706,7 +1756,7 @@ def progress_context(self, total: int, /, label: Optional[str] = None) -> Genera update_progress(i, f"Finalizing ({i})") # Update both ```""" if total <= 0: - raise ValueError("The 'total' parameter must be a positive integer.") + raise ValueError(f"The 'total' parameter must be a positive integer, got {total!r}") try: yield _ProgressContextHelper(self, total, label) @@ -1952,7 +2002,7 @@ def set_format(self, throbber_format: list[str] | tuple[str, ...], *, sep: Optio - `sep` -⠀the separator string used to join multiple format strings""" if not any(_PATTERNS.animation.search(fmt) for fmt in throbber_format): raise ValueError( - "At least one format string in 'throbber_format' must contain the '{animation}' or '{a}' placeholder." + f"At least one format string in 'throbber_format' must contain the '{{animation}}' or '{{a}}' placeholder, got {throbber_format!r}" ) self.throbber_format = throbber_format @@ -1963,7 +2013,7 @@ def set_frames(self, frames: tuple[str, ...], /) -> None: --------------------------------------------------------------------- - `frames` -⠀a tuple of strings representing the animation frames""" if len(frames) < 2: - raise ValueError("The 'frames' parameter must contain at least two frames.") + raise ValueError(f"The 'frames' parameter must contain at least two frames, got {frames!r}") self.frames = frames @@ -1972,7 +2022,7 @@ def set_interval(self, interval: int | float, /) -> None: ------------------------------------------------------------------- - `interval` -⠀the time in seconds between each animation frame""" if interval <= 0: - raise ValueError("The 'interval' parameter must be a positive number.") + raise ValueError(f"The 'interval' parameter must be a positive number, got {interval!r}") self.interval = interval diff --git a/src/xulbux/data.py b/src/xulbux/data.py index 8d742fa..b88be18 100644 --- a/src/xulbux/data.py +++ b/src/xulbux/data.py @@ -229,8 +229,8 @@ def remove_comments( * `value4` The whole value is removed, since the whole value was a comment. - For `key2`, the key, including its whole values will be removed. - For `key3`, since all its values are just comments, the key will still exist, but with a value of `None`.""" - if len(comment_start) == 0: - raise ValueError("The 'comment_start' parameter string must not be empty.") + if not comment_start: + raise ValueError("The 'comment_start' parameter must be a non-empty string.") return cast( DataObj, @@ -268,8 +268,8 @@ def is_equal( ------------------------------------------------------------------------------------------------ The paths from `ignore_paths` and the `path_sep` parameter work exactly the same way as for the method `Data.get_path_id()`. See its documentation for more details.""" - if len(path_sep) == 0: - raise ValueError("The 'path_sep' parameter string must not be empty.") + if not path_sep: + raise ValueError("The 'path_sep' parameter must be a non-empty string.") if isinstance(ignore_paths, str): ignore_paths = [ignore_paths] @@ -360,8 +360,8 @@ def get_path_id( … if you want to change the value of `"apples"` to `"strawberries"`, the value path would be `healthy->fruit->apples` or if you don't know that the value is `"apples"` you can also use the index of the value, so `healthy->fruit->0`.""" - if len(path_sep) == 0: - raise ValueError("The 'path_sep' parameter string must not be empty.") + if not path_sep: + raise ValueError(f"The 'path_sep' parameter must be a non-empty string, got {path_sep!r}") data = cls.remove_comments(data, comment_start=comment_start, comment_end=comment_end) if isinstance(value_paths, str): @@ -471,9 +471,9 @@ def render( --------------------------------------------------------------------------------------------------------------- For more detailed information about formatting codes, see the `format_codes` module documentation.""" if indent < 0: - raise ValueError("The 'indent' parameter must be a non-negative integer.") + raise ValueError(f"The 'indent' parameter must be a non-negative integer, got {indent!r}") if max_width <= 0: - raise ValueError("The 'max_width' parameter must be a positive integer.") + raise ValueError(f"The 'max_width' parameter must be a positive integer, got {max_width!r}") return _DataRenderHelper( cls, @@ -783,7 +783,7 @@ def __init__( if syntax_highlighting is True: syntax_highlighting = {} elif not isinstance(syntax_highlighting, dict): - raise TypeError(f"Expected 'syntax_highlighting' to be a dict or bool. Got: {type(syntax_highlighting)}") + raise TypeError(f"The 'syntax_highlighting' parameter must be a dict or bool, got {type(syntax_highlighting)}") self.syntax_hl.update({ key: (f"[{val}]", "[_]") if key in self.syntax_hl and val not in {"", None} else ("", "") diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 3490a88..3d50a72 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -298,7 +298,7 @@ def to_ansi( For exact information about how to use special formatting codes, see the `format_codes` module documentation.""" if not (0 < brightness_steps <= 100): - raise ValueError("The 'brightness_steps' parameter must be between 1 and 100.") + raise ValueError(f"The 'brightness_steps' parameter must be in range [1, 100] inclusive, got {brightness_steps!r}") if _validate_default: use_default, default_color = cls._validate_default_color(default_color) @@ -518,7 +518,7 @@ def _validate_default_color(default_color: Optional[Rgba | Hexa], /) -> tuple[bo return True, hexa(cast(str | int, default_color)).to_rgba() elif Color.is_valid_rgba(default_color, allow_alpha=False): return True, Color._parse_rgba(cast(Rgba, default_color)) - raise TypeError("The 'default_color' parameter must be either a valid RGBA or HEXA color, or None.") + raise ValueError(f"The 'default_color' parameter must be either a valid RGBA or HEXA color, or None, got {default_color!r}") @staticmethod def _formats_to_keys(formats: str, /) -> list[str]: diff --git a/src/xulbux/json.py b/src/xulbux/json.py index e5be53d..de0c0e3 100644 --- a/src/xulbux/json.py +++ b/src/xulbux/json.py @@ -79,7 +79,7 @@ def read( raise ValueError(f"Error parsing JSON in {file_path!r}:\n {fmt_error}") from e if not (processed_data := dict(Data.remove_comments(data, comment_start=comment_start, comment_end=comment_end))): - raise ValueError(f"The JSON file {file_path!r} is empty or contains only comments.") + raise ValueError(f"The JSON file {file_path!r} contains no data") return (processed_data, data) if return_original else processed_data diff --git a/tests/test_cli.py b/tests/test_cli.py index 0959413..ecb77d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -84,8 +84,8 @@ def test_show_help_does_not_require_elevated_privileges(monkeypatch: pytest.Monk def test_show_help_no_privileges_needed_when_properly_implemented(monkeypatch: pytest.MonkeyPatch): - """With the cross-platform _read_single_key implementation, show_help() must complete - without errors — no elevated privileges required.""" + """With the cross-platform _read_single_key implementation, show_help() + must complete without errors – no elevated privileges required.""" monkeypatch.setattr("xulbux.console._read_single_key", MagicMock()) # Must not raise at all diff --git a/tests/test_console.py b/tests/test_console.py index 9d98336..9ee0516 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -247,6 +247,57 @@ def test_console_supports_color(): "after": {"exists": True, "is_pos": True, "values": ["--also-no-flag"], "flag": None}, }, ), + + # --- SPACE-SEPARATED FLAG VALUES (allow_space_value=True default) --- + # SIMPLE SPACE-SEPARATED VALUE + ( + ["script.py", "--flag", "myValue"], + {"flag": {"--flag"}}, + {"flag": {"exists": True, "is_pos": False, "values": ["myValue"], "flag": "--flag"}}, + ), + # SHORT FLAG WITH SPACE-SEPARATED VALUE + ( + ["script.py", "-f", "file.txt"], + {"file": {"-f", "--file"}}, + {"file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}}, + ), + # KNOWN FLAG FOLLOWING A FLAG IS NOT CONSUMED AS VALUE + ( + ["script.py", "--msg", "--flag"], + {"message": {"--msg"}, "flag": {"--flag"}}, + { + "message": {"exists": True, "is_pos": False, "values": [], "flag": "--msg"}, + "flag": {"exists": True, "is_pos": False, "values": [], "flag": "--flag"}, + }, + ), + # MULTIPLE FLAGS WITH SPACE-SEPARATED VALUES + ( + ["script.py", "--msg", "hello", "-n", "42"], + {"message": {"--msg"}, "number": {"-n"}}, + { + "message": {"exists": True, "is_pos": False, "values": ["hello"], "flag": "--msg"}, + "number": {"exists": True, "is_pos": False, "values": ["42"], "flag": "-n"}, + }, + ), + # SPACE-SEPARATED VALUE CONSUMED BY FLAG, REMAINING TOKEN IS "after" POSITIONAL + ( + ["script.py", "--flag", "val", "after1"], + {"flag": {"--flag"}, "after": "after"}, + { + "flag": {"exists": True, "is_pos": False, "values": ["val"], "flag": "--flag"}, + "after": {"exists": True, "is_pos": True, "values": ["after1"], "flag": None}, + }, + ), + # SPACE-SEPARATED VALUE WITH BOTH "before" AND "after" POSITIONALS + ( + ["script.py", "pre", "--flag", "val", "post"], + {"before": "before", "flag": {"--flag"}, "after": "after"}, + { + "before": {"exists": True, "is_pos": True, "values": ["pre"], "flag": None}, + "flag": {"exists": True, "is_pos": False, "values": ["val"], "flag": "--flag"}, + "after": {"exists": True, "is_pos": True, "values": ["post"], "flag": None}, + }, + ), ] ) def test_get_args( @@ -274,7 +325,7 @@ def test_get_args_invalid_params(): with pytest.raises(ValueError, match="The 'flags'-key set must contain at least one flag to search for."): Console.get_args({"arg": {"flags": set(), "default": "..."}}) - with pytest.raises(ValueError, match="The 'flag_value_sep' parameter must be a non-empty string."): + with pytest.raises(ValueError, match=r"non-empty string or None, got"): Console.get_args({"arg": {"-a"}}, flag_value_sep="") @@ -299,20 +350,63 @@ def test_get_args_custom_sep(monkeypatch: pytest.MonkeyPatch): } +def test_get_args_no_sep(monkeypatch: pytest.MonkeyPatch): + """Test that flag_value_sep=None disables separator syntax and only space-separated values work.""" + # SEPARATOR SYNTAX SHOULD NOT BE RECOGNIZED – '--flag=val' IS TREATED AS A STANDALONE UNKNOWN TOKEN + monkeypatch.setattr(sys, "argv", ["script.py", "--flag", "space_val", "--other=ignored"]) + result = Console.get_args( + {"flag": {"--flag"}, "other": {"--other"}, "after": "after"}, + flag_value_sep=None, + ) + + # '--flag' consumes 'space_val' via space-separated syntax + assert result.flag.exists is True + assert result.flag.values == ["space_val"] + assert result.flag.flag == "--flag" + + # '--other=ignored' is not recognized as a flag (no separator processing) – goes to "after" + assert result.other.exists is False + assert result.other.values == [] + + assert result.after.exists is True + assert result.after.values == ["--other=ignored"] + + +def test_get_args_allow_space_value_false(monkeypatch: pytest.MonkeyPatch): + """Test that allow_space_value=False does not consume the next token as a flag value.""" + monkeypatch.setattr(sys, "argv", ["script.py", "--flag", "val", "after1"]) + result = Console.get_args( + {"flag": {"--flag"}, "after": "after"}, + allow_space_value=False, + ) + + # 'val' must NOT be consumed by --flag + assert result.flag.exists is True + assert result.flag.values == [] + assert result.flag.flag == "--flag" + + # both 'val' and 'after1' are unclaimed and collected as "after" positionals + assert result.after.exists is True + assert result.after.values == ["val", "after1"] + + def test_get_args_mixed_dash_scenarios(monkeypatch: pytest.MonkeyPatch): """Test complex scenario mixing defined flags with dash-prefixed values""" monkeypatch.setattr( sys, "argv", \ ["script.py", "before string", "-42", "-d=256", "--file=my-file.txt", "-vv", "after string", "--also-no-flag"] ) - result = Console.get_args({ - "before": "before", - "data": {"-d", "--data"}, - "file": {"-f", "--file"}, - "verbose": {"-v", "-vv", "-vvv"}, - "help": {"-h", "--help"}, - "after": "after", - }) + result = Console.get_args( + { + "before": "before", + "data": {"-d", "--data"}, + "file": {"-f", "--file"}, + "verbose": {"-v", "-vv", "-vvv"}, + "help": {"-h", "--help"}, + "after": "after", + }, + allow_space_value=False, + ) assert result.before.exists is True assert result.before.is_pos is True diff --git a/tests/test_data.py b/tests/test_data.py index 51bb54e..714eb90 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -157,7 +157,7 @@ def test_get_path_id(): } -def test_get_value_by_path_id(): +def test_get_value_by_path_id() -> None: data: dict[str, Any] = {"a": [1, {"b": "c"}], "d": ("e", "f")} path_id_1 = str(Data.get_path_id(data, "a->1->b")) path_id_2 = str(Data.get_path_id(data, "d->1")) @@ -175,10 +175,11 @@ def test_get_value_by_path_id(): Data.get_value_by_path_id({"a": [1]}, "1>01") -def test_set_value_by_path_id(): +def test_set_value_by_path_id() -> None: data: dict[str, Any] = {"a": [1, {"b": "c"}], "d": ("e", "f")} path_id_c = Data.get_path_id(data, "a->1->b") path_id_f = Data.get_path_id(data, "d->1") + assert path_id_c is not None and path_id_f is not None updated_data = Data.set_value_by_path_id(data, {path_id_c: "NEW_C", path_id_f: "NEW_F"}) # type: ignore[assignment] expected_data: dict[str, Any] = {"a": [1, {"b": "NEW_C"}], "d": ("e", "NEW_F")} diff --git a/tests/test_json.py b/tests/test_json.py index 03614c8..63050f1 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -122,12 +122,12 @@ def test_read_empty_json(tmp_path: Path): data = Json.read(str(file_path)) assert data == {} except ValueError as e: - assert "empty or contains only comments" in str(e) + assert "contains no data" in str(e) def test_read_comment_only_json(tmp_path: Path): file_path = create_test_json_string(tmp_path, "comment_only.json", '{\n">>": "comment"\n}') - with pytest.raises(ValueError, match="empty or contains only comments"): + with pytest.raises(ValueError, match="contains no data"): Json.read(str(file_path)) From 881ae5bb951085e444eacf7d7af98a2f291309f8 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Thu, 7 May 2026 15:35:55 +0200 Subject: [PATCH 02/18] Add better typing for the arg values `.get()` method --- src/xulbux/console.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 3b055d4..c2412e0 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -97,6 +97,18 @@ def dict(self) -> ArgData: """Returns the argument result as a dictionary.""" return ArgData(exists=self.exists, is_pos=self.is_pos, values=self.values, flag=self.flag) + @overload + def get(self, index: int, /) -> Optional[str]: + ... + + @overload + def get(self, index: int, /, default: None) -> Optional[str]: + ... + + @overload + def get(self, index: int, /, default: str) -> str: + ... + def get(self, index: int, /, default: Optional[str] = None) -> Optional[str]: """Safely access a value from the `values` list by index.\n ------------------------------------------------------------------- From 015ceb62210e7a1c85e82efc795bf3cad692565a Mon Sep 17 00:00:00 2001 From: XulbuX Date: Sun, 10 May 2026 22:31:00 +0200 Subject: [PATCH 03/18] Follow no-single-letter-names everywhere, remove redundant format code prefixes, reformat all docstrings & linting --- .style.yapf | 24 +- CHANGELOG.md | 7 + README.md | 20 +- setup.py | 4 +- src/xulbux/base/consts.py | 9 +- src/xulbux/base/decorators.py | 5 +- src/xulbux/base/types.py | 50 +- src/xulbux/cli/__init__.py | 1 + src/xulbux/cli/help.py | 16 +- src/xulbux/cli/tools.py | 11 +- src/xulbux/code.py | 37 +- src/xulbux/color.py | 1063 +++++++++++++++------------- src/xulbux/console.py | 582 +++++++++------ src/xulbux/data.py | 273 +++---- src/xulbux/env_path.py | 51 +- src/xulbux/file.py | 32 +- src/xulbux/file_sys.py | 95 +-- src/xulbux/format_codes.py | 414 ++++++----- src/xulbux/json.py | 84 ++- src/xulbux/regex.py | 147 ++-- src/xulbux/string.py | 86 ++- src/xulbux/system.py | 69 +- tests/test_code.py | 2 +- tests/test_color.py | 10 +- tests/test_console.py | 8 +- tests/test_metadata_consistency.py | 8 +- tests/test_regex.py | 2 +- 27 files changed, 1761 insertions(+), 1349 deletions(-) diff --git a/.style.yapf b/.style.yapf index 66e86d7..53c00ed 100644 --- a/.style.yapf +++ b/.style.yapf @@ -1,19 +1,19 @@ [style] +ALLOW_SPLIT_BEFORE_DICT_VALUE = true BASED_ON_STYLE = pep8 +BLANK_LINE_BEFORE_NESTED_CLASS_OR_DEF = 1 +BLANK_LINES_BETWEEN_TOP_LEVEL_IMPORTS_AND_VARIABLES = 2 +COALESCE_BRACKETS = true COLUMN_LIMIT = 127 -ALLOW_SPLIT_BEFORE_DICT_VALUE = true -SPLIT_BEFORE_FIRST_ARGUMENT = true -SPLIT_BEFORE_LOGICAL_OPERATOR = true -SPLIT_BEFORE_BITWISE_OPERATOR = true -SPLIT_BEFORE_ARITHMETIC_OPERATOR = true -SPLIT_BEFORE_DOT = true -SPLIT_COMPLEX_COMPREHENSION = false DEDENT_CLOSING_BRACKETS = true -INDENT_CLOSING_BRACKETS = false -COALESCE_BRACKETS = true -EACH_DICT_ENTRY_ON_SEPARATE_LINE = false -BLANK_LINES_BETWEEN_TOP_LEVEL_IMPORTS_AND_VARIABLES = 2 DISABLE_SPLIT_LIST_WITH_COMMENT = true +EACH_DICT_ENTRY_ON_SEPARATE_LINE = false +INDENT_CLOSING_BRACKETS = false SPACES_AROUND_SUBSCRIPT_COLON = false SPACES_BEFORE_COMMENT = 2 -BLANK_LINE_BEFORE_NESTED_CLASS_OR_DEF = 1 +SPLIT_BEFORE_ARITHMETIC_OPERATOR = true +SPLIT_BEFORE_BITWISE_OPERATOR = true +SPLIT_BEFORE_DOT = true +SPLIT_BEFORE_FIRST_ARGUMENT = true +SPLIT_BEFORE_LOGICAL_OPERATOR = true +SPLIT_COMPLEX_COMPREHENSION = false diff --git a/CHANGELOG.md b/CHANGELOG.md index 57e4862..608c69b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ * Unified all error messages throughout the whole library, to always pass the given value if the error is caused by that value being invalid. * Added a new param `allow_space_value` to `Console.get_args()` and made `flag_value_sep` optional, which allows you to specify whether flags should be able to receive their values with a space in between (*e.g.* `--flag value` instead of just `--flag=value`). +* Reformat all docstrings of the whole library. + +**BREAKING CHANGES:** +* Renamed `r`, `g`, `b` and `a` to `red`, `green`, `blue` and `alpha` everywhere in the library, to follow the no-single-letter-names convention. +* Renamed `h`, `s` and `l` to `hue`, `sat` and `light` everywhere in the library, to follow the no-single-letter-names convention. +* Renamed the `Console.w` and `Console.h` properties to `Console.width` and `Console.height`, to follow the no-single-letter-names convention. +* Removed the `background:` and `bright:` prefixes from the library, so now you can just use the `bg:` and `br:` ones, for consistency. diff --git a/README.md b/README.md index 2533e61..289a48d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![](https://img.shields.io/pypi/v/xulbux?style=flat&labelColor=404560&color=7075FF)](https://pypi.org/project/xulbux) [![](https://img.shields.io/pepy/dt/xulbux?style=flat&labelColor=404560&color=7075FF)](https://clickpy.clickhouse.com/dashboard/xulbux) [![](https://img.shields.io/github/license/xulbux/python-lib-xulbux?style=flat&labelColor=405555&color=70FFEE)](https://github.com/xulbux/python-lib-xulbux/blob/main/LICENSE) [![](https://img.shields.io/github/last-commit/xulbux/python-lib-xulbux?style=flat&labelColor=554045&color=FF6065)](https://github.com/xulbux/python-lib-xulbux/commits) [![](https://img.shields.io/github/issues/xulbux/python-lib-xulbux?style=flat&labelColor=554045&color=FF6065)](https://github.com/xulbux/python-lib-xulbux/issues) [![](https://img.shields.io/github/stars/xulbux/python-lib-xulbux?label=★&style=flat&labelColor=604A40&color=FF9673)](https://github.com/xulbux/python-lib-xulbux/stargazers) **`xulbux`** is a library that contains many useful classes, types, and functions, -ranging from console logging and working with colors to file management and system operations. +ranging from terminal logging and working with colors to file management and system operations. The library is designed to simplify common programming tasks and improve code readability through its collection of tools. For precise information about the library, see the library's [**documentation**](https://github.com/xulbux/python-lib-xulbux/wiki).
@@ -17,15 +17,15 @@ For the libraries latest changes and updates, see the [**change log**](https://g ## Installation -Run the following commands in a console with administrator privileges, so the actions take effect for all users. +Run the following commands in a terminal with administrator privileges, so the actions take effect for all users. Install the library and all its dependencies with the command: -```console +```shell pip install xulbux ``` Upgrade the library and all its dependencies to their latest available version with the command: -```console +```shell pip install --upgrade xulbux ``` @@ -33,12 +33,12 @@ pip install --upgrade xulbux ## CLI Commands -When the library is installed, the following commands are available in the console: +When the library is installed, the following commands are available in the terminal: -| Command | Description | -| :---------------- | :--------------------------------------------------------------- | -| `xulbux-lib` | Show some information about the library. | -| `xulbux-lib fc` | Parse and render a string's format codes as ANSI console output. | +| Command | Description | +| :---------------- | :---------------------------------------------------------------- | +| `xulbux-lib` | Show some information about the library. | +| `xulbux-lib fc` | Parse and render a string's format codes as ANSI terminal output. |
@@ -131,7 +131,7 @@ from xulbux.color import rgba, hsla, hexa format_codes FormatCodes class, which includes methods to print and work with strings that contain
- special formatting codes, which are then converted to ANSI codes for pretty console output. + special formatting codes, which are then converted to ANSI codes for pretty terminal output. json diff --git a/setup.py b/setup.py index 50eeefc..b805a70 100644 --- a/setup.py +++ b/setup.py @@ -61,8 +61,8 @@ def generate_stubs_for_package(): print(f"\nStub generation complete. ({generated_count} generated, {skipped_count} copied)\n") - except Exception as e: - fmt_error = "\n ".join(str(e).splitlines()) + except Exception as exc: + fmt_error = "\n ".join(str(exc).splitlines()) print(f"[WARNING] Could not generate stubs:\n {fmt_error}\n") diff --git a/src/xulbux/base/consts.py b/src/xulbux/base/consts.py index 0bd6d13..df59581 100644 --- a/src/xulbux/base/consts.py +++ b/src/xulbux/base/consts.py @@ -90,6 +90,7 @@ class ANSI: @classmethod def seq(cls, placeholders: int = 1, /) -> FormattableString: """Generates an ANSI escape sequence with the specified number of placeholders.""" + return cls.CHAR + cls.START + cls.SEP.join(["{}" for _ in range(placeholders)]) + cls.END SEQ_COLOR: Final[FormattableString] = CHAR + START + "38" + SEP + "2" + SEP + "{}" + SEP + "{}" + SEP + "{}" + END @@ -123,14 +124,6 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString: "br:magenta", "br:cyan", "br:white", - "bright:black", - "bright:red", - "bright:green", - "bright:yellow", - "bright:blue", - "bright:magenta", - "bright:cyan", - "bright:white", } """All color variants that can be used in formatting.""" diff --git a/src/xulbux/base/decorators.py b/src/xulbux/base/decorators.py index 7d3d092..18213be 100644 --- a/src/xulbux/base/decorators.py +++ b/src/xulbux/base/decorators.py @@ -18,8 +18,9 @@ def mypyc_attr(**kwargs: Any) -> Callable[[T], T]: or acts as a no-op decorator when `mypy_extensions` is not installed.\n This allows the use of `mypyc` compilation hints for compiling without making `mypy_extensions` a required dependency.\n - ----------------------------------------------------------------------------------------- - - `**kwargs` -⠀keyword arguments to pass to `mypy_extensions.mypyc_attr` if available""" + ------------------------------------------------------------------------------------------- + * `**kwargs` – keyword arguments to pass to `mypy_extensions.mypyc_attr` if available""" + try: from mypy_extensions import mypyc_attr as _mypyc_attr return _mypyc_attr(**kwargs) diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py index af8cc2d..d246dfa 100644 --- a/src/xulbux/base/types.py +++ b/src/xulbux/base/types.py @@ -40,24 +40,24 @@ class _RgbaObj(Protocol): """Protocol for rgba-like color objects (structurally matches `rgba`).""" - r: int - g: int - b: int - a: Optional[float] + red: int + green: int + blue: int + alpha: Optional[float] class _HslaObj(Protocol): """Protocol for hsla-like color objects (structurally matches `hsla`).""" - h: int - s: int - l: int - a: Optional[float] + hue: int + sat: int + light: int + alpha: Optional[float] class _HexaObj(Protocol): """Protocol for hexa-like color objects (structurally matches `hexa`).""" - r: int - g: int - b: int - a: Optional[float] + red: int + green: int + blue: int + alpha: Optional[float] Rgba: TypeAlias = Union[ tuple[Int_0_255, Int_0_255, Int_0_255], @@ -118,24 +118,24 @@ class ArgData(TypedDict): class RgbaDict(TypedDict): """Dictionary schema for RGBA color components.""" - r: Int_0_255 - g: Int_0_255 - b: Int_0_255 - a: Optional[Float_0_1] + red: Int_0_255 + green: Int_0_255 + blue: Int_0_255 + alpha: Optional[Float_0_1] class HslaDict(TypedDict): """Dictionary schema for HSLA color components.""" - h: Int_0_360 - s: Int_0_100 - l: Int_0_100 - a: Optional[Float_0_1] + hue: Int_0_360 + sat: Int_0_100 + light: Int_0_100 + alpha: Optional[Float_0_1] class HexaDict(TypedDict): """Dictionary schema for HEXA color components.""" - r: str - g: str - b: str - a: Optional[str] + red: str + green: str + blue: str + alpha: Optional[str] class MissingLibsMsgs(TypedDict): """Configuration schema for custom messages in `System.check_libs()` when checking library dependencies.""" @@ -146,7 +146,7 @@ class MissingLibsMsgs(TypedDict): ################################################## Protocol ################################################## class ProgressUpdater(Protocol): - """Protocol for a progress updater function used in console progress bars.""" + """Protocol for a progress updater function used in terminal progress bars.""" def __call__(self, current: Optional[int] = None, label: Optional[str] = None) -> None: """Update the current progress value and/or label.""" diff --git a/src/xulbux/cli/__init__.py b/src/xulbux/cli/__init__.py index 998352d..54b69dc 100644 --- a/src/xulbux/cli/__init__.py +++ b/src/xulbux/cli/__init__.py @@ -3,6 +3,7 @@ def main() -> None: """Main entry point for the `xulbux-lib` CLI command.""" + match sys.argv[1] if len(sys.argv) > 1 else "": case "fc": from .tools import render_format_codes diff --git a/src/xulbux/cli/help.py b/src/xulbux/cli/help.py index 6bf2d89..5315e5f 100644 --- a/src/xulbux/cli/help.py +++ b/src/xulbux/cli/help.py @@ -10,23 +10,27 @@ def get_latest_version() -> Optional[str]: """Fetches the latest version of the library from PyPI.""" + with _request.urlopen(URL) as response: if response.status == 200: - data = _json.load(response) - return data["info"]["version"] + return _json.load(response)["info"]["version"] else: raise HTTPError(URL, response.status, "Failed to fetch latest version info", response.headers, None) def is_latest_version() -> Optional[bool]: - """Checks if the currently installed version of the + """Checks if the currently installed version of the
library is the latest one available on PyPI.""" + try: if (latest := get_latest_version()) in {"", None}: return None + latest_v_parts = tuple(int(part) for part in (latest or "").lower().lstrip("v").split(".")) installed_v_parts = tuple(int(part) for part in __version__.lower().lstrip("v").split(".")) + return latest_v_parts <= installed_v_parts + except Exception: return None @@ -82,7 +86,9 @@ def is_latest_version() -> Optional[bool]: def show_help() -> None: - """CLI command function for `xulbux-lib` command, which shows some information about the library.""" - FormatCodes._config_console() + """CLI command function for `xulbux-lib` command,
+ which shows some information about the library.""" + + FormatCodes._config_terminal() print(CLI_HELP) Console.pause_exit(" [dim](Press any key to exit...)\n\n", pause=True) diff --git a/src/xulbux/cli/tools.py b/src/xulbux/cli/tools.py index 410c584..e701dd8 100644 --- a/src/xulbux/cli/tools.py +++ b/src/xulbux/cli/tools.py @@ -3,14 +3,17 @@ def render_format_codes(): - """CLI command function for `xulbux-lib fc` command, which allows you to parse - and render a given string's format codes as ANSI console output.""" + """CLI command function for `xulbux-lib fc` command, which allows you to parse
+ and render a given string's format codes as ANSI terminal output.""" + args = Console.get_args({"input": "before"}) vals = args.input.values[1:] # EXCLUDE THE COMMAND ITSELF if not vals: - FormatCodes.print("\n[_|i|dim]Provide a string to parse and render\n" - "its format codes as ANSI console output.[_]\n") + FormatCodes.print( + "\n[_|i|dim]Provide a string to parse and render\n" + "its format codes as ANSI terminal output.[_]\n" + ) else: ansi = FormatCodes.to_ansi("".join(vals)) diff --git a/src/xulbux/code.py b/src/xulbux/code.py index dc789ed..7780a32 100644 --- a/src/xulbux/code.py +++ b/src/xulbux/code.py @@ -16,9 +16,10 @@ class Code: @classmethod def add_indent(cls, code: str, indent: int, /) -> str: """Adds `indent` spaces at the beginning of each line.\n - -------------------------------------------------------------------------- - - `code` -⠀the code to indent - - `indent` -⠀the amount of spaces to add at the beginning of each line""" + ----------------------------------------------------------------------------- + * `code` – The code to indent. + * `indent` – The amount of spaces to add at the beginning of each line.""" + if indent < 0: raise ValueError(f"The 'indent' parameter must be non-negative, got {indent!r}") @@ -28,17 +29,19 @@ def add_indent(cls, code: str, indent: int, /) -> str: def get_tab_spaces(cls, code: str, /) -> int: """Will try to get the amount of spaces used for indentation.\n ---------------------------------------------------------------- - - `code` -⠀the code to analyze""" + * `code` – The code to analyze.""" + indents = [len(line) - len(line.lstrip()) for line in String.get_lines(code, remove_empty_lines=True)] - return min(non_zero_indents) if (non_zero_indents := [i for i in indents if i > 0]) else 0 + return min(non_zero_indents) if (non_zero_indents := [indt for indt in indents if indt > 0]) else 0 @classmethod def change_tab_size(cls, code: str, new_tab_size: int, /, *, remove_empty_lines: bool = False) -> str: """Replaces all tabs with `new_tab_size` spaces.\n - -------------------------------------------------------------------------------- - - `code` -⠀the code to modify the tab size of - - `new_tab_size` -⠀the new amount of spaces per tab - - `remove_empty_lines` -⠀is true, empty lines will be removed in the process""" + ----------------------------------------------------------------------------------- + * `code` – The code to modify the tab size of. + * `new_tab_size` – The new amount of spaces per tab. + * `remove_empty_lines` – If true, empty lines will be removed in the process.""" + if new_tab_size < 0: raise ValueError(f"The 'new_tab_size' parameter must be non-negative, got {new_tab_size!r}") @@ -60,7 +63,8 @@ def change_tab_size(cls, code: str, new_tab_size: int, /, *, remove_empty_lines: def get_func_calls(cls, code: str, /) -> list[list[Any]]: """Will try to get all function calls and return them as a list.\n ------------------------------------------------------------------- - - `code` -⠀the code to analyze""" + * `code` – The code to analyze.""" + nested_func_calls: list[list[Any]] = [] for _, func_attrs in (funcs := _rx.findall(r"(?i)" + Regex.func_call(), code)): @@ -72,14 +76,15 @@ def get_func_calls(cls, code: str, /) -> list[list[Any]]: @classmethod def is_js(cls, code: str, /, *, funcs: set[str] = {"__", "$t", "$lang"}) -> bool: """Will check if the code is very likely to be JavaScript.\n - ------------------------------------------------------------- - - `code` -⠀the code to analyze - - `funcs` -⠀a list of custom function names to check for""" + --------------------------------------------------------------- + * `code` – The code to analyze. + * `funcs` – A list of custom function names to check for.""" + if len(code.strip()) < 3: return False - for func in funcs: - if _rx.match(r"^[\s\n]*" + _rx.escape(func) + r"\([^\)]*\)[\s\n]*$", code): + for fn in funcs: + if _rx.match(r"^[\s\n]*" + _rx.escape(fn) + r"\([^\)]*\)[\s\n]*$", code): return True direct_js_patterns = [ @@ -105,7 +110,7 @@ def is_js(cls, code: str, /, *, funcs: set[str] = {"__", "$t", "$lang"}) -> bool return True js_score = 0.0 - funcs_pattern = r"(" + "|".join(_rx.escape(func) for func in funcs) + r")" + Regex.brackets("()") + funcs_pattern = r"(" + "|".join(_rx.escape(fn) for fn in funcs) + r")" + Regex.brackets("()") js_indicators: list[tuple[str, float]] = [ (r"\b(var|let|const)\s+[\w_$]+", 2.0), # JS VARIABLE DECLARATIONS (r"\$[\w_$]+\s*=", 2.0), # jQuery-STYLE VARIABLES diff --git a/src/xulbux/color.py b/src/xulbux/color.py index dd912ed..b52e3a3 100644 --- a/src/xulbux/color.py +++ b/src/xulbux/color.py @@ -18,61 +18,62 @@ class rgba: """An RGB/RGBA color object that includes a bunch of methods to manipulate the color.\n ---------------------------------------------------------------------------------------- - - `r` -⠀the red channel in range [0, 255] inclusive - - `g` -⠀the green channel in range [0, 255] inclusive - - `b` -⠀the blue channel in range [0, 255] inclusive - - `a` -⠀the alpha channel in range [0.0, 1.0] inclusive - or `None` if the color has no alpha channel\n + * `red` – The red channel in range [0, 255] inclusive. + * `green` – The green channel in range [0, 255] inclusive. + * `blue` – The blue channel in range [0, 255] inclusive. + * `alpha` – The alpha channel in range [0.0, 1.0] inclusive
+ or `None` if the color has no alpha channel. ---------------------------------------------------------------------------------------- Includes methods: - - `to_hsla()` to convert to HSL color - - `to_hexa()` to convert to HEX color - - `has_alpha()` to check if the color has an alpha channel - - `lighten(amount)` to create a lighter version of the color - - `darken(amount)` to create a darker version of the color - - `saturate(amount)` to increase color saturation - - `desaturate(amount)` to decrease color saturation - - `rotate(degrees)` to rotate the hue by degrees - - `invert()` to get the inverse color - - `grayscale()` to convert to grayscale - - `blend(other, ratio)` to blend with another color - - `is_dark()` to check if the color is considered dark - - `is_light()` to check if the color is considered light - - `is_grayscale()` to check if the color is grayscale - - `is_opaque()` to check if the color has no transparency - - `with_alpha(alpha)` to create a new color with different alpha - - `complementary()` to get the complementary color""" - - def __init__(self, r: int, g: int, b: int, a: Optional[float] = None, /, *, _validate: bool = True): - self.r: int + * `to_hsla()` to convert to HSL color + * `to_hexa()` to convert to HEX color + * `has_alpha()` to check if the color has an alpha channel + * `lighten(amount)` to create a lighter version of the color + * `darken(amount)` to create a darker version of the color + * `saturate(amount)` to increase color saturation + * `desaturate(amount)` to decrease color saturation + * `rotate(degrees)` to rotate the hue by degrees + * `invert()` to get the inverse color + * `grayscale()` to convert to grayscale + * `blend(other, ratio)` to blend with another color + * `is_dark()` to check if the color is considered dark + * `is_light()` to check if the color is considered light + * `is_grayscale()` to check if the color is grayscale + * `is_opaque()` to check if the color has no transparency + * `with_alpha(alpha)` to create a new color with different alpha + * `complementary()` to get the complementary color""" + + def __init__(self, red: int, green: int, blue: int, alpha: Optional[float] = None, /, *, _validate: bool = True): + self.red: int """The red channel in range [0, 255] inclusive.""" - self.g: int + self.green: int """The green channel in range [0, 255] inclusive.""" - self.b: int + self.blue: int """The blue channel in range [0, 255] inclusive.""" - self.a: Optional[float] + self.alpha: Optional[float] """The alpha channel in range [0.0, 1.0] inclusive or `None` if not set.""" if not _validate: - self.r, self.g, self.b, self.a = r, g, b, a + self.red, self.green, self.blue, self.alpha = red, green, blue, alpha return - if not all((0 <= x <= 255) for x in (r, g, b)): + if not all((0 <= ch <= 255) for ch in (red, green, blue)): raise ValueError( - f"The 'r', 'g' and 'b' parameters must be integers in range [0, 255] inclusive, got {r=} {g=} {b=}" + f"The 'red', 'green' and 'blue' parameters must be integers in range [0, 255] inclusive, got {red=!r} {green=!r} {blue=!r}" ) - if a is not None and not (0.0 <= a <= 1.0): - raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}") + if alpha is not None and not (0.0 <= alpha <= 1.0): + raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") - self.r, self.g, self.b = r, g, b - self.a = None if a is None else (1.0 if a > 1.0 else float(a)) + self.red, self.green, self.blue = red, green, blue + self.alpha = None if alpha is None else (1.0 if alpha > 1.0 else float(alpha)) def __len__(self) -> int: """The number of components in the color (3 or 4).""" - return 3 if self.a is None else 4 + + return 3 if self.alpha is None else 4 def __iter__(self) -> Iterator[int | Optional[float]]: - return iter((self.r, self.g, self.b) + (() if self.a is None else (self.a, ))) + return iter((self.red, self.green, self.blue) + (() if self.alpha is None else (self.alpha, ))) @overload def __getitem__(self, index: Literal[0, 1, 2], /) -> int: @@ -87,244 +88,269 @@ def __getitem__(self, index: int, /) -> int | Optional[float]: ... def __getitem__(self, index: int, /) -> int | Optional[float]: - return ((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))[index] + return ((self.red, self.green, self.blue) + (() if self.alpha is None else (self.alpha, )))[index] def __eq__(self, other: object, /) -> bool: """Check if two `rgba` objects are the same color.""" + if not isinstance(other, rgba): return False - return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a) + return (self.red, self.green, self.blue, self.alpha) == (other.red, other.green, other.blue, other.alpha) def __ne__(self, other: object, /) -> bool: """Check if two `rgba` objects are different colors.""" + return not self.__eq__(other) def __repr__(self) -> str: - return f"rgba({self.r}, {self.g}, {self.b}{'' if self.a is None else f', {self.a}'})" + return f"rgba({self.red}, {self.green}, {self.blue}{'' if self.alpha is None else f', {self.alpha}'})" def __str__(self) -> str: return self.__repr__() def dict(self) -> RgbaDict: - """Returns the color components as a dictionary with keys `"r"`, `"g"`, `"b"` and optionally `"a"`.""" - return {"r": self.r, "g": self.g, "b": self.b, "a": self.a} + """Returns the color components as a dictionary with keys `"red"`, `"green"`, `"blue"` and optionally `"alpha"`.""" + + return RgbaDict(red=self.red, green=self.green, blue=self.blue, alpha=self.alpha) def values(self) -> tuple[int, int, int, Optional[float]]: - """Returns the color components as separate values `r, g, b, a`.""" - return self.r, self.g, self.b, self.a + """Returns the color components as separate values `red, green, blue, alpha`.""" + + return self.red, self.green, self.blue, self.alpha def to_hsla(self) -> hsla: """Returns the color as `hsla()` color object.""" - h, s, l = self._rgb_to_hsl(self.r, self.g, self.b) - return hsla(h, s, l, self.a, _validate=False) + + hue, sat, light = self._rgb_to_hsl(self.red, self.green, self.blue) + return hsla(hue, sat, light, self.alpha, _validate=False) def to_hexa(self) -> hexa: """Returns the color as `hexa()` color object.""" - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" - return self.a is not None + + return self.alpha is not None def lighten(self, amount: float, /) -> rgba: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_hsla().lighten(amount).to_rgba().values() - return rgba(self.r, self.g, self.b, self.a, _validate=False) + self.red, self.green, self.blue, self.alpha = self.to_hsla().lighten(amount).to_rgba().values() + return rgba(self.red, self.green, self.blue, self.alpha, _validate=False) def darken(self, amount: float, /) -> rgba: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_hsla().darken(amount).to_rgba().values() - return rgba(self.r, self.g, self.b, self.a, _validate=False) + self.red, self.green, self.blue, self.alpha = self.to_hsla().darken(amount).to_rgba().values() + return rgba(self.red, self.green, self.blue, self.alpha, _validate=False) def saturate(self, amount: float, /) -> rgba: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_hsla().saturate(amount).to_rgba().values() - return rgba(self.r, self.g, self.b, self.a, _validate=False) + self.red, self.green, self.blue, self.alpha = self.to_hsla().saturate(amount).to_rgba().values() + return rgba(self.red, self.green, self.blue, self.alpha, _validate=False) def desaturate(self, amount: float, /) -> rgba: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_hsla().desaturate(amount).to_rgba().values() - return rgba(self.r, self.g, self.b, self.a, _validate=False) + self.red, self.green, self.blue, self.alpha = self.to_hsla().desaturate(amount).to_rgba().values() + return rgba(self.red, self.green, self.blue, self.alpha, _validate=False) def rotate(self, degrees: int, /) -> rgba: """Rotates the colors hue by the specified number of degrees.""" - self.r, self.g, self.b, self.a = self.to_hsla().rotate(degrees).to_rgba().values() - return rgba(self.r, self.g, self.b, self.a, _validate=False) + + self.red, self.green, self.blue, self.alpha = self.to_hsla().rotate(degrees).to_rgba().values() + return rgba(self.red, self.green, self.blue, self.alpha, _validate=False) def invert(self, *, invert_alpha: bool = False) -> rgba: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" - self.r, self.g, self.b = 255 - self.r, 255 - self.g, 255 - self.b - if invert_alpha and self.a is not None: - self.a = 1 - self.a - return rgba(self.r, self.g, self.b, self.a, _validate=False) + + self.red, self.green, self.blue = 255 - self.red, 255 - self.green, 255 - self.blue + if invert_alpha and self.alpha is not None: + self.alpha = 1 - self.alpha + return rgba(self.red, self.green, self.blue, self.alpha, _validate=False) def grayscale(self, *, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> rgba: """Converts the color to grayscale using the luminance formula.\n - --------------------------------------------------------------------------- - - `method` -⠀the luminance calculation method to use: - * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception) - * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients - * `"simple"` Simple arithmetic mean (less accurate) - * `"bt601"` ITU-R BT.601 standard (older TV standard)""" + ------------------------------------------------------------------------------- + * `method` – The luminance calculation method to use: + - `"wcag2"` WCAG 2.0 standard (default and most accurate for perception) + - `"wcag3"` draft WCAG 3.0 standard with improved coefficients + - `"simple"` simple arithmetic mean (less accurate) + - `"bt601"` ITU-R BT.601 standard (older TV standard)""" + # THE 'method' PARAM IS CHECKED IN 'Color.luminance()' - self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method)) - return rgba(self.r, self.g, self.b, self.a, _validate=False) + self.red = self.green = self.blue = int(Color.luminance(self.red, self.green, self.blue, method=method)) + return rgba(self.red, self.green, self.blue, self.alpha, _validate=False) def blend(self, other: Rgba, /, ratio: float = 0.5, *, additive_alpha: bool = False) -> rgba: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - - `other` -⠀the other RGBA color to blend with - - `ratio` -⠀the blend ratio between the two colors: - * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture) - * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture) - * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture) - - `additive_alpha` -⠀whether to blend the alpha channels additively or not""" + * `other` – The other RGBA color to blend with. + * `ratio` – The blend ratio between the two colors: + - If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture). + - If `ratio` is `0.5` it means 50% of both colors (1:1 mixture). + - If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture). + * `additive_alpha` – Whether to blend the alpha channels additively or not.""" + if not (0.0 <= ratio <= 1.0): raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}") other_rgba = Color.to_rgba(other) - ratio *= 2 - self.r = int(max(0, min(255, int((self.r * (2 - ratio)) + (other_rgba.r * ratio) + 0.5)))) - self.g = int(max(0, min(255, int((self.g * (2 - ratio)) + (other_rgba.g * ratio) + 0.5)))) - self.b = int(max(0, min(255, int((self.b * (2 - ratio)) + (other_rgba.b * ratio) + 0.5)))) - none_alpha = self.a is None and (len(other_rgba) <= 3 or other_rgba[3] is None) + + self.red = int(max(0, min(255, int((self.red * (2 - ratio)) + (other_rgba.red * ratio) + 0.5)))) + self.green = int(max(0, min(255, int((self.green * (2 - ratio)) + (other_rgba.green * ratio) + 0.5)))) + self.blue = int(max(0, min(255, int((self.blue * (2 - ratio)) + (other_rgba.blue * ratio) + 0.5)))) + none_alpha = self.alpha is None and (len(other_rgba) <= 3 or other_rgba[3] is None) if not none_alpha: - self_a: float = 1.0 if self.a is None else self.a + self_a: float = 1.0 if self.alpha is None else self.alpha other_a: float = cast(float, 1.0 if other_rgba[3] is None else other_rgba[3]) if len(other_rgba) > 3 else 1.0 if additive_alpha: - self.a = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio))) + self.alpha = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio))) else: - self.a = max(0, min(1, (self_a * (1 - (ratio / 2))) + (other_a * (ratio / 2)))) + self.alpha = max(0, min(1, (self_a * (1 - (ratio / 2))) + (other_a * (ratio / 2)))) else: - self.a = None + self.alpha = None - return rgba(self.r, self.g, self.b, None if none_alpha else self.a, _validate=False) + return rgba(self.red, self.green, self.blue, None if none_alpha else self.alpha, _validate=False) def is_dark(self) -> bool: """Returns `True` if the color is considered dark (`lightness < 50%`).""" + return self.to_hsla().is_dark() def is_light(self) -> bool: """Returns `True` if the color is considered light (`lightness >= 50%`).""" + return not self.is_dark() def is_grayscale(self) -> bool: """Returns `True` if the color is grayscale.""" - return self.r == self.g == self.b + + return self.red == self.green == self.blue def is_opaque(self) -> bool: """Returns `True` if the color has no transparency.""" - return self.a == 1 or self.a is None + + return self.alpha == 1 or self.alpha is None def with_alpha(self, alpha: float, /) -> rgba: """Returns a new color with the specified alpha value.""" + if not (0.0 <= alpha <= 1.0): raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") - return rgba(self.r, self.g, self.b, alpha, _validate=False) + return rgba(self.red, self.green, self.blue, alpha, _validate=False) def complementary(self) -> rgba: """Returns the complementary color (180 degrees on the color wheel).""" + return self.to_hsla().complementary().to_rgba() @staticmethod - def _rgb_to_hsl(r: int, g: int, b: int) -> tuple[int, int, int]: + def _rgb_to_hsl(red: int, green: int, blue: int) -> tuple[int, int, int]: """Internal method to convert RGB to HSL color space.""" - _r, _g, _b = r / 255.0, g / 255.0, b / 255.0 - max_c, min_c = max(_r, _g, _b), min(_r, _g, _b) - l = (max_c + min_c) / 2 + + _red, _green, _blue = red / 255.0, green / 255.0, blue / 255.0 + max_c, min_c = max(_red, _green, _blue), min(_red, _green, _blue) + light = (max_c + min_c) / 2 if max_c == min_c: - h = s = 0.0 + hue = sat = 0.0 + else: delta = max_c - min_c - s = delta / (1 - abs(2 * l - 1)) + sat = delta / (1 - abs(2 * light - 1)) - if max_c == _r: - h = ((_g - _b) / delta) % 6 - elif max_c == _g: - h = ((_b - _r) / delta) + 2 + if max_c == _red: + hue = ((_green - _blue) / delta) % 6 + elif max_c == _green: + hue = ((_blue - _red) / delta) + 2 else: - h = ((_r - _g) / delta) + 4 - h /= 6 + hue = ((_red - _green) / delta) + 4 + + hue /= 6 - return int(round(h * 360)), int(round(s * 100)), int(round(l * 100)) + return int(round(hue * 360)), int(round(sat * 100)), int(round(light * 100)) class hsla: """A HSL/HSLA color object that includes a bunch of methods to manipulate the color.\n --------------------------------------------------------------------------------------- - - `h` -⠀the hue channel in range [0, 360] inclusive - - `s` -⠀the saturation channel in range [0, 100] inclusive - - `l` -⠀the lightness channel in range [0, 100] inclusive - - `a` -⠀the alpha channel in range [0.0, 1.0] inclusive - or `None` if the color has no alpha channel\n + * `hue` – The hue channel in range [0, 360] inclusive. + * `sat` – The saturation channel in range [0, 100] inclusive. + * `light` – The lightness channel in range [0, 100] inclusive. + * `alpha` – The alpha channel in range [0.0, 1.0] inclusive
+ or `None` if the color has no alpha channel. --------------------------------------------------------------------------------------- Includes methods: - - `to_rgba()` to convert to RGB color - - `to_hexa()` to convert to HEX color - - `has_alpha()` to check if the color has an alpha channel - - `lighten(amount)` to create a lighter version of the color - - `darken(amount)` to create a darker version of the color - - `saturate(amount)` to increase color saturation - - `desaturate(amount)` to decrease color saturation - - `rotate(degrees)` to rotate the hue by degrees - - `invert()` to get the inverse color - - `grayscale()` to convert to grayscale - - `blend(other, ratio)` to blend with another color - - `is_dark()` to check if the color is considered dark - - `is_light()` to check if the color is considered light - - `is_grayscale()` to check if the color is grayscale - - `is_opaque()` to check if the color has no transparency - - `with_alpha(alpha)` to create a new color with different alpha - - `complementary()` to get the complementary color""" - - def __init__(self, h: int, s: int, l: int, a: Optional[float] = None, /, *, _validate: bool = True): - self.h: int + * `to_rgba()` to convert to RGB color + * `to_hexa()` to convert to HEX color + * `has_alpha()` to check if the color has an alpha channel + * `lighten(amount)` to create a lighter version of the color + * `darken(amount)` to create a darker version of the color + * `saturate(amount)` to increase color saturation + * `desaturate(amount)` to decrease color saturation + * `rotate(degrees)` to rotate the hue by degrees + * `invert()` to get the inverse color + * `grayscale()` to convert to grayscale + * `blend(other, ratio)` to blend with another color + * `is_dark()` to check if the color is considered dark + * `is_light()` to check if the color is considered light + * `is_grayscale()` to check if the color is grayscale + * `is_opaque()` to check if the color has no transparency + * `with_alpha(alpha)` to create a new color with different alpha + * `complementary()` to get the complementary color""" + + def __init__(self, hue: int, sat: int, light: int, alpha: Optional[float] = None, /, *, _validate: bool = True): + self.hue: int """The hue channel in range [0, 360] inclusive.""" - self.s: int + self.sat: int """The saturation channel in range [0, 100] inclusive.""" - self.l: int + self.light: int """The lightness channel in range [0, 100] inclusive.""" - self.a: Optional[float] + self.alpha: Optional[float] """The alpha channel in range [0.0, 1.0] inclusive or `None` if not set.""" if not _validate: - self.h, self.s, self.l, self.a = h, s, l, a + self.hue, self.sat, self.light, self.alpha = hue, sat, light, alpha return - if not (0 <= h <= 360): - raise ValueError(f"The 'h' parameter must be in range [0, 360] inclusive, got {h!r}") - if not all((0 <= x <= 100) for x in (s, l)): - raise ValueError(f"The 's' and 'l' parameters must be in range [0, 100] inclusive, got {s=} {l=}") - if a is not None and not (0.0 <= a <= 1.0): - raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}") + if not (0 <= hue <= 360): + raise ValueError(f"The 'hue' parameter must be in range [0, 360] inclusive, got {hue!r}") + if not all((0 <= ch <= 100) for ch in (sat, light)): + raise ValueError(f"The 'sat' and 'light' parameters must be in range [0, 100] inclusive, got {sat=!r} {light=!r}") + if alpha is not None and not (0.0 <= alpha <= 1.0): + raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") - self.h, self.s, self.l = h, s, l - self.a = None if a is None else (1.0 if a > 1.0 else float(a)) + self.hue, self.sat, self.light = hue, sat, light + self.alpha = None if alpha is None else (1.0 if alpha > 1.0 else float(alpha)) def __len__(self) -> int: """The number of components in the color (3 or 4).""" - return 3 if self.a is None else 4 + + return 3 if self.alpha is None else 4 def __iter__(self) -> Iterator[int | Optional[float]]: - return iter((self.h, self.s, self.l) + (() if self.a is None else (self.a, ))) + return iter((self.hue, self.sat, self.light) + (() if self.alpha is None else (self.alpha, ))) @overload def __getitem__(self, index: Literal[0, 1, 2], /) -> int: @@ -339,236 +365,258 @@ def __getitem__(self, index: int, /) -> int | Optional[float]: ... def __getitem__(self, index: int, /) -> int | Optional[float]: - return ((self.h, self.s, self.l) + (() if self.a is None else (self.a, )))[index] + return ((self.hue, self.sat, self.light) + (() if self.alpha is None else (self.alpha, )))[index] def __eq__(self, other: object, /) -> bool: """Check if two `hsla` objects are the same color.""" + if not isinstance(other, hsla): return False - return (self.h, self.s, self.l, self.a) == (other.h, other.s, other.l, other.a) + return (self.hue, self.sat, self.light, self.alpha) == (other.hue, other.sat, other.light, other.alpha) def __ne__(self, other: object, /) -> bool: """Check if two `hsla` objects are different colors.""" + return not self.__eq__(other) def __repr__(self) -> str: - return f"hsla({self.h}°, {self.s}%, {self.l}%{'' if self.a is None else f', {self.a}'})" + return f"hsla({self.hue}°, {self.sat}%, {self.light}%{'' if self.alpha is None else f', {self.alpha}'})" def __str__(self) -> str: return self.__repr__() def dict(self) -> HslaDict: - """Returns the color components as a dictionary with keys `"h"`, `"s"`, `"l"` and optionally `"a"`.""" - return {"h": self.h, "s": self.s, "l": self.l, "a": self.a} + """Returns the color components as a dictionary with keys `"hue"`, `"sat"`, `"light"` and optionally `"alpha"`.""" + + return HslaDict(hue=self.hue, sat=self.sat, light=self.light, alpha=self.alpha) def values(self) -> tuple[int, int, int, Optional[float]]: - """Returns the color components as separate values `h, s, l, a`.""" - return self.h, self.s, self.l, self.a + """Returns the color components as separate values `hue, sat, light, alpha`.""" + + return self.hue, self.sat, self.light, self.alpha def to_rgba(self) -> rgba: """Returns the color as `rgba()` color object.""" - r, g, b = self._hsl_to_rgb(self.h, self.s, self.l) - return rgba(r, g, b, self.a, _validate=False) + + red, green, blue = self._hsl_to_rgb(self.hue, self.sat, self.light) + return rgba(red, green, blue, self.alpha, _validate=False) def to_hexa(self) -> hexa: """Returns the color as `hexa()` color object.""" - r, g, b = self._hsl_to_rgb(self.h, self.s, self.l) - return hexa(_r=r, _g=g, _b=b, _a=self.a) + + red, green, blue = self._hsl_to_rgb(self.hue, self.sat, self.light) + return hexa(_red=red, _green=green, _blue=blue, _alpha=self.alpha) def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" - return self.a is not None + + return self.alpha is not None def lighten(self, amount: float, /) -> hsla: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.l = int(min(100, self.l + (100 - self.l) * amount)) - return hsla(self.h, self.s, self.l, self.a, _validate=False) + self.light = int(min(100, self.light + (100 - self.light) * amount)) + return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False) def darken(self, amount: float, /) -> hsla: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.l = int(max(0, self.l * (1 - amount))) - return hsla(self.h, self.s, self.l, self.a, _validate=False) + self.light = int(max(0, self.light * (1 - amount))) + return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False) def saturate(self, amount: float, /) -> hsla: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.s = int(min(100, self.s + (100 - self.s) * amount)) - return hsla(self.h, self.s, self.l, self.a, _validate=False) + self.sat = int(min(100, self.sat + (100 - self.sat) * amount)) + return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False) def desaturate(self, amount: float, /) -> hsla: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.s = int(max(0, self.s * (1 - amount))) - return hsla(self.h, self.s, self.l, self.a, _validate=False) + self.sat = int(max(0, self.sat * (1 - amount))) + return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False) def rotate(self, degrees: int, /) -> hsla: """Rotates the colors hue by the specified number of degrees.""" - self.h = (self.h + degrees) % 360 - return hsla(self.h, self.s, self.l, self.a, _validate=False) + + self.hue = (self.hue + degrees) % 360 + return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False) def invert(self, *, invert_alpha: bool = False) -> hsla: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" - self.h = (self.h + 180) % 360 - self.l = 100 - self.l - if invert_alpha and self.a is not None: - self.a = 1 - self.a - return hsla(self.h, self.s, self.l, self.a, _validate=False) + self.hue = (self.hue + 180) % 360 + self.light = 100 - self.light + if invert_alpha and self.alpha is not None: + self.alpha = 1 - self.alpha + return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False) def grayscale(self, *, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> hsla: """Converts the color to grayscale using the luminance formula.\n - --------------------------------------------------------------------------- - - `method` -⠀the luminance calculation method to use: - * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception) - * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients - * `"simple"` Simple arithmetic mean (less accurate) - * `"bt601"` ITU-R BT.601 standard (older TV standard)""" + ------------------------------------------------------------------------------- + * `method` – the luminance calculation method to use: + - `"wcag2"` WCAG 2.0 standard (default and most accurate for perception) + - `"wcag3"` draft WCAG 3.0 standard with improved coefficients + - `"simple"` simple arithmetic mean (less accurate) + - `"bt601"` ITU-R BT.601 standard (older TV standard)""" + # THE 'method' PARAM IS CHECKED IN 'Color.luminance()' - r, g, b = self._hsl_to_rgb(self.h, self.s, self.l) - l = int(Color.luminance(r, g, b, output_type=None, method=method)) - self.h, self.s, self.l, _ = rgba(l, l, l, _validate=False).to_hsla().values() - return hsla(self.h, self.s, self.l, self.a, _validate=False) + red, green, blue = self._hsl_to_rgb(self.hue, self.sat, self.light) + light = int(Color.luminance(red, green, blue, output_type=None, method=method)) + + self.hue, self.sat, self.light, _ = rgba(light, light, light, _validate=False).to_hsla().values() + return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False) def blend(self, other: Hsla, /, ratio: float = 0.5, *, additive_alpha: bool = False) -> hsla: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - - `other` -⠀the other HSLA color to blend with - - `ratio` -⠀the blend ratio between the two colors: - * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture) - * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture) - * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture) - - `additive_alpha` -⠀whether to blend the alpha channels additively or not""" + * `other` – The other HSLA color to blend with. + * `ratio` – The blend ratio between the two colors: + - If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture). + - If `ratio` is `0.5` it means 50% of both colors (1:1 mixture). + - If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture). + * `additive_alpha` – whether to blend the alpha channels additively or not.""" + if not Color.is_valid_hsla(other): raise TypeError(f"The 'other' parameter must be a valid HSLA color, got {type(other)}") if not (0.0 <= ratio <= 1.0): raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}") - self.h, self.s, self.l, self.a = self.to_rgba().blend( - Color.to_rgba(other), - ratio, - additive_alpha=additive_alpha, + self.hue, self.sat, self.light, self.alpha = self.to_rgba().blend( + Color.to_rgba(other), ratio, additive_alpha=additive_alpha ).to_hsla().values() - return hsla(self.h, self.s, self.l, self.a, _validate=False) + return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False) def is_dark(self) -> bool: """Returns `True` if the color is considered dark (`lightness < 50%`).""" - return self.l < 50 + + return self.light < 50 def is_light(self) -> bool: """Returns `True` if the color is considered light (`lightness >= 50%`).""" + return not self.is_dark() def is_grayscale(self) -> bool: """Returns `True` if the color is considered grayscale.""" - return self.s == 0 + + return self.sat == 0 def is_opaque(self) -> bool: """Returns `True` if the color has no transparency.""" - return self.a == 1 or self.a is None + + return self.alpha == 1 or self.alpha is None def with_alpha(self, alpha: float, /) -> hsla: """Returns a new color with the specified alpha value.""" + if not isinstance(alpha, float): raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}") elif not (0.0 <= alpha <= 1.0): raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") - return hsla(self.h, self.s, self.l, alpha, _validate=False) + return hsla(self.hue, self.sat, self.light, alpha, _validate=False) def complementary(self) -> hsla: """Returns the complementary color (180 degrees on the color wheel).""" - return hsla((self.h + 180) % 360, self.s, self.l, self.a, _validate=False) + + return hsla((self.hue + 180) % 360, self.sat, self.light, self.alpha, _validate=False) @classmethod - def _hsl_to_rgb(cls, h: int, s: int, l: int) -> tuple[int, int, int]: + def _hsl_to_rgb(cls, hue: int, sat: int, light: int) -> tuple[int, int, int]: """Internal method to convert HSL to RGB color space.""" - _h, _s, _l = h / 360, s / 100, l / 100 - if _s == 0: - r = g = b = int(_l * 255) + _hue, _sat, _light = hue / 360, sat / 100, light / 100 + + if _sat == 0: + red = green = blue = int(_light * 255) + else: - q = _l * (1 + _s) if _l < 0.5 else _l + _s - _l * _s - p = 2 * _l - q - r = int(round(cls._hue_to_rgb(p, q, _h + 1 / 3) * 255)) - g = int(round(cls._hue_to_rgb(p, q, _h) * 255)) - b = int(round(cls._hue_to_rgb(p, q, _h - 1 / 3) * 255)) + chroma_max = _light * (1 + _sat) if _light < 0.5 else _light + _sat - _light * _sat + chroma_min = 2 * _light - chroma_max - return r, g, b + red = int(round(cls._hue_to_rgb(chroma_min, chroma_max, _hue + 1 / 3) * 255)) + green = int(round(cls._hue_to_rgb(chroma_min, chroma_max, _hue) * 255)) + blue = int(round(cls._hue_to_rgb(chroma_min, chroma_max, _hue - 1 / 3) * 255)) + + return red, green, blue @staticmethod - def _hue_to_rgb(p: float, q: float, t: float) -> float: - if t < 0: - t += 1 - if t > 1: - t -= 1 - if t < 1 / 6: - return p + (q - p) * 6 * t - if t < 1 / 2: - return q - if t < 2 / 3: - return p + (q - p) * (2 / 3 - t) * 6 - return p + def _hue_to_rgb(chroma_min: float, chroma_max: float, hue_pos: float) -> float: + if hue_pos < 0: + hue_pos += 1 + if hue_pos > 1: + hue_pos -= 1 + if hue_pos < 1 / 6: + return chroma_min + (chroma_max - chroma_min) * 6 * hue_pos + if hue_pos < 1 / 2: + return chroma_max + if hue_pos < 2 / 3: + return chroma_min + (chroma_max - chroma_min) * (2 / 3 - hue_pos) * 6 + return chroma_min class hexa: """A HEXA color object that includes a bunch of methods to manipulate the color.\n - -------------------------------------------------------------------------------------------- - - `color` -⠀the HEXA color string (prefix optional) or HEX integer, that can be in formats: - * `RGB` short format without alpha (only for strings) - * `RGBA` short format with alpha (only for strings) - * `RRGGBB` long format without alpha (for strings and HEX integers) - * `RRGGBBAA` long format with alpha (for strings and HEX integers) - -------------------------------------------------------------------------------------------- + ---------------------------------------------------------------------------------------------- + * `color` – The HEXA color string (prefix optional) or HEX integer, that can be in formats: + - `RGB` short format without alpha (only for strings) + - `RGBA` short format with alpha (only for strings) + - `RRGGBB` long format without alpha (for strings and HEX integers) + - `RRGGBBAA` long format with alpha (for strings and HEX integers) + ---------------------------------------------------------------------------------------------- Includes methods: - - `to_rgba()` to convert to RGB color - - `to_hsla()` to convert to HSL color - - `has_alpha()` to check if the color has an alpha channel - - `lighten(amount)` to create a lighter version of the color - - `darken(amount)` to create a darker version of the color - - `saturate(amount)` to increase color saturation - - `desaturate(amount)` to decrease color saturation - - `rotate(degrees)` to rotate the hue by degrees - - `invert()` to get the inverse color - - `grayscale()` to convert to grayscale - - `blend(other, ratio)` to blend with another color - - `is_dark()` to check if the color is considered dark - - `is_light()` to check if the color is considered light - - `is_grayscale()` to check if the color is grayscale - - `is_opaque()` to check if the color has no transparency - - `with_alpha(alpha)` to create a new color with different alpha - - `complementary()` to get the complementary color""" + * `to_rgba()` to convert to RGB color + * `to_hsla()` to convert to HSL color + * `has_alpha()` to check if the color has an alpha channel + * `lighten(amount)` to create a lighter version of the color + * `darken(amount)` to create a darker version of the color + * `saturate(amount)` to increase color saturation + * `desaturate(amount)` to decrease color saturation + * `rotate(degrees)` to rotate the hue by degrees + * `invert()` to get the inverse color + * `grayscale()` to convert to grayscale + * `blend(other, ratio)` to blend with another color + * `is_dark()` to check if the color is considered dark + * `is_light()` to check if the color is considered light + * `is_grayscale()` to check if the color is grayscale + * `is_opaque()` to check if the color has no transparency + * `with_alpha(alpha)` to create a new color with different alpha + * `complementary()` to get the complementary color""" def __init__( self, color: Optional[str | int] = None, /, *, - _r: Optional[int] = None, - _g: Optional[int] = None, - _b: Optional[int] = None, - _a: Optional[float] = None, + _red: Optional[int] = None, + _green: Optional[int] = None, + _blue: Optional[int] = None, + _alpha: Optional[float] = None, ) -> None: - self.r: int + self.red: int """The red channel in range [0, 255] inclusive.""" - self.g: int + self.green: int """The green channel in range [0, 255] inclusive.""" - self.b: int + self.blue: int """The blue channel in range [0, 255] inclusive.""" - self.a: Optional[float] + self.alpha: Optional[float] """The alpha channel in range [0.0, 1.0] inclusive or `None` if not set.""" - if all(x is not None for x in (_r, _g, _b)): - self.r, self.g, self.b, self.a = cast(int, _r), cast(int, _g), cast(int, _b), _a + if all(ch is not None for ch in (_red, _green, _blue)): + self.red, self.green, self.blue, self.alpha = cast(int, _red), cast(int, _green), cast(int, _blue), _alpha return if isinstance(color, hexa): @@ -581,28 +629,28 @@ def __init__( color = color[2:].upper() if len(color) == 3: # RGB - self.r, self.g, self.b, self.a = ( + self.red, self.green, self.blue, self.alpha = ( int(color[0] * 2, 16), int(color[1] * 2, 16), int(color[2] * 2, 16), None, ) elif len(color) == 4: # RGBA - self.r, self.g, self.b, self.a = ( + self.red, self.green, self.blue, self.alpha = ( int(color[0] * 2, 16), int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16) / 255.0, ) elif len(color) == 6: # RRGGBB - self.r, self.g, self.b, self.a = ( + self.red, self.green, self.blue, self.alpha = ( int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), None, ) elif len(color) == 8: # RRGGBBAA - self.r, self.g, self.b, self.a = ( + self.red, self.green, self.blue, self.alpha = ( int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16), @@ -612,170 +660,195 @@ def __init__( raise ValueError(f"Invalid HEXA color string '{color}'. Must be in formats RGB, RGBA, RRGGBB or RRGGBBAA.") elif isinstance(color, int): - self.r, self.g, self.b, self.a = Color.hex_int_to_rgba(color).values() + self.red, self.green, self.blue, self.alpha = Color.hex_int_to_rgba(color).values() def __len__(self) -> int: """The number of components in the color (3 or 4).""" - return 3 if self.a is None else 4 + + return 3 if self.alpha is None else 4 def __iter__(self) -> Iterator[str]: - return iter((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") - + (() if self.a is None else (f"{int(self.a * 255):02X}", ))) + return iter((f"{self.red:02X}", f"{self.green:02X}", f"{self.blue:02X}") + + (() if self.alpha is None else (f"{int(self.alpha * 255):02X}", ))) def __getitem__(self, index: int, /) -> str: - return ((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") \ - + (() if self.a is None else (f"{int(self.a * 255):02X}", )))[index] + return ((f"{self.red:02X}", f"{self.green:02X}", f"{self.blue:02X}") \ + + (() if self.alpha is None else (f"{int(self.alpha * 255):02X}", )))[index] def __eq__(self, other: object, /) -> bool: """Check if two `hexa` objects are the same color.""" + if not isinstance(other, hexa): return False - return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a) + return (self.red, self.green, self.blue, self.alpha) == (other.red, other.green, other.blue, other.alpha) def __ne__(self, other: object, /) -> bool: """Check if two `hexa` objects are different colors.""" + return not self.__eq__(other) def __repr__(self) -> str: - return f"hexa(#{self.r:02X}{self.g:02X}{self.b:02X}{'' if self.a is None else f'{int(self.a * 255):02X}'})" + return f"hexa(#{self.red:02X}{self.green:02X}{self.blue:02X}{'' if self.alpha is None else f'{int(self.alpha * 255):02X}'})" def __str__(self) -> str: - return f"#{self.r:02X}{self.g:02X}{self.b:02X}{'' if self.a is None else f'{int(self.a * 255):02X}'}" + return f"#{self.red:02X}{self.green:02X}{self.blue:02X}{'' if self.alpha is None else f'{int(self.alpha * 255):02X}'}" def dict(self) -> HexaDict: - """Returns the color components as a dictionary with hex string values for keys `"r"`, `"g"`, `"b"` and optionally `"a"`.""" - return { - "r": f"{self.r:02X}", "g": f"{self.g:02X}", "b": f"{self.b:02X}", "a": - None if self.a is None else f"{int(self.a * 255):02X}" - } + """Returns the color components as a dictionary with hex string values for keys `"red"`, `"green"`, `"blue"` and optionally `"alpha"`.""" + + return HexaDict( + red=f"{self.red:02X}", + green=f"{self.green:02X}", + blue=f"{self.blue:02X}", + alpha=(f"{int(self.alpha * 255):02X}" if self.alpha is not None else None), + ) def values(self, *, round_alpha: bool = True) -> tuple[int, int, int, Optional[float]]: - """Returns the color components as separate values `r, g, b, a`.""" - return self.r, self.g, self.b, None if self.a is None else (round(self.a, 2) if round_alpha else self.a) + """Returns the color components as separate values `red, green, blue, alpha`.""" + + return self.red, self.green, self.blue, None if self.alpha is None else ( + round(self.alpha, 2) if round_alpha else self.alpha + ) def to_rgba(self, *, round_alpha: bool = True) -> rgba: """Returns the color as `rgba()` color object.""" + return rgba( - self.r, - self.g, - self.b, - None if self.a is None else (round(self.a, 2) if round_alpha else self.a), + self.red, + self.green, + self.blue, + None if self.alpha is None else (round(self.alpha, 2) if round_alpha else self.alpha), _validate=False, ) def to_hsla(self, *, round_alpha: bool = True) -> hsla: """Returns the color as `hsla()` color object.""" + return self.to_rgba(round_alpha=round_alpha).to_hsla() def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" - return self.a is not None + + return self.alpha is not None def lighten(self, amount: float, /) -> hexa: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).lighten(amount).values() - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).lighten(amount).values() + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def darken(self, amount: float, /) -> hexa: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).darken(amount).values() - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).darken(amount).values() + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def saturate(self, amount: float, /) -> hexa: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).saturate(amount).values() - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).saturate(amount).values() + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def desaturate(self, amount: float, /) -> hexa: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).desaturate(amount).values() - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).desaturate(amount).values() + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def rotate(self, degrees: int, /) -> hexa: """Rotates the colors hue by the specified number of degrees.""" - self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).rotate(degrees).values() - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + + self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).rotate(degrees).values() + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def invert(self, *, invert_alpha: bool = False) -> hexa: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" - self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).invert().values() - if invert_alpha and self.a is not None: - self.a = 1 - self.a - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).invert().values() + if invert_alpha and self.alpha is not None: + self.alpha = 1 - self.alpha + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def grayscale(self, *, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> hexa: """Converts the color to grayscale using the luminance formula.\n - --------------------------------------------------------------------------- - - `method` -⠀the luminance calculation method to use: - * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception) - * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients - * `"simple"` Simple arithmetic mean (less accurate) - * `"bt601"` ITU-R BT.601 standard (older TV standard)""" + ------------------------------------------------------------------------------- + * `method` – The luminance calculation method to use: + - `"wcag2"` WCAG 2.0 standard (default and most accurate for perception) + - `"wcag3"` draft WCAG 3.0 standard with improved coefficients + - `"simple"` simple arithmetic mean (less accurate) + - `"bt601"` ITU-R BT.601 standard (older TV standard)""" + # THE 'method' PARAM IS CHECKED IN 'Color.luminance()' - self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method)) - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + self.red = self.green = self.blue = int(Color.luminance(self.red, self.green, self.blue, method=method)) + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def blend(self, other: Hexa, /, ratio: float = 0.5, *, additive_alpha: bool = False) -> hexa: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - - `other` -⠀the other HEXA color to blend with - - `ratio` -⠀the blend ratio between the two colors: - * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture) - * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture) - * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture) - - `additive_alpha` -⠀whether to blend the alpha channels additively or not""" + * `other` – The other HEXA color to blend with. + * `ratio` – The blend ratio between the two colors: + - If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture). + - If `ratio` is `0.5` it means 50% of both colors (1:1 mixture). + - If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture). + * `additive_alpha` – Whether to blend the alpha channels additively or not.""" + if not Color.is_valid_hexa(other): raise TypeError(f"The 'other' parameter must be a valid HEXA color, got {type(other)}") if not (0.0 <= ratio <= 1.0): raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}") - self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).blend( + self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).blend( Color.to_rgba(other), ratio, additive_alpha=additive_alpha, ).values() - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha) def is_dark(self) -> bool: """Returns `True` if the color is considered dark (`lightness < 50%`).""" + return self.to_hsla(round_alpha=False).is_dark() def is_light(self) -> bool: """Returns `True` if the color is considered light (`lightness >= 50%`).""" + return not self.is_dark() def is_grayscale(self) -> bool: """Returns `True` if the color is grayscale (`saturation == 0`).""" + return self.to_hsla(round_alpha=False).is_grayscale() def is_opaque(self) -> bool: """Returns `True` if the color has no transparency (`alpha == 1.0`).""" - return self.a == 1 or self.a is None + + return self.alpha == 1 or self.alpha is None def with_alpha(self, alpha: float, /) -> hexa: """Returns a new color with the specified alpha value.""" + if not isinstance(alpha, float): raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}") elif not (0.0 <= alpha <= 1.0): raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") - return hexa(_r=self.r, _g=self.g, _b=self.b, _a=alpha) + return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=alpha) def complementary(self) -> hexa: """Returns the complementary color (180 degrees on the color wheel).""" + return self.to_hsla(round_alpha=False).complementary().to_hexa() @@ -785,9 +858,9 @@ class Color: @classmethod def is_valid_rgba(cls, color: AnyRgba, /, *, allow_alpha: bool = True) -> bool: """Check if the given color is a valid RGBA color.\n - ----------------------------------------------------------------- - - `color` -⠀the color to check (can be in any supported format) - - `allow_alpha` -⠀whether to allow alpha channel in the color""" + -------------------------------------------------------------------- + * `color` – The color to check (can be in any supported format). + * `allow_alpha` – Whether to allow alpha channel in the color.""" try: if isinstance(color, rgba): return True @@ -814,15 +887,15 @@ def is_valid_rgba(cls, color: AnyRgba, /, *, allow_alpha: bool = True) -> bool: if (allow_alpha \ and len(dict_color) == 4 - and all(isinstance(dict_color.get(ch), int) for ch in ("r", "g", "b")) - and isinstance(dict_color.get("a", "no alpha"), (float, type(None))) + and all(isinstance(dict_color.get(ch), int) for ch in ("red", "green", "blue")) + and isinstance(dict_color.get("alpha", "no alpha"), (float, type(None))) ): return ( - 0 <= dict_color["r"] <= 255 and 0 <= dict_color["g"] <= 255 and 0 <= dict_color["b"] <= 255 - and (dict_color["a"] is None or 0 <= dict_color["a"] <= 1) + 0 <= dict_color["red"] <= 255 and 0 <= dict_color["green"] <= 255 and 0 <= dict_color["blue"] <= 255 + and (dict_color["alpha"] is None or 0 <= dict_color["alpha"] <= 1) ) - elif len(dict_color) == 3 and all(isinstance(dict_color.get(ch), int) for ch in ("r", "g", "b")): - return 0 <= dict_color["r"] <= 255 and 0 <= dict_color["g"] <= 255 and 0 <= dict_color["b"] <= 255 + elif len(dict_color) == 3 and all(isinstance(dict_color.get(ch), int) for ch in ("red", "green", "blue")): + return 0 <= dict_color["red"] <= 255 and 0 <= dict_color["green"] <= 255 and 0 <= dict_color["blue"] <= 255 else: return False @@ -836,9 +909,10 @@ def is_valid_rgba(cls, color: AnyRgba, /, *, allow_alpha: bool = True) -> bool: @classmethod def is_valid_hsla(cls, color: AnyHsla, /, *, allow_alpha: bool = True) -> bool: """Check if the given color is a valid HSLA color.\n - ----------------------------------------------------------------- - - `color` -⠀the color to check (can be in any supported format) - - `allow_alpha` -⠀whether to allow alpha channel in the color""" + -------------------------------------------------------------------- + * `color` – The color to check (can be in any supported format). + * `allow_alpha` – Whether to allow alpha channel in the color.""" + try: if isinstance(color, hsla): return True @@ -865,15 +939,15 @@ def is_valid_hsla(cls, color: AnyHsla, /, *, allow_alpha: bool = True) -> bool: if (allow_alpha \ and len(dict_color) == 4 - and all(isinstance(dict_color.get(ch), int) for ch in ("h", "s", "l")) - and isinstance(dict_color.get("a", "no alpha"), (float, type(None))) + and all(isinstance(dict_color.get(ch), int) for ch in ("hue", "sat", "light")) + and isinstance(dict_color.get("alpha", "no alpha"), (float, type(None))) ): return ( - 0 <= dict_color["h"] <= 360 and 0 <= dict_color["s"] <= 100 and 0 <= dict_color["l"] <= 100 - and (dict_color["a"] is None or 0 <= dict_color["a"] <= 1) + 0 <= dict_color["hue"] <= 360 and 0 <= dict_color["sat"] <= 100 and 0 <= dict_color["light"] <= 100 + and (dict_color["alpha"] is None or 0 <= dict_color["alpha"] <= 1) ) - elif len(dict_color) == 3 and all(isinstance(dict_color.get(ch), int) for ch in ("h", "s", "l")): - return 0 <= dict_color["h"] <= 360 and 0 <= dict_color["s"] <= 100 and 0 <= dict_color["l"] <= 100 + elif len(dict_color) == 3 and all(isinstance(dict_color.get(ch), int) for ch in ("hue", "sat", "light")): + return 0 <= dict_color["hue"] <= 360 and 0 <= dict_color["sat"] <= 100 and 0 <= dict_color["light"] <= 100 else: return False @@ -930,10 +1004,11 @@ def is_valid_hexa( get_prefix: bool = False, ) -> bool | tuple[bool, Optional[Literal["#", "0x"]]]: """Check if the given color is a valid HEXA color.\n - --------------------------------------------------------------------------------------------------- - - `color` -⠀the color to check (can be in any supported format) - - `allow_alpha` -⠀whether to allow alpha channel in the color - - `get_prefix` -⠀if true, the prefix used in the color (if any) is returned along with validity""" + ------------------------------------------------------------------------------------------------------ + * `color` – The color to check (can be in any supported format). + * `allow_alpha` – Whether to allow alpha channel in the color. + * `get_prefix` – If true, the prefix used in the color (if any) is returned along with validity.""" + try: if isinstance(color, hexa): return (True, "#") if get_prefix else True @@ -958,9 +1033,10 @@ def is_valid_hexa( @classmethod def is_valid(cls, color: AnyRgba | AnyHsla | AnyHexa, /, *, allow_alpha: bool = True) -> bool: """Check if the given color is a valid RGBA, HSLA or HEXA color.\n - ------------------------------------------------------------------- - - `color` -⠀the color to check (can be in any supported format) - - `allow_alpha` -⠀whether to allow alpha channel in the color""" + -------------------------------------------------------------------- + * `color` – The color to check (can be in any supported format). + * `allow_alpha` – Whether to allow alpha channel in the color.""" + return bool( cls.is_valid_rgba(color, allow_alpha=allow_alpha) \ or cls.is_valid_hsla(color, allow_alpha=allow_alpha) \ @@ -970,8 +1046,9 @@ def is_valid(cls, color: AnyRgba | AnyHsla | AnyHexa, /, *, allow_alpha: bool = @classmethod def has_alpha(cls, color: Rgba | Hsla | Hexa, /) -> bool: """Check if the given color has an alpha channel.\n - --------------------------------------------------------------------------- - - `color` -⠀the color to check (can be in any supported format)""" + ---------------------------------------------------------------------- + * `color` – The color to check (can be in any supported format).""" + if isinstance(color, (rgba, hsla, hexa)): return color.has_alpha() @@ -1002,8 +1079,9 @@ def has_alpha(cls, color: Rgba | Hsla | Hexa, /) -> bool: @classmethod def to_rgba(cls, color: Rgba | Hsla | Hexa, /) -> rgba: """Will try to convert any color type to a color of type RGBA.\n - --------------------------------------------------------------------- - - `color` -⠀the color to convert (can be in any supported format)""" + ------------------------------------------------------------------------ + * `color` – The color to convert (can be in any supported format).""" + if isinstance(color, (hsla, hexa)): return color.to_rgba() elif cls.is_valid_hsla(color): @@ -1017,8 +1095,9 @@ def to_rgba(cls, color: Rgba | Hsla | Hexa, /) -> rgba: @classmethod def to_hsla(cls, color: Rgba | Hsla | Hexa, /) -> hsla: """Will try to convert any color type to a color of type HSLA.\n - --------------------------------------------------------------------- - - `color` -⠀the color to convert (can be in any supported format)""" + ------------------------------------------------------------------------ + * `color` – The color to convert (can be in any supported format).""" + if isinstance(color, (rgba, hexa)): return color.to_hsla() elif cls.is_valid_rgba(color): @@ -1032,8 +1111,9 @@ def to_hsla(cls, color: Rgba | Hsla | Hexa, /) -> hsla: @classmethod def to_hexa(cls, color: Rgba | Hsla | Hexa, /) -> hexa: """Will try to convert any color type to a color of type HEXA.\n - --------------------------------------------------------------------- - - `color` -⠀the color to convert (can be in any supported format)""" + ------------------------------------------------------------------------ + * `color` – The color to convert (can be in any supported format).""" + if isinstance(color, (rgba, hsla)): return color.to_hexa() elif cls.is_valid_rgba(color): @@ -1062,12 +1142,14 @@ def str_to_rgba(cls, string: str, /, *, only_first: bool = False) -> Optional[rg @classmethod def str_to_rgba(cls, string: str, /, *, only_first: bool = False) -> Optional[rgba | list[rgba]]: """Will try to recognize RGBA colors inside a string and output the found ones as RGBA objects.\n - --------------------------------------------------------------------------------------------------------------- - - `string` -⠀the string to search for RGBA colors - - `only_first` -⠀if true, only the first found color will be returned, otherwise a list of all found colors""" + ------------------------------------------------------------------------------------------------------------------ + * `string` – The string to search for RGBA colors. + * `only_first` – If true, only the first found color will be returned, otherwise a list of all found colors.""" + if only_first: if not (match := _re.search(Regex.rgba_str(allow_alpha=True), string)): return None + groups = match.groups() return rgba( int(groups[0]), @@ -1080,6 +1162,7 @@ def str_to_rgba(cls, string: str, /, *, only_first: bool = False) -> Optional[rg else: if not (matches := _re.findall(Regex.rgba_str(allow_alpha=True), string)): return None + return [ rgba( int(match[0]), @@ -1108,73 +1191,79 @@ def str_to_hsla(cls, string: str, /, *, only_first: bool = False) -> Optional[hs @classmethod def str_to_hsla(cls, string: str, /, *, only_first: bool = False) -> Optional[hsla | list[hsla]]: """Will try to recognize HSLA colors inside a string and output the found ones as HSLA objects.\n - --------------------------------------------------------------------------------------------------------------- - - `string` -⠀the string to search for HSLA colors - - `only_first` -⠀if true, only the first found color will be returned, otherwise a list of all found colors""" + ------------------------------------------------------------------------------------------------------------------ + * `string` – The string to search for HSLA colors. + * `only_first` – If true, only the first found color will be returned, otherwise a list of all found colors.""" + if only_first: if not (match := _re.search(Regex.hsla_str(allow_alpha=True), string)): return None - m = match.groups() + + groups = match.groups() return hsla( - int(m[0]), - int(m[1]), - int(m[2]), - ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None), + int(groups[0]), + int(groups[1]), + int(groups[2]), + ((int(groups[3]) if "." not in groups[3] else float(groups[3])) if groups[3] else None), _validate=False, ) else: if not (matches := _re.findall(Regex.hsla_str(allow_alpha=True), string)): return None + return [ hsla( - int(m[0]), - int(m[1]), - int(m[2]), - ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None), + int(match[0]), + int(match[1]), + int(match[2]), + ((int(match[3]) if "." not in match[3] else float(match[3])) if match[3] else None), _validate=False, - ) for m in matches + ) for match in matches ] @classmethod def rgba_to_hex_int( cls, - r: int, - g: int, - b: int, - a: Optional[float] = None, + red: int, + green: int, + blue: int, + alpha: Optional[float] = None, /, *, preserve_original: bool = False, ) -> int: """Convert RGBA channels to a HEXA integer (alpha is optional).\n - -------------------------------------------------------------------------------------------- - - `r`, `g`, `b` -⠀the red, green and blue channels (`0` – `255`) - - `a` -⠀the alpha channel (`0.0` – `1.0`) or `None` if not set - - `preserve_original` -⠀whether to preserve the original color exactly (explained below)\n - -------------------------------------------------------------------------------------------- - To preserve leading zeros, the function will add a `1` at the beginning, if the HEX integer - would start with a `0`. - This could affect the color a little bit, but will make sure, that it won't be interpreted - as a completely different color, when initializing it as a `hexa()` color or changing it + ----------------------------------------------------------------------------------------------- + * `red`, `green`, `blue` – The red, green, and blue channels in range [0, 255] inclusive. + * `alpha` – The alpha channel in range [0.0, 1.0] inclusive or `None` if not set. + * `preserve_original` – Whether to preserve the original color exactly (explained below). + ----------------------------------------------------------------------------------------------- + To preserve leading zeros, the function will add a `1` at the beginning,
+ if the HEX integer would start with a `0`.\n + This could affect the color a little bit, but will make sure, that it won't be interpreted
+ as a completely different color, when initializing it as a `hexa()` color or changing it
back to RGBA using `Color.hex_int_to_rgba()`.""" - if not all((0 <= c <= 255) for c in (r, g, b)): - raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}") - if a is not None and not (0.0 <= a <= 1.0): - raise ValueError(f"The 'a' parameter must be a float in [0.0, 1.0] or None, got {a!r}") - r = max(0, min(255, int(r))) - g = max(0, min(255, int(g))) - b = max(0, min(255, int(b))) + if not all((0 <= ch <= 255) for ch in (red, green, blue)): + raise ValueError( + f"The 'red', 'green' and 'blue' parameters must be integers in [0, 255], got {red=!r} {green=!r} {blue=!r}" + ) + if alpha is not None and not (0.0 <= alpha <= 1.0): + raise ValueError(f"The 'alpha' parameter must be a float in [0.0, 1.0] or None, got {alpha!r}") + + red = max(0, min(255, int(red))) + green = max(0, min(255, int(green))) + blue = max(0, min(255, int(blue))) - if a is None: - hex_int = (r << 16) | (g << 8) | b + if alpha is None: + hex_int = (red << 16) | (green << 8) | blue if not preserve_original and (hex_int & 0xF00000) == 0: hex_int |= 0x010000 else: - a = max(0, min(255, int(a * 255))) - hex_int = (r << 24) | (g << 16) | (b << 8) | a - if not preserve_original and r == 0: + alpha = max(0, min(255, int(alpha * 255))) + hex_int = (red << 24) | (green << 16) | (blue << 8) | alpha + if not preserve_original and red == 0: hex_int |= 0x01000000 return hex_int @@ -1182,20 +1271,21 @@ def rgba_to_hex_int( @classmethod def hex_int_to_rgba(cls, hex_int: int, /, *, preserve_original: bool = False) -> rgba: """Convert a HEX integer to RGBA channels.\n - ------------------------------------------------------------------------------------------- - - `hex_int` -⠀the HEX integer to convert - - `preserve_original` -⠀whether to preserve the original color exactly (explained below)\n - ------------------------------------------------------------------------------------------- - If the red channel is `1` after conversion, it will be set to `0`, because when converting - from RGBA to a HEX integer, the first `0` will be set to `1` to preserve leading zeros. + ----------------------------------------------------------------------------------------------- + * `hex_int` – The HEX integer to convert. + * `preserve_original` – Whether to preserve the original color exactly (explained below). + ----------------------------------------------------------------------------------------------- + If the red channel is `1` after conversion, it will be set to `0`, because when converting
+ from RGBA to a HEX integer, the first `0` will be set to `1` to preserve leading zeros.\n This is the correction, so the color doesn't even look slightly different.""" + if not (0 <= hex_int <= 0xFFFFFFFF): raise ValueError(f"Expected HEX integer in range [0x000000, 0xFFFFFFFF] inclusive, got 0x{hex_int:X}") if len(hex_str := f"{hex_int:X}") <= 6: hex_str = hex_str.zfill(6) return rgba( - r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0, + red if (red := int(hex_str[0:2], 16)) != 1 or preserve_original else 0, int(hex_str[2:4], 16), int(hex_str[4:6], 16), None, @@ -1205,7 +1295,7 @@ def hex_int_to_rgba(cls, hex_int: int, /, *, preserve_original: bool = False) -> elif len(hex_str) <= 8: hex_str = hex_str.zfill(8) return rgba( - r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0, + red if (red := int(hex_str[0:2], 16)) != 1 or preserve_original else 0, int(hex_str[2:4], 16), int(hex_str[4:6], 16), int(hex_str[6:8], 16) / 255.0, @@ -1219,9 +1309,9 @@ def hex_int_to_rgba(cls, hex_int: int, /, *, preserve_original: bool = False) -> @classmethod def luminance( cls, - r: int, - g: int, - b: int, + red: int, + green: int, + blue: int, /, *, output_type: type[int], @@ -1233,9 +1323,9 @@ def luminance( @classmethod def luminance( cls, - r: int, - g: int, - b: int, + red: int, + green: int, + blue: int, /, *, output_type: type[float], @@ -1247,9 +1337,9 @@ def luminance( @classmethod def luminance( cls, - r: int, - g: int, - b: int, + red: int, + green: int, + blue: int, /, *, output_type: None = None, @@ -1261,9 +1351,9 @@ def luminance( @classmethod def luminance( cls, - r: int, - g: int, - b: int, + red: int, + green: int, + blue: int, /, *, output_type: Optional[type[int | float]] = None, @@ -1274,45 +1364,48 @@ def luminance( @classmethod def luminance( cls, - r: int, - g: int, - b: int, + red: int, + green: int, + blue: int, /, *, output_type: Optional[type[int | float]] = None, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2", ) -> int | float: """Calculates the relative luminance of a color according to various standards.\n - ---------------------------------------------------------------------------------- - - `r`, `g`, `b` -⠀the red, green and blue channels in range [0, 255] inclusive - - `output_type` -⠀the range of the returned luminance value: - * `int` returns integer in range [0, 100] inclusive - * `float` returns float in range [0.0, 1.0] inclusive - * `None` returns integer in range [0, 255] inclusive - - `method` -⠀the luminance calculation method to use: - * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception) - * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients - * `"simple"` Simple arithmetic mean (less accurate) - * `"bt601"` ITU-R BT.601 standard (older TV standard)""" - if not all(0 <= c <= 255 for c in (r, g, b)): - raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}") - - _r, _g, _b = r / 255.0, g / 255.0, b / 255.0 + ------------------------------------------------------------------------------------------- + * `red`, `green`, `blue` – The red, green and blue channels in range [0, 255] inclusive. + * `output_type` – The range of the returned luminance value: + - `int` returns integer in range [0, 100] inclusive. + - `float` returns float in range [0.0, 1.0] inclusive. + - `None` returns integer in range [0, 255] inclusive. + * `method` – The luminance calculation method to use: + - `"wcag2"` WCAG 2.0 standard (default and most accurate for perception) + - `"wcag3"` draft WCAG 3.0 standard with improved coefficients + - `"simple"` simple arithmetic mean (less accurate) + - `"bt601"` ITU-R BT.601 standard (older TV standard)""" + + if not all(0 <= ch <= 255 for ch in (red, green, blue)): + raise ValueError( + f"The 'red', 'green' and 'blue' parameters must be integers in [0, 255], got {red=!r} {green=!r} {blue=!r}" + ) + + _red, _green, _blue = red / 255.0, green / 255.0, blue / 255.0 if method == "simple": - luminance = (_r + _g + _b) / 3 + luminance = (_red + _green + _blue) / 3 elif method == "bt601": - luminance = 0.299 * _r + 0.587 * _g + 0.114 * _b + luminance = 0.299 * _red + 0.587 * _green + 0.114 * _blue elif method == "wcag3": - _r = cls._linearize_srgb(_r) - _g = cls._linearize_srgb(_g) - _b = cls._linearize_srgb(_b) - luminance = 0.2126729 * _r + 0.7151522 * _g + 0.0721750 * _b + _red = cls._linearize_srgb(_red) + _green = cls._linearize_srgb(_green) + _blue = cls._linearize_srgb(_blue) + luminance = 0.2126729 * _red + 0.7151522 * _green + 0.0721750 * _blue else: - _r = cls._linearize_srgb(_r) - _g = cls._linearize_srgb(_g) - _b = cls._linearize_srgb(_b) - luminance = 0.2126 * _r + 0.7152 * _g + 0.0722 * _b + _red = cls._linearize_srgb(_red) + _green = cls._linearize_srgb(_green) + _blue = cls._linearize_srgb(_blue) + luminance = 0.2126 * _red + 0.7152 * _green + 0.0722 * _blue if output_type == int: return round(luminance * 100) @@ -1345,105 +1438,105 @@ def text_color_for_on_bg(cls, text_bg_color: Rgba | Hexa, /) -> rgba | hexa | in def text_color_for_on_bg(cls, text_bg_color: Rgba | Hexa, /) -> rgba | hexa | int: """Returns either black or white text color for optimal contrast on the given background color.\n -------------------------------------------------------------------------------------------------- - - `text_bg_color` -⠀the background color (can be in RGBA or HEXA format)""" + * `text_bg_color` – The background color (can be in RGBA or HEXA format).""" + was_hexa, was_int = cls.is_valid_hexa(text_bg_color), isinstance(text_bg_color, int) text_bg_rgba = cls.to_rgba(text_bg_color) brightness = 0.2126 * text_bg_rgba[0] + 0.7152 * text_bg_rgba[1] + 0.0722 * text_bg_rgba[2] return ( - (0xFFFFFF if was_int else hexa(_r=255, _g=255, _b=255)) if was_hexa \ + (0xFFFFFF if was_int else hexa(_red=255, _green=255, _blue=255)) if was_hexa \ else rgba(255, 255, 255, _validate=False) ) if brightness < 128 else ( - (0x000 if was_int else hexa(_r=0, _g=0, _b=0)) if was_hexa \ + (0x000 if was_int else hexa(_red=0, _green=0, _blue=0)) if was_hexa \ else rgba(0, 0, 0, _validate=False) ) @overload @classmethod - def adjust_lightness(cls, color: rgba, lightness_change: float, /) -> rgba: + def adjust_lightness(cls, color: rgba, light_change: float, /) -> rgba: ... @overload @classmethod - def adjust_lightness(cls, color: hexa, lightness_change: float, /) -> hexa: + def adjust_lightness(cls, color: hexa, light_change: float, /) -> hexa: ... @overload @classmethod - def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float, /) -> rgba | hexa: + def adjust_lightness(cls, color: Rgba | Hexa, light_change: float, /) -> rgba | hexa: ... @classmethod - def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float, /) -> rgba | hexa: + def adjust_lightness(cls, color: Rgba | Hexa, light_change: float, /) -> rgba | hexa: """In- or decrease the lightness of the input color.\n - ------------------------------------------------------------------ - - `color` -⠀the color to adjust (can be in RGBA or HEXA format) - - `lightness_change` -⠀the amount to change the lightness by, - in range `-1.0` (darken by 100%) and `1.0` (lighten by 100%)""" - if not (-1.0 <= lightness_change <= 1.0): - raise ValueError( - f"The 'lightness_change' parameter must be in range [-1.0, 1.0] inclusive, got {lightness_change!r}" - ) + --------------------------------------------------------------------- + * `color` – The color to adjust (can be in RGBA or HEXA format). + * `light_change` – The amount to change the lightness by,
+ in range `-1.0` (darken by 100%) and `1.0` (lighten by 100%).""" + + if not (-1.0 <= light_change <= 1.0): + raise ValueError(f"The 'light_change' parameter must be in range [-1.0, 1.0] inclusive, got {light_change!r}") was_hexa = cls.is_valid_hexa(color) hsla_color = cls.to_hsla(color) - h, s, l, a = ( + hue, sat, light, alpha = ( int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \ hsla_color[3] if hsla_color.has_alpha() else None ) - l = int(max(0, min(100, l + lightness_change * 100))) + light = int(max(0, min(100, light + light_change * 100))) return ( - hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \ - else hsla(h, s, l, a, _validate=False).to_rgba() + hsla(hue, sat, light, alpha, _validate=False).to_hexa() if was_hexa \ + else hsla(hue, sat, light, alpha, _validate=False).to_rgba() ) @overload @classmethod - def adjust_saturation(cls, color: rgba, saturation_change: float, /) -> rgba: + def adjust_saturation(cls, color: rgba, sat_change: float, /) -> rgba: ... @overload @classmethod - def adjust_saturation(cls, color: hexa, saturation_change: float, /) -> hexa: + def adjust_saturation(cls, color: hexa, sat_change: float, /) -> hexa: ... @overload @classmethod - def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float, /) -> rgba | hexa: + def adjust_saturation(cls, color: Rgba | Hexa, sat_change: float, /) -> rgba | hexa: ... @classmethod - def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float, /) -> rgba | hexa: + def adjust_saturation(cls, color: Rgba | Hexa, sat_change: float, /) -> rgba | hexa: """In- or decrease the saturation of the input color.\n - ----------------------------------------------------------------------- - - `color` -⠀the color to adjust (can be in RGBA or HEXA format) - - `saturation_change` -⠀the amount to change the saturation by, - in range `-1.0` (saturate by 100%) and `1.0` (desaturate by 100%)""" - if not (-1.0 <= saturation_change <= 1.0): - raise ValueError( - f"The 'saturation_change' parameter must be in range [-1.0, 1.0] inclusive, got {saturation_change!r}" - ) + -------------------------------------------------------------------------- + * `color` – The color to adjust (can be in RGBA or HEXA format). + * `sat_change` – The amount to change the saturation by,
+ in range `-1.0` (saturate by 100%) and `1.0` (desaturate by 100%).""" + + if not (-1.0 <= sat_change <= 1.0): + raise ValueError(f"The 'sat_change' parameter must be in range [-1.0, 1.0] inclusive, got {sat_change!r}") was_hexa = cls.is_valid_hexa(color) hsla_color = cls.to_hsla(color) - h, s, l, a = ( + hue, sat, light, alpha = ( int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \ hsla_color[3] if hsla_color.has_alpha() else None ) - s = int(max(0, min(100, s + saturation_change * 100))) + sat = int(max(0, min(100, sat + sat_change * 100))) return ( - hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \ - else hsla(h, s, l, a, _validate=False).to_rgba() + hsla(hue, sat, light, alpha, _validate=False).to_hexa() if was_hexa \ + else hsla(hue, sat, light, alpha, _validate=False).to_rgba() ) @classmethod def _parse_rgba(cls, color: Rgba, /) -> rgba: """Internal method to parse a color to an RGBA object.""" + if isinstance(color, rgba): return color @@ -1451,7 +1544,11 @@ def _parse_rgba(cls, color: Rgba, /) -> rgba: array_color = cast(list[Any] | tuple[Any, ...], color) if len(array_color) == 4: return rgba( - int(array_color[0]), int(array_color[1]), int(array_color[2]), float(array_color[3]), _validate=False + int(array_color[0]), + int(array_color[1]), + int(array_color[2]), + float(array_color[3]), + _validate=False, ) elif len(array_color) == 3: return rgba(int(array_color[0]), int(array_color[1]), int(array_color[2]), None, _validate=False) @@ -1459,7 +1556,13 @@ def _parse_rgba(cls, color: Rgba, /) -> rgba: elif isinstance(color, dict): dict_color = cast(dict[str, Any], color) - return rgba(int(dict_color["r"]), int(dict_color["g"]), int(dict_color["b"]), dict_color.get("a"), _validate=False) + return rgba( + int(dict_color["red"]), + int(dict_color["green"]), + int(dict_color["blue"]), + dict_color.get("alpha"), + _validate=False, + ) elif isinstance(color, str): if parsed := cls.str_to_rgba(color, only_first=True): @@ -1470,6 +1573,7 @@ def _parse_rgba(cls, color: Rgba, /) -> rgba: @classmethod def _parse_hsla(cls, color: Hsla, /) -> hsla: """Internal method to parse a color to an HSLA object.""" + if isinstance(color, hsla): return color @@ -1477,7 +1581,11 @@ def _parse_hsla(cls, color: Hsla, /) -> hsla: array_color = cast(list[Any] | tuple[Any, ...], color) if len(color) == 4: return hsla( - int(array_color[0]), int(array_color[1]), int(array_color[2]), float(array_color[3]), _validate=False + int(array_color[0]), + int(array_color[1]), + int(array_color[2]), + float(array_color[3]), + _validate=False, ) elif len(color) == 3: return hsla(int(array_color[0]), int(array_color[1]), int(array_color[2]), None, _validate=False) @@ -1485,7 +1593,13 @@ def _parse_hsla(cls, color: Hsla, /) -> hsla: elif isinstance(color, dict): dict_color = cast(dict[str, Any], color) - return hsla(int(dict_color["h"]), int(dict_color["s"]), int(dict_color["l"]), dict_color.get("a"), _validate=False) + return hsla( + int(dict_color["hue"]), + int(dict_color["sat"]), + int(dict_color["light"]), + dict_color.get("alpha"), + _validate=False, + ) elif isinstance(color, str): if parsed := cls.str_to_hsla(color, only_first=True): @@ -1494,12 +1608,13 @@ def _parse_hsla(cls, color: Hsla, /) -> hsla: raise ValueError(f"Could not parse HSLA color: {color!r}") @staticmethod - def _linearize_srgb(c: float, /) -> float: + def _linearize_srgb(component: float, /) -> float: """Helper method to linearize sRGB component following the WCAG standard.""" - if not (0.0 <= c <= 1.0): - raise ValueError(f"The 'c' parameter must be in range [0.0, 1.0] inclusive, got {c!r}") - if c <= 0.03928: - return c / 12.92 + if not (0.0 <= component <= 1.0): + raise ValueError(f"The 'component' parameter must be in range [0.0, 1.0] inclusive, got {component!r}") + + if component <= 0.03928: + return component / 12.92 else: - return ((c + 0.055) / 1.055)**2.4 + return ((component + 0.055) / 1.055)**2.4 diff --git a/src/xulbux/console.py b/src/xulbux/console.py index c2412e0..7c26cba 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -1,11 +1,11 @@ """ This module provides the `Console`, `ProgressBar`, and `Throbber` classes -which offer methods for logging and other actions within the console. +which offer methods for logging and other actions within the terminal. """ from .base.types import ProgressUpdater, AllTextChars, ArgParseConfigs, ArgParseConfig, ArgData, Rgba, Hexa from .base.decorators import mypyc_attr -from .base.consts import COLOR, CHARS, ANSI +from .base.consts import CHARS, ANSI from .format_codes import _PATTERNS as _FC_PATTERNS, FormatCodes from .string import String @@ -51,10 +51,10 @@ class ParsedArgData: """Represents the result of a parsed command-line argument, containing the attributes listed below.\n ------------------------------------------------------------------------------------------------------------ - - `exists` - whether the argument was found in the command-line arguments or not - - `is_pos` - whether the argument is a positional `"before"`/`"after"` argument or not - - `values` - the list of values associated with the argument - - `flag` - the specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args\n + * `exists` – Whether the argument was found in the command-line arguments or not. + * `is_pos` – Whether the argument is a positional `"before"`/`"after"` argument or not. + * `values` – The list of values associated with the argument. + * `flag` – The specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args. ------------------------------------------------------------------------------------------------------------ When the `ParsedArgData` instance is accessed as a boolean it will correspond to the `exists` attribute.""" @@ -70,10 +70,12 @@ def __init__(self, *, exists: bool, values: list[str], is_pos: bool, flag: Optio def __bool__(self) -> bool: """Whether the argument was found or not (i.e. the `exists` attribute).""" + return self.exists def __eq__(self, other: object, /) -> bool: """Check if two `ParsedArgData` objects are equal by comparing their attributes.""" + if not isinstance(other, ParsedArgData): return False return ( @@ -85,6 +87,7 @@ def __eq__(self, other: object, /) -> bool: def __ne__(self, other: object, /) -> bool: """Check if two `ParsedArgData` objects are not equal by comparing their attributes.""" + return not self.__eq__(other) def __repr__(self) -> str: @@ -95,6 +98,7 @@ def __str__(self) -> str: def dict(self) -> ArgData: """Returns the argument result as a dictionary.""" + return ArgData(exists=self.exists, is_pos=self.is_pos, values=self.values, flag=self.flag) @overload @@ -112,10 +116,11 @@ def get(self, index: int, /, default: str) -> str: def get(self, index: int, /, default: Optional[str] = None) -> Optional[str]: """Safely access a value from the `values` list by index.\n ------------------------------------------------------------------- - - `index` -⠀the index of the value to access - - `default` -⠀the fallback value if the index is out of range\n + * `index` – The index of the value to access. + * `default` – The fallback value if the index is out of range. ------------------------------------------------------------------- Returns the value at `index` if it exists, otherwise `default`.""" + if 0 <= index < len(self.values): return self.values[index] return default @@ -124,11 +129,11 @@ def get(self, index: int, /, default: Optional[str] = None) -> Optional[str]: @mypyc_attr(native_class=False) class ParsedArgs: """Container for parsed command-line arguments, allowing attribute-style access.\n - ----------------------------------------------------------------------------------- - - `**parsed_args` -⠀a mapping of argument aliases to their corresponding data - saved in an `ParsedArgData` object\n - ----------------------------------------------------------------------------------- - For example, if an argument `foo` was parsed, it can be accessed via `args.foo`. + ------------------------------------------------------------------------------------- + * `**parsed_args` – A mapping of argument aliases to their corresponding data
+ saved in an `ParsedArgData` object. + ------------------------------------------------------------------------------------- + For example, if an argument `foo` was parsed, it can be accessed via `args.foo`.
Each such attribute (e.g. `args.foo`) is an instance of `ParsedArgData`.""" def __init__(self, **parsed_args: ParsedArgData): @@ -137,14 +142,17 @@ def __init__(self, **parsed_args: ParsedArgData): def __len__(self): """The number of arguments stored in the `ParsedArgs` object.""" + return len(vars(self)) def __contains__(self, key: str, /) -> bool: """Checks if an argument with the given alias exists in the `ParsedArgs` object.""" + return key in vars(self) def __bool__(self) -> bool: """Whether the `ParsedArgs` object contains any arguments.""" + return len(self) > 0 def __getattr__(self, name: str, /) -> ParsedArgData: @@ -161,12 +169,14 @@ def __iter__(self) -> Generator[tuple[str, ParsedArgData], None, None]: def __eq__(self, other: object, /) -> bool: """Check if two `ParsedArgs` objects are equal by comparing their stored arguments.""" + if not isinstance(other, ParsedArgs): return False return vars(self) == vars(other) def __ne__(self, other: object, /) -> bool: """Check if two `ParsedArgs` objects are not equal by comparing their stored arguments.""" + return not self.__eq__(other) def __repr__(self) -> str: @@ -182,33 +192,40 @@ def __str__(self) -> str: def dict(self) -> dict[str, ArgData]: """Returns the arguments as a dictionary.""" + return {key: val.dict() for key, val in self.__iter__()} def get(self, key: str, /, default: Any = None) -> ParsedArgData | Any: """Returns the argument result for the given alias, or `default` if not found.""" + return getattr(self, key, default) def keys(self) -> KeysView[str]: """Returns the argument aliases as `dict_keys([…])`.""" + return vars(self).keys() def values(self) -> ValuesView[ParsedArgData]: """Returns the argument results as `dict_values([…])`.""" + return vars(self).values() def items(self) -> Generator[tuple[str, ParsedArgData], None, None]: """Yields tuples of `(alias, ParsedArgData)`.""" + for key, val in self.__iter__(): yield (key, val) def existing(self) -> Generator[tuple[str, ParsedArgData], None, None]: """Yields tuples of `(alias, ParsedArgData)` for existing arguments only.""" + for key, val in self.__iter__(): if val.exists: yield (key, val) def missing(self) -> Generator[tuple[str, ParsedArgData], None, None]: """Yields tuples of `(alias, ParsedArgData)` for missing arguments only.""" + for key, val in self.__iter__(): if not val.exists: yield (key, val) @@ -218,16 +235,18 @@ def missing(self) -> Generator[tuple[str, ParsedArgData], None, None]: class _ConsoleMeta(type): @property - def w(cls) -> int: - """The width of the console in characters.""" + def width(cls) -> int: + """The terminal width in characters.""" + try: return _os.get_terminal_size().columns except OSError: return 80 @property - def h(cls) -> int: - """The height of the console in lines.""" + def height(cls) -> int: + """The terminal height in lines.""" + try: return _os.get_terminal_size().lines except OSError: @@ -235,7 +254,8 @@ def h(cls) -> int: @property def size(cls) -> tuple[int, int]: - """A tuple with the width and height of the console in characters and lines.""" + """A tuple with the terminal width and height in characters and lines.""" + try: size = _os.get_terminal_size() return (size.columns, size.lines) @@ -245,16 +265,19 @@ def size(cls) -> tuple[int, int]: @property def user(cls) -> str: """The name of the current user.""" + return _os.getenv("USER") or _os.getenv("USERNAME") or _getpass.getuser() @property def is_tty(cls) -> bool: - """Whether the current output is a terminal/console or not.""" + """Whether the terminal is connected to a TTY or not.""" + return _sys.stdout.isatty() @property def encoding(cls) -> str: - """The encoding used by the console (e.g. `utf-8`, `cp1252`, …).""" + """The encoding used by the terminal (e.g. `utf-8`, `cp1252`, …).""" + try: encoding = _sys.stdout.encoding return encoding if encoding is not None else "utf-8" @@ -264,24 +287,28 @@ def encoding(cls) -> str: @property def supports_color(cls) -> bool: """Whether the terminal supports ANSI color codes or not.""" + if not cls.is_tty: return False + if _os.name == "nt": # CHECK IF VT100 MODE IS ENABLED ON WINDOWS try: kernel32 = getattr(_ctypes, "windll").kernel32 - h = kernel32.GetStdHandle(-11) + handle = kernel32.GetStdHandle(-11) mode = _ctypes.c_ulong() - if kernel32.GetConsoleMode(h, _ctypes.byref(mode)): + if kernel32.GetConsoleMode(handle, _ctypes.byref(mode)): return (mode.value & 0x0004) != 0 except Exception: pass + return False + return _os.getenv("TERM", "").lower() not in {"", "dumb"} class Console(metaclass=_ConsoleMeta): - """This class provides methods for logging and other actions within the console.""" + """This class provides methods for logging and other actions within the terminal.""" @classmethod def get_args( @@ -294,33 +321,33 @@ def get_args( ) -> ParsedArgs: """Will search for the specified args in the command-line arguments and return the results as a special `ParsedArgs` object.\n - ------------------------------------------------------------------------------------------------- - - `arg_parse_configs` - a dictionary where each key is an alias name for the argument - and the key's value is the parsing configuration for that argument - - `flag_value_sep` - the character/s used to separate flags from their values; - pass `None` to disable separator-based syntax (e.g. `--flag=value`) entirely - - `allow_space_value` - whether to allow space-separated flag values (e.g. `--flag value`) - in addition to the separator-based syntax; enabled by default\n - ------------------------------------------------------------------------------------------------- + --------------------------------------------------------------------------------------------------------- + * `arg_parse_configs` – A dictionary where each key is an alias name for the argument
+ and the key's value is the parsing configuration for that argument. + * `flag_value_sep` – The character/s used to separate flags from their values;
+ pass `None` to disable separator-based syntax (e.g. `--flag=value`) entirely. + * `allow_space_value` – Whether to allow space-separated flag values (e.g. `--flag value`)
+ in addition to the separator-based syntax; enabled by default. + --------------------------------------------------------------------------------------------------------- The `arg_parse_configs` dictionary can have the following structures for each item: 1. Simple set of flags (when no default value is needed): - ```python + ```python "alias_name": {"-f", "--flag"} - ``` + ``` 2. Dictionary with the`"flags"` set, plus a specified `"default"` value: - ```python + ```python "alias_name": { "flags": {"-f", "--flag"}, "default": "some_value", } - ``` + ``` 3. Positional value collection using the literals `"before"` or `"after"`: - ```python + ```python # COLLECT ALL NON-FLAGGED VALUES THAT APPEAR BEFORE THE FIRST FLAG "alias_name": "before" # COLLECT ALL NON-FLAGGED VALUES THAT APPEAR AFTER THE LAST FLAG'S VALUE "alias_name": "after" - ``` + ``` #### Example usage: If you call the `get_args()` method in your script like this: ```python @@ -349,12 +376,13 @@ def get_args( text_after = ParsedArgData(exists=True, is_pos=True, values=["Goodbye"], flag=None), ) ``` - ------------------------------------------------------------------------------------------------- - NOTE: When `allow_space_value` is `True`, a value that directly follows a flag - (e.g. `--flag value`) is consumed as that flag's value and is not available - as a positional `"after"` argument.""" + --------------------------------------------------------------------------------------------------------- + NOTE: When `allow_space_value` is `True`, a value that directly follows a flag (e.g. `--flag value`)
+ is consumed as that flag's value and is not available as a positional `"after"` argument.""" + if flag_value_sep is not None and not flag_value_sep: raise ValueError(f"The 'flag_value_sep' parameter must be a non-empty string or None, got {flag_value_sep!r}") + return _ConsoleArgsParseHelper( arg_parse_configs, flag_value_sep=flag_value_sep, @@ -373,12 +401,13 @@ def pause_exit( reset_ansi: bool = False, ) -> None: """Will print the `prompt` and then pause and/or exit the program based on the given options.\n - -------------------------------------------------------------------------------------------------- - - `prompt` -⠀the message to print before pausing/exiting - - `pause` -⠀whether to pause and wait for a key press after printing the prompt - - `exit` -⠀whether to exit the program after printing the prompt (and pausing if `pause` is true) - - `exit_code` -⠀the exit code to use when exiting the program - - `reset_ansi` -⠀whether to reset the ANSI formatting after printing the prompt""" + ----------------------------------------------------------------------------------------------------- + * `prompt` – The message to print before pausing/exiting. + * `pause` – Whether to pause and wait for a key press after printing the prompt. + * `exit` – Whether to exit the program after printing the prompt (and pausing if `pause` is true). + * `exit_code` – The exit code to use when exiting the program. + * `reset_ansi` – Whether to reset the ANSI formatting after printing the prompt.""" + FormatCodes.print(prompt, end="", flush=True) if reset_ansi: FormatCodes.print("[_]", end="") @@ -389,7 +418,8 @@ def pause_exit( @classmethod def cls(cls) -> None: - """Will clear the console in addition to completely resetting the ANSI formats.""" + """Will clear the terminal in addition to completely resetting the ANSI formats.""" + if _shutil.which("cls"): _subprocess.run(["cls"]) elif _shutil.which("clear"): @@ -413,20 +443,21 @@ def log( title_mx: int = 2, ) -> None: """Prints a nicely formatted log message.\n - ------------------------------------------------------------------------------------------- - - `title` -⠀the title of the log message (e.g. `DEBUG`, `WARN`, `FAIL`, etc.) - - `prompt` -⠀the log message - - `format_linebreaks` -⠀whether to format (indent after) the line breaks or not - - `start` -⠀something to print before the log is printed - - `end` -⠀something to print after the log is printed (e.g. `\\n`) - - `title_bg_color` -⠀the background color of the `title` (console color, RGBA, or HEXA) - - `default_color` -⠀the default text color of the `prompt` (RGBA or HEXA) - - `tab_size` -⠀the tab size used for the log (default is 8 like console tabs) - - `title_px` -⠀the horizontal padding (in chars) to the title (if `title_bg_color` is set) - - `title_mx` -⠀the horizontal margin (in chars) to the title\n - ------------------------------------------------------------------------------------------- - The log message can be formatted with special formatting codes. For more detailed + ---------------------------------------------------------------------------------------------- + * `title` – The title of the log message (e.g. `DEBUG`, `WARN`, `FAIL`, …). + * `prompt` – The log message. + * `format_linebreaks` – Whether to format (indent after) the line breaks or not. + * `start` – Something to print before the log is printed. + * `end` – Something to print after the log is printed (e.g. `\\n`). + * `title_bg_color` – The background color of the `title` (terminal color, RGBA, or HEXA). + * `default_color` – The default text color of the `prompt` (RGBA or HEXA). + * `tab_size` – The tab size used for the log (default is 8 – matches terminal tabs). + * `title_px` – The horizontal padding (in chars) to the title (if `title_bg_color` is set). + * `title_mx` – The horizontal margin (in chars) to the title. + ---------------------------------------------------------------------------------------------- + The log message can be formatted with special formatting codes. For more detailed
information about formatting codes, see `format_codes` module documentation.""" + if tab_size < 0: raise ValueError(f"The 'tab_size' parameter must be a non-negative integer, got {tab_size!r}") if title_px < 0: @@ -444,7 +475,9 @@ def log( title_bg_color = Color.to_hexa(title_bg_color) title_fg = str(Color.text_color_for_on_bg(title_bg_color)) else: - raise ValueError(f"The 'title_bg_color' parameter must be a valid console color, RGBA value, or HEXA value, got {title_bg_color!r}") + raise ValueError( + f"The 'title_bg_color' parameter must be a valid terminal color, RGBA value, or HEXA value, got {title_bg_color!r}" + ) px, mx = (" " * title_px) if has_title_bg else "", " " * title_mx tab = " " * (tab_size - 1 - ((len(mx) + (title_len := len(title) + 2 * len(px))) % tab_size)) @@ -453,7 +486,7 @@ def log( clean_prompt, removals = *FormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True), prompt_lst: list[str] = [ item for lst in [ - String.split_count(line, cls.w - (title_len + len(tab) + 2 * len(mx))) \ + String.split_count(line, cls.width - (title_len + len(tab) + 2 * len(mx))) \ for line in str(clean_prompt).splitlines() ] for item in ([""] if lst == [] else lst) ] @@ -489,9 +522,10 @@ def debug( exit_code: int = 0, reset_ansi: bool = True, ) -> None: - """A preset for `log()`: `DEBUG` log message with the options to pause - at the message and exit the program after the message was printed. + """A preset for `log()`: `DEBUG` log message with the options to pause
+ at the message and exit the program after the message was printed.\n If `active` is false, no debug message will be printed.""" + if active: cls.log( "DEBUG", @@ -519,8 +553,9 @@ def info( exit_code: int = 0, reset_ansi: bool = True, ) -> None: - """A preset for `log()`: `INFO` log message with the options to pause + """A preset for `log()`: `INFO` log message with the options to pause
at the message and exit the program after the message was printed.""" + cls.log( "INFO", prompt, @@ -547,8 +582,9 @@ def done( exit_code: int = 0, reset_ansi: bool = True, ) -> None: - """A preset for `log()`: `DONE` log message with the options to pause + """A preset for `log()`: `DONE` log message with the options to pause
at the message and exit the program after the message was printed.""" + cls.log( "DONE", prompt, @@ -575,15 +611,16 @@ def warn( exit_code: int = 1, reset_ansi: bool = True, ) -> None: - """A preset for `log()`: `WARN` log message with the options to pause + """A preset for `log()`: `WARN` log message with the options to pause
at the message and exit the program after the message was printed.""" + cls.log( "WARN", prompt, format_linebreaks=format_linebreaks, start=start, end=end, - title_bg_color=COLOR.ORANGE, + title_bg_color="br:yellow", default_color=default_color, ) cls.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi) @@ -603,8 +640,9 @@ def fail( exit_code: int = 1, reset_ansi: bool = True, ) -> None: - """A preset for `log()`: `FAIL` log message with the options to pause + """A preset for `log()`: `FAIL` log message with the options to pause
at the message and exit the program after the message was printed.""" + cls.log( "FAIL", prompt, @@ -631,8 +669,9 @@ def exit( exit_code: int = 0, reset_ansi: bool = True, ) -> None: - """A preset for `log()`: `EXIT` log message with the options to pause + """A preset for `log()`: `EXIT` log message with the options to pause
at the message and exit the program after the message was printed.""" + cls.log( "EXIT", prompt, @@ -657,18 +696,19 @@ def log_box_filled( indent: int = 0, ) -> None: """Will print a box with a colored background, containing a formatted log message.\n - ------------------------------------------------------------------------------------- - - `*values` -⠀the box content (each value is on a new line) - - `start` -⠀something to print before the log box is printed (e.g. `\\n`) - - `end` -⠀something to print after the log box is printed (e.g. `\\n`) - - `box_bg_color` -⠀the background color of the box (console color, RGBA, or HEXA) - - `default_color` -⠀the default text color of the `*values` - - `w_padding` -⠀the horizontal padding (in chars) to the box content - - `w_full` -⠀whether to make the box be the full console width or not - - `indent` -⠀the indentation of the box (in chars)\n - ------------------------------------------------------------------------------------- - The box content can be formatted with special formatting codes. For more detailed + -------------------------------------------------------------------------------------- + * `*values` – The box content (each value is on a new line). + * `start` – Something to print before the log box is printed (e.g. `\\n`). + * `end` – Something to print after the log box is printed (e.g. `\\n`). + * `box_bg_color` – The background color of the box (terminal color, RGBA, or HEXA). + * `default_color` – The default text color of the `*values`. + * `w_padding` – The horizontal padding (in chars) to the box content. + * `w_full` – Whether to make the box be the full terminal width or not. + * `indent` – The indentation of the box (in chars). + -------------------------------------------------------------------------------------- + The box content can be formatted with special formatting codes. For more detailed
information about formatting codes, see `format_codes` module documentation.""" + if w_padding < 0: raise ValueError(f"The 'w_padding' parameter must be a non-negative integer, got {w_padding!r}") if indent < 0: @@ -680,13 +720,15 @@ def log_box_filled( elif Color.is_valid_rgba(box_bg_color) or Color.is_valid_hexa(box_bg_color): box_bg_color = Color.to_hexa(box_bg_color) else: - raise ValueError(f"The 'box_bg_color' parameter must be a valid console color, RGBA value, or HEXA value, got {box_bg_color!r}") + raise ValueError( + f"The 'box_bg_color' parameter must be a valid terminal color, RGBA value, or HEXA value, got {box_bg_color!r}" + ) lines, unfmt_lines, max_line_len = cls._prepare_log_box(values, default_color) spaces_l = " " * indent - pady = " " * (cls.w if w_full else max_line_len + (2 * w_padding)) - pad_w_full = (cls.w - (max_line_len + (2 * w_padding))) if w_full else 0 + pady = " " * (cls.width if w_full else max_line_len + (2 * w_padding)) + pad_w_full = (cls.width - (max_line_len + (2 * w_padding))) if w_full else 0 default_color = default_color or "#000" bg_fc = f"_c|invert|bg:{default_color}" if box_bg_color is None else f"bg:{box_bg_color}" @@ -717,7 +759,7 @@ def log_box_bordered( start: str = "", end: str = "\n", border_type: Literal["standard", "rounded", "strong", "double"] = "rounded", - border_style: str | Rgba | Hexa = f"dim|{COLOR.GRAY}", + border_style: str | Rgba | Hexa = f"br:black", default_color: Optional[Rgba | Hexa] = None, w_padding: int = 1, w_full: bool = False, @@ -726,27 +768,27 @@ def log_box_bordered( ) -> None: """Will print a bordered box, containing a formatted log message.\n --------------------------------------------------------------------------------------------- - - `*values` -⠀the box content (each value is on a new line) - - `start` -⠀something to print before the log box is printed (e.g. `\\n`) - - `end` -⠀something to print after the log box is printed (e.g. `\\n`) - - `border_type` -⠀one of the predefined border character sets - - `border_style` -⠀the style of the border (special formatting codes) - - `default_color` -⠀the default text color of the `*values` - - `w_padding` -⠀the horizontal padding (in chars) to the box content - - `w_full` -⠀whether to make the box be the full console width or not - - `indent` -⠀the indentation of the box (in chars) - - `_border_chars` -⠀define your own border characters set (overwrites `border_type`)\n + * `*values` – The box content (each value is on a new line). + * `start` – Something to print before the log box is printed (e.g. `\\n`). + * `end` – Something to print after the log box is printed (e.g. `\\n`). + * `border_type` – One of the predefined border character sets. + * `border_style` – The style of the border (special formatting codes). + * `default_color` – The default text color of the `*values`. + * `w_padding` – The horizontal padding (in chars) to the box content. + * `w_full` – Whether to make the box be the full terminal width or not. + * `indent` – The indentation of the box (in chars). + * `_border_chars` – Define your own border characters set (overwrites `border_type`). --------------------------------------------------------------------------------------------- You can insert horizontal rules to split the box content by using `{hr}` in the `*values`.\n --------------------------------------------------------------------------------------------- - The box content can be formatted with special formatting codes. For more detailed + The box content can be formatted with special formatting codes. For more detailed
information about formatting codes, see `format_codes` module documentation.\n --------------------------------------------------------------------------------------------- The `border_type` can be one of the following: - - `"standard" = ('┌', '─', '┐', '│', '┘', '─', '└', '│', '├', '─', '┤')` - - `"rounded" = ('╭', '─', '╮', '│', '╯', '─', '╰', '│', '├', '─', '┤')` - - `"strong" = ('┏', '━', '┓', '┃', '┛', '━', '┗', '┃', '┣', '━', '┫')` - - `"double" = ('╔', '═', '╗', '║', '╝', '═', '╚', '║', '╠', '═', '╣')`\n + * `"standard" = ('┌', '─', '┐', '│', '┘', '─', '└', '│', '├', '─', '┤')` + * `"rounded" = ('╭', '─', '╮', '│', '╯', '─', '╰', '│', '├', '─', '┤')` + * `"strong" = ('┏', '━', '┓', '┃', '┛', '━', '┗', '┃', '┣', '━', '┫')` + * `"double" = ('╔', '═', '╗', '║', '╝', '═', '╚', '║', '╠', '═', '╣')`\n The order of the characters is always: 1. top-left corner 2. top border @@ -759,6 +801,7 @@ def log_box_bordered( 9. left horizontal rule connector 10. horizontal rule 11. right horizontal rule connector""" + if w_padding < 0: raise ValueError(f"The 'w_padding' parameter must be a non-negative integer, got {w_padding!r}") if indent < 0: @@ -767,7 +810,9 @@ def log_box_bordered( if len(_border_chars) != 11: raise ValueError(f"The '_border_chars' parameter must contain exactly 11 characters, got {len(_border_chars)}") if not all(len(char) == 1 for char in _border_chars): - raise ValueError(f"The '_border_chars' parameter must only contain single-character strings, got {_border_chars!r}") + raise ValueError( + f"The '_border_chars' parameter must only contain single-character strings, got {_border_chars!r}" + ) if Color.is_valid(border_style): border_style = Color.to_hexa(border_style) @@ -783,14 +828,14 @@ def log_box_bordered( lines, unfmt_lines, max_line_len = cls._prepare_log_box(values, default_color, has_rules=True) spaces_l = " " * indent - pad_w_full = (cls.w - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0 + pad_w_full = (cls.width - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0 border_l = f"[{border_style}]{border_chars[7]}[*]" border_r = f"[{border_style}]{border_chars[3]}[_]" - border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_chars[1] * (cls.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]" - border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_chars[5] * (cls.w - (len(border_chars[5] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]" + border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_chars[1] * (cls.width - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]" + border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_chars[5] * (cls.width - (len(border_chars[5] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]" - h_rule = f"{spaces_l}[{border_style}]{border_chars[8]}{border_chars[9] * (cls.w - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[10]}[_]" + h_rule = f"{spaces_l}[{border_style}]{border_chars[8]}{border_chars[9] * (cls.width - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[10]}[_]" lines = [( \ h_rule if _PATTERNS.hr.match(line) else f"{spaces_l}{border_l}{' ' * w_padding}{line}[_]" @@ -823,14 +868,15 @@ def confirm( ) -> bool: """Ask a yes/no question.\n ------------------------------------------------------------------------------------ - - `prompt` -⠀the input prompt - - `start` -⠀something to print before the input - - `end` -⠀something to print after the input (e.g. `\\n`) - - `default_color` -⠀the default text color of the `prompt` - - `default_is_yes` -⠀the default answer if the user just presses enter + * `prompt` – The input prompt. + * `start` – Something to print before the input. + * `end` – Something to print after the input (e.g. `\\n`). + * `default_color` – The default text color of the `prompt`. + * `default_is_yes` – The default answer if the user just presses enter. ------------------------------------------------------------------------------------ - The prompt can be formatted with special formatting codes. For more detailed + The prompt can be formatted with special formatting codes. For more detailed
information about formatting codes, see the `format_codes` module documentation.""" + confirmed = cls.input( FormatCodes.to_ansi( f"{start}{str(prompt)} [_|dim](({'Y' if default_is_yes else 'y'}/{'n' if default_is_yes else 'N'}): )", @@ -857,16 +903,17 @@ def multiline_input( ) -> str: """An input where users can write (and paste) text over multiple lines.\n --------------------------------------------------------------------------------------- - - `prompt` -⠀the input prompt - - `start` -⠀something to print before the input - - `end` -⠀something to print after the input (e.g. `\\n`) - - `default_color` -⠀the default text color of the `prompt` - - `show_keybindings` -⠀whether to show the special keybindings or not - - `input_prefix` -⠀the prefix of the input line - - `reset_ansi` -⠀whether to reset the ANSI codes after the input or not + * `prompt` – The input prompt. + * `start` – Something to print before the input. + * `end` – Something to print after the input (e.g. `\\n`). + * `default_color` – The default text color of the `prompt`. + * `show_keybindings` – Whether to show the special keybindings or not. + * `input_prefix` – The prefix of the input line. + * `reset_ansi` – Whether to reset the ANSI codes after the input or not. --------------------------------------------------------------------------------------- - The input prompt can be formatted with special formatting codes. For more detailed + The input prompt can be formatted with special formatting codes. For more detailed
information about formatting codes, see the `format_codes` module documentation.""" + kb = KeyBindings() kb.add("c-d", eager=True)(cls._multiline_input_submit) @@ -942,25 +989,26 @@ def input( output_type: type[Any] = str, ) -> Any: """Acts like a standard Python `input()` a bunch of cool extra features.\n - ------------------------------------------------------------------------------------ - - `prompt` -⠀the input prompt - - `start` -⠀something to print before the input - - `end` -⠀something to print after the input (e.g. `\\n`) - - `default_color` -⠀the default text color of the `prompt` - - `placeholder` -⠀a placeholder text that is shown when the input is empty - - `mask_char` -⠀if set, the input will be masked with this character - - `min_len` -⠀the minimum length of the input (required to submit) - - `max_len` -⠀the maximum length of the input (can't write further if reached) - - `allowed_chars` -⠀a string of characters that are allowed to be inputted - (default allows all characters) - - `allow_paste` -⠀whether to allow pasting text into the input or not - - `validator` -⠀a function that takes the input string and returns a string error - message if invalid, or nothing if valid - - `default_val` -⠀the default value to return if the input is empty - - `output_type` -⠀the type (class) to convert the input to before returning it\n - ------------------------------------------------------------------------------------ - The input prompt can be formatted with special formatting codes. For more detailed + ---------------------------------------------------------------------------------------- + * `prompt` – The input prompt. + * `start` – Something to print before the input. + * `end` – Something to print after the input (e.g. `\\n`). + * `default_color` – The default text color of the `prompt`. + * `placeholder` – A placeholder text that is shown when the input is empty. + * `mask_char` – If set, the input will be masked with this character. + * `min_len` – The minimum length of the input (required to submit). + * `max_len` – The maximum length of the input (can't write further if reached). + * `allowed_chars` – A string of characters that are allowed to be inputted
+ (default allows all characters). + * `allow_paste` – Whether to allow pasting text into the input or not. + * `validator` – A function that takes the input string and returns a string error
+ message if invalid, or nothing if valid. + * `default_val` – The default value to return if the input is empty. + * `output_type` – The type (class) to convert the input to before returning it. + ---------------------------------------------------------------------------------------- + The input prompt can be formatted with special formatting codes. For more detailed
information about formatting codes, see the `format_codes` module documentation.""" + if mask_char is not None and len(mask_char) != 1: raise ValueError(f"The 'mask_char' parameter must be a single character, got {mask_char!r}") if min_len is not None and min_len < 0: @@ -1004,14 +1052,14 @@ def input( session.prompt() FormatCodes.print(end, end="") - result_text = helper.get_text() - if result_text in {"", None}: + if (result_text := helper.get_text()) in {"", None}: if default_val is not None: return default_val result_text = "" if output_type == str: return result_text + else: try: return output_type(result_text) # type: ignore[call-arg] @@ -1024,17 +1072,22 @@ def input( def _read_single_key() -> None: """Wait for a single key press without requiring elevated privileges.
Falls back to reading a line when stdin is not a TTY (e.g. piped input).""" + if not _sys.stdin.isatty(): _sys.stdin.readline() return + if _sys.platform == "win32": import msvcrt as _msvcrt # type: ignore[import-not-found] _msvcrt.getch() # type: ignore[attr-defined] + else: import tty as _tty # type: ignore[import-not-found] import termios as _termios # type: ignore[import-not-found] + fd = _sys.stdin.fileno() old_settings = _termios.tcgetattr(fd) # type: ignore[attr-defined] + try: _tty.setraw(fd) # type: ignore[attr-defined] _sys.stdin.read(1) @@ -1044,6 +1097,7 @@ def _read_single_key() -> None: @classmethod def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[int, str], ...], /) -> list[str]: """Adds back the removed parts into the split string parts at their original positions.""" + cumulative_pos = [0] for length in [len(part) for part in split_string]: cumulative_pos.append(cumulative_pos[-1] + length) @@ -1067,15 +1121,19 @@ def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[ @staticmethod def _find_string_part(pos: int, cumulative_pos: list[int], /) -> int: """Finds the index of the string part that contains the given position.""" + left, right = 0, len(cumulative_pos) - 1 + while left < right: mid = (left + right) // 2 + if cumulative_pos[mid] <= pos < cumulative_pos[mid + 1]: return mid elif pos < cumulative_pos[mid]: right = mid else: left = mid + 1 + return left @staticmethod @@ -1087,6 +1145,7 @@ def _prepare_log_box( has_rules: bool = False, ) -> tuple[list[str], list[str], int]: """Prepares the log box content and returns it along with the max line length.""" + if has_rules: lines: list[str] = [] @@ -1107,6 +1166,7 @@ def _prepare_log_box( current_pos = end else: current_pos = start + else: if should_split_after: result_parts.append(val_str[current_pos:end]) @@ -1120,11 +1180,13 @@ def _prepare_log_box( for part in result_parts: lines.extend(part.splitlines()) + else: lines = [line for val in values for line in str(val).splitlines()] unfmt_lines = [FormatCodes.remove(line, default_color) for line in lines] max_line_len = max(len(line) for line in unfmt_lines) if unfmt_lines else 0 + return lines, unfmt_lines, max_line_len @staticmethod @@ -1168,6 +1230,7 @@ def __call__(self) -> ParsedArgs: def parse_arg_configs(self) -> None: """Parse the `arg_parse_configs` configuration and build lookup structures.""" + for alias, config in self.arg_parse_configs.items(): if not alias.isidentifier(): raise ValueError(f"Invalid argument alias '{alias}'.\n" @@ -1184,6 +1247,7 @@ def parse_arg_configs(self) -> None: def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[set[str]]: """Parse an individual argument configuration.""" + # POSITIONAL ARGUMENT CONFIGURATION if isinstance(config, str): if config == "before": @@ -1199,8 +1263,10 @@ def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[s f"Invalid positional argument type '{config}' under alias '{alias}'.\n" "Must be either 'before' or 'after'." ) + self.positional_configs[alias] = config self.parsed_args[alias] = ParsedArgData(exists=False, values=[], is_pos=True) + return None # NO FLAGS TO RETURN FOR POSITIONAL ARGS # NORMAL SET OF FLAGS @@ -1210,7 +1276,9 @@ def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[s f"The flag set under alias '{alias}' is empty.\n" "The set must contain at least one flag to search for." ) + self.parsed_args[alias] = ParsedArgData(exists=False, values=[], is_pos=False) + return config # SET OF FLAGS WITH SPECIFIED DEFAULT VALUE @@ -1220,15 +1288,18 @@ def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[s f"No flags provided under alias '{alias}'.\n" "The 'flags'-key set must contain at least one flag to search for." ) + self.parsed_args[alias] = ParsedArgData( exists=False, values=[config["default"]], is_pos=False, ) + return config["flags"] def find_flag_positions(self) -> None: """Find positions of first and last flags for positional argument collection.""" + i = 0 while i < self.args_len: arg = self.args[i] @@ -1268,6 +1339,7 @@ def find_flag_positions(self) -> None: def process_positional_args(self) -> None: """Collect positional `"before"`/`"after"` arguments.""" + for alias, pos_type in self.positional_configs.items(): if pos_type == "before": self._collect_before_arg(alias) @@ -1281,6 +1353,7 @@ def process_positional_args(self) -> None: def _collect_before_arg(self, alias: str, /) -> None: """Collect positional `"before"` arguments.""" + before_args: list[str] = [] end_pos: int = self.first_flag_pos if self.first_flag_pos is not None else self.args_len @@ -1294,6 +1367,7 @@ def _collect_before_arg(self, alias: str, /) -> None: def _collect_after_arg(self, alias: str, /) -> None: """Collect positional `"after"` arguments.""" + after_args: list[str] = [] start_pos: int = (self.last_flag_pos + 1) if self.last_flag_pos is not None else 0 @@ -1327,25 +1401,32 @@ def _collect_after_arg(self, alias: str, /) -> None: def _is_positional_arg(self, arg: str, /, *, allow_separator: bool = True) -> bool: """Check if an argument is positional (not a flag or separator).""" - if self.flag_value_sep and self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup: + + if (self.flag_value_sep \ + and self.flag_value_sep in arg + and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup): return True if arg not in self.arg_lookup and (allow_separator or not self.flag_value_sep or arg != self.flag_value_sep): return True return False def _is_flag_value(self, arg: str, /) -> bool: - """Check if an argument can be treated as a space-separated flag value + """Check if an argument can be treated as a space-separated flag value
(i.e. it is not a known flag, not the separator, and not a `flag=value` token).""" + if arg in self.arg_lookup: return False if self.flag_value_sep and arg.strip() == self.flag_value_sep: return False - if self.flag_value_sep and self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup: + if (self.flag_value_sep \ + and self.flag_value_sep in arg + and arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup): return False return True def process_flagged_args(self) -> None: """Process flagged arguments.""" + i = 0 while i < self.args_len: @@ -1398,8 +1479,8 @@ class _ConsoleLogBoxBgReplacer: def __init__(self, bg_fc: str, /) -> None: self.bg_fc = bg_fc - def __call__(self, m: _rx.Match[str], /) -> str: - return f"{m.group(0)}[{self.bg_fc}]" + def __call__(self, match: _rx.Match[str], /) -> str: + return f"{match.group(0)}[{self.bg_fc}]" class _ConsoleInputHelper: @@ -1427,10 +1508,12 @@ def __init__( def get_text(self) -> str: """Returns the current result text.""" + return self.result_text def bottom_toolbar(self) -> _pt.formatted_text.ANSI: """Generates the bottom toolbar text based on the current input state.""" + try: if self.mask_char: text_to_check = self.result_text @@ -1462,12 +1545,13 @@ def bottom_toolbar(self) -> _pt.formatted_text.ANSI: def process_insert_text(self, text: str, /) -> tuple[str, set[str]]: """Processes the inserted text according to the allowed characters and max length.""" + removed_chars: set[str] = set() if not text: return "", removed_chars - processed_text = "".join(c for c in text if ord(c) >= 32) + processed_text = "".join(char for char in text if ord(char) >= 32) if self.allowed_chars is not CHARS.ALL: filtered_text = "" for char in processed_text: @@ -1488,6 +1572,7 @@ def process_insert_text(self, text: str, /) -> tuple[str, set[str]]: def insert_text_event(self, event: KeyPressEvent, /) -> None: """Handles text insertion events (typing/pasting).""" + try: if not (insert_text := event.data): return @@ -1509,6 +1594,7 @@ def insert_text_event(self, event: KeyPressEvent, /) -> None: def remove_text_event(self, event: KeyPressEvent, /, *, is_backspace: bool = False) -> None: """Handles text removal events (backspace/delete).""" + try: buffer = event.app.current_buffer cursor_pos = buffer.cursor_position @@ -1572,6 +1658,8 @@ def __init__( self.validator = validator def validate(self, document: Document) -> None: + """Validates the input text according to the minimum length and custom validator function.""" + text_to_validate = self.get_text() if self.mask_char else document.text if self.min_len and len(text_to_validate) < self.min_len: raise ValidationError(message="", cursor_position=len(document.text)) @@ -1580,26 +1668,26 @@ def validate(self, document: Document) -> None: class ProgressBar: - """A console progress bar with smooth transitions and customizable appearance.\n - -------------------------------------------------------------------------------------------------- - - `min_width` -⠀the min width of the progress bar in chars - - `max_width` -⠀the max width of the progress bar in chars - - `bar_format` -⠀the format strings used to render the progress bar, containing placeholders: - * `{label}` `{l}` - * `{bar}` `{b}` - * `{current}` `{c}` (optional `:` format specifier for thousands separator, e.g. `{c:,}`) - * `{total}` `{t}` (optional `:` format specifier for thousands separator, e.g. `{t:,}`) - * `{percentage}` `{percent}` `{p}` (optional `:.f` format specifier to round - to specified number of decimal places, e.g. `{p:.1f}`) - - `limited_bar_format` -⠀a simplified format string used when the console width is too small - for the normal `bar_format` - - `chars` -⠀a tuple of characters ordered from full to empty progress
- The first character represents completely filled sections, intermediate - characters create smooth transitions, and the last character represents - empty sections. Default is a set of Unicode block characters. - -------------------------------------------------------------------------------------------------- - The bar format (also limited) can additionally be formatted with special formatting codes. For - more detailed information about formatting codes, see the `format_codes` module documentation.""" + """A terminal progress bar with smooth transitions and customizable appearance.\n + ------------------------------------------------------------------------------------------------------ + * `min_width` – The min width of the progress bar in chars. + * `max_width` – The max width of the progress bar in chars. + * `bar_format` – The format strings used to render the progress bar, containing placeholders: + - `{label}` `{l}` + - `{bar}` `{b}` + - `{current}` `{c}` (optional `:` format specifier for thousands separator, e.g. `{c:,}`) + - `{total}` `{t}` (optional `:` format specifier for thousands separator, e.g. `{t:,}`) + - `{percentage}` `{percent}` `{p}` (optional `:.f` format specifier to round
+ to specified number of decimal places, e.g. `{p:.1f}`) + * `limited_bar_format` – A simplified format string used when the terminal width is too small
+ for the normal `bar_format`. + * `chars` – A tuple of characters ordered from full to empty progress:
+ The first character represents completely filled sections.
+ Intermediate characters create smooth transitions
+ The last character represents empty sections. + ------------------------------------------------------------------------------------------------------ + The bar format (also limited) can additionally be formatted with special formatting codes.
+ For more detailed information about formatting codes, see the `format_codes` module documentation.""" def __init__( self, @@ -1620,7 +1708,7 @@ def __init__( self.bar_format: list[str] | tuple[str, ...] """The format strings used to render the progress bar (joined by `sep`).""" self.limited_bar_format: list[str] | tuple[str, ...] - """The simplified format strings used when the console width is too small.""" + """The simplified format strings used when the terminal width is too small.""" self.sep: str """The separator string used to join multiple bar-format strings.""" self.chars: tuple[str, ...] @@ -1639,9 +1727,10 @@ def __init__( def set_width(self, min_width: Optional[int] = None, max_width: Optional[int] = None) -> None: """Set the width of the progress bar.\n - -------------------------------------------------------------- - - `min_width` -⠀the min width of the progress bar in chars - - `max_width` -⠀the max width of the progress bar in chars""" + ----------------------------------------------------------------- + * `min_width` – The min width of the progress bar in chars. + * `max_width` – The max width of the progress bar in chars.""" + if min_width is not None: if min_width < 1: raise ValueError(f"The 'min_width' parameter must be a positive integer, got {min_width!r}") @@ -1662,28 +1751,33 @@ def set_bar_format( sep: Optional[str] = None, ) -> None: """Set the format string used to render the progress bar.\n - -------------------------------------------------------------------------------------------------- - - `bar_format` -⠀the format strings used to render the progress bar, containing placeholders: - * `{label}` `{l}` - * `{bar}` `{b}` - * `{current}` `{c}` (optional `:` format specifier for thousands separator, e.g. `{c:,}`) - * `{total}` `{t}` (optional `:` format specifier for thousands separator, e.g. `{t:,}`) - * `{percentage}` `{percent}` `{p}` (optional `:.f` format specifier to round - to specified number of decimal places, e.g. `{p:.1f}`) - - `limited_bar_format` -⠀a simplified format strings used when the console width is too small - - `sep` -⠀the separator string used to join multiple format strings - -------------------------------------------------------------------------------------------------- - The bar format (also limited) can additionally be formatted with special formatting codes. For - more detailed information about formatting codes, see the `format_codes` module documentation.""" + ------------------------------------------------------------------------------------------------------ + * `bar_format` – The format strings used to render the progress bar, containing placeholders: + - `{label}` `{l}` + - `{bar}` `{b}` + - `{current}` `{c}` (optional `:` format specifier for thousands separator, e.g. `{c:,}`) + - `{total}` `{t}` (optional `:` format specifier for thousands separator, e.g. `{t:,}`) + - `{percentage}` `{percent}` `{p}` (optional `:.f` format specifier to round
+ to specified number of decimal places, e.g. `{p:.1f}`) + * `limited_bar_format` – A simplified format strings used when the terminal width is too small. + * `sep` – The separator string used to join multiple format strings. + ------------------------------------------------------------------------------------------------------ + The bar format (also limited) can additionally be formatted with special formatting codes.
+ For more detailed information about formatting codes, see the `format_codes` module documentation.""" + if bar_format is not None: if not any(_PATTERNS.bar.search(part) for part in bar_format): - raise ValueError(f"The 'bar_format' parameter value must contain the '{{bar}}' or '{{b}}' placeholder, got {bar_format!r}") + raise ValueError( + f"The 'bar_format' parameter value must contain the '{{bar}}' or '{{b}}' placeholder, got {bar_format!r}" + ) self.bar_format = bar_format if limited_bar_format is not None: if not any(_PATTERNS.bar.search(part) for part in limited_bar_format): - raise ValueError(f"The 'limited_bar_format' parameter value must contain the '{{bar}}' or '{{b}}' placeholder, got {limited_bar_format!r}") + raise ValueError( + f"The 'limited_bar_format' parameter value must contain the '{{bar}}' or '{{b}}' placeholder, got {limited_bar_format!r}" + ) self.limited_bar_format = limited_bar_format @@ -1692,11 +1786,13 @@ def set_bar_format( def set_chars(self, chars: tuple[str, ...], /) -> None: """Set the characters used to render the progress bar.\n - -------------------------------------------------------------------------- - - `chars` -⠀a tuple of characters ordered from full to empty progress
- The first character represents completely filled sections, intermediate - characters create smooth transitions, and the last character represents - empty sections. If None, uses default Unicode block characters.""" + ----------------------------------------------------------------------------- + * `chars` – A tuple of characters ordered from full to empty progress:
+ The first character represents completely filled sections.
+ Intermediate characters create smooth transitions.
+ The last character represents empty sections.
+ If `None`, uses default Unicode block characters.""" + if len(chars) < 2: raise ValueError(f"The 'chars' parameter must contain at least two characters (full and empty), got {chars!r}") elif not all(len(char) == 1 for char in chars): @@ -1706,10 +1802,11 @@ def set_chars(self, chars: tuple[str, ...], /) -> None: def show_progress(self, current: int, total: int, /, label: Optional[str] = None) -> None: """Show or update the progress bar.\n - ------------------------------------------------------------------------------------------- - - `current` -⠀the current progress value (below `0` or greater than `total` hides the bar) - - `total` -⠀the total value representing 100% progress (must be greater than `0`) - - `label` -⠀an optional label which is inserted at the `{label}` or `{l}` placeholder""" + ---------------------------------------------------------------------------------------------- + * `current` – The current progress value (below `0` or greater than `total` hides the bar). + * `total` – The total value representing 100% progress (must be greater than `0`). + * `label` – An optional label which is inserted at the `{label}` or `{l}` placeholder.""" + # THROTTLE UPDATES (UNLESS IT'S THE FIRST/FINAL UPDATE) current_time = _time.time() if ( @@ -1736,7 +1833,8 @@ def show_progress(self, current: int, total: int, /, label: Optional[str] = None raise def hide_progress(self) -> None: - """Hide the progress bar and restore normal console output.""" + """Hide the progress bar and restore normal terminal output.""" + if self.active: self._clear_progress_line() self._stop_intercepting() @@ -1744,13 +1842,14 @@ def hide_progress(self) -> None: @contextmanager def progress_context(self, total: int, /, label: Optional[str] = None) -> Generator[ProgressUpdater, None, None]: """Context manager for automatic cleanup. Returns a function to update progress.\n - ---------------------------------------------------------------------------------------------------- - - `total` -⠀the total value representing 100% progress (must be greater than `0`) - - `label` -⠀an optional label which is inserted at the `{label}` or `{l}` placeholder - ---------------------------------------------------------------------------------------------------- - The returned callable accepts keyword arguments. At least one of these parameters must be provided: - - `current` -⠀update the current progress value - - `label` -⠀update the progress label\n + ----------------------------------------------------------------------------------------- + * `total` – The total value representing 100% progress (must be greater than `0`). + * `label` – An optional label which is inserted at the `{label}` or `{l}` placeholder. + ----------------------------------------------------------------------------------------- + The returned callable accepts keyword arguments.
+ At least one of these parameters must be provided: + * `current` – Update the current progress value. + * `label` – Update the progress label. #### Example usage: ```python @@ -1767,6 +1866,7 @@ def progress_context(self, total: int, /, label: Optional[str] = None) -> Genera # Do some work... update_progress(i, f"Finalizing ({i})") # Update both ```""" + if total <= 0: raise ValueError(f"The 'total' parameter must be a positive integer, got {total!r}") @@ -1820,7 +1920,7 @@ def _get_formatted_info_and_bar_width( fmt_str = self.sep.join(fmt_parts) fmt_str = FormatCodes.to_ansi(fmt_str) - bar_space = Console.w - len(FormatCodes.remove_ansi(_PATTERNS.bar.sub("", fmt_str))) + bar_space = Console.width - len(FormatCodes.remove_ansi(_PATTERNS.bar.sub("", fmt_str))) bar_width = min(bar_space, self.max_width) if bar_space > 0 else 0 return fmt_str, bar_width @@ -1858,6 +1958,7 @@ def _stop_intercepting(self) -> None: def _emergency_cleanup(self) -> None: """Emergency cleanup to restore stdout in case of exceptions.""" + try: self._stop_intercepting() except Exception: @@ -1884,11 +1985,11 @@ def _redraw_display(self) -> None: class _ProgressContextHelper: """Internal, callable helper class to update the progress bar's current value and/or label.\n - ---------------------------------------------------------------------------------------------- - - `current` -⠀the current progress value - - `label` -⠀the progress label - - `type_checking` -⠀whether to check the parameters' types: - Is false per default to save performance, but can be set to true for debugging purposes.""" + ------------------------------------------------------------------------------------------------ + * `current` – The current progress value. + * `label` – The progress label. + * `type_checking` – Whether to check the parameters' types:
+ Is false per default to save performance, but can be set to true for debugging purposes.""" def __init__(self, progress_bar: ProgressBar, total: int, label: Optional[str], /): self.progress_bar = progress_bar @@ -1957,17 +2058,17 @@ def __call__(self, match: _rx.Match[str], /) -> str: class Throbber: - """A console throbber for indeterminate processes with customizable appearance. + """A terminal throbber for indeterminate processes with customizable appearance.
This class intercepts stdout to allow printing while the animation is active.\n - --------------------------------------------------------------------------------------------- - - `label` -⠀the current label text - - `throbber_format` -⠀the format string used to render the throbber, containing placeholders: - * `{label}` `{l}` - * `{animation}` `{a}` - - `frames` -⠀a tuple of strings representing the animation frames - - `interval` -⠀the time in seconds between each animation frame - --------------------------------------------------------------------------------------------- - The `throbber_format` can additionally be formatted with special formatting codes. For more + ------------------------------------------------------------------------------------------------ + * `label` – The current label text. + * `throbber_format` – The format string used to render the throbber, containing placeholders: + - `{label}` `{l}` + - `{animation}` `{a}` + * `frames` – A tuple of strings representing the animation frames. + * `interval` – The time in seconds between each animation frame. + ------------------------------------------------------------------------------------------------ + The `throbber_format` can additionally be formatted with special formatting codes. For more
detailed information about formatting codes, see the `format_codes` module documentation.""" def __init__( @@ -2007,11 +2108,12 @@ def __init__( def set_format(self, throbber_format: list[str] | tuple[str, ...], *, sep: Optional[str] = None) -> None: """Set the format string used to render the throbber.\n - --------------------------------------------------------------------------------------------- - - `throbber_format` -⠀the format strings used to render the throbber, containing placeholders: - * `{label}` `{l}` - * `{animation}` `{a}` - - `sep` -⠀the separator string used to join multiple format strings""" + ------------------------------------------------------------------------------------------------- + * `throbber_format` – The format strings used to render the throbber, containing placeholders: + - `{label}` `{l}` + - `{animation}` `{a}` + * `sep` – The separator string used to join multiple format strings.""" + if not any(_PATTERNS.animation.search(fmt) for fmt in throbber_format): raise ValueError( f"At least one format string in 'throbber_format' must contain the '{{animation}}' or '{{a}}' placeholder, got {throbber_format!r}" @@ -2022,8 +2124,9 @@ def set_format(self, throbber_format: list[str] | tuple[str, ...], *, sep: Optio def set_frames(self, frames: tuple[str, ...], /) -> None: """Set the frames used for the throbber animation.\n - --------------------------------------------------------------------- - - `frames` -⠀a tuple of strings representing the animation frames""" + ------------------------------------------------------------------------ + * `frames` – A tuple of strings representing the animation frames.""" + if len(frames) < 2: raise ValueError(f"The 'frames' parameter must contain at least two frames, got {frames!r}") @@ -2031,8 +2134,9 @@ def set_frames(self, frames: tuple[str, ...], /) -> None: def set_interval(self, interval: int | float, /) -> None: """Set the time interval between each animation frame.\n - ------------------------------------------------------------------- - - `interval` -⠀the time in seconds between each animation frame""" + ---------------------------------------------------------------------- + * `interval` – The time in seconds between each animation frame.""" + if interval <= 0: raise ValueError(f"The 'interval' parameter must be a positive number, got {interval!r}") @@ -2040,8 +2144,9 @@ def set_interval(self, interval: int | float, /) -> None: def start(self, label: Optional[str] = None, /) -> None: """Start the throbber animation and intercept stdout.\n - ---------------------------------------------------------- - - `label` -⠀the label to display alongside the throbber""" + -------------------------------------------------------------- + * `label` – The label to display alongside the throbber.""" + if self.active: return @@ -2052,7 +2157,8 @@ def start(self, label: Optional[str] = None, /) -> None: self._animation_thread.start() def stop(self) -> None: - """Stop and hide the throbber and restore normal console output.""" + """Stop and hide the throbber and restore normal terminal output.""" + if self.active: if self._stop_event: self._stop_event.set() @@ -2068,18 +2174,19 @@ def stop(self) -> None: def update_label(self, label: Optional[str], /) -> None: """Update the throbber's label text.\n - -------------------------------------- - - `new_label` -⠀the new label text""" + ----------------------------------------- + * `new_label` – The new label text.""" + self.label = label @contextmanager def context(self, label: Optional[str] = None, /) -> Generator[Callable[[str], None], None, None]: """Context manager for automatic cleanup. Returns a function to update the label.\n - ---------------------------------------------------------------------------------------------- - - `label` -⠀the label to display alongside the throbber - ----------------------------------------------------------------------------------------------- + ------------------------------------------------------------------------------------ + * `label` – The label to display alongside the throbber. + ------------------------------------------------------------------------------------ The returned callable accepts a single parameter: - - `new_label` -⠀the new label text\n + * `new_label` – The new label text. #### Example usage: ```python @@ -2090,6 +2197,7 @@ def context(self, label: Optional[str] = None, /) -> Generator[Callable[[str], N update_label("Finishing...") time.sleep(2) ```""" + try: self.start(label) yield self.update_label @@ -2101,6 +2209,7 @@ def context(self, label: Optional[str] = None, /) -> Generator[Callable[[str], N def _animation_loop(self) -> None: """The internal thread target that runs the animation loop.""" + self._frame_index = 0 while self._stop_event and not self._stop_event.is_set(): try: @@ -2143,6 +2252,7 @@ def _stop_intercepting(self) -> None: def _emergency_cleanup(self) -> None: """Emergency cleanup to restore stdout in case of exceptions.""" + try: self._stop_intercepting() except Exception: diff --git a/src/xulbux/data.py b/src/xulbux/data.py index b88be18..39f5f56 100644 --- a/src/xulbux/data.py +++ b/src/xulbux/data.py @@ -34,7 +34,7 @@ class Data: def serialize_bytes(cls, data: bytes | bytearray, /) -> dict[str, str]: """Converts bytes or bytearray to a JSON-compatible format (dictionary) with explicit keys.\n ---------------------------------------------------------------------------------------------- - - `data` -⠀the bytes or bytearray to serialize""" + * `data` – The bytes or bytearray to serialize.""" key = "bytearray" if isinstance(data, bytearray) else "bytes" try: @@ -48,9 +48,9 @@ def serialize_bytes(cls, data: bytes | bytearray, /) -> dict[str, str]: def deserialize_bytes(cls, obj: dict[str, str], /) -> bytes | bytearray: """Tries to converts a JSON-compatible bytes/bytearray format (dictionary) back to its original type.\n -------------------------------------------------------------------------------------------------------- - - `obj` -⠀the dictionary to deserialize\n + * `obj` – The dictionary to deserialize. -------------------------------------------------------------------------------------------------------- - If the serialized object was created with `Data.serialize_bytes()`, it will work. + If the serialized object was created with `Data.serialize_bytes()`, it will work.
If it fails to decode the data, it will raise a `ValueError`.""" for key in ("bytes", "bytearray"): if key in obj and "encoding" in obj: @@ -69,7 +69,7 @@ def deserialize_bytes(cls, obj: dict[str, str], /) -> bytes | bytearray: def chars_count(cls, data: DataObjType, /) -> int: """The sum of all the characters amount including the keys in dictionaries.\n ------------------------------------------------------------------------------ - - `data` -⠀the data structure to count the characters from""" + * `data` – The data structure to count the characters from.""" chars_count = 0 if isinstance(data, dict): @@ -93,7 +93,7 @@ def chars_count(cls, data: DataObjType, /) -> int: def strip(cls, data: DataObj, /) -> DataObj: """Removes leading and trailing whitespaces from the data structure's items.\n ------------------------------------------------------------------------------- - - `data` -⠀the data structure to strip the items from""" + * `data` – The data structure to strip the items from.""" if isinstance(data, dict): return type(data)({key.strip(): ( cls.strip(cast(DataObjType, val)) \ @@ -112,9 +112,9 @@ def strip(cls, data: DataObj, /) -> DataObj: @classmethod def remove_empty_items(cls, data: DataObj, /, *, spaces_are_empty: bool = False) -> DataObj: """Removes empty items from the data structure.\n - --------------------------------------------------------------------------------- - - `data` -⠀the data structure to remove empty items from. - - `spaces_are_empty` -⠀if true, it will count items with only spaces as empty""" + ------------------------------------------------------------------------------------ + * `data` – The data structure to remove empty items from. + * `spaces_are_empty` – If true, it will count items with only spaces as empty.""" if isinstance(data, dict): return type(data)({ key: ( @@ -140,8 +140,8 @@ def remove_empty_items(cls, data: DataObj, /, *, spaces_are_empty: bool = False) @classmethod def remove_duplicates(cls, data: DataObj, /) -> DataObj: """Removes all duplicates from the data structure.\n - ----------------------------------------------------------- - - `data` -⠀the data structure to remove duplicates from""" + -------------------------------------------------------------- + * `data` – The data structure to remove duplicates from.""" if isinstance(data, dict): return type(data)({ key: cls.remove_duplicates(cast(DataObjType, val)) if isinstance(val, DataObjTT) else val @@ -182,12 +182,12 @@ def remove_comments( comment_sep: str = "", ) -> DataObj: """Remove comments from a list, tuple or dictionary.\n - --------------------------------------------------------------------------------------------------------------- - - `data` -⠀list, tuple or dictionary, where the comments should get removed from - - `comment_start` -⠀the string that marks the start of a comment inside `data` - - `comment_end` -⠀the string that marks the end of a comment inside `data` - - `comment_sep` -⠀the string with which a comment will be replaced, if it is in the middle of a value\n - --------------------------------------------------------------------------------------------------------------- + ----------------------------------------------------------------------------------------------------------------- + * `data` – List, tuple or dictionary, where the comments should get removed from. + * `comment_start` – The string that marks the start of a comment inside `data`. + * `comment_end` – The string that marks the end of a comment inside `data`. + * `comment_sep` – The string with which a comment will be replaced, if it is in the middle of a value. + ----------------------------------------------------------------------------------------------------------------- #### Examples: ```python data = { @@ -211,8 +211,8 @@ def remove_comments( comment_end="<<", comment_sep="__", ) - ```\n - --------------------------------------------------------------------------------------------------------------- + ``` + ----------------------------------------------------------------------------------------------------------------- For this example, `processed_data` will be: ```python { @@ -223,14 +223,15 @@ def remove_comments( ], "key3": None, } - ```\n - - For `key1`, all the comments will just be removed, except at `value3` and `value4`: - * `value3` The comment is removed and the parts left and right are joined through `comment_sep`. - * `value4` The whole value is removed, since the whole value was a comment. - - For `key2`, the key, including its whole values will be removed. - - For `key3`, since all its values are just comments, the key will still exist, but with a value of `None`.""" + ``` + * For `key1`, all the comments will just be removed, except at `value3` and `value4`: + - `value3` The comment is removed and the parts left and right are joined through `comment_sep`. + - `value4` The whole value is removed, since the whole value was a comment. + * For `key2`, the key, including its whole values will be removed. + * For `key3`, since all its values are just comments, the key will still exist, but with a value of `None`.""" + if not comment_start: - raise ValueError("The 'comment_start' parameter must be a non-empty string.") + raise ValueError(f"The 'comment_start' parameter must be a non-empty string, got {comment_start!r}") return cast( DataObj, @@ -256,20 +257,21 @@ def is_equal( ) -> bool: """Compares two structures and returns `True` if they are equal and `False` otherwise.\n ⇾ Will not detect, if a key-name has changed, only if removed or added.\n - ------------------------------------------------------------------------------------------------ - - `data1` -⠀the first data structure to compare - - `data2` -⠀the second data structure to compare - - `ignore_paths` -⠀a path or list of paths to key/s and item/s to ignore during comparison:
- Comments are not ignored when comparing. `comment_start` and `comment_end` are only used - to correctly recognize the keys in the `ignore_paths`. - - `path_sep` -⠀the separator between the keys/indexes in the `ignore_paths` - - `comment_start` -⠀the string that marks the start of a comment inside `data1` and `data2` - - `comment_end` -⠀the string that marks the end of a comment inside `data1` and `data2`\n - ------------------------------------------------------------------------------------------------ - The paths from `ignore_paths` and the `path_sep` parameter work exactly the same way as for + --------------------------------------------------------------------------------------------------- + * `data1` – The first data structure to compare. + * `data2` – The second data structure to compare. + * `ignore_paths` – A path or list of paths to key/s and item/s to ignore during comparison:
+ Comments are not ignored when comparing. `comment_start` and `comment_end` are only used
+ to correctly recognize the keys in the `ignore_paths`. + * `path_sep` – The separator between the keys/indexes in the `ignore_paths`. + * `comment_start` – The string that marks the start of a comment inside `data1` and `data2`. + * `comment_end` – The string that marks the end of a comment inside `data1` and `data2`. + --------------------------------------------------------------------------------------------------- + The paths from `ignore_paths` and the `path_sep` parameter work exactly the same way as for
the method `Data.get_path_id()`. See its documentation for more details.""" + if not path_sep: - raise ValueError("The 'path_sep' parameter must be a non-empty string.") + raise ValueError(f"The 'path_sep' parameter must be a non-empty string, got {path_sep!r}") if isinstance(ignore_paths, str): ignore_paths = [ignore_paths] @@ -338,15 +340,15 @@ def get_path_id( ignore_not_found: bool = False, ) -> Optional[str | list[Optional[str]]]: """Generates a unique ID based on the path to a specific value within a nested data structure.\n - -------------------------------------------------------------------------------------------------- - -`data` -⠀the list, tuple, or dictionary, which the id should be generated for - - `value_paths` -⠀a path or list of paths to the value/s to generate the id for (explained below) - - `path_sep` -⠀the separator between the keys/indexes in the `value_paths` - - `comment_start` -⠀the string that marks the start of a comment inside `data` - - `comment_end` -⠀the string that marks the end of a comment inside `data` - - `ignore_not_found` -⠀if true, the function will return `None` if the value is not found - instead of raising an error\n - -------------------------------------------------------------------------------------------------- + ----------------------------------------------------------------------------------------------------- + * `data` – The list, tuple, or dictionary, which the id should be generated for. + * `value_paths` – A path or list of paths to the value/s to generate the id for (explained below). + * `path_sep` – The separator between the keys/indexes in the `value_paths`. + * `comment_start` – The string that marks the start of a comment inside `data`. + * `comment_end` – The string that marks the end of a comment inside `data`. + * `ignore_not_found` – If true, the function will return `None` if the value is not found
+ instead of raising an error. + ----------------------------------------------------------------------------------------------------- The param `value_path` is a sort of path (or a list of paths) to the value/s to be updated. #### In this example: ```python @@ -357,13 +359,15 @@ def get_path_id( } } ``` - … if you want to change the value of `"apples"` to `"strawberries"`, the value path would be - `healthy->fruit->apples` or if you don't know that the value is `"apples"` you can also use the - index of the value, so `healthy->fruit->0`.""" + … if you want to change the value of `"apples"` to `"strawberries"`, the value path would be
+ `healthy->fruit->apples` or if you don't know that the value is `"apples"` you can also use
+ the index of the value, so `healthy->fruit->0`.""" + if not path_sep: raise ValueError(f"The 'path_sep' parameter must be a non-empty string, got {path_sep!r}") data = cls.remove_comments(data, comment_start=comment_start, comment_end=comment_end) + if isinstance(value_paths, str): return _DataGetPathIdHelper(value_paths, path_sep=path_sep, data_obj=data, ignore_not_found=ignore_not_found)() @@ -371,16 +375,18 @@ def get_path_id( _DataGetPathIdHelper(path, path_sep=path_sep, data_obj=data, ignore_not_found=ignore_not_found)() for path in value_paths ] + return results if len(results) > 1 else results[0] if results else None @classmethod def get_value_by_path_id(cls, data: DataObjType, path_id: str, /, *, get_key: bool = False) -> Any: - """Retrieves the value from `data` using the provided `path_id`, as long as the data structure - hasn't changed since creating the path ID.\n - -------------------------------------------------------------------------------------------------- - - `data` -⠀the list, tuple, or dictionary to retrieve the value from - - `path_id` -⠀the path ID to the value to retrieve, created before using `Data.get_path_id()` - - `get_key` -⠀if true and the final item is in a dict, it returns the key instead of the value""" + """Retrieves the value from `data` using the provided `path_id`,
+ as long as the data structure hasn't changed since creating the path ID.\n + ----------------------------------------------------------------------------------------------------- + * `data` – The list, tuple, or dictionary to retrieve the value from. + * `path_id` – The path ID to the value to retrieve, created before using `Data.get_path_id()`. + * `get_key` – If true and the final item is in a dict, it returns the key instead of the value.""" + parent: Optional[DataObjType] = None path = cls._sep_path_id(path_id) current_data: Any = data @@ -410,16 +416,17 @@ def get_value_by_path_id(cls, data: DataObjType, path_id: str, /, *, get_key: bo @classmethod def set_value_by_path_id(cls, data: DataObj, update_values: dict[str, Any], /) -> DataObj: - """Updates the value/s from `update_values` in the `data`, as long as the data structure - hasn't changed since creating the path ID to that value.\n - ----------------------------------------------------------------------------------------- - - `data` -⠀the list, tuple, or dictionary to update the value/s in - - `update_values` -⠀a dictionary where keys are path IDs and values are the new values - to insert, for example: - ```python - { "1>012": "new value", "1>31": ["new value 1", "new value 2"], … } - ``` - The path IDs should have been created using `Data.get_path_id()`.""" + """Updates the value/s from `update_values` in the `data`, as long as the
+ data structure hasn't changed since creating the path ID to that value.\n + ------------------------------------------------------------------------------ + * `data` – The list, tuple, or dictionary to update the value/s in. + * `update_values` – A dictionary where keys are path IDs
+ and values are the new values to insert, for example: + ```python + { "1>012": "new value", "1>31": ["new value 1", "new value 2"], ... } + ``` + The path IDs should have been created using `Data.get_path_id()`.""" + if not (valid_update_values := [(path_id, new_val) for path_id, new_val in update_values.items()]): raise ValueError(f"No valid 'update_values' found in dictionary:\n{update_values!r}") @@ -442,34 +449,35 @@ def render( syntax_highlighting: dict[str, str] | bool = False, ) -> str: """Get nicely formatted data structure-strings.\n - --------------------------------------------------------------------------------------------------------------- - - `data` -⠀the data structure to format - - `indent` -⠀the amount of spaces to use for indentation - - `compactness` -⠀the level of compactness for the output (explained below – section 1) - - `max_width` -⠀the maximum width of a line before expanding (only used if `compactness` is `1`) - - `sep` -⠀the separator between items in the data structure - - `as_json` -⠀if true, the output will be in valid JSON format - - `syntax_highlighting` -⠀a dictionary defining the syntax highlighting styles (explained below – section 2) - or `True` to apply default syntax highlighting styles or `False`/`None` to disable syntax highlighting\n - --------------------------------------------------------------------------------------------------------------- + ------------------------------------------------------------------------------------------------------------------- + * `data` – The data structure to format. + * `indent` – The amount of spaces to use for indentation. + * `compactness` – The level of compactness for the output (explained below – section 1). + * `max_width` – The maximum width of a line before expanding (only used if `compactness` is `1`). + * `sep` – The separator between items in the data structure. + * `as_json` – if true, the output will be in valid JSON format. + * `syntax_highlighting` – A dictionary defining the syntax highlighting styles (explained below – section 2)
+ or `True` to apply default syntax highlighting styles or `False`/`None` to disable syntax highlighting. + ------------------------------------------------------------------------------------------------------------------- There are three different levels of `compactness`: - - `0` expands everything possible - - `1` only expands if there's other lists, tuples or dicts inside of data or, - if the data's content is longer than `max_width` - - `2` keeps everything collapsed (all on one line)\n - --------------------------------------------------------------------------------------------------------------- + * `0` expands everything possible. + * `1` only expands if there's other lists, tuples or dicts inside of data,
+ or if the data's content is longer than `max_width`. + * `2` keeps everything collapsed (all on one line). + ------------------------------------------------------------------------------------------------------------------- The `syntax_highlighting` dictionary has 5 keys for each part of the data.
The key's values are the formatting codes to apply to this data part.
- The formatting can be changed by simply adding the key with the new value + The formatting can be changed by simply adding the key with the new value
inside the `syntax_highlighting` dictionary.\n The keys with their default values are: - - `str: "br:blue"` - - `number: "br:magenta"` - - `literal: "magenta"` - - `type: "i|green"` - - `punctuation: "br:black"`\n - --------------------------------------------------------------------------------------------------------------- + * `str: "br:blue"` + * `number: "br:magenta"` + * `literal: "magenta"` + * `type: "i|green"` + * `punctuation: "br:black" + ------------------------------------------------------------------------------------------------------------------- For more detailed information about formatting codes, see the `format_codes` module documentation.""" + if indent < 0: raise ValueError(f"The 'indent' parameter must be a non-negative integer, got {indent!r}") if max_width <= 0: @@ -501,35 +509,36 @@ def print( syntax_highlighting: dict[str, str] | bool = {}, ) -> None: """Print nicely formatted data structures.\n - --------------------------------------------------------------------------------------------------------------- - - `data` -⠀the data structure to format and print - - `indent` -⠀the amount of spaces to use for indentation - - `compactness` -⠀the level of compactness for the output (explained below – section 1) - - `max_width` -⠀the maximum width of a line before expanding (only used if `compactness` is `1`) - - `sep` -⠀the separator between items in the data structure - - `end` -⠀the string appended after the last value, default a newline `\\n` - - `as_json` -⠀if true, the output will be in valid JSON format - - `syntax_highlighting` -⠀a dictionary defining the syntax highlighting styles (explained below – section 2)\n - --------------------------------------------------------------------------------------------------------------- + ---------------------------------------------------------------------------------------------------------------- + * `data` – The data structure to format and print. + * `indent` – The amount of spaces to use for indentation. + * `compactness` – The level of compactness for the output (explained below – section 1). + * `max_width` – The maximum width of a line before expanding (only used if `compactness` is `1`). + * `sep` – The separator between items in the data structure. + * `end` – The string appended after the last value, default a newline `\\n`. + * `as_json` – If true, the output will be in valid JSON format. + * `syntax_highlighting` – A dictionary defining the syntax highlighting styles (explained below – section 2). + ---------------------------------------------------------------------------------------------------------------- There are three different levels of `compactness`: - - `0` expands everything possible - - `1` only expands if there's other lists, tuples or dicts inside of data or, - if the data's content is longer than `max_width` - - `2` keeps everything collapsed (all on one line)\n - --------------------------------------------------------------------------------------------------------------- + * `0` expands everything possible. + * `1` only expands if there's other lists, tuples or dicts inside of data,
+ or if the data's content is longer than `max_width`. + * `2` keeps everything collapsed (all on one line). + ---------------------------------------------------------------------------------------------------------------- The `syntax_highlighting` parameter is a dictionary with 5 keys for each part of the data.
The key's values are the formatting codes to apply to this data part.
- The formatting can be changed by simply adding the key with the new value inside the - `syntax_highlighting` dictionary.\n + The formatting can be changed by simply adding the key with the new value
+ inside the `syntax_highlighting` dictionary.\n The keys with their default values are: - - `str: "br:blue"` - - `number: "br:magenta"` - - `literal: "magenta"` - - `type: "i|green"` - - `punctuation: "br:black"`\n + * `str: "br:blue"` + * `number: "br:magenta"` + * `literal: "magenta"` + * `type: "i|green"` + * `punctuation: "br:black"`\n For no syntax highlighting, set `syntax_highlighting` to `False` or `None`.\n - --------------------------------------------------------------------------------------------------------------- + ---------------------------------------------------------------------------------------------------------------- For more detailed information about formatting codes, see the `format_codes` module documentation.""" + FormatCodes.print( cls.render( data, @@ -552,6 +561,8 @@ def _compare_nested( ignore_paths: list[list[str]], current_path: list[str] = [], ) -> bool: + """Internal method to recursively compare two nested data structures while ignoring specified paths.""" + if any(current_path == path[:len(current_path)] for path in ignore_paths): return True @@ -588,6 +599,7 @@ def _compare_nested( @staticmethod def _sep_path_id(path_id: str, /) -> list[int]: """Internal method to separate a path-ID string into its ID parts as a list of integers.""" + if len(split_id := path_id.split(">")) == 2: id_part_len, path_id_parts = split_id @@ -602,6 +614,7 @@ def _sep_path_id(path_id: str, /) -> list[int]: @classmethod def _set_nested_val(cls, data: DataObjType, id_path: list[int], value: Any, /) -> Any: """Internal method to set a value in a nested data structure based on the provided ID path.""" + current_data: Any = data if len(id_path) == 1: @@ -656,12 +669,14 @@ def __call__(self) -> DataObjType: return self.remove_nested_comments(self.data) def remove_nested_comments(self, item: Any, /) -> Any: + """Recursively removes comments from the given item, which can be a dictionary, list, tuple, or string.""" + if isinstance(item, dict): dict_item = cast(dict[Any, Any], item) return { key: val for key, val in [ - (self.remove_nested_comments(k), self.remove_nested_comments(v)) for k, v in dict_item.items() \ + (self.remove_nested_comments(key), self.remove_nested_comments(val)) for key, val in dict_item.items() \ ] if key is not None } @@ -705,6 +720,7 @@ def __call__(self) -> Optional[str]: def process_key(self, key: str, /) -> bool: """Process a single key and update `path_ids`. Returns `False` if processing should stop.""" + idx: Optional[int] = None if isinstance(self.current_data, dict): @@ -722,6 +738,7 @@ def process_key(self, key: str, /) -> bool: def process_dict_key(self, key: str, /) -> Optional[int]: """Process a key for dictionary data. Returns the index or `None` if not found.""" + if key.isdigit(): if self.ignore_not_found: return None @@ -738,6 +755,7 @@ def process_dict_key(self, key: str, /) -> Optional[int]: def process_iterable_key(self, key: str, /) -> Optional[int]: """Process a key for iterable data. Returns the index or `None` if not found.""" + try: idx = int(key) self.current_data = list(self.current_data)[idx] @@ -782,19 +800,20 @@ def __init__( if self.do_syntax_hl: if syntax_highlighting is True: syntax_highlighting = {} - elif not isinstance(syntax_highlighting, dict): - raise TypeError(f"The 'syntax_highlighting' parameter must be a dict or bool, got {type(syntax_highlighting)}") - self.syntax_hl.update({ - key: (f"[{val}]", "[_]") if key in self.syntax_hl and val not in {"", None} else ("", "") - for key, val in syntax_highlighting.items() - }) + elif isinstance(syntax_highlighting, dict): + self.syntax_hl.update({ + key: (f"[{val}]", "[_]") if key in self.syntax_hl and val not in {"", None} else ("", "") + for key, val in syntax_highlighting.items() + }) + sep = f"{self.syntax_hl['punctuation'][0]}{sep}{self.syntax_hl['punctuation'][1]}" - sep = f"{self.syntax_hl['punctuation'][0]}{sep}{self.syntax_hl['punctuation'][1]}" + else: + raise TypeError(f"The 'syntax_highlighting' parameter must be a dict or bool, got {type(syntax_highlighting)}") self.sep = sep - punct_map: dict[str, str | tuple[str, str]] = {"(": ("/(", "("), **{c: c for c in "'\":)[]{}"}} + punct_map: dict[str, str | tuple[str, str]] = {"(": ("/(", "("), **{char: char for char in "'\":)[]{}"}} self.punct: dict[str, str] = { key: ( ( @@ -818,12 +837,17 @@ def __call__(self) -> str: ) def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> str: + """Formats a single value based on its type and the current indentation level.""" + if current_indent is not None and isinstance(value, dict): return self.format_dict(cast(dict[Any, Any], value), current_indent + self.indent) + elif current_indent is not None and hasattr(value, "__dict__"): return self.format_dict(value.__dict__, current_indent + self.indent) + elif current_indent is not None and isinstance(value, IndexIterableTT): return self.format_sequence(cast(IndexIterable, value), current_indent + self.indent) + elif current_indent is not None and isinstance(value, (bytes, bytearray)): obj_dict = self.cls.serialize_bytes(value) return ( @@ -834,12 +858,15 @@ def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> s + self.format_sequence((obj_dict[key], obj_dict["encoding"]), current_indent + self.indent) ) ) + elif isinstance(value, bool): val = str(value).lower() if self.as_json else str(value) return f"{self.syntax_hl['literal'][0]}{val}{self.syntax_hl['literal'][1]}" if self.do_syntax_hl else val + elif isinstance(value, (int, float)): val = "null" if self.as_json and (_math.isinf(value) or _math.isnan(value)) else str(value) return f"{self.syntax_hl['number'][0]}{val}{self.syntax_hl['number'][1]}" if self.do_syntax_hl else val + elif current_indent is not None and isinstance(value, complex): return ( self.format_value(str(value).strip("()")) if self.as_json else ( @@ -848,9 +875,11 @@ def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> s f"complex{self.format_sequence((value.real, value.imag), current_indent + self.indent)}" ) ) + elif value is None: val = "null" if self.as_json else "None" return f"{self.syntax_hl['literal'][0]}{val}{self.syntax_hl['literal'][1]}" if self.do_syntax_hl else val + else: return (( self.punct['"'] + self.syntax_hl["str"][0] + String.escape(str(value), '"') + self.syntax_hl["str"][1] @@ -861,6 +890,8 @@ def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> s )) def should_expand(self, seq: IndexIterable, /) -> bool: + """Determines whether a sequence should be expanded based on its content and the current compactness settings.""" + if self.compactness == 0: return True if self.compactness == 2: @@ -877,6 +908,8 @@ def should_expand(self, seq: IndexIterable, /) -> bool: or self.cls.chars_count(seq) + (len(seq) * len(self.sep)) > self.max_width def format_dict(self, data_dict: dict[Any, Any], current_indent: int, /) -> str: + """Formats a dictionary as a string, applying indentation and compactness rules.""" + if self.compactness == 2 or not data_dict or not self.should_expand(list(data_dict.values())): return self.punct["{"] + self.sep.join( f"{self.format_value(key)}{self.punct[':']} {self.format_value(val, current_indent)}" @@ -891,6 +924,8 @@ def format_dict(self, data_dict: dict[Any, Any], current_indent: int, /) -> str: return self.punct["{"] + "\n" + f"{self.sep}\n".join(items) + f"\n{' ' * current_indent}" + self.punct["}"] def format_sequence(self, seq: IndexIterable, current_indent: int, /) -> str: + """Formats a list or tuple as a string, applying indentation and compactness rules.""" + if self.as_json: seq = list(seq) diff --git a/src/xulbux/env_path.py b/src/xulbux/env_path.py index 7831182..3d0e562 100644 --- a/src/xulbux/env_path.py +++ b/src/xulbux/env_path.py @@ -33,42 +33,52 @@ def paths(cls, *, as_list: bool = False) -> Path | list[Path]: @classmethod def paths(cls, *, as_list: bool = False) -> Path | list[Path]: """Get the PATH environment variable.\n - ------------------------------------------------------------------------------------------------ - - `as_list` -⠀if true, returns the paths as a list of `Path`s; otherwise, as a single `Path`""" + --------------------------------------------------------------------------------------------------- + * `as_list` – If true, returns the paths as a list of `Path`s; otherwise, as a single `Path`.""" + paths_str = _os.environ.get("PATH", "") + if as_list: return [Path(path) for path in paths_str.split(_os.pathsep) if path] + return Path(paths_str) @classmethod def has_path(cls, path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: bool = False) -> bool: """Check if a path is present in the PATH environment variable.\n - ------------------------------------------------------------------------ - - `path` -⠀the path to check for - - `cwd` -⠀if true, uses the current working directory as the path - - `base_dir` -⠀if true, uses the script's base directory as the path""" - check_path = cls._get(path, cwd=cwd, base_dir=base_dir).resolve() - return check_path in {path.resolve() for path in cls.paths(as_list=True)} + --------------------------------------------------------------------------- + * `path` – The path to check for. + * `cwd` – If true, uses the current working directory as the path. + * `base_dir` – If true, uses the script's base directory as the path.""" + + return bool( + cls._get(path, cwd=cwd, base_dir=base_dir).resolve() \ + in {path.resolve() for path in cls.paths(as_list=True)} + ) @classmethod def add_path(cls, path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: bool = False) -> None: """Add a path to the PATH environment variable.\n - ------------------------------------------------------------------------ - - `path` -⠀the path to add - - `cwd` -⠀if true, uses the current working directory as the path - - `base_dir` -⠀if true, uses the script's base directory as the path""" + --------------------------------------------------------------------------- + * `path` – The path to add. + * `cwd` – If true, uses the current working directory as the path. + * `base_dir` – If true, uses the script's base directory as the path.""" + path_obj = cls._get(path, cwd=cwd, base_dir=base_dir) + if not cls.has_path(path_obj): cls._persistent(path_obj) @classmethod def remove_path(cls, path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: bool = False) -> None: """Remove a path from the PATH environment variable.\n - ------------------------------------------------------------------------ - - `path` -⠀the path to remove - - `cwd` -⠀if true, uses the current working directory as the path - - `base_dir` -⠀if true, uses the script's base directory as the path""" + --------------------------------------------------------------------------- + * `path` – The path to remove. + * `cwd` – If true, uses the current working directory as the path. + * `base_dir` – If true, uses the script's base directory as the path.""" + path_obj = cls._get(path, cwd=cwd, base_dir=base_dir) + if cls.has_path(path_obj): cls._persistent(path_obj, remove=True) @@ -77,6 +87,7 @@ def _get(path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: b """Internal method to get the normalized `path`, CWD path or script directory path.\n -------------------------------------------------------------------------------------- Raise an error if no path is provided and neither `cwd` or `base_dir` is true.""" + if cwd: if base_dir: raise ValueError("Both 'cwd' and 'base_dir' cannot be True at the same time.") @@ -91,8 +102,9 @@ def _get(path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: b @classmethod def _persistent(cls, path: Path, /, *, remove: bool = False) -> None: - """Internal method to add or remove a path from the PATH environment variable, + """Internal method to add or remove a path from the PATH environment variable,
persistently, across sessions, as well as the current session.""" + current_paths = cls.paths(as_list=True) path_resolved = path.resolve() @@ -114,8 +126,9 @@ def _persistent(cls, path: Path, /, *, remove: bool = False) -> None: key = _winreg.OpenKey(_winreg.HKEY_CURRENT_USER, "Environment", 0, _winreg.KEY_ALL_ACCESS) _winreg.SetValueEx(key, "PATH", 0, _winreg.REG_EXPAND_SZ, new_path) _winreg.CloseKey(key) - except Exception as e: - raise RuntimeError("Failed to update PATH in registry:\n " + str(e).replace("\n", " \n")) + + except Exception as exc: + raise RuntimeError("Failed to update PATH in registry:\n " + str(exc).replace("\n", " \n")) else: # UNIX-LIKE (LINUX/macOS) home_path = Path.home() diff --git a/src/xulbux/file.py b/src/xulbux/file.py index a199fbc..5f720dc 100644 --- a/src/xulbux/file.py +++ b/src/xulbux/file.py @@ -23,13 +23,14 @@ def rename_extension( camel_case_filename: bool = False, ) -> Path: """Rename the extension of a file.\n - ---------------------------------------------------------------------------- - - `file_path` -⠀the path to the file whose extension should be changed - - `new_extension` -⠀the new extension for the file (with or without dot) - - `full_extension` -⠀whether to replace the full extension (e.g. `.tar.gz`) - or just the last part of it (e.g. `.gz`) - - `camel_case_filename` -⠀whether to convert the filename to CamelCase - in addition to changing the files extension""" + ---------------------------------------------------------------------------------- + * `file_path` – The path to the file whose extension should be changed. + * `new_extension` – The new extension for the file (with or without dot). + * `full_extension` – Whether to replace the full extension (e.g. `.tar.gz`)
+ or just the last part of it (e.g. `.gz`). + * `camel_case_filename` – Whether to convert the filename to CamelCase
+ in addition to changing the files extension.""" + path = Path(file_path) filename_with_ext = path.name @@ -52,15 +53,16 @@ def rename_extension( @classmethod def create(cls, file_path: Path | str, content: str = "", /, *, force: bool = False) -> Path: """Create a file with ot without content.\n - ------------------------------------------------------------------ - - `file_path` -⠀the path where the file should be created - - `content` -⠀the content to write into the file - - `force` -⠀if true, will overwrite existing files - without throwing an error (errors explained below)\n - ------------------------------------------------------------------ - The method will throw a `FileExistsError` if a file with the same - name already exists and a `SameContentFileExistsError` if a file + ---------------------------------------------------------------------- + * `file_path` – The path where the file should be created. + * `content` – The content to write into the file. + * `force` – If true, will overwrite existing files without
+ throwing an error (errors explained below). + ---------------------------------------------------------------------- + The method will throw a `FileExistsError` if a file with the same
+ name already exists and a `SameContentFileExistsError` if a file
with the same name and same content already exists.""" + path = Path(file_path) if path.exists() and not force: diff --git a/src/xulbux/file_sys.py b/src/xulbux/file_sys.py index 5caa309..868fd35 100644 --- a/src/xulbux/file_sys.py +++ b/src/xulbux/file_sys.py @@ -22,16 +22,19 @@ class _FileSysMeta(type): @property def cwd(cls) -> Path: """The path to the current working directory.""" + return Path.cwd() @property def home(cls) -> Path: """The path to the user's home directory.""" + return Path.home() @property def script_dir(cls) -> Path: """The path to the directory of the current script.""" + if getattr(_sys, "frozen", False): base_path = Path(_sys.executable).parent else: @@ -59,19 +62,20 @@ def extend_path( raise_error: bool = False, ) -> Optional[Path]: """Tries to resolve and extend a relative path to an absolute path.\n - ------------------------------------------------------------------------------------------- - - `rel_path` -⠀the relative path to extend - - `search_in` -⠀a directory or a list of directories to search in, - in addition to the predefined directories (see exact procedure below) - - `fuzzy_match` -⠀if true, it will try to find the closest matching file/folder - names in the `search_in` directories, allowing for typos in `rel_path` and `search_in` - - `raise_error` -⠀if true, raises a `PathNotFoundError` if - the path couldn't be found (otherwise it returns `None`)\n - ------------------------------------------------------------------------------------------- - If the `rel_path` couldn't be located in predefined directories, - it will be searched in the `search_in` directory/s.
- If the `rel_path` is still not found, it returns `None` or + ---------------------------------------------------------------------------------------------- + * `rel_path` – The relative path to extend. + * `search_in` – A directory or a list of directories to search in,
+ in addition to the predefined directories (see exact procedure below). + * `fuzzy_match` – If true, it will try to find the closest matching file/folder
+ names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`. + * `raise_error` – If true, raises a `PathNotFoundError` if
+ the path couldn't be found (otherwise it returns `None`). + ---------------------------------------------------------------------------------------------- + If the `rel_path` couldn't be located in predefined directories,
+ it will be searched in the `search_in` directory/s.\n + If the `rel_path` is still not found, it returns `None` or
raises a `PathNotFoundError` if `raise_error` is true.""" + search_dirs: list[Path] = [] path: Path @@ -106,24 +110,25 @@ def extend_or_make_path( prefer_script_dir: bool = True, fuzzy_match: bool = False, ) -> Path: - """Tries to locate and extend a relative path to an absolute path, and if the `rel_path` - couldn't be located, it generates a path, as if it was located.\n - ------------------------------------------------------------------------------------------- - - `rel_path` -⠀the relative path to extend or make - - `search_in` -⠀a directory or a list of directories to search in, - in addition to the predefined directories (see exact procedure below) - - `prefer_script_dir` -⠀if true, the script directory is preferred - when making a new path (otherwise the CWD is preferred) - - `fuzzy_match` -⠀if true, it will try to find the closest matching file/folder - names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`\n - ------------------------------------------------------------------------------------------- - If the `rel_path` couldn't be located in predefined directories, - it will be searched in the `search_in` directory/s.
- If the `rel_path` is still not found, it will makes a path - that points to where the `rel_path` would be in the script directory, - even though the `rel_path` doesn't exist there.
- If `prefer_script_dir` is false, it will instead make a path + """Tries to locate and extend a relative path to an absolute path, and if
+ the `rel_path` couldn't be located, it generates a path, as if it was located.\n + ---------------------------------------------------------------------------------------------- + * `rel_path` – The relative path to extend or make. + * `search_in` – A directory or a list of directories to search in,
+ in addition to the predefined directories (see exact procedure below). + * `prefer_script_dir` – If true, the script directory is preferred
+ when making a new path (otherwise the CWD is preferred). + * `fuzzy_match` – If true, it will try to find the closest matching file/folder
+ names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`. + ---------------------------------------------------------------------------------------------- + If the `rel_path` couldn't be located in predefined directories,
+ it will be searched in the `search_in` directory/s.\n + If the `rel_path` is still not found, it will makes a path
+ that points to where the `rel_path` would be in the script directory,
+ even though the `rel_path` doesn't exist there.\n + If `prefer_script_dir` is false, it will instead make a path
that points to where the `rel_path` would be in the CWD.""" + try: result = cls.extend_path(rel_path, search_in=search_in, raise_error=True, fuzzy_match=fuzzy_match) return result if result is not None else Path() @@ -136,10 +141,10 @@ def extend_or_make_path( @classmethod def remove(cls, path: Path | str, /, *, only_content: bool = False) -> None: """Removes the directory or the directory's content at the specified path.\n - ----------------------------------------------------------------------------- - - `path` -⠀the path to the directory or file to remove - - `only_content` -⠀if true, only the content of the directory is removed - and the directory itself is kept""" + ---------------------------------------------------------------------------------------------------------------- + * `path` – The path to the directory or file to remove. + * `only_content` – If true, only the content of the directory is removed and the directory itself is kept.""" + if not (path_obj := Path(path)).exists(): return None @@ -156,9 +161,10 @@ def remove(cls, path: Path | str, /, *, only_content: bool = False) -> None: item.unlink() elif item.is_dir(): _shutil.rmtree(item) - except Exception as e: - fmt_error = "\n ".join(str(e).splitlines()) - raise Exception(f"Failed to delete {item!r}:\n {fmt_error}") from e + + except Exception as exc: + fmt_error = "\n ".join(str(exc).splitlines()) + raise Exception(f"Failed to delete {item!r}:\n {fmt_error}") from exc class _ExtendPathHelper: @@ -182,6 +188,7 @@ def __init__( def __call__(self) -> Optional[Path]: """Execute the path extension logic.""" + expanded_path = self.expand_env_vars(self.rel_path) if expanded_path.is_absolute(): @@ -192,6 +199,7 @@ def __call__(self) -> Optional[Path]: self.search_dirs.extend([Path(_os.sep)]) # REMOVE ROOT FROM PATH PARTS FOR SEARCHING expanded_path = Path(*expanded_path.parts[1:]) + else: # ADD PREDEFINED SEARCH DIRS self.search_dirs.extend([ @@ -206,6 +214,7 @@ def __call__(self) -> Optional[Path]: @staticmethod def expand_env_vars(path: Path, /) -> Path: """Expand all environment variables in the given path.""" + if "%" not in (str_path := str(path)): return path @@ -217,6 +226,7 @@ def expand_env_vars(path: Path, /) -> Path: def search_in_dirs(self, path: Path, /) -> Optional[Path]: """Search for the path in all configured directories.""" + for search_dir in self.search_dirs: if (full_path := search_dir / path).exists(): return full_path @@ -226,11 +236,13 @@ def search_in_dirs(self, path: Path, /) -> Optional[Path]: if self.raise_error: raise PathNotFoundError(f"Path {self.rel_path!r} not found in specified directories.") + return None def find_path(self, base_dir: Path, target_path: Path, /, *, fuzzy_match: bool) -> Optional[Path]: - """Find a path by traversing the given parts from the base directory, + """Find a path by traversing the given parts from the base directory,
optionally using closest matches for each part.""" + current_path: Path = base_dir for part in target_path.parts: @@ -244,10 +256,13 @@ def find_path(self, base_dir: Path, target_path: Path, /, *, fuzzy_match: bool) @staticmethod def get_closest_match(dir: Path, path_part: str, /) -> Optional[str]: - """Internal method to get the closest matching file or folder name + """Internal method to get the closest matching file or folder name
in the given directory for the given path part.""" + try: - items = [item.name for item in dir.iterdir()] - return matches[0] if (matches := _difflib.get_close_matches(path_part, items, n=1, cutoff=0.6)) else None + return matches[0] if ( + matches := _difflib.get_close_matches(path_part, [item.name for item in dir.iterdir()], n=1, cutoff=0.6) + ) else None + except Exception: return None diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 3d50a72..88579b1 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -1,6 +1,6 @@ """ This module provides the `FormatCodes` class, which includes methods to print and work with strings that -contain special formatting codes, which are then converted to ANSI codes for pretty console output. +contain special formatting codes, which are then converted to ANSI codes for pretty terminal output. ------------------------------------------------------------------------------------------------------------------------------------ ### The Easy Formatting @@ -48,93 +48,91 @@ ------------------------------------------------------------------------------------------------------------------------------------ #### All possible Formatting Keys -- RGB colors: - Change the text color directly with an RGB color inside the square brackets. (With or without `rgb()` brackets doesn't matter.) - Examples: - - `[rgb(115, 117, 255)]` - - `[(255, 0, 136)]` - - `[255, 0, 136]` -- HEX colors: - Change the text color directly with a HEX color inside the square brackets. (Whether the `RGB` or `RRGGBB` HEX format is used, - and if there's a `#` or `0x` prefix, doesn't matter.) - Examples: - - `[0x7788FF]` - - `[#7788FF]` - - `[7788FF]` - - `[0x78F]` - - `[#78F]` - - `[78F]` -- background RGB / HEX colors: - Change the background color directly with an RGB or HEX color inside the square brackets, using the `background:` `BG:` prefix. - (Same RGB / HEX formatting code rules as for text color.) - Examples: - - `[background:rgb(115, 117, 255)]` - - `[BG:(255, 0, 136)]` - - `[background:#7788FF]` - - `[BG:#78F]` -- standard console colors: - Change the text color to one of the standard console colors by just writing the color name in the square brackets. - - `[black]` - - `[red]` - - `[green]` - - `[yellow]` - - `[blue]` - - `[magenta]` - - `[cyan]` - - `[white]` -- bright console colors: - Use the prefix `bright:` `BR:` to use the bright variant of the standard console color. - Examples: - - `[bright:black]` `[BR:black]` - - `[bright:red]` `[BR:red]` - - … -- Background console colors: - Use the prefix `background:` `BG:` to set the background to a standard console color. (Not all consoles support bright - standard colors.) - Examples: - - `[background:black]` `[BG:black]` - - `[background:red]` `[BG:red]` - - … -- Bright background console colors: - Combine the prefixes `background:` / `BG:` and `bright:` / `BR:` to set the background to a bright console color. - (The order of the prefixes doesn't matter.) - Examples: - - `[background:bright:black]` `[BG:BR:black]` - - `[background:bright:red]` `[BG:BR:red]` - - … -- Text styles: - Use the built-in text formatting to change the style of the text. There are long and short forms for each formatting code. - (Not all consoles support all text styles.) - - `[bold]` `[b]` - - `[dim]` - - `[italic]` `[i]` - - `[underline]` `[u]` - - `[inverse]` `[invert]` `[in]` - - `[hidden]` `[hide]` `[h]` - - `[strikethrough]` `[s]` - - `[double-underline]` `[du]` -- Specific reset: - Use these reset codes to remove a specific style, color or background. Again, there are long and - short forms for each reset code. - - `[_bold]` `[_b]` - - `[_dim]` - - `[_italic]` `[_i]` - - `[_underline]` `[_u]` - - `[_inverse]` `[_invert]` `[_in]` - - `[_hidden]` `[_hide]` `[_h]` - - `[_strikethrough]` `[_s]` - - `[_double-underline]` `[_du]` - - `[_color]` `[_c]` - - `[_background]` `[_bg]` -- Total reset: - This will reset all previously applied formatting codes. - - `[_]` -- Hyperlinks: - Create a clickable hyperlink using the `link:` prefix followed by any URL. - Auto-reset braces are required to define the visible, clickable text. - Examples: - - `[link:file:///C:/path/to/file.txt](open file)` - - `[link:https://example.com|br:blue](click here)` +* RGB colors: + Change the text color directly with an RGB color inside the square brackets. (With or without `rgb()` brackets doesn't matter.) + Examples: + - `[rgb(115, 117, 255)]` + - `[(255, 0, 136)]` + - `[255, 0, 136]` +* HEX colors: + Change the text color directly with a HEX color inside the square brackets. (Whether the `RGB` or `RRGGBB` HEX format is used, + and if there's a `#` or `0x` prefix, doesn't matter.) + Examples: + - `[0x7788FF]` + - `[#7788FF]` + - `[7788FF]` + - `[0x78F]` + - `[#78F]` + - `[78F]` +* Background RGB / HEX colors: + Change the background color directly with an RGB or HEX color inside the square brackets, using the `background:` `BG:` prefix. + (Same RGB / HEX formatting code rules as for text color.) + Examples: + - `[bg:rgb(115, 117, 255)]` + - `[bg:(255, 0, 136)]` + - `[bg:#7788FF]` + - `[bg:#78F]` +* Standard terminal colors: + Change the text color to one of the standard terminal colors by just writing the color name in the square brackets. + - `[black]` + - `[red]` + - `[green]` + - `[yellow]` + - `[blue]` + - `[magenta]` + - `[cyan]` + - `[white]` +* Bright terminal colors: + Use the prefix `br:` to use the bright variant of the standard terminal color. + Examples: + - `[br:black]` + - `[br:red]` + - … +* Background terminal colors: + Use the prefix `bg:` to set the background to a standard terminal color. + Examples: + - `[bg:black]` + - `[bg:red]` + - … +* Bright background terminal colors: + Combine the prefixes `bg:` and `br:` to set the background to a bright terminal color. + Examples: + - `[bg:br:black]` + - `[bg:br:red]` + - … +* Text styles: + Use the built-in text formatting to change the style of the text. There are long and short forms for each formatting code. + (Not all terminals support all text styles.) + - `[bold]` `[b]` + - `[dim]` + - `[italic]` `[i]` + - `[underline]` `[u]` + - `[inverse]` `[invert]` `[in]` + - `[hidden]` `[hide]` `[h]` + - `[strikethrough]` `[s]` + - `[double-underline]` `[du]` +* Specific reset: + Use these reset codes to remove a specific style, color or background. Again, there are long and + short forms for each reset code. + - `[_bold]` `[_b]` + - `[_dim]` + - `[_italic]` `[_i]` + - `[_underline]` `[_u]` + - `[_inverse]` `[_invert]` `[_in]` + - `[_hidden]` `[_hide]` `[_h]` + - `[_strikethrough]` `[_s]` + - `[_double-underline]` `[_du]` + - `[_color]` `[_c]` + - `[_background]` `[_bg]` +* Total reset: + This will reset all previously applied formatting codes. + - `[_]` +* Hyperlinks: + Create a clickable hyperlink using the `link:` prefix followed by any URL. + Auto-reset braces are required to define the visible, clickable text. + Examples: + - `[link:file:///path/to/file.txt](open file)` + - `[link:https://example.com|br:blue](click here)` ------------------------------------------------------------------------------------------------------------------------------------ #### Additional Formatting Codes when a `default_color` is set @@ -146,16 +144,18 @@ 3. `[background:default]` `[BG:default]` will color the background in `default_color` (if no `default_color` is set, both are treated as invalid formatting codes)\n -Unlike the standard console colors, the default color can be changed by using the following modifiers: +Unlike the standard terminal colors, the default color can be changed by using the following modifiers: + +* `[l]` will lighten the `default_color` text by `brightness_steps`%. +* `[ll]` will lighten the `default_color` text by `2 × brightness_steps`%. +* `[lll]` will lighten the `default_color` text by `3 × brightness_steps`%. +* … +* Same thing for darkening: +* `[d]` will darken the `default_color` text by `brightness_steps`%. +* `[dd]` will darken the `default_color` text by `2 × brightness_steps`%. +* `[ddd]` will darken the `default_color` text by `3 × brightness_steps`%. +* … -- `[l]` will lighten the `default_color` text by `brightness_steps`% -- `[ll]` will lighten the `default_color` text by `2 × brightness_steps`% -- `[lll]` will lighten the `default_color` text by `3 × brightness_steps`% -- … etc. Same thing for darkening: -- `[d]` will darken the `default_color` text by `brightness_steps`% -- `[dd]` will darken the `default_color` text by `2 × brightness_steps`% -- `[ddd]` will darken the `default_color` text by `3 × brightness_steps`% -- … etc. Per default, you can also use `+` and `-` to get lighter and darker `default_color` versions. All of these lighten/darken formatting codes are treated as invalid if no `default_color` is set. """ @@ -174,8 +174,8 @@ import os as _os -_CONSOLE_ANSI_CONFIGURED: bool = False -"""Whether the console was already configured to be able to interpret and render ANSI formatting.""" +_TERMINAL_ANSI_CONFIGURED: bool = False +"""Whether the terminal was already configured to be able to interpret and render ANSI formatting.""" _ANSI_SEQ_1: Final[FormattableString] = ANSI.seq(1) """ANSI escape sequence with a single placeholder.""" @@ -185,13 +185,13 @@ } """Formatting codes for lightening and darkening the `default_color`.""" _PREFIX: Final[dict[str, set[str]]] = { - "BG": {"background", "bg"}, - "BR": {"bright", "br"}, + "bg": {"bg"}, + "br": {"br"}, } """Formatting code prefixes for setting background- and bright-colors.""" _PREFIX_RX: Final[dict[str, str]] = { - "BG": rf"(?:{'|'.join(_PREFIX['BG'])})\s*:", - "BR": rf"(?:{'|'.join(_PREFIX['BR'])})\s*:", + "bg": rf"(?:{'|'.join(_PREFIX['bg'])})\s*:", + "br": rf"(?:{'|'.join(_PREFIX['br'])})\s*:", } """Regex patterns for matching background- and bright-color prefixes.""" @@ -206,20 +206,20 @@ ), escape_char=r"(\s*)(\/|\\)", escape_char_cond=r"(\s*\[\s*)(\/|\\)(?!\2+)", - bg_opt_default=r"(?i)((?:" + _PREFIX_RX["BG"] + r")?)\s*default", - bg_default=r"(?i)" + _PREFIX_RX["BG"] + r"\s*default", + bg_opt_default=r"(?i)((?:" + _PREFIX_RX["bg"] + r")?)\s*default", + bg_default=r"(?i)" + _PREFIX_RX["bg"] + r"\s*default", modifier=( r"(?i)^((?:BG\s*:)?)\s*(" + "|".join([f"{_rx.escape(m)}+" for m in _DEFAULT_COLOR_MODS["lighten"] + _DEFAULT_COLOR_MODS["darken"]]) + r")$" ), - rgb=r"(?i)^\s*(" + _PREFIX_RX["BG"] + r")?\s*(?:rgb|rgba)?\s*\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?\s*$", - hex=r"(?i)^\s*(" + _PREFIX_RX["BG"] + r")?\s*(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})\s*$", + rgb=r"(?i)^\s*(" + _PREFIX_RX["bg"] + r")?\s*(?:rgb|rgba)?\s*\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?\s*$", + hex=r"(?i)^\s*(" + _PREFIX_RX["bg"] + r")?\s*(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})\s*$", ) class FormatCodes: """This class provides methods to print and work with strings that contain special formatting codes, - which are then converted to ANSI codes for pretty console output.""" + which are then converted to ANSI codes for pretty terminal output.""" @classmethod def print( @@ -232,17 +232,18 @@ def print( flush: bool = True, ) -> None: """A print function, whose print `values` can be formatted using formatting codes.\n - -------------------------------------------------------------------------------------------------- - - `values` -⠀the values to print - - `default_color` -⠀the default text color to use if no other text color was applied - - `brightness_steps` -⠀the amount to increase/decrease default-color brightness per modifier code - - `sep` -⠀the separator to use between multiple values - - `end` -⠀the string to append at the end of the printed values - - `flush` -⠀whether to flush the output buffer after printing\n - -------------------------------------------------------------------------------------------------- - For exact information about how to use special formatting codes, + ----------------------------------------------------------------------------------------------------- + * `values` – The values to print. + * `default_color` – The default text color to use if no other text color was applied. + * `brightness_steps` – The amount to increase/decrease default-color brightness per modifier code. + * `sep` – The separator to use between multiple values. + * `end` – The string to append at the end of the printed values. + * `flush` – Whether to flush the output buffer after printing. + ----------------------------------------------------------------------------------------------------- + For exact information about how to use special formatting codes,
see the `format_codes` module documentation.""" - cls._config_console() + + cls._config_terminal() _sys.stdout.write(cls.to_ansi(sep.join(map(str, values)) + end, default_color, brightness_steps)) if flush: @@ -259,20 +260,22 @@ def input( reset_ansi: bool = False, ) -> str: """An input, whose `prompt` can be formatted using formatting codes.\n - -------------------------------------------------------------------------------------------------- - - `prompt` -⠀the prompt to show to the user - - `default_color` -⠀the default text color to use if no other text color was applied - - `brightness_steps` -⠀the amount to increase/decrease default-color brightness per modifier code - - `reset_ansi` -⠀if true, all ANSI formatting will be reset, after the user confirmed the input - and the program continues to run\n - -------------------------------------------------------------------------------------------------- - For exact information about how to use special formatting codes, see the - `format_codes` module documentation.""" - cls._config_console() + ------------------------------------------------------------------------------------------------------ + * `prompt` – The prompt to show to the user. + * `default_color` – The default text color to use if no other text color was applied. + * `brightness_steps` – The amount to increase/decrease default-color brightness per modifier code. + * `reset_ansi` – If true, all ANSI formatting will be reset, after the user confirmed the input
+ and the program continues to run. + ------------------------------------------------------------------------------------------------------ + For exact information about how to use special formatting codes,
+ see the `format_codes` module documentation.""" + + cls._config_terminal() user_input = input(cls.to_ansi(str(prompt), default_color, brightness_steps)) if reset_ansi: _sys.stdout.write(f"{ANSI.CHAR}[0m") + return user_input @classmethod @@ -287,16 +290,17 @@ def to_ansi( _validate_default: bool = True, ) -> str: """Convert the formatting codes inside a string to ANSI formatting.\n - -------------------------------------------------------------------------------------------------- - - `string` -⠀the string that contains the formatting codes to convert - - `default_color` -⠀the default text color to use if no other text color was applied - - `brightness_steps` -⠀the amount to increase/decrease default-color brightness per modifier code - - `_default_start` -⠀whether to start the string with the `default_color` ANSI code, if set - - `_validate_default` -⠀whether to validate the `default_color` before use - (expects valid RGBA color or None, if not validated)\n - -------------------------------------------------------------------------------------------------- - For exact information about how to use special formatting codes, + ----------------------------------------------------------------------------------------------------- + * `string` – The string that contains the formatting codes to convert. + * `default_color` – The default text color to use if no other text color was applied. + * `brightness_steps` – The amount to increase/decrease default-color brightness per modifier code. + * `_default_start` – Whether to start the string with the `default_color` ANSI code, if set. + * `_validate_default` – Whether to validate the `default_color` before use
+ (expects valid RGBA color or None, if not validated). + ----------------------------------------------------------------------------------------------------- + For exact information about how to use special formatting codes,
see the `format_codes` module documentation.""" + if not (0 < brightness_steps <= 100): raise ValueError(f"The 'brightness_steps' parameter must be in range [1, 100] inclusive, got {brightness_steps!r}") @@ -336,15 +340,16 @@ def escape( *, _escape_char: Literal["/", "\\"] = "/", ) -> str: - """Escapes all valid formatting codes in the string, so they are visible when output - to the console using `FormatCodes.print()`. Invalid formatting codes remain unchanged.\n + """Escapes all valid formatting codes in the string, so they are visible when output
+ to the terminal using `FormatCodes.print()`. Invalid formatting codes remain unchanged.\n ----------------------------------------------------------------------------------------- - - `string` -⠀the string that contains the formatting codes to escape - - `default_color` -⠀the default text color to use if no other text color was applied - - `_escape_char` -⠀the character to use to escape formatting codes (`/` or `\\`)\n + * `string` – The string that contains the formatting codes to escape. + * `default_color` – The default text color to use if no other text color was applied. + * `_escape_char` – The character to use to escape formatting codes (`/` or `\\`). ----------------------------------------------------------------------------------------- - For exact information about how to use special formatting codes, + For exact information about how to use special formatting codes,
see the `format_codes` module documentation.""" + use_default, default_color = cls._validate_default_color(default_color) return "\n".join( @@ -356,9 +361,10 @@ def escape( @classmethod def escape_ansi(cls, ansi_string: str, /) -> str: - """Escapes all ANSI codes in the string, so they are visible when output to the console.\n - ------------------------------------------------------------------------------------------- - - `ansi_string` -⠀the string that contains the ANSI codes to escape""" + """Escapes all ANSI codes in the string, so they are visible when output to the terminal.\n + -------------------------------------------------------------------------------------------- + * `ansi_string` – The string that contains the ANSI codes to escape.""" + return ansi_string.replace(ANSI.CHAR, ANSI.CHAR_ESCAPED) @overload @@ -411,12 +417,13 @@ def remove( _ignore_linebreaks: bool = False, ) -> str | tuple[str, tuple[tuple[int, str], ...]]: """Removes all formatting codes from the string with optional tracking of removed codes.\n - -------------------------------------------------------------------------------------------------------- - - `string` -⠀the string that contains the formatting codes to remove - - `default_color` -⠀the default text color to use if no other text color was applied - - `get_removals` -⠀if true, additionally to the cleaned string, a list of tuples will be returned, - where each tuple contains the position of the removed formatting code and the removed formatting code - - `_ignore_linebreaks` -⠀whether to ignore line breaks for the removal positions""" + ----------------------------------------------------------------------------------------------------------- + * `string` – The string that contains the formatting codes to remove. + * `default_color` – The default text color to use if no other text color was applied. + * `get_removals` – If true, additionally to the cleaned string, a list of tuples will be returned,
+ where each tuple contains the position of the removed formatting code and the removed formatting code. + * `_ignore_linebreaks` – Whether to ignore line breaks for the removal positions.""" + return cls.remove_ansi( cls.to_ansi(string, default_color=default_color), get_removals=get_removals, @@ -469,11 +476,12 @@ def remove_ansi( _ignore_linebreaks: bool = False, ) -> str | tuple[str, tuple[tuple[int, str], ...]]: """Removes all ANSI codes from the string with optional tracking of removed codes.\n - --------------------------------------------------------------------------------------------------- - - `ansi_string` -⠀the string that contains the ANSI codes to remove - - `get_removals` -⠀if true, additionally to the cleaned string, a list of tuples will be returned, - where each tuple contains the position of the removed ansi code and the removed ansi code - - `_ignore_linebreaks` -⠀whether to ignore line breaks for the removal positions""" + --------------------------------------------------------------------------------------------------------- + * `ansi_string` – The string that contains the ANSI codes to remove. + * `get_removals` – If true, additionally to the cleaned string, a list of tuples will be returned,
+ where each tuple contains the position of the removed ansi code and the removed ansi code. + * `_ignore_linebreaks` – Whether to ignore line breaks for the removal positions.""" + if get_removals: removals: list[tuple[int, str]] = [] @@ -490,50 +498,58 @@ def remove_ansi( return _PATTERNS.ansi_seq.sub("", ansi_string) @classmethod - def _config_console(cls) -> None: - """Internal method which configure the console to be able to interpret and render ANSI formatting.\n - ----------------------------------------------------------------------------------------------------- + def _config_terminal(cls) -> None: + """Internal method which configures the terminal to be able to interpret and render ANSI formatting.\n + ------------------------------------------------------------------------------------------------------- This method will only do something the first time it's called. Subsequent calls will do nothing.""" - global _CONSOLE_ANSI_CONFIGURED - if not _CONSOLE_ANSI_CONFIGURED: + + global _TERMINAL_ANSI_CONFIGURED + if not _TERMINAL_ANSI_CONFIGURED: _sys.stdout.flush() if _os.name == "nt": try: # ENABLE VT100 MODE ON WINDOWS TO BE ABLE TO USE ANSI CODES kernel32 = getattr(_ctypes, "windll").kernel32 - h = kernel32.GetStdHandle(-11) + handle = kernel32.GetStdHandle(-11) mode = _ctypes.c_ulong() - kernel32.GetConsoleMode(h, _ctypes.byref(mode)) - kernel32.SetConsoleMode(h, mode.value | 0x0004) + kernel32.GetConsoleMode(handle, _ctypes.byref(mode)) + kernel32.SetConsoleMode(handle, mode.value | 0x0004) except Exception: pass - _CONSOLE_ANSI_CONFIGURED = True # type: ignore[assignment] + _TERMINAL_ANSI_CONFIGURED = True # type: ignore[assignment] @staticmethod def _validate_default_color(default_color: Optional[Rgba | Hexa], /) -> tuple[bool, Optional[rgba]]: """Internal method to validate and convert `default_color` to a `rgba` color object.""" + if default_color is None: return False, None if Color.is_valid_hexa(default_color, allow_alpha=False): return True, hexa(cast(str | int, default_color)).to_rgba() elif Color.is_valid_rgba(default_color, allow_alpha=False): return True, Color._parse_rgba(cast(Rgba, default_color)) - raise ValueError(f"The 'default_color' parameter must be either a valid RGBA or HEXA color, or None, got {default_color!r}") + raise ValueError( + f"The 'default_color' parameter must be either a valid RGBA or HEXA color, or None, got {default_color!r}" + ) @staticmethod def _formats_to_keys(formats: str, /) -> list[str]: - """Internal method to convert a string of multiple format keys - to a list of individual, stripped format keys.""" + """Internal method to convert a string of multiple format + keys to a list of individual, stripped format keys.""" + return [key.strip() for key in formats.split("|") if key.strip()] @classmethod def _get_replacement(cls, format_key: str, default_color: Optional[rgba], /, brightness_steps: int = 20) -> str: - """Internal method that gives you the corresponding ANSI code for the given format key. - If `default_color` is not `None`, the text color will be `default_color` if all formats + """Internal method that gives you the corresponding ANSI code for the given format key.\n + If `default_color` is not `None`, the text color will be `default_color` if all formats
are reset or you can get lighter or darker version of `default_color` (also as BG)""" + _format_key, format_key = format_key, cls._normalize_key(format_key) # NORMALIZE KEY AND SAVE ORIGINAL + if default_color and (new_default_color := cls._get_default_ansi(default_color, format_key, brightness_steps)): return new_default_color + for map_key in ANSI.CODES_MAP: if (isinstance(map_key, tuple) and format_key in map_key) or format_key == map_key: return _ANSI_SEQ_1.format( @@ -542,14 +558,17 @@ def _get_replacement(cls, format_key: str, default_color: Optional[rgba], /, bri if format_key == key or (isinstance(key, tuple) and format_key in key) ), None) ) + rgb_match = _PATTERNS.rgb.match(format_key) hex_match = _PATTERNS.hex.match(format_key) + try: if rgb_match: is_bg = rgb_match.group(1) - r, g, b = map(int, rgb_match.groups()[1:]) - if Color.is_valid_rgba((r, g, b)): - return ANSI.SEQ_BG_COLOR.format(r, g, b) if is_bg else ANSI.SEQ_COLOR.format(r, g, b) + red, green, blue = map(int, rgb_match.groups()[1:]) + if Color.is_valid_rgba((red, green, blue)): + return ANSI.SEQ_BG_COLOR.format(red, green, blue) if is_bg else ANSI.SEQ_COLOR.format(red, green, blue) + elif hex_match: is_bg = hex_match.group(1) rgb = Color.to_rgba(hex_match.group(2)) @@ -557,8 +576,10 @@ def _get_replacement(cls, format_key: str, default_color: Optional[rgba], /, bri ANSI.SEQ_BG_COLOR.format(rgb[0], rgb[1], rgb[2]) if is_bg else ANSI.SEQ_COLOR.format(rgb[0], rgb[1], rgb[2]) ) + except Exception: pass + return _format_key @staticmethod @@ -571,39 +592,52 @@ def _get_default_ansi( _modifiers: tuple[str, str] = (_DEFAULT_COLOR_MODS["lighten"], _DEFAULT_COLOR_MODS["darken"]), ) -> Optional[str]: """Internal method to get the `default_color` and lighter/darker versions of it as ANSI code.""" + _default_color: tuple[int, int, int] = (default_color[0], default_color[1], default_color[2]) + if brightness_steps is None or (format_key and _PATTERNS.bg_opt_default.search(format_key)): return (ANSI.SEQ_BG_COLOR if format_key and _PATTERNS.bg_default.search(format_key) else ANSI.SEQ_COLOR).format( *_default_color ) + if format_key is None or not (match := _PATTERNS.modifier.match(format_key)): return None + is_bg, modifiers = match.groups() adjust = 0 + for mod in _modifiers[0] + _modifiers[1]: adjust = String.single_char_repeats(modifiers, mod) if adjust and adjust > 0: modifiers = mod break + new_rgb = _default_color + if adjust == 0: return None + elif modifiers in _modifiers[0]: adjusted_rgb = Color.adjust_lightness(default_color, (brightness_steps / 100) * adjust) new_rgb = (adjusted_rgb[0], adjusted_rgb[1], adjusted_rgb[2]) + elif modifiers in _modifiers[1]: adjusted_rgb = Color.adjust_lightness(default_color, -(brightness_steps / 100) * adjust) new_rgb = (adjusted_rgb[0], adjusted_rgb[1], adjusted_rgb[2]) + return (ANSI.SEQ_BG_COLOR if is_bg else ANSI.SEQ_COLOR).format(*new_rgb[:3]) @staticmethod def _normalize_key(format_key: str, /) -> str: """Internal method to normalize the given format key.""" + k_parts = format_key.replace(" ", "").lower().split(":") + prefix_str = "".join( f"{prefix_key.lower()}:" for prefix_key, prefix_values in _PREFIX.items() if any(k_part in prefix_values for k_part in k_parts) ) + return prefix_str + ":".join( part for part in k_parts \ if part not in {val for values in _PREFIX.values() for val in values} @@ -656,6 +690,7 @@ def __call__(self, match: _rx.Match[str], /) -> str: escaped_auto_reset = self.cls.escape(auto_reset_txt, self.default_color, _escape_char=self.escape_char) escaped += f"({escaped_auto_reset})" return escaped + else: # KEEP INVALID FORMATTING CODES AS-IS result = f"[{formats}]" @@ -674,9 +709,12 @@ def __init__(self, removals: list[tuple[int, str]], /): def __call__(self, match: _rx.Match[str], /) -> str: start_pos = match.start() - sum(len(removed) for _, removed in self.removals) + if self.removals and self.removals[-1][0] == start_pos: start_pos = self.removals[-1][0] + self.removals.append((start_pos, match.group())) + return "" @@ -732,7 +770,8 @@ def __call__(self, match: _rx.Match[str], /) -> str: def handle_link(self, match: _rx.Match[str], all_keys: list[str], /) -> Optional[str]: """Handle a hyperlink format code, returning the OSC 8 sequence or None if not a link.""" - link_key = next((k for k in all_keys if _PATTERNS.link.match(k)), None) + + link_key = next((key for key in all_keys if _PATTERNS.link.match(key)), None) if link_key is None: return None @@ -744,9 +783,10 @@ def handle_link(self, match: _rx.Match[str], all_keys: list[str], /) -> Optional link_url = _PATTERNS.link.match(link_key).group(1) # type: ignore[union-attr] display = self.auto_reset_txt - if other_keys := [k for k in all_keys if k != link_key]: + if other_keys := [key for key in all_keys if key != link_key]: # APPLY REMAINING FORMAT CODES TO DISPLAY TEXT WITH AUTO-RESET display = "[{}]({})".format("|".join(other_keys), display) + if other_keys or ("[" in display and "]" in display): display = self.cls.to_ansi( display, @@ -760,6 +800,7 @@ def handle_link(self, match: _rx.Match[str], all_keys: list[str], /) -> Optional def process_formats_and_auto_reset(self) -> None: """Process nested formatting in both formats and auto-reset text.""" + # PROCESS AUTO-RESET TEXT IF IT CONTAINS NESTED FORMATTING if self.auto_reset_txt and self.auto_reset_txt.count("[") > 0 and self.auto_reset_txt.count("]") > 0: self.auto_reset_txt = self.cls.to_ansi( @@ -782,6 +823,7 @@ def process_formats_and_auto_reset(self) -> None: def convert_to_ansi(self) -> None: """Convert format keys to ANSI codes and generate resets if needed.""" + self.format_keys = self.cls._formats_to_keys(self.formats) self.ansi_formats = [( ansi_code \ @@ -797,6 +839,7 @@ def convert_to_ansi(self) -> None: def gen_reset_codes(self) -> None: """Generate appropriate ANSI reset codes for each format key.""" + default_color_resets = ("_bg", "default") if self.use_default else ("_bg", "_c") reset_keys: list[str] = [] @@ -805,15 +848,16 @@ def gen_reset_codes(self) -> None: k_set = set(k_lower.split(":")) # BACKGROUND COLOR FORMAT - if _PREFIX["BG"] & k_set and len(k_set) <= 3: - if k_set & _PREFIX["BR"]: - # BRIGHT BACKGROUND COLOR - RESET BOTH BG AND COLOR + if _PREFIX["bg"] & k_set and len(k_set) <= 3: + if k_set & _PREFIX["br"]: + # BRIGHT BACKGROUND COLOR – RESET BOTH BG AND COLOR for i in range(len(format_key)): if self.is_valid_color(format_key[i:]): reset_keys.extend(default_color_resets) break + else: - # REGULAR BACKGROUND COLOR - RESET ONLY BG + # REGULAR BACKGROUND COLOR – RESET ONLY BG for i in range(len(format_key)): if self.is_valid_color(format_key[i:]): reset_keys.append("_bg") @@ -822,7 +866,7 @@ def gen_reset_codes(self) -> None: # TEXT COLOR FORMAT elif self.is_valid_color(format_key) or any( k_lower.startswith(pref_colon := f"{prefix}:") and self.is_valid_color(format_key[len(pref_colon):]) \ - for prefix in _PREFIX["BR"] + for prefix in _PREFIX["br"] ): reset_keys.append(default_color_resets[1]) @@ -839,6 +883,7 @@ def gen_reset_codes(self) -> None: def build_output(self, match: _rx.Match[str], /) -> str: """Build the final output string based on processed formats and resets.""" + # CHECK IF ALL FORMATS WERE VALID has_single_valid_ansi = len(self.ansi_formats) == 1 and self.ansi_formats[0].count(f"{ANSI.CHAR}{ANSI.START}") >= 1 all_formats_valid = all(ansi_format.startswith(f"{ANSI.CHAR}{ANSI.START}") for ansi_format in self.ansi_formats) @@ -867,4 +912,9 @@ def build_output(self, match: _rx.Match[str], /) -> str: def is_valid_color(self, color: str, /) -> bool: """Check whether the given color string is a valid formatting-key color.""" - return bool((color in ANSI.COLOR_MAP) or Color.is_valid_rgba(color) or Color.is_valid_hexa(color)) + + return bool( + color in ANSI.COLOR_MAP \ + or Color.is_valid_rgba(color) + or Color.is_valid_hexa(color) + ) diff --git a/src/xulbux/json.py b/src/xulbux/json.py index de0c0e3..0a1aea9 100644 --- a/src/xulbux/json.py +++ b/src/xulbux/json.py @@ -14,7 +14,7 @@ class Json: - """This class provides methods to read, create and update JSON files, + """This class provides methods to read, create and update JSON files,
with support for comments inside the JSON data.""" @overload @@ -54,17 +54,18 @@ def read( return_original: bool = False, ) -> dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]: """Read JSON files, ignoring comments.\n - ------------------------------------------------------------------------------------ - - `json_file` -⠀the path (relative or absolute) to the JSON file to read - - `comment_start` -⠀the string that indicates the start of a comment - - `comment_end` -⠀the string that indicates the end of a comment - - `return_original` -⠀if true, the original JSON data is returned additionally:
- ```python - (processed_json, original_json) - ```\n - ------------------------------------------------------------------------------------ - For more detailed information about the comment handling, + -------------------------------------------------------------------------------------- + * `json_file` – The path (relative or absolute) to the JSON file to read. + * `comment_start` – The string that indicates the start of a comment. + * `comment_end` – The string that indicates the end of a comment. + * `return_original` – If true, the original JSON data is returned additionally:
+ ```python + (processed_json, original_json) + ``` + -------------------------------------------------------------------------------------- + For more detailed information about the comment handling,
see the `Data.remove_comments()` method documentation.""" + if (json_path := Path(json_file) if isinstance(json_file, str) else json_file).suffix != ".json": json_path = json_path.with_suffix(".json") file_path = FileSys.extend_or_make_path(json_path, prefer_script_dir=True) @@ -74,9 +75,9 @@ def read( try: data = _json.loads(content) - except _json.JSONDecodeError as e: - fmt_error = "\n ".join(str(e).splitlines()) - raise ValueError(f"Error parsing JSON in {file_path!r}:\n {fmt_error}") from e + except _json.JSONDecodeError as exc: + fmt_error = "\n ".join(str(exc).splitlines()) + raise ValueError(f"Error parsing JSON in {file_path!r}:\n {fmt_error}") from exc if not (processed_data := dict(Data.remove_comments(data, comment_start=comment_start, comment_end=comment_end))): raise ValueError(f"The JSON file {file_path!r} contains no data") @@ -95,18 +96,19 @@ def create( force: bool = False, ) -> Path: """Create a nicely formatted JSON file from a dictionary.\n - --------------------------------------------------------------------------- - - `json_file` -⠀the path (relative or absolute) to the JSON file to create - - `data` -⠀the dictionary data to write to the JSON file - - `indent` -⠀the amount of spaces to use for indentation - - `compactness` -⠀can be `0`, `1` or `2` and indicates how compact - the data should be formatted (see `Data.render()` for more info) - - `force` -⠀if true, will overwrite existing files - without throwing an error (errors explained below)\n - --------------------------------------------------------------------------- - The method will throw a `FileExistsError` if a file with the same - name already exists and a `SameContentFileExistsError` if a file + ------------------------------------------------------------------------------ + * `json_file` – The path (relative or absolute) to the JSON file to create. + * `data` – The dictionary data to write to the JSON file. + * `indent` – The amount of spaces to use for indentation. + * `compactness` – Can be `0`, `1` or `2` and indicates how compact
+ the data should be formatted (see `Data.render()` for more info). + * `force` – If true, will overwrite existing files
+ without throwing an error (errors explained below). + ------------------------------------------------------------------------------ + The method will throw a `FileExistsError` if a file with the same
+ name already exists and a `SameContentFileExistsError` if a file
with the same name and same content already exists.""" + if (json_path := Path(json_file) if isinstance(json_file, str) else json_file).suffix != ".json": json_path = json_path.with_suffix(".json") @@ -130,20 +132,20 @@ def update( comment_end: str = "<<", path_sep: str = "->", ) -> None: - """Update single/multiple values inside JSON files, + """Update single/multiple values inside JSON files,
without needing to know the rest of the data.\n ----------------------------------------------------------------------------------- - - `json_file` -⠀the path (relative or absolute) to the JSON file to update - - `update_values` -⠀a dictionary with the paths to the values to update - and the new values to set (see explanation below – section 2) - - `comment_start` -⠀the string that indicates the start of a comment - - `comment_end` -⠀the string that indicates the end of a comment - - `path_sep` -⠀the separator used inside the value-paths in `update_values`\n + * `json_file` – The path (relative or absolute) to the JSON file to update. + * `update_values` – A dictionary with the paths to the values to update
+ and the new values to set (see explanation below – section 2). + * `comment_start` – The string that indicates the start of a comment. + * `comment_end` – The string that indicates the end of a comment. + * `path_sep` – The separator used inside the value-paths in `update_values`. ----------------------------------------------------------------------------------- - For more detailed information about the comment handling, + For more detailed information about the comment handling,
see the `Data.remove_comments()` method documentation.\n ----------------------------------------------------------------------------------- - The `update_values` is a dictionary, where the keys are the paths + The `update_values` is a dictionary, where the keys are the paths
to the data to update, and the values are the new values to set.\n For example for this JSON data: ```python @@ -163,11 +165,12 @@ def update( "healthy->vegetables": [1, 2, 3] } ``` - In this example, if you want to change the value of `"apples"`, - you can use `healthy->fruits->apples` as the value-path.
- If you don't know that the first list item is `"apples"`, + In this example, if you want to change the value of `"apples"`,
+ you can use `healthy->fruits->apples` as the value-path.\n + If you don't know that the first list item is `"apples"`,
you can use the items list index inside the value-path, so `healthy->fruits->0`.\n ⇾ If the given value-path doesn't exist, it will be created.""" + processed_data, data = cls.read( json_file, comment_start=comment_start, @@ -192,19 +195,22 @@ def update( @staticmethod def _create_nested_path(data_obj: dict[str, Any], path_keys: list[str], value: Any, /) -> dict[str, Any]: - """Internal method that creates nested dictionaries/lists based on the + """Internal method that creates nested dictionaries/lists based on the
given path keys and sets the specified value at the end of the path.""" + last_idx, current = len(path_keys) - 1, data_obj for i, key in enumerate(path_keys): if i == last_idx: if isinstance(current, dict): current[key] = value + elif isinstance(current, list) and key.isdigit(): idx = int(key) while len(cast(list[Any], current)) <= idx: cast(list[Any], current).append(None) current[idx] = value + else: raise TypeError(f"Cannot set key '{key}' on {type(cast(Any, current))}") @@ -214,6 +220,7 @@ def _create_nested_path(data_obj: dict[str, Any], path_keys: list[str], value: A if key not in current: current[key] = [] if next_key.isdigit() else {} current = cast(dict[str, Any], current)[key] # type: ignore[unnecessary-cast] + elif isinstance(current, list) and key.isdigit(): idx = int(key) while len(cast(list[Any], current)) <= idx: @@ -221,6 +228,7 @@ def _create_nested_path(data_obj: dict[str, Any], path_keys: list[str], value: A if current[idx] is None: current[idx] = [] if next_key.isdigit() else {} current = cast(list[Any], current)[idx] + else: raise TypeError(f"Cannot navigate through {type(cast(Any, current))}") diff --git a/src/xulbux/regex.py b/src/xulbux/regex.py index 16729f0..d8bf30a 100644 --- a/src/xulbux/regex.py +++ b/src/xulbux/regex.py @@ -18,10 +18,11 @@ def quotes(cls) -> str: """Matches pairs of quotes. (strings)\n -------------------------------------------------------------------------------- Will create two named groups: - - `quote` the quote type (single or double) - - `string` everything inside the found quote pair\n + * `quote` – The quote type (single or double). + * `string` – Everything inside the found quote pair. --------------------------------------------------------------------------------- Attention: Requires non-standard library `regex`, not standard library `re`!""" + return r"""(?P["'])(?P(?:\\.|(?!\g).)*?)\g""" @classmethod @@ -36,16 +37,17 @@ def brackets( ignore_in_strings: bool = True, ) -> str: """Matches everything inside pairs of brackets, including other nested brackets.\n - --------------------------------------------------------------------------------------- - - `bracket1` -⠀the opening bracket (e.g. `(`, `{`, `[`, …) - - `bracket2` -⠀the closing bracket (e.g. `)`, `}`, `]`, …) - - `is_group` -⠀whether to create a capturing group for the content inside the brackets - - `strip_spaces` -⠀whether to strip spaces from the bracket content or not - - `ignore_in_strings` -⠀whether to ignore closing brackets that are inside - strings/quotes (e.g. `'…)…'` or `"…)…"`)\n - --------------------------------------------------------------------------------------- + ------------------------------------------------------------------------------------------ + * `bracket1` – The opening bracket (e.g. `(`, `{`, `[`, …). + * `bracket2` – The closing bracket (e.g. `)`, `}`, `]`, …). + * `is_group` – Whether to create a capturing group for the content inside the brackets. + * `strip_spaces` – Whether to strip spaces from the bracket content or not. + * `ignore_in_strings` – Whether to ignore closing brackets that are inside
+ strings/quotes (e.g. `'…)…'` or `"…)…"`). + ------------------------------------------------------------------------------------------ Attention: Requires non-standard library `regex`, not standard library `re`!""" - g = "" if is_group else "?:" + + gr = "" if is_group else "?:" b1 = _rx.escape(bracket1) if len(bracket1) == 1 else bracket1 b2 = _rx.escape(bracket2) if len(bracket2) == 1 else bracket2 s1 = r"\s*" if strip_spaces else "" @@ -53,7 +55,7 @@ def brackets( if ignore_in_strings: return cls._clean( \ - rf"""{b1}{s1}({g}{s2}(?: + rf"""{b1}{s1}({gr}{s2}(?: [^{b1}{b2}"'] |"(?:\\.|[^"\\])*" |'(?:\\.|[^'\\])*' @@ -67,7 +69,7 @@ def brackets( ) else: return cls._clean( \ - rf"""{b1}{s1}({g}{s2}(?: + rf"""{b1}{s1}({gr}{s2}(?: [^{b1}{b2}] |{b1}(?: [^{b1}{b2}] @@ -79,23 +81,25 @@ def brackets( @classmethod def outside_strings(cls, pattern: str = r".*", /) -> str: """Matches the `pattern` only when it is not found inside a string (`'…'` or `"…"`).""" + return rf"""(? str: - """Matches everything up to the `disallowed_pattern`, unless the + """Matches everything up to the `disallowed_pattern`, unless the
`disallowed_pattern` is found inside a string/quotes (`'…'` or `"…"`).\n - ------------------------------------------------------------------------------------- - - `disallowed_pattern` -⠀the pattern that is not allowed to be matched - - `ignore_pattern` -⠀a pattern that, if found, will make the regex ignore the - `disallowed_pattern` (even if it contains the `disallowed_pattern` inside it):
- For example if `disallowed_pattern` is `>` and `ignore_pattern` is `->`, - the `->`-arrows will be allowed, even though they have `>` in them. - - `is_group` -⠀whether to create a capturing group for the matched content""" - g = "" if is_group else "?:" + --------------------------------------------------------------------------------------- + * `disallowed_pattern` – The pattern that is not allowed to be matched. + * `ignore_pattern` – A pattern that, if found, will make the regex ignore the
+ `disallowed_pattern` (even if it contains the `disallowed_pattern` inside it):
+ For example if `disallowed_pattern` is `>` and `ignore_pattern` is `->`,
+ the `->`-arrows will be allowed, even though they have `>` in them. + * `is_group` – Whether to create a capturing group for the matched content.""" + + gr = "" if is_group else "?:" return cls._clean( \ - rf"""({g} + rf"""({gr} (?:(?!{ignore_pattern}).)* (?:(?!{cls.outside_strings(disallowed_pattern)}).)* )""" @@ -104,11 +108,12 @@ def all_except(cls, disallowed_pattern: str, /, ignore_pattern: str = "", *, is_ @classmethod def func_call(cls, func_name: Optional[str] = None, /) -> str: """Match a function call, and get back two groups: - 1. The function name - 2. The function's arguments (content inside the parentheses)\n + 1. The function name. + 2. The function's arguments (content inside the parentheses).\n If no `func_name` is given, it will match any function call.\n --------------------------------------------------------------------------------- Attention: Requires non-standard library `regex`, not standard library `re`!""" + if func_name in {"", None}: func_name = r"[\w_]+" @@ -117,24 +122,25 @@ def func_call(cls, func_name: Optional[str] = None, /) -> str: @classmethod def rgba_str(cls, fix_sep: Optional[str] = ",", *, allow_alpha: bool = True) -> str: """Matches an RGBA color inside a string.\n - ---------------------------------------------------------------------------------- - - `fix_sep` -⠀the fixed separator between the RGBA values (e.g. `,`, `;` …)
- If set to nothing or `None`, any char that is not a letter or number - can be used to separate the RGBA values, including just a space. - - `allow_alpha` -⠀whether to include the alpha channel in the match\n - ---------------------------------------------------------------------------------- + ----------------------------------------------------------------------------------- + * `fix_sep` – The fixed separator between the RGBA values (e.g. `,`, `;` …):
+ If set to nothing or `None`, any char that is not a letter or number
+ can be used to separate the RGBA values, including just a space. + * `allow_alpha` – Whether to include the alpha channel in the match. + ----------------------------------------------------------------------------------- The RGBA color can be in the formats (for `fix_sep = ','`): - - `rgba(r, g, b)` - - `rgba(r, g, b, a)` (if `allow_alpha=True`) - - `(r, g, b)` - - `(r, g, b, a)` (if `allow_alpha=True`) - - `r, g, b` - - `r, g, b, a` (if `allow_alpha=True`)\n + * `rgba(red, green, blue)` + * `rgba(red, green, blue, alpha)` (if `allow_alpha=True`) + * `(red, green, blue)` + * `(red, green, blue, alpha)` (if `allow_alpha=True`) + * `red, green, blue` + * `red, green, blue, alpha` (if `allow_alpha=True`)\n #### Valid ranges: - - `r` 0-255 (int: red) - - `g` 0-255 (int: green) - - `b` 0-255 (int: blue) - - `a` 0.0-1.0 (float: opacity)""" + * `red` 0-255 (int: red) + * `green` 0-255 (int: green) + * `blue` 0-255 (int: blue) + * `alpha` 0.0-1.0 (float: opacity)""" + fix_sep = _re.escape(fix_sep) if isinstance(fix_sep, str) else r"[^0-9A-Z]" rgb_part = rf"""((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}}))) @@ -159,24 +165,25 @@ def rgba_str(cls, fix_sep: Optional[str] = ",", *, allow_alpha: bool = True) -> @classmethod def hsla_str(cls, fix_sep: Optional[str] = ",", *, allow_alpha: bool = True) -> str: """Matches a HSLA color inside a string.\n - ---------------------------------------------------------------------------------- - - `fix_sep` -⠀the fixed separator between the HSLA values (e.g. `,`, `;` …)
- If set to nothing or `None`, any char that is not a letter or number - can be used to separate the HSLA values, including just a space. - - `allow_alpha` -⠀whether to include the alpha channel in the match\n - ---------------------------------------------------------------------------------- + ----------------------------------------------------------------------------------- + * `fix_sep` – The fixed separator between the HSLA values (e.g. `,`, `;` …):
+ If set to nothing or `None`, any char that is not a letter or number
+ can be used to separate the HSLA values, including just a space. + * `allow_alpha` – Whether to include the alpha channel in the match. + ----------------------------------------------------------------------------------- The HSLA color can be in the formats (for `fix_sep = ','`): - - `hsla(h, s, l)` - - `hsla(h, s, l, a)` (if `allow_alpha=True`) - - `(h, s, l)` - - `(h, s, l, a)` (if `allow_alpha=True`) - - `h, s, l` - - `h, s, l, a` (if `allow_alpha=True`)\n + * `hsla(hue, sat, light)` + * `hsla(hue, sat, light, alpha)` (if `allow_alpha=True`) + * `(hue, sat, light)` + * `(hue, sat, light, alpha)` (if `allow_alpha=True`) + * `hue, sat, light` + * `hue, sat, light, alpha` (if `allow_alpha=True`)\n #### Valid ranges: - - `h` 0-360 (int: hue) - - `s` 0-100 (int: saturation) - - `l` 0-100 (int: lightness) - - `a` 0.0-1.0 (float: opacity)""" + * `hue` 0-360 (int: hue) + * `sat` 0-100 (int: saturation) + * `light` 0-100 (int: lightness) + * `alpha` 0.0-1.0 (float: opacity)""" + fix_sep = _re.escape(fix_sep) if isinstance(fix_sep, str) else r"[^0-9A-Z]" hsl_part = rf"""((?:0*(?:360|3[0-5][0-9]|[12][0-9][0-9]|[1-9]?[0-9])))(?:\s*°)? @@ -201,32 +208,34 @@ def hsla_str(cls, fix_sep: Optional[str] = ",", *, allow_alpha: bool = True) -> @classmethod def hexa_str(cls, *, allow_alpha: bool = True) -> str: """Matches a HEXA color inside a string.\n - ---------------------------------------------------------------------- - - `allow_alpha` -⠀whether to include the alpha channel in the match\n - ---------------------------------------------------------------------- + ----------------------------------------------------------------------- + * `allow_alpha` – Whether to include the alpha channel in the match. + ----------------------------------------------------------------------- The HEXA color can be in the formats (prefix `#`, `0x` or no prefix): - - `RGB` - - `RGBA` (if `allow_alpha=True`) - - `RRGGBB` - - `RRGGBBAA` (if `allow_alpha=True`)\n + * `RGB` + * `RGBA` (if `allow_alpha=True`) + * `RRGGBB` + * `RRGGBBAA` (if `allow_alpha=True`)\n #### Valid ranges: - every channel from 0-9 and A-F (case insensitive)""" + Every channel from 0-9 and A-F (case insensitive)""" + return r"(?i)(?:#|0x)?([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{4}|[0-9A-F]{3})" \ if allow_alpha else r"(?i)(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})" @classmethod def _clean(cls, pattern: str) -> str: """Internal method to make a multiline-string regex pattern into a single-line pattern.""" + return "".join(line.strip() for line in pattern.splitlines()).strip() @mypyc_attr(native_class=False) class LazyRegex: """A class that lazily compiles and caches regex patterns on first access.\n - -------------------------------------------------------------------------------- - - `**patterns` -⠀keyword arguments where the key is the name of the pattern and - the value is the regex pattern string to compile\n - -------------------------------------------------------------------------------- + ---------------------------------------------------------------------------------- + * `**patterns` – Keyword arguments where the key is the name of the pattern
+ and the value is the regex pattern string to compile. + ---------------------------------------------------------------------------------- #### Example usage: ```python PATTERNS = LazyRegex( diff --git a/src/xulbux/string.py b/src/xulbux/string.py index aa16ff6..ab96384 100644 --- a/src/xulbux/string.py +++ b/src/xulbux/string.py @@ -16,7 +16,8 @@ class String: def to_type(cls, string: str, /) -> Any: """Will convert a string to the found type, including complex nested structures.\n ----------------------------------------------------------------------------------- - - `string` -⠀the string to convert""" + * `string` – The string to convert.""" + try: return _ast.literal_eval(string := string.strip()) except (ValueError, SyntaxError): @@ -28,8 +29,9 @@ def to_type(cls, string: str, /) -> Any: @classmethod def normalize_spaces(cls, string: str, /, tab_spaces: int = 4) -> str: """Replaces all special space characters with normal spaces.\n - --------------------------------------------------------------- - - `tab_spaces` -⠀number of spaces to replace tab chars with""" + ------------------------------------------------------------------ + * `tab_spaces` – Number of spaces to replace tab chars with.""" + if tab_spaces < 0: raise ValueError(f"The 'tab_spaces' parameter must be non-negative, got {tab_spaces!r}") @@ -40,12 +42,13 @@ def normalize_spaces(cls, string: str, /, tab_spaces: int = 4) -> str: @classmethod def escape(cls, string: str, /, str_quotes: Optional[Literal["'", '"']] = None) -> str: """Escapes Python's special characters (e.g. `\\n`, `\\t`, …) and quotes inside the string.\n - -------------------------------------------------------------------------------------------------------- - - `string` -⠀the string to escape - - `str_quotes` -⠀the type of quotes the string will be put inside of (or None to not escape quotes)
- Can be either `"` or `'` and should match the quotes, the string will be put inside of.
- So if your string will be `"string"`, `str_quotes` should be `"`.
- That way, if the string includes the same quotes, they will be escaped.""" + ------------------------------------------------------------------------------------------------------------- + * `string` – The string to escape. + * `str_quotes` – The type of quotes the string will be put inside of (or `None` to not escape quotes):
+ Can be either `"` or `'` and should match the quotes, the string will be put inside of.
+ So if your string will be `"string"`, `str_quotes` should be `"`.
+ That way, if the string includes the same quotes, they will be escaped.""" + string = string.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") \ .replace("\b", "\\b").replace("\f", "\\f").replace("\a", "\\a") @@ -59,9 +62,10 @@ def escape(cls, string: str, /, str_quotes: Optional[Literal["'", '"']] = None) @classmethod def is_empty(cls, string: Optional[str], /, *, spaces_are_empty: bool = False) -> bool: """Returns `True` if the string is considered empty and `False` otherwise.\n - ----------------------------------------------------------------------------------------------- - - `string` -⠀the string to check (or `None`, which is considered empty) - - `spaces_are_empty` -⠀if true, strings consisting only of spaces are also considered empty""" + -------------------------------------------------------------------------------------------------- + * `string` – The string to check (or `None`, which is considered empty). + * `spaces_are_empty` – If true, strings consisting only of spaces are also considered empty.""" + return bool( (string in {"", None}) or \ (spaces_are_empty and isinstance(string, str) and not string.strip()) @@ -69,11 +73,12 @@ def is_empty(cls, string: Optional[str], /, *, spaces_are_empty: bool = False) - @classmethod def single_char_repeats(cls, string: str, char: str, /) -> int: - """- If the string consists of only the same `char`, it returns the number of times it is present. - - If the string is empty or doesn't consist of only the same character, it returns `0`.\n - --------------------------------------------------------------------------------------------------- - - `string` -⠀the string to check - - `char` -⠀the character to check for repetition""" + """* If the string consists of only the same `char`, it returns the number of times it is present.
+ * If the string is empty or doesn't consist of only the same character, it returns `0`.\n + --------------------------------------------------------------------------------------------------------- + * `string` – The string to check. + * `char` – The character to check for repetition.""" + if len(char) != 1: raise ValueError(f"The 'char' parameter must be a single character, got {char!r}") @@ -86,9 +91,10 @@ def single_char_repeats(cls, string: str, char: str, /) -> int: def decompose(cls, case_string: str, /, seps: str = "-_", *, lower_all: bool = True) -> list[str]: """Will decompose the string (any type of casing, also mixed) into parts.\n ---------------------------------------------------------------------------- - - `case_string` -⠀the string to decompose - - `seps` -⠀additional separators to split the string at - - `lower_all` -⠀if true, all parts will be converted to lowercase""" + * `case_string` – The string to decompose. + * `seps` – Additional separators to split the string at. + * `lower_all` – If true, all parts will be converted to lowercase.""" + return [ (part.lower() if lower_all else part) \ for part in _re.split(rf"(?<=[a-z])(?=[A-Z])|[{_re.escape(seps)}]", case_string) @@ -97,10 +103,10 @@ def decompose(cls, case_string: str, /, seps: str = "-_", *, lower_all: bool = T @classmethod def to_camel_case(cls, string: str, /, *, upper: bool = True) -> str: """Will convert the string of any type of casing to CamelCase.\n - ----------------------------------------------------------------- - - `string` -⠀the string to convert - - `upper` -⠀if true, it will convert to UpperCamelCase, - otherwise to lowerCamelCase""" + ------------------------------------------------------------------------------------------ + * `string` – The string to convert. + * `upper` – If true, it will convert to UpperCamelCase, otherwise to lowerCamelCase.""" + parts = cls.decompose(string) return ( @@ -112,9 +118,10 @@ def to_camel_case(cls, string: str, /, *, upper: bool = True) -> str: def to_delimited_case(cls, string: str, /, delimiter: str = "_", *, screaming: bool = False) -> str: """Will convert the string of any type of casing to delimited case.\n ----------------------------------------------------------------------- - - `string` -⠀the string to convert - - `delimiter` -⠀the delimiter to use between parts - - `screaming` -⠀whether to convert all parts to uppercase""" + * `string` – The string to convert. + * `delimiter` – The delimiter to use between parts. + * `screaming` – Whether to convert all parts to uppercase.""" + return delimiter.join( part.upper() if screaming else part \ for part in cls.decompose(string) @@ -123,9 +130,10 @@ def to_delimited_case(cls, string: str, /, delimiter: str = "_", *, screaming: b @classmethod def get_lines(cls, string: str, /, *, remove_empty_lines: bool = False) -> list[str]: """Will split the string into lines.\n - ------------------------------------------------------------------------------------ - - `string` -⠀the string to split - - `remove_empty_lines` -⠀if true, it will remove all empty lines from the result""" + --------------------------------------------------------------------------------------- + * `string` – The string to split. + * `remove_empty_lines` – If true, it will remove all empty lines from the result.""" + if not remove_empty_lines: return string.splitlines() elif not (lines := string.splitlines()): @@ -138,12 +146,13 @@ def get_lines(cls, string: str, /, *, remove_empty_lines: bool = False) -> list[ @classmethod def remove_consecutive_empty_lines(cls, string: str, /, max_consecutive: int = 0) -> str: """Will remove consecutive empty lines from the string.\n - ------------------------------------------------------------------------------------- - - `string` -⠀the string to process - - `max_consecutive` -⠀the maximum number of allowed consecutive empty lines.
- * If `0`, it will remove all consecutive empty lines. - * If bigger than `0`, it will only allow `max_consecutive` consecutive empty lines - and everything above it will be cut down to `max_consecutive` empty lines.""" + --------------------------------------------------------------------------------------------- + * `string` – The string to process. + * `max_consecutive` – The maximum number of allowed consecutive empty lines:
+ - If `0`, it will remove all consecutive empty lines. + - If bigger than `0`, it will only allow `max_consecutive` consecutive empty lines
+ and everything above it will be cut down to `max_consecutive` empty lines.""" + if max_consecutive < 0: raise ValueError(f"The 'max_consecutive' parameter must be non-negative, got {max_consecutive!r}") @@ -153,8 +162,9 @@ def remove_consecutive_empty_lines(cls, string: str, /, max_consecutive: int = 0 def split_count(cls, string: str, count: int, /) -> list[str]: """Will split the string every `count` characters.\n ----------------------------------------------------- - - `string` -⠀the string to split - - `count` -⠀the number of characters per part""" + * `string` – The string to split. + * `count` – The number of characters per part.""" + if count <= 0: raise ValueError(f"The 'count' parameter must be a positive integer, got {count!r}") diff --git a/src/xulbux/system.py b/src/xulbux/system.py index 350107e..2bf5daa 100644 --- a/src/xulbux/system.py +++ b/src/xulbux/system.py @@ -27,6 +27,7 @@ class _SystemMeta(type): @property def is_elevated(cls) -> bool: """Whether the current process has elevated privileges or not.""" + try: if _os.name == "nt": return getattr(_ctypes, "windll").shell32.IsUserAnAdmin() != 0 @@ -39,26 +40,31 @@ def is_elevated(cls) -> bool: @property def is_win(cls) -> bool: """Whether the current operating system is Windows or not.""" + return _platform.system() == "Windows" @property def is_linux(cls) -> bool: """Whether the current operating system is Linux or not.""" + return _platform.system() == "Linux" @property def is_mac(cls) -> bool: """Whether the current operating system is macOS or not.""" + return _platform.system() == "Darwin" @property def is_unix(cls) -> bool: """Whether the current operating system is a Unix-like OS (Linux, macOS, BSD, …) or not.""" + return _os.name == "posix" @property def hostname(cls) -> str: """The network hostname of the current machine.""" + try: return _socket.gethostname() except Exception: @@ -67,6 +73,7 @@ def hostname(cls) -> str: @property def username(cls) -> str: """The name of the current user.""" + try: return _getpass.getuser() except Exception: @@ -78,11 +85,13 @@ def username(cls) -> str: @property def os_name(cls) -> str: """The name of the operating system (e.g. `Windows`, `Linux`, …).""" + return _platform.system() @property def os_version(cls) -> str: """The version of the operating system.""" + try: return _platform.version() except Exception: @@ -91,11 +100,13 @@ def os_version(cls) -> str: @property def architecture(cls) -> str: """The CPU architecture (e.g. `x86_64`, `ARM`, …).""" + return _platform.machine() @property def cpu_count(cls) -> int: """The number of CPU cores available.""" + try: return _multiprocessing.cpu_count() except (NotImplementedError, AttributeError): @@ -104,6 +115,7 @@ def cpu_count(cls) -> int: @property def python_version(cls) -> str: """The version string of the currently running Python interpreter (e.g. `3.10.4`).""" + return _platform.python_version() @@ -113,11 +125,12 @@ class System(metaclass=_SystemMeta): @classmethod def restart(cls, prompt: object = "", /, *, wait: int = 0, continue_program: bool = False, force: bool = False) -> None: """Restarts the system with some advanced options\n - -------------------------------------------------------------------------------------------------- - - `prompt` -⠀the message to be displayed in the systems restart notification - - `wait` -⠀the time to wait until restarting in seconds - - `continue_program` -⠀whether to continue the current Python program after calling this function - - `force` -⠀whether to force a restart even if other processes are still running""" + ----------------------------------------------------------------------------------------------------- + * `prompt` – The message to be displayed in the systems restart notification. + * `wait` – The time to wait until restarting in seconds. + * `continue_program` – Whether to continue the current Python program after calling this function. + * `force` – Whether to force a restart even if other processes are still running.""" + if wait < 0: raise ValueError(f"The 'wait' parameter must be non-negative, got {wait!r}") @@ -137,15 +150,17 @@ def check_libs( confirm_install: bool = True, ) -> Optional[list[str]]: """Checks if the given list of libraries are installed and optionally installs missing libraries.\n - ------------------------------------------------------------------------------------------------------------ - - `lib_names` -⠀a list of library names to check - - `install_missing` -⠀whether to directly missing libraries will be installed automatically using pip - - `missing_libs_msgs` -⠀two messages: the first one is displayed when missing libraries are found, - the second one is the confirmation message before installing missing libraries - - `confirm_install` -⠀whether the user will be asked for confirmation before installing missing libraries\n - ------------------------------------------------------------------------------------------------------------ - If some libraries are missing or they could not be installed, their names will be returned as a list. + ------------------------------------------------------------------------------------------------------------- + * `lib_names` – A list of library names to check. + * `install_missing` – Whether to directly missing libraries will be installed automatically using pip. + * `missing_libs_msgs` – Two messages: + - The first one is displayed when missing libraries are found. + - The second one is the confirmation message before installing missing libraries. + * `confirm_install` – Whether the user will be asked for confirmation before installing missing libraries. + ------------------------------------------------------------------------------------------------------------- + If some libraries are missing or they could not be installed, their names will be returned as a list.
If all libraries are installed (or were installed successfully), `None` will be returned.""" + return _SystemCheckLibsHelper( lib_names, install_missing=install_missing, @@ -156,16 +171,17 @@ def check_libs( @classmethod def elevate(cls, win_title: Optional[str] = None, args: Optional[list[str]] = None) -> bool: """Attempts to start a new process with elevated privileges.\n - --------------------------------------------------------------------------------- - - `win_title` -⠀the window title of the elevated process (only on Windows) - - `args` -⠀a list of additional arguments to be passed to the elevated process\n - --------------------------------------------------------------------------------- - After the elevated process started, the original process will exit.
- This means, that this method has to be run at the beginning of the program or + ------------------------------------------------------------------------------------- + * `win_title` – The window title of the elevated process (only on Windows). + * `args` – A list of additional arguments to be passed to the elevated process. + ------------------------------------------------------------------------------------- + After the elevated process started, the original process will exit.\n + This means, that this method has to be run at the beginning of the program or
or else the program has to continue in a new window after elevation.\n - --------------------------------------------------------------------------------- - Returns `True` if the current process already has elevated privileges and raises + ------------------------------------------------------------------------------------- + Returns `True` if the current process already has elevated privileges and raises
a `PermissionError` if the user denied the elevation or the elevation failed.""" + if cls.is_elevated: return True @@ -177,14 +193,14 @@ def elevate(cls, win_title: Optional[str] = None, args: Optional[list[str]] = No else: args_str = f'-c "exec(open(\\"{_sys.argv[0]}\\").read())" {" ".join(args_list)}' - result = getattr(_ctypes, "windll").shell32.ShellExecuteW(None, "runas", _sys.executable, args_str, None, 1) - if result <= 32: + if getattr(_ctypes, "windll").shell32.ShellExecuteW(None, "runas", _sys.executable, args_str, None, 1) <= 32: raise PermissionError("Failed to launch elevated process.") else: _sys.exit(0) else: # POSIX cmd = ["pkexec"] + if win_title: cmd.extend(["--description", win_title]) cmd.extend([_sys.executable] + _sys.argv[1:] + args_list) @@ -215,6 +231,7 @@ def __call__(self) -> None: def check_running_processes(self, command: str | list[str], /, skip_lines: int = 0) -> None: """Check if processes are running and raise error if force is False.""" + if self.force: return @@ -229,6 +246,7 @@ def check_running_processes(self, command: str | list[str], /, skip_lines: int = def restart_windows(self) -> None: """Handle Windows system restart.""" + self.check_running_processes("tasklist", skip_lines=3) if self.prompt: @@ -241,6 +259,7 @@ def restart_windows(self) -> None: def restart_posix(self) -> None: """Handle Linux/macOS system restart.""" + self.check_running_processes(["ps", "-A"], skip_lines=1) if self.prompt: @@ -257,6 +276,7 @@ def restart_posix(self) -> None: def wait_for_restart(self) -> None: """Wait and print message before restart.""" + print(f"Restarting in {self.wait} seconds...") _time.sleep(self.wait) @@ -293,6 +313,7 @@ def __call__(self) -> Optional[list[str]]: def find_missing_libs(self) -> list[str]: """Find which libraries are missing.""" + missing: list[str] = [] for lib in self.lib_names: try: @@ -303,6 +324,7 @@ def find_missing_libs(self) -> list[str]: def confirm_installation(self, missing: list[str], /) -> bool: """Ask user for confirmation before installing libraries.""" + FormatCodes.print(f"[b]({self.missing_libs_msgs['found_missing']})") for lib in missing: FormatCodes.print(f" [dim](•) [i]{lib}[_i]") @@ -311,6 +333,7 @@ def confirm_installation(self, missing: list[str], /) -> bool: def install_libs(self, missing: list[str], /) -> Optional[list[str]]: """Install missing libraries using pip.""" + for lib in missing[:]: try: _subprocess.check_call([_sys.executable, "-m", "pip", "install", lib]) diff --git a/tests/test_code.py b/tests/test_code.py index efb705e..5b0b9cc 100644 --- a/tests/test_code.py +++ b/tests/test_code.py @@ -77,7 +77,7 @@ def test_is_js(): assert Code.is_js(js_sample) is True js_sample = "__('translation_key')" assert Code.is_js(js_sample) is True - js_sample = "const func = () => { return 42; }" + js_sample = "const fn = () => { return 42; }" assert Code.is_js(js_sample) is True js_sample = "customFunc()" assert Code.is_js(js_sample, funcs={"customFunc"}) is True diff --git a/tests/test_color.py b/tests/test_color.py index 0652b14..55cd41e 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -26,8 +26,8 @@ def test_is_valid_rgba(): assert Color.is_valid_rgba((255, 0, 0, 0.5)) is True assert Color.is_valid_rgba("rgb(255, 0, 0)") is True assert Color.is_valid_rgba("rgba(255, 0, 0, .5)") is True - assert Color.is_valid_rgba({"r": 255, "g": 0, "b": 0}) is True - assert Color.is_valid_rgba({"r": 255, "g": 0, "b": 0, "a": 0.5}) is True + assert Color.is_valid_rgba({"red": 255, "green": 0, "blue": 0}) is True + assert Color.is_valid_rgba({"red": 255, "green": 0, "blue": 0, "alpha": 0.5}) is True assert Color.is_valid_rgba(rgba(255, 0, 0)) is True assert Color.is_valid_rgba((300, 0, 0)) is False assert Color.is_valid_rgba((255, 0)) is False @@ -42,8 +42,8 @@ def test_is_valid_hsla(): assert Color.is_valid_hsla((0, 100, 50, 0.5)) is True assert Color.is_valid_hsla("hsl(0, 100%, 50%)") is True assert Color.is_valid_hsla("hsla(0, 100%, 50%, .5)") is True - assert Color.is_valid_hsla({"h": 0, "s": 100, "l": 50}) is True - assert Color.is_valid_hsla({"h": 0, "s": 100, "l": 50, "a": 0.5}) is True + assert Color.is_valid_hsla({"hue": 0, "sat": 100, "light": 50}) is True + assert Color.is_valid_hsla({"hue": 0, "sat": 100, "light": 50, "alpha": 0.5}) is True assert Color.is_valid_hsla(hsla(0, 100, 50)) is True assert Color.is_valid_hsla((370, 100, 50)) is False assert Color.is_valid_hsla((0, 101, 50)) is False @@ -164,7 +164,7 @@ def test_adjust_saturation(): color = rgba(128, 80, 80) saturated = Color.adjust_saturation(color, 0.25) assert isinstance(saturated, rgba) - assert saturated.to_hsla().s > color.to_hsla().s + assert saturated.to_hsla().sat > color.to_hsla().sat assert saturated == rgba(155, 54, 54) desaturated = Color.adjust_saturation(hexa("#FF0000"), -1.0) diff --git a/tests/test_console.py b/tests/test_console.py index 9ee0516..58b63d0 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -56,13 +56,13 @@ def test_console_user(): def test_console_width(mock_terminal_size: MagicMock): - width_output = Console.w + width_output = Console.width assert isinstance(width_output, int) assert width_output == 80 def test_console_height(mock_terminal_size: MagicMock): - height_output = Console.h + height_output = Console.height assert isinstance(height_output, int) assert height_output == 24 @@ -1065,11 +1065,11 @@ def test_progressbar_create_bar(): bar = pb._create_bar(100, 100, 10) assert len(bar) == 10 - assert all(c == pb.chars[0] for c in bar) + assert all(char == pb.chars[0] for char in bar) bar = pb._create_bar(0, 100, 10) assert len(bar) == 10 - assert all(c == pb.chars[-1] for c in bar) + assert all(char == pb.chars[-1] for char in bar) def test_progressbar_intercepted_output(): diff --git a/tests/test_metadata_consistency.py b/tests/test_metadata_consistency.py index 8c0cde9..2e30339 100644 --- a/tests/test_metadata_consistency.py +++ b/tests/test_metadata_consistency.py @@ -23,7 +23,13 @@ def get_current_branch() -> Optional[str]: # FALLBACK TO GIT COMMAND FOR LOCAL DEV try: - result = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True, check=True) + result = subprocess.run( + ["git", "branch", "--show-current"], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + check=True, + ) return result.stdout.strip() or None except (subprocess.CalledProcessError, FileNotFoundError): return None diff --git a/tests/test_regex.py b/tests/test_regex.py index f47d276..0b28fbc 100644 --- a/tests/test_regex.py +++ b/tests/test_regex.py @@ -169,7 +169,7 @@ def test_regex_brackets_as_group(): def test_regex_brackets_ignore_in_strings(): """Test brackets pattern with ignore_in_strings option""" - text = 'func(param = "f(x)")' + text = 'fn(param = "f(x)")' pattern = Regex.brackets(ignore_in_strings=True) matches = rx.findall(pattern, text) assert len(matches) == 1 From 24a862193b96538c30acb331d75263d5938c8633 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 12 May 2026 23:00:10 +0200 Subject: [PATCH 04/18] Performance improvements for `Console.log()`, `Data.remove_duplicates()`, and `String.normalize_spaces()` --- CHANGELOG.md | 4 ++ setup.py | 10 ++++- src/xulbux/console.py | 81 ++++++++++++++++++++------------------ src/xulbux/data.py | 24 +++++------ src/xulbux/format_codes.py | 5 ++- src/xulbux/string.py | 20 ++++++++-- 6 files changed, 88 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 608c69b..ec0a7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,12 +22,16 @@ * Unified all error messages throughout the whole library, to always pass the given value if the error is caused by that value being invalid. * Added a new param `allow_space_value` to `Console.get_args()` and made `flag_value_sep` optional, which allows you to specify whether flags should be able to receive their values with a space in between (*e.g.* `--flag value` instead of just `--flag=value`). * Reformat all docstrings of the whole library. +* Improved the performance of `Console.log()` by restructuring the way it processes the output and its formatting +* Improved the performance of `String.normalize_spaces()` by using `str.translate()` instead of multiple `str.replace()` calls. +* Improved the performance of `Data.remove_duplicates()` for lists and tuples: hashable items now deduplicate in O(n) using `dict.fromkeys()`, with an O(n²) equality-check fallback only for unhashable items (*lists, dicts, sets*). **BREAKING CHANGES:** * Renamed `r`, `g`, `b` and `a` to `red`, `green`, `blue` and `alpha` everywhere in the library, to follow the no-single-letter-names convention. * Renamed `h`, `s` and `l` to `hue`, `sat` and `light` everywhere in the library, to follow the no-single-letter-names convention. * Renamed the `Console.w` and `Console.h` properties to `Console.width` and `Console.height`, to follow the no-single-letter-names convention. * Removed the `background:` and `bright:` prefixes from the library, so now you can just use the `bg:` and `br:` ones, for consistency. +* Removed the `format_linebreaks` param from `Console.log()`, as the whole point of the `log()` method is to get a nicely formatted log message. diff --git a/setup.py b/setup.py index b805a70..d181ed1 100644 --- a/setup.py +++ b/setup.py @@ -73,8 +73,13 @@ def delete_project_stub_files(): ext_modules = [] +# ONLY COMPILE AND GENERATE STUBS WHEN ACTUALLY BUILDING, NOT DURING METADATA-ONLY +# PHASES (egg_info, dist_info) THAT PIP INVOKES AS PART OF PEP 517 PREPARATION. +_BUILD_COMMANDS = {"bdist_wheel", "build_ext", "build", "develop", "editable_wheel", "install"} +_is_building = bool(set(sys.argv[1:]) & _BUILD_COMMANDS) + # OPTIONALLY USE MYPYC COMPILATION -if os.environ.get("XULBUX_USE_MYPYC", "1") == "1": +if os.environ.get("XULBUX_USE_MYPYC", "1") == "1" and _is_building: try: from mypyc.build import mypycify @@ -97,4 +102,5 @@ def delete_project_stub_files(): ext_modules=ext_modules, ) -delete_project_stub_files() +if _is_building: + delete_project_stub_files() diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 7c26cba..b4e4332 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -19,6 +19,7 @@ from prompt_toolkit.styles import Style from prompt_toolkit.keys import Keys from contextlib import contextmanager +from itertools import chain from io import StringIO import prompt_toolkit as _pt import subprocess as _subprocess @@ -433,7 +434,6 @@ def log( prompt: object = "", /, *, - format_linebreaks: bool = True, start: str = "", end: str = "\n", title_bg_color: Optional[str | Rgba | Hexa] = None, @@ -478,33 +478,40 @@ def log( raise ValueError( f"The 'title_bg_color' parameter must be a valid terminal color, RGBA value, or HEXA value, got {title_bg_color!r}" ) - - px, mx = (" " * title_px) if has_title_bg else "", " " * title_mx - tab = " " * (tab_size - 1 - ((len(mx) + (title_len := len(title) + 2 * len(px))) % tab_size)) - - if format_linebreaks: - clean_prompt, removals = *FormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True), - prompt_lst: list[str] = [ - item for lst in [ - String.split_count(line, cls.width - (title_len + len(tab) + 2 * len(mx))) \ - for line in str(clean_prompt).splitlines() - ] for item in ([""] if lst == [] else lst) - ] - prompt = f"\n{mx}{' ' * title_len}{mx}{tab}".join(cls._add_back_removed_parts(prompt_lst, removals)) - - if title == "": - FormatCodes.print( - f"{start} {f'[{default_color}]' if default_color else ''}{prompt}[_]", - default_color=default_color, - end=end, - ) else: - FormatCodes.print( - f"{start}{mx}[b|{title_fg}{f'|bg:{title_bg_color}' if has_title_bg else ''}]{px}{title}{px}[_]{mx}" - f"{tab}{f'[{default_color}]' if default_color else ''}{prompt}[_]", - default_color=default_color, - end=end, - ) + title_px = 0 # REMOVE PADDING IF TITLE HAS NO BG COLOR + + # PADDING = SPACE INSIDE TITLE BG COLOR + # MARGIN = SPACE OUTSIDE TITLE BG COLOR + px, mx = " " * title_px, " " * title_mx + + # TITLE LENGTH INCLUDING PADDING AND MARGIN + title_len: int = len(title) + (title_px * 2) + (title_mx * 2) + + # CALCULATE DISTANCE TO NEXT TAB STOP + tab: str = " " * (-title_len % tab_size) + + # POSITION WHERE PROMPT NEEDS TO WRAP TO NEXT LINE + wrap_len: int = cls.width - (title_len + len(tab)) + + # REMOVE ALL FORMAT CODES AS THEY WON'T AFFECT THE VISIBLE LENGTH OF THE PROMPT + clean_prompt, removals = (*FormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True), ) + + # SPLIT PROMPT INTO LINES AND THEN SPLIT EACH LINE INTO CHUNKS THAT FIT WITHIN THE WRAP LENGTH + prompt_lst: list[str] = list(chain.from_iterable(cls._process_lines(clean_prompt, wrap_len))) + + # ADD BACK REMOVED FORMAT CODES TO THEIR ORIGINAL POSITIONS IN THE PROMPT + prompt = f"\n{' ' * title_len}{tab}".join(cls._add_back_removed_parts(prompt_lst, removals)) + + out: str = ( + # LOG WITHOUT A TITLE + f"{start}{mx}{f'[{default_color}]' if default_color else ''}{prompt}[_]" if title == "" else + # LOG WITH A TITLE + f"{start}{mx}[b|{title_fg}{f'|bg:{title_bg_color}' if has_title_bg else ''}]{px}{title}{px}[_]{mx}" + f"{tab}{f'[{default_color}]' if default_color else ''}{prompt}[_]" + ) + + FormatCodes.print(out, default_color=default_color, end=end) @classmethod def debug( @@ -513,7 +520,6 @@ def debug( /, *, active: bool = True, - format_linebreaks: bool = True, start: str = "", end: str = "\n", default_color: Optional[Rgba | Hexa] = None, @@ -530,7 +536,6 @@ def debug( cls.log( "DEBUG", prompt, - format_linebreaks=format_linebreaks, start=start, end=end, title_bg_color="br:yellow", @@ -544,7 +549,6 @@ def info( prompt: object = "Program running.", /, *, - format_linebreaks: bool = True, start: str = "", end: str = "\n", default_color: Optional[Rgba | Hexa] = None, @@ -559,7 +563,6 @@ def info( cls.log( "INFO", prompt, - format_linebreaks=format_linebreaks, start=start, end=end, title_bg_color="br:blue", @@ -573,7 +576,6 @@ def done( prompt: object = "Program finished.", /, *, - format_linebreaks: bool = True, start: str = "", end: str = "\n", default_color: Optional[Rgba | Hexa] = None, @@ -588,7 +590,6 @@ def done( cls.log( "DONE", prompt, - format_linebreaks=format_linebreaks, start=start, end=end, title_bg_color="br:green", @@ -602,7 +603,6 @@ def warn( prompt: object = "Important message.", /, *, - format_linebreaks: bool = True, start: str = "", end: str = "\n", default_color: Optional[Rgba | Hexa] = None, @@ -617,7 +617,6 @@ def warn( cls.log( "WARN", prompt, - format_linebreaks=format_linebreaks, start=start, end=end, title_bg_color="br:yellow", @@ -631,7 +630,6 @@ def fail( prompt: object = "Program error.", /, *, - format_linebreaks: bool = True, start: str = "", end: str = "\n", default_color: Optional[Rgba | Hexa] = None, @@ -646,7 +644,6 @@ def fail( cls.log( "FAIL", prompt, - format_linebreaks=format_linebreaks, start=start, end=end, title_bg_color="br:red", @@ -660,7 +657,6 @@ def exit( prompt: object = "Program ended.", /, *, - format_linebreaks: bool = True, start: str = "", end: str = "\n", default_color: Optional[Rgba | Hexa] = None, @@ -675,7 +671,6 @@ def exit( cls.log( "EXIT", prompt, - format_linebreaks=format_linebreaks, start=start, end=end, title_bg_color="br:magenta", @@ -1094,6 +1089,14 @@ def _read_single_key() -> None: finally: _termios.tcsetattr(fd, _termios.TCSADRAIN, old_settings) # type: ignore[attr-defined] + @staticmethod + def _process_lines(clean_prompt: str, wrap_len: int) -> Generator[tuple[Literal[""]] | list[str], Any, None]: + """Splits the clean prompt into lines and then splits each line into chunks that fit within the wrap length.""" + + for line in clean_prompt.splitlines(): + lst = String.split_count(line, wrap_len) + yield ("", ) if not lst else lst + @classmethod def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[int, str], ...], /) -> list[str]: """Adds back the removed parts into the split string parts at their original positions.""" diff --git a/src/xulbux/data.py b/src/xulbux/data.py index 39f5f56..424468f 100644 --- a/src/xulbux/data.py +++ b/src/xulbux/data.py @@ -149,18 +149,20 @@ def remove_duplicates(cls, data: DataObj, /) -> DataObj: }) elif isinstance(data, (list, tuple)): - result: list[Any] = [] - for item in data: - processed_item = cls.remove_duplicates(cast(DataObjType, item)) if isinstance(item, DataObjTT) else item - is_duplicate: bool = False - - for existing_item in result: - if processed_item == existing_item: - is_duplicate = True - break + processed: list[Any] = [ + cls.remove_duplicates(cast(DataObjType, item)) if isinstance(item, DataObjTT) else item \ + for item in data + ] - if not is_duplicate: - result.append(processed_item) + try: + result: list[Any] = list(dict.fromkeys(processed)) + + except TypeError: + # UNHASHABLE ITEMS (LISTS, DICTS, SETS) – FALL BACK TO O(n²) EQUALITY CHECK + result = [] + for item in processed: + if item not in result: + result.append(item) return cast(DataObj, type(data)(result)) diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 88579b1..9e5bf52 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -168,6 +168,7 @@ from .color import Color, rgba, hexa from typing import Optional, Literal, Final, overload, cast +from itertools import chain as _chain import ctypes as _ctypes import regex as _rx import sys as _sys @@ -189,6 +190,8 @@ "br": {"br"}, } """Formatting code prefixes for setting background- and bright-colors.""" +_PREFIX_VALUES: Final[frozenset[str]] = frozenset(_chain.from_iterable(_PREFIX.values())) +"""Flat frozenset of all prefix values, precomputed for fast membership tests.""" _PREFIX_RX: Final[dict[str, str]] = { "bg": rf"(?:{'|'.join(_PREFIX['bg'])})\s*:", "br": rf"(?:{'|'.join(_PREFIX['br'])})\s*:", @@ -640,7 +643,7 @@ def _normalize_key(format_key: str, /) -> str: return prefix_str + ":".join( part for part in k_parts \ - if part not in {val for values in _PREFIX.values() for val in values} + if part not in _PREFIX_VALUES ) diff --git a/src/xulbux/string.py b/src/xulbux/string.py index ab96384..0a34d9f 100644 --- a/src/xulbux/string.py +++ b/src/xulbux/string.py @@ -30,14 +30,28 @@ def to_type(cls, string: str, /) -> Any: def normalize_spaces(cls, string: str, /, tab_spaces: int = 4) -> str: """Replaces all special space characters with normal spaces.\n ------------------------------------------------------------------ + * `string` – The string to normalize. * `tab_spaces` – Number of spaces to replace tab chars with.""" if tab_spaces < 0: raise ValueError(f"The 'tab_spaces' parameter must be non-negative, got {tab_spaces!r}") - return string.replace("\t", " " * tab_spaces).replace("\u2000", " ").replace("\u2001", " ").replace("\u2002", " ") \ - .replace("\u2003", " ").replace("\u2004", " ").replace("\u2005", " ").replace("\u2006", " ") \ - .replace("\u2007", " ").replace("\u2008", " ").replace("\u2009", " ").replace("\u200A", " ") + table: dict[str, str | int | None] = { + "\t": " " * tab_spaces, + "\u2000": " ", + "\u2001": " ", + "\u2002": " ", + "\u2003": " ", + "\u2004": " ", + "\u2005": " ", + "\u2006": " ", + "\u2007": " ", + "\u2008": " ", + "\u2009": " ", + "\u200A": " ", + } + + return string.translate(str.maketrans(table)) @classmethod def escape(cls, string: str, /, str_quotes: Optional[Literal["'", '"']] = None) -> str: From 0c5aa16710ab7e163fc66a499589430b171ce83a Mon Sep 17 00:00:00 2001 From: XulbuX Date: Wed, 13 May 2026 08:48:52 +0200 Subject: [PATCH 05/18] Improve the `ProgressBar` style --- src/xulbux/console.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/xulbux/console.py b/src/xulbux/console.py index b4e4332..21bee7e 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -1697,8 +1697,8 @@ def __init__( *, min_width: int = 10, max_width: int = 50, - bar_format: list[str] | tuple[str, ...] = ["{l}", "▕{b}▏", "[b]({c:,})/{t:,}", "[dim](([i]({p}%)))"], - limited_bar_format: list[str] | tuple[str, ...] = ["▕{b}▏"], + bar_format: list[str] | tuple[str, ...] = ["{l}", "[bg:black]({b})", "[b]({c:,})/{t:,}", "[dim](([i]({p}%)))"], + limited_bar_format: list[str] | tuple[str, ...] = ["[bg:black]({b})"], sep: str = " ", chars: tuple[str, ...] = ("█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", " "), ): From 4e5144e9a658e31078f625afda677d6cce6eef1a Mon Sep 17 00:00:00 2001 From: XulbuX Date: Wed, 13 May 2026 11:11:23 +0200 Subject: [PATCH 06/18] Performance improvements for `FormatCodes` class --- src/xulbux/console.py | 2 +- src/xulbux/format_codes.py | 140 ++++++++++++++++++++++++++++++++----- 2 files changed, 122 insertions(+), 20 deletions(-) diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 21bee7e..f53bbf7 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -754,7 +754,7 @@ def log_box_bordered( start: str = "", end: str = "\n", border_type: Literal["standard", "rounded", "strong", "double"] = "rounded", - border_style: str | Rgba | Hexa = f"br:black", + border_style: str | Rgba | Hexa = "br:black", default_color: Optional[Rgba | Hexa] = None, w_padding: int = 1, w_full: bool = False, diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 9e5bf52..65071d4 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -220,6 +220,41 @@ ) +def _build_ansi_flat() -> dict[str, str]: + """Build flat mapping from every individual format key (including all aliases) + to its fully-formed ANSI escape sequence string (precomputed once).""" + + flat: dict[str, str] = {} + + for map_key, code in ANSI.CODES_MAP.items(): + ansi_str = _ANSI_SEQ_1.format(code) + if isinstance(map_key, tuple): + for k in map_key: + flat[k] = ansi_str + else: + flat[map_key] = ansi_str + + return flat + + +_ANSI_FLAT: Final[dict[str, str]] = _build_ansi_flat() +"""Precomputed direct-lookup table from format key to ANSI escape sequence.""" + +_NORMALIZE_KEY_CACHE: dict[str, str] = {} +"""Cache for `FormatCodes._normalize_key` results.""" +_NORMALIZE_KEY_CACHE_MAX: Final[int] = 4096 + +_REPLACEMENT_CACHE: dict[str, str] = {} +"""Cache for `FormatCodes._get_replacement` results when no `default_color` is set.""" +_REPLACEMENT_CACHE_MAX: Final[int] = 4096 + +_TO_ANSI_CACHE: dict[tuple[str, Optional[tuple[int, int, int]], int], str] = {} +"""Cache for full `FormatCodes.to_ansi` results on the public entry path.""" +_TO_ANSI_CACHE_MAX: Final[int] = 1024 +_TO_ANSI_CACHE_MAX_LEN: Final[int] = 8192 +"""Strings longer than this are not cached end-to-end.""" + + class FormatCodes: """This class provides methods to print and work with strings that contain special formatting codes, which are then converted to ANSI codes for pretty terminal output.""" @@ -307,16 +342,54 @@ def to_ansi( if not (0 < brightness_steps <= 100): raise ValueError(f"The 'brightness_steps' parameter must be in range [1, 100] inclusive, got {brightness_steps!r}") + # FAST PATH: NO FORMATTING CODES POSSIBLE WITHOUT '[' + if "[" not in string: + if _validate_default: + _, default_color = cls._validate_default_color(default_color) + if _default_start and default_color is not None: + prefix = cls._get_default_ansi(cast(rgba, default_color)) + if prefix: + return prefix + string + return string + + # END-TO-END CACHE LOOKUP (PUBLIC ENTRY PATH ONLY) + cache_key: Optional[tuple[str, Optional[tuple[int, int, int]], int]] = None + if ( + _default_start \ + and _validate_default + and len(string) <= _TO_ANSI_CACHE_MAX_LEN + ): + dc_key: Optional[tuple[int, int, int]] + + if default_color is None: + dc_key = None + elif isinstance(default_color, tuple) and len(default_color) >= 3: + try: + dc_key = (int(default_color[0]), int(default_color[1]), int(default_color[2])) + except (TypeError, ValueError): + dc_key = None + elif isinstance(default_color, str): + dc_key = None # HEX STRINGS HANDLED THROUGH _validate_default_color; SKIP CACHE + else: + dc_key = None + + if dc_key is not None or default_color is None: + cache_key = (string, dc_key, brightness_steps) + cached = _TO_ANSI_CACHE.get(cache_key) + if cached is not None: + return cached + if _validate_default: use_default, default_color = cls._validate_default_color(default_color) else: use_default = default_color is not None default_color = cast(Optional[rgba], default_color) - if use_default: - string = _PATTERNS.star_reset.sub(r"[\1_|default\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|default|…]` - else: - string = _PATTERNS.star_reset.sub(r"[\1_\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|…]` + if "*" in string: + if use_default: + string = _PATTERNS.star_reset.sub(r"[\1_|default\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|default|…]` + else: + string = _PATTERNS.star_reset.sub(r"[\1_\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|…]` string = "\n".join( _PATTERNS.formatting.sub( @@ -329,11 +402,18 @@ def to_ansi( ) for line in string.split("\n") ) - return ( + result = ( ((cls._get_default_ansi(default_color) or "") if _default_start else "") \ + string ) if default_color is not None else string + if cache_key is not None: + if len(_TO_ANSI_CACHE) >= _TO_ANSI_CACHE_MAX: + _TO_ANSI_CACHE.clear() + _TO_ANSI_CACHE[cache_key] = result + + return result + @classmethod def escape( cls, @@ -548,34 +628,41 @@ def _get_replacement(cls, format_key: str, default_color: Optional[rgba], /, bri If `default_color` is not `None`, the text color will be `default_color` if all formats
are reset or you can get lighter or darker version of `default_color` (also as BG)""" - _format_key, format_key = format_key, cls._normalize_key(format_key) # NORMALIZE KEY AND SAVE ORIGINAL + # FAST PATH WHEN NO DEFAULT COLOR: USE CACHED RESULTS + if default_color is None: + cached = _REPLACEMENT_CACHE.get(format_key) + if cached is not None: + return cached + + _format_key = format_key + format_key = cls._normalize_key(format_key) # NORMALIZE KEY AND SAVE ORIGINAL - if default_color and (new_default_color := cls._get_default_ansi(default_color, format_key, brightness_steps)): + # DIRECT LOOKUP IN PRECOMPUTED FLAT TABLE (NO O(N) SCAN OVER CODES_MAP) + flat_hit = _ANSI_FLAT.get(format_key) + + if default_color is not None and ( \ + new_default_color := cls._get_default_ansi(default_color, format_key, brightness_steps) + ): return new_default_color - for map_key in ANSI.CODES_MAP: - if (isinstance(map_key, tuple) and format_key in map_key) or format_key == map_key: - return _ANSI_SEQ_1.format( - next(( - val for key, val in ANSI.CODES_MAP.items() \ - if format_key == key or (isinstance(key, tuple) and format_key in key) - ), None) - ) + if flat_hit is not None: + return flat_hit rgb_match = _PATTERNS.rgb.match(format_key) hex_match = _PATTERNS.hex.match(format_key) + result = _format_key try: if rgb_match: is_bg = rgb_match.group(1) red, green, blue = map(int, rgb_match.groups()[1:]) if Color.is_valid_rgba((red, green, blue)): - return ANSI.SEQ_BG_COLOR.format(red, green, blue) if is_bg else ANSI.SEQ_COLOR.format(red, green, blue) + result = ANSI.SEQ_BG_COLOR.format(red, green, blue) if is_bg else ANSI.SEQ_COLOR.format(red, green, blue) elif hex_match: is_bg = hex_match.group(1) rgb = Color.to_rgba(hex_match.group(2)) - return ( + result = ( ANSI.SEQ_BG_COLOR.format(rgb[0], rgb[1], rgb[2]) if is_bg else ANSI.SEQ_COLOR.format(rgb[0], rgb[1], rgb[2]) ) @@ -583,7 +670,12 @@ def _get_replacement(cls, format_key: str, default_color: Optional[rgba], /, bri except Exception: pass - return _format_key + if default_color is None: + if len(_REPLACEMENT_CACHE) >= _REPLACEMENT_CACHE_MAX: + _REPLACEMENT_CACHE.clear() + _REPLACEMENT_CACHE[_format_key] = result + + return result @staticmethod def _get_default_ansi( @@ -634,6 +726,10 @@ def _get_default_ansi( def _normalize_key(format_key: str, /) -> str: """Internal method to normalize the given format key.""" + cached = _NORMALIZE_KEY_CACHE.get(format_key) + if cached is not None: + return cached + k_parts = format_key.replace(" ", "").lower().split(":") prefix_str = "".join( @@ -641,11 +737,17 @@ def _normalize_key(format_key: str, /) -> str: if any(k_part in prefix_values for k_part in k_parts) ) - return prefix_str + ":".join( + result = prefix_str + ":".join( part for part in k_parts \ if part not in _PREFIX_VALUES ) + if len(_NORMALIZE_KEY_CACHE) >= _NORMALIZE_KEY_CACHE_MAX: + _NORMALIZE_KEY_CACHE.clear() + _NORMALIZE_KEY_CACHE[format_key] = result + + return result + class _EscapeFormatCodeHelper: """Internal, callable helper class to escape formatting codes.""" From a242f2beade9cfa81e4816bbf6c374140e770f80 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Wed, 13 May 2026 11:31:10 +0200 Subject: [PATCH 07/18] Restructure `FormatCodes.to_ansi()` as it was getting too complex --- src/xulbux/format_codes.py | 123 +++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 40 deletions(-) diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 65071d4..d3b78d2 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -344,40 +344,20 @@ def to_ansi( # FAST PATH: NO FORMATTING CODES POSSIBLE WITHOUT '[' if "[" not in string: - if _validate_default: - _, default_color = cls._validate_default_color(default_color) - if _default_start and default_color is not None: - prefix = cls._get_default_ansi(cast(rgba, default_color)) - if prefix: - return prefix + string - return string + return cls._no_bracket_fast_path( + string, + default_color, + _default_start=_default_start, + _validate_default=_validate_default, + ) # END-TO-END CACHE LOOKUP (PUBLIC ENTRY PATH ONLY) - cache_key: Optional[tuple[str, Optional[tuple[int, int, int]], int]] = None - if ( - _default_start \ - and _validate_default - and len(string) <= _TO_ANSI_CACHE_MAX_LEN - ): - dc_key: Optional[tuple[int, int, int]] - - if default_color is None: - dc_key = None - elif isinstance(default_color, tuple) and len(default_color) >= 3: - try: - dc_key = (int(default_color[0]), int(default_color[1]), int(default_color[2])) - except (TypeError, ValueError): - dc_key = None - elif isinstance(default_color, str): - dc_key = None # HEX STRINGS HANDLED THROUGH _validate_default_color; SKIP CACHE - else: - dc_key = None - - if dc_key is not None or default_color is None: - cache_key = (string, dc_key, brightness_steps) - cached = _TO_ANSI_CACHE.get(cache_key) - if cached is not None: - return cached + cache_key = ( + cls._build_cache_key(string, default_color, brightness_steps) \ + if _default_start and _validate_default else None + ) + if cache_key is not None and (cached := _TO_ANSI_CACHE.get(cache_key)) is not None: + return cached if _validate_default: use_default, default_color = cls._validate_default_color(default_color) @@ -385,11 +365,7 @@ def to_ansi( use_default = default_color is not None default_color = cast(Optional[rgba], default_color) - if "*" in string: - if use_default: - string = _PATTERNS.star_reset.sub(r"[\1_|default\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|default|…]` - else: - string = _PATTERNS.star_reset.sub(r"[\1_\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|…]` + string = cls._apply_star_reset(string, use_default) string = "\n".join( _PATTERNS.formatting.sub( @@ -408,9 +384,7 @@ def to_ansi( ) if default_color is not None else string if cache_key is not None: - if len(_TO_ANSI_CACHE) >= _TO_ANSI_CACHE_MAX: - _TO_ANSI_CACHE.clear() - _TO_ANSI_CACHE[cache_key] = result + cls._store_in_cache(cache_key, result) return result @@ -601,6 +575,64 @@ def _config_terminal(cls) -> None: pass _TERMINAL_ANSI_CONFIGURED = True # type: ignore[assignment] + @classmethod + def _no_bracket_fast_path( + cls, + string: str, + default_color: Optional[Rgba | Hexa], + *, + _default_start: bool, + _validate_default: bool, + ) -> str: + """Handle fast path when the string contains no `[` bracket.""" + + if _validate_default: + _, default_color = cls._validate_default_color(default_color) + + if _default_start and default_color is not None: + prefix = cls._get_default_ansi(cast(rgba, default_color)) + if prefix: + return prefix + string + + return string + + @classmethod + def _build_cache_key( + cls, + string: str, + default_color: Optional[Rgba | Hexa], + brightness_steps: int, + ) -> Optional[tuple[str, Optional[tuple[int, int, int]], int]]: + """Build a cache key for `to_ansi`, returning None if caching should be skipped.""" + + if len(string) > _TO_ANSI_CACHE_MAX_LEN: + return None + + if default_color is None: + return (string, None, brightness_steps) + + if isinstance(default_color, tuple) and len(default_color) >= 3: + try: + dc_key = (int(default_color[0]), int(default_color[1]), int(default_color[2])) + return (string, dc_key, brightness_steps) + except (TypeError, ValueError): + return None + + return None # HEX STRINGS AND OTHER TYPES SKIP CACHE + + @staticmethod + def _store_in_cache( + cache_key: tuple[str, Optional[tuple[int, int, int]], int], + result: str, + /, + ) -> None: + """Store a `to_ansi` result in the end-to-end cache, evicting all entries if at capacity.""" + + if len(_TO_ANSI_CACHE) >= _TO_ANSI_CACHE_MAX: + _TO_ANSI_CACHE.clear() + + _TO_ANSI_CACHE[cache_key] = result + @staticmethod def _validate_default_color(default_color: Optional[Rgba | Hexa], /) -> tuple[bool, Optional[rgba]]: """Internal method to validate and convert `default_color` to a `rgba` color object.""" @@ -615,6 +647,17 @@ def _validate_default_color(default_color: Optional[Rgba | Hexa], /) -> tuple[bo f"The 'default_color' parameter must be either a valid RGBA or HEXA color, or None, got {default_color!r}" ) + @staticmethod + def _apply_star_reset(string: str, use_default: bool, /) -> str: + """Replace `[*]` star-reset tokens with the appropriate reset sequences.""" + + if "*" not in string: + return string + if use_default: + return _PATTERNS.star_reset.sub(r"[\1_|default\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|default|…]` + + return _PATTERNS.star_reset.sub(r"[\1_\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|…]` + @staticmethod def _formats_to_keys(formats: str, /) -> list[str]: """Internal method to convert a string of multiple format From e12c62d6f63264de07dcb5ff9375bf3bdf22f7f6 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Wed, 13 May 2026 14:53:35 +0200 Subject: [PATCH 08/18] Add a new `skip` param to `Console.get_args()` and new props to the `ParsedArgs` object --- CHANGELOG.md | 8 ++- src/xulbux/cli/tools.py | 4 +- src/xulbux/console.py | 38 ++++++++++++- tests/test_console.py | 123 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0a7f9..ddb5ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,14 @@ * Unified all error messages throughout the whole library, to always pass the given value if the error is caused by that value being invalid. * Added a new param `allow_space_value` to `Console.get_args()` and made `flag_value_sep` optional, which allows you to specify whether flags should be able to receive their values with a space in between (*e.g.* `--flag value` instead of just `--flag=value`). +* Added a new `skip` param to `Console.get_args()`, which skips the first N command-line arguments before parsing; useful when the leading argv entries are a command/subcommand and not relevant to the caller. +* Added three new read-only properties to `ParsedArgs`: + - `is_empty` is true if no argument was found **and** none have any values (*not even defaults*). + - `any_exist` is true if at least one argument was explicitly found. + - `all_exist` is true if every argument was explicitly found. +* Added `ParsedArgs.RESERVED_ALIASES` – a `frozenset` of names that cannot be used as argument aliases. Passing a reserved name now raises a clear `ValueError`. * Reformat all docstrings of the whole library. -* Improved the performance of `Console.log()` by restructuring the way it processes the output and its formatting +* Improved the performance of `Console.log()` and `FormatCodes.to_ansi()` by restructuring the way they process the formatting and output. * Improved the performance of `String.normalize_spaces()` by using `str.translate()` instead of multiple `str.replace()` calls. * Improved the performance of `Data.remove_duplicates()` for lists and tuples: hashable items now deduplicate in O(n) using `dict.fromkeys()`, with an O(n²) equality-check fallback only for unhashable items (*lists, dicts, sets*). diff --git a/src/xulbux/cli/tools.py b/src/xulbux/cli/tools.py index e701dd8..9d14462 100644 --- a/src/xulbux/cli/tools.py +++ b/src/xulbux/cli/tools.py @@ -6,8 +6,8 @@ def render_format_codes(): """CLI command function for `xulbux-lib fc` command, which allows you to parse
and render a given string's format codes as ANSI terminal output.""" - args = Console.get_args({"input": "before"}) - vals = args.input.values[1:] # EXCLUDE THE COMMAND ITSELF + args = Console.get_args({"input": "before"}, skip=1) + vals = args.input.values if not vals: FormatCodes.print( diff --git a/src/xulbux/console.py b/src/xulbux/console.py index f53bbf7..52e4bf5 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -137,6 +137,11 @@ class ParsedArgs: For example, if an argument `foo` was parsed, it can be accessed via `args.foo`.
Each such attribute (e.g. `args.foo`) is an instance of `ParsedArgData`.""" + RESERVED_ALIASES: frozenset[str] = frozenset({ + "all_exist", "any_exist", "dict", "existing", "get", "is_empty", "items", "keys", "missing", "values" + }) + """Alias names that are reserved and cannot be used as argument aliases.""" + def __init__(self, **parsed_args: ParsedArgData): for alias_name, parsed_arg_data in parsed_args.items(): setattr(self, alias_name, parsed_arg_data) @@ -231,6 +236,24 @@ def missing(self) -> Generator[tuple[str, ParsedArgData], None, None]: if not val.exists: yield (key, val) + @property + def is_empty(self) -> bool: + """Whether no argument was found and none have any values (not even defaults).""" + + return all(not val.exists and not val.values for val in cast(dict[str, ParsedArgData], vars(self)).values()) + + @property + def any_exist(self) -> bool: + """Whether at least one argument was explicitly found.""" + + return any(val.exists for val in cast(dict[str, ParsedArgData], vars(self)).values()) + + @property + def all_exist(self) -> bool: + """Whether all arguments were explicitly found.""" + + return all(val.exists for val in cast(dict[str, ParsedArgData], vars(self)).values()) + @mypyc_attr(native_class=False) class _ConsoleMeta(type): @@ -317,6 +340,7 @@ def get_args( arg_parse_configs: ArgParseConfigs, /, *, + skip: int = 0, flag_value_sep: Optional[str] = "=", allow_space_value: bool = True, ) -> ParsedArgs: @@ -325,6 +349,8 @@ def get_args( --------------------------------------------------------------------------------------------------------- * `arg_parse_configs` – A dictionary where each key is an alias name for the argument
and the key's value is the parsing configuration for that argument. + * `skip` – The number of leading command-line arguments to skip before parsing;
+ useful when the first N args are a command/subcommand and not relevant to the caller. * `flag_value_sep` – The character/s used to separate flags from their values;
pass `None` to disable separator-based syntax (e.g. `--flag=value`) entirely. * `allow_space_value` – Whether to allow space-separated flag values (e.g. `--flag value`)
@@ -381,11 +407,14 @@ def get_args( NOTE: When `allow_space_value` is `True`, a value that directly follows a flag (e.g. `--flag value`)
is consumed as that flag's value and is not available as a positional `"after"` argument.""" + if skip < 0: + raise ValueError(f"The 'skip' parameter must be a non-negative integer, got {skip!r}") if flag_value_sep is not None and not flag_value_sep: raise ValueError(f"The 'flag_value_sep' parameter must be a non-empty string or None, got {flag_value_sep!r}") return _ConsoleArgsParseHelper( arg_parse_configs, + skip=skip, flag_value_sep=flag_value_sep, allow_space_value=allow_space_value, )() @@ -1205,6 +1234,7 @@ def __init__( arg_parse_configs: ArgParseConfigs, /, *, + skip: int = 0, flag_value_sep: Optional[str], allow_space_value: bool = True, ): @@ -1216,7 +1246,7 @@ def __init__( self.positional_configs: dict[str, str] = {} self.arg_lookup: dict[str, str] = {} - self.args = _sys.argv[1:] + self.args = _sys.argv[1 + skip:] self.args_len = len(self.args) self.pos_before_configured = False self.pos_after_configured = False @@ -1238,6 +1268,12 @@ def parse_arg_configs(self) -> None: if not alias.isidentifier(): raise ValueError(f"Invalid argument alias '{alias}'.\n" "Aliases must be valid Python identifiers.") + if alias in ParsedArgs.RESERVED_ALIASES: + raise ValueError( + f"Invalid argument alias '{alias}'.\n" + f"The following names are reserved and cannot be used as aliases:\n" + f"{', '.join(sorted(ParsedArgs.RESERVED_ALIASES))}" + ) # PARSE ARG CONFIG & BUILD FLAG LOOKUP FOR NON-POSITIONAL ARGS if (flags := self._parse_arg_config(alias, config)) is not None: diff --git a/tests/test_console.py b/tests/test_console.py index 58b63d0..c1741eb 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -328,6 +328,13 @@ def test_get_args_invalid_params(): with pytest.raises(ValueError, match=r"non-empty string or None, got"): Console.get_args({"arg": {"-a"}}, flag_value_sep="") + with pytest.raises(ValueError, match="non-negative integer"): + Console.get_args({"arg": {"-a"}}, skip=-1) + + for reserved in ParsedArgs.RESERVED_ALIASES: + with pytest.raises(ValueError, match=f"Invalid argument alias '{reserved}'"): + Console.get_args({reserved: {"-a"}}) + def test_get_args_custom_sep(monkeypatch: pytest.MonkeyPatch): """Test custom flag-value separator handling""" @@ -448,6 +455,122 @@ def test_get_args_mixed_dash_scenarios(monkeypatch: pytest.MonkeyPatch): } +def test_get_args_skip(monkeypatch: pytest.MonkeyPatch): + """Test that skip=N drops the first N argv entries before any parsing.""" + # WITH skip=1: argv[1] ('fc') IS SKIPPED, PARSING STARTS AT argv[2] + monkeypatch.setattr(sys, "argv", ["script.py", "fc", "hello", "world"]) + result = Console.get_args({"input": "before"}, skip=1) + assert result.input.exists is True + assert result.input.values == ["hello", "world"] + + # WITH skip=2: argv[1] AND argv[2] ARE SKIPPED, PARSING STARTS AT argv[3] + monkeypatch.setattr(sys, "argv", ["script.py", "sub", "cmd", "--flag=val"]) + result = Console.get_args({"flag": {"--flag"}}, skip=2) + assert result.flag.exists is True + assert result.flag.values == ["val"] + + # WITH skip EXCEEDING ARGV LENGTH: NO ARGS ARE PARSED + monkeypatch.setattr(sys, "argv", ["script.py", "only"]) + result = Console.get_args({"flag": {"--flag"}}, skip=5) + assert result.flag.exists is False + + # WITH skip=0 (DEFAULT): BEHAVIOUR IS UNCHANGED + monkeypatch.setattr(sys, "argv", ["script.py", "--flag=val"]) + result = Console.get_args({"flag": {"--flag"}}, skip=0) + assert result.flag.exists is True + assert result.flag.values == ["val"] + + +def test_parsed_args_is_empty(): + # ALL MISSING, NO VALUES: is_empty IS True + args = ParsedArgs( + a=ParsedArgData(exists=False, values=[], is_pos=False), + b=ParsedArgData(exists=False, values=[], is_pos=True), + ) + assert args.is_empty is True + + # ONE HAS A DEFAULT VALUE: is_empty IS False + args_with_default = ParsedArgs( + a=ParsedArgData(exists=False, values=["default"], is_pos=False), + b=ParsedArgData(exists=False, values=[], is_pos=False), + ) + assert args_with_default.is_empty is False + + # ONE EXISTS: is_empty IS False + args_with_existing = ParsedArgs( + a=ParsedArgData(exists=True, values=["val"], is_pos=False), + b=ParsedArgData(exists=False, values=[], is_pos=False), + ) + assert args_with_existing.is_empty is False + + # EMPTY ParsedArgs: is_empty IS True + assert ParsedArgs().is_empty is True + + +def test_parsed_args_any_exist(): + # NONE EXIST + args_none = ParsedArgs( + a=ParsedArgData(exists=False, values=[], is_pos=False), + b=ParsedArgData(exists=False, values=["default"], is_pos=False), + ) + assert args_none.any_exist is False + + # ONE EXISTS + args_one = ParsedArgs( + a=ParsedArgData(exists=True, values=["v"], is_pos=False), + b=ParsedArgData(exists=False, values=[], is_pos=False), + ) + assert args_one.any_exist is True + + # ALL EXIST + args_all = ParsedArgs( + a=ParsedArgData(exists=True, values=["v"], is_pos=False), + b=ParsedArgData(exists=True, values=[], is_pos=False), + ) + assert args_all.any_exist is True + + # EMPTY ParsedArgs: any_exist IS False + assert ParsedArgs().any_exist is False + + +def test_parsed_args_all_exist(): + # ALL EXIST + args_all = ParsedArgs( + a=ParsedArgData(exists=True, values=["v"], is_pos=False), + b=ParsedArgData(exists=True, values=[], is_pos=False), + ) + assert args_all.all_exist is True + + # ONE MISSING + args_partial = ParsedArgs( + a=ParsedArgData(exists=True, values=["v"], is_pos=False), + b=ParsedArgData(exists=False, values=[], is_pos=False), + ) + assert args_partial.all_exist is False + + # NONE EXIST + args_none = ParsedArgs( + a=ParsedArgData(exists=False, values=[], is_pos=False), + ) + assert args_none.all_exist is False + + # EMPTY ParsedArgs: all_exist IS True (vacuously) + assert ParsedArgs().all_exist is True + + +def test_parsed_args_properties_not_in_iter(): + """Properties is_empty, any_exist, all_exist must not appear when iterating.""" + args = ParsedArgs( + flag=ParsedArgData(exists=True, values=[], is_pos=False), + ) + keys = [k for k, _ in args] + assert "is_empty" not in keys + assert "any_exist" not in keys + assert "all_exist" not in keys + assert len(args) == 1 + + + def test_args_dunder_methods(): args = ParsedArgs( before=ParsedArgData(exists=True, values=["arg1", "arg2"], is_pos=True), From 2de8e093cf1cf224730d45e280121a2d736e002a Mon Sep 17 00:00:00 2001 From: XulbuX Date: Thu, 14 May 2026 22:40:16 +0200 Subject: [PATCH 09/18] Added new `unknown_flags` attr to `ParsedArgs` and changed the type of `ParsedArgData.values` and `ArgData.values` to `tuple` for immutability --- CHANGELOG.md | 1136 ++++++++++++++++++++------------------ src/xulbux/base/types.py | 2 +- src/xulbux/console.py | 138 +++-- tests/test_console.py | 178 +++--- 4 files changed, 778 insertions(+), 676 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb5ef8..ccd77bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,14 @@ -
- -
scroll to bottom 🠫
-
+ @@ -19,114 +19,122 @@ ## … `v1.9.8` -* Unified all error messages throughout the whole library, to always pass the given value if the error is caused by that value being invalid. -* Added a new param `allow_space_value` to `Console.get_args()` and made `flag_value_sep` optional, which allows you to specify whether flags should be able to receive their values with a space in between (*e.g.* `--flag value` instead of just `--flag=value`). -* Added a new `skip` param to `Console.get_args()`, which skips the first N command-line arguments before parsing; useful when the leading argv entries are a command/subcommand and not relevant to the caller. -* Added three new read-only properties to `ParsedArgs`: - - `is_empty` is true if no argument was found **and** none have any values (*not even defaults*). - - `any_exist` is true if at least one argument was explicitly found. - - `all_exist` is true if every argument was explicitly found. -* Added `ParsedArgs.RESERVED_ALIASES` – a `frozenset` of names that cannot be used as argument aliases. Passing a reserved name now raises a clear `ValueError`. -* Reformat all docstrings of the whole library. -* Improved the performance of `Console.log()` and `FormatCodes.to_ansi()` by restructuring the way they process the formatting and output. -* Improved the performance of `String.normalize_spaces()` by using `str.translate()` instead of multiple `str.replace()` calls. -* Improved the performance of `Data.remove_duplicates()` for lists and tuples: hashable items now deduplicate in O(n) using `dict.fromkeys()`, with an O(n²) equality-check fallback only for unhashable items (*lists, dicts, sets*). +* Unified all error messages throughout the whole library, to always pass the given value if the error is caused by that value being invalid. +* Added a new param `allow_space_value` to `Console.get_args()` and made `flag_value_sep` optional, which allows you to specify whether flags should be able to receive their values with a space in between (*e.g.* `--flag value` instead of just `--flag=value`). +* Added a new `skip` param to `Console.get_args()`, which skips the first N command-line arguments before parsing; useful when the leading argv entries are a command/subcommand and not relevant to the caller. +* Added three new read-only attributes to `ParsedArgs`: + - `is_empty` is true if no argument was found **and** none have any values (*not even defaults*). + - `any_exist` is true if at least one argument was explicitly found. + - `all_exist` is true if every argument was explicitly found. + - `unknown_flags` is a list of all the unknown flags that were found in the command-line arguments (*args that look like flags but are not defined in the config*). +* Added `ParsedArgs.RESERVED_ALIASES` – a `frozenset` of names that cannot be used as argument aliases. Passing a reserved name now raises a clear `ValueError`. +* Reformat all docstrings of the whole library. +* Improved the performance of `Console.log()` and `FormatCodes.to_ansi()` by restructuring the way they process the formatting and output. +* Improved the performance of `String.normalize_spaces()` by using `str.translate()` instead of multiple `str.replace()` calls. +* Improved the performance of `Data.remove_duplicates()` for lists and tuples: hashable items now deduplicate in O(n) using `dict.fromkeys()`, with an O(n²) equality-check fallback only for unhashable items (*lists, dicts, sets*). **BREAKING CHANGES:** -* Renamed `r`, `g`, `b` and `a` to `red`, `green`, `blue` and `alpha` everywhere in the library, to follow the no-single-letter-names convention. -* Renamed `h`, `s` and `l` to `hue`, `sat` and `light` everywhere in the library, to follow the no-single-letter-names convention. -* Renamed the `Console.w` and `Console.h` properties to `Console.width` and `Console.height`, to follow the no-single-letter-names convention. -* Removed the `background:` and `bright:` prefixes from the library, so now you can just use the `bg:` and `br:` ones, for consistency. -* Removed the `format_linebreaks` param from `Console.log()`, as the whole point of the `log()` method is to get a nicely formatted log message. + +* Renamed `r`, `g`, `b` and `a` to `red`, `green`, `blue` and `alpha` everywhere in the library, to follow the no-single-letter-names convention. +* Renamed `h`, `s` and `l` to `hue`, `sat` and `light` everywhere in the library, to follow the no-single-letter-names convention. +* Renamed the `Console.w` and `Console.h` properties to `Console.width` and `Console.height`, to follow the no-single-letter-names convention. +* Removed the `background:` and `bright:` prefixes from the library, so now you can just use the `bg:` and `br:` ones, for consistency. +* Removed the `format_linebreaks` param from `Console.log()`, as the whole point of the `log()` method is to get a nicely formatted log message. +* The `Console.get_args()` method no longer treats unknown flags as values but therefore saves them to the new `unknown_flags` property of the returned `ParsedArgs` object. +* Changed the type of `ParsedArgData.values` and `ArgData.values` from list[*str*] to tuple[*str*, ...], since the values of an argument should be immutable after parsing. ## 26.04.2026 `v1.9.7` -* Restructured CLI commands under a single `xulbux-lib` entry point: - - `xulbux-lib` shows library info. - - `xulbux-lib fc` (*new*) parses and renders a string's format codes as ANSI console output. -* Added `.get()` method to `ParsedArgData` for safe index access on parsed argument values. -* Added missing `__init__.py` files to the `base` and `cli` subpackages. -* Fixed `ModuleNotFoundError` caused by `mypyc` compiling `__init__.py` files, which broke subpackage imports. -* Simplified CI workflows to use `pip`'s build isolation instead of manually specifying build dependencies. -* Fixed a small bug in `ProgressBar`, where it would only overwrite and not actually clear the previous line. -* Added a new constant `ANSI.COLOR_VARIANTS_MAP`, which contains all possible color variants that can be used in formatting. -* Made it possible to also pass console default colors to `title_bg_color` in `Console.log()`, instead of only custom RGBA or HEXA colors. -* Added a new format key `link:…` to `FormatCodes`, which allows you to create hyperlinks in the console output with the syntax `[link:URL](display text)`. +* Restructured CLI commands under a single `xulbux-lib` entry point: + - `xulbux-lib` shows library info. + - `xulbux-lib fc` (*new*) parses and renders a string's format codes as ANSI console output. +* Added `.get()` method to `ParsedArgData` for safe index access on parsed argument values. +* Added missing `__init__.py` files to the `base` and `cli` subpackages. +* Fixed `ModuleNotFoundError` caused by `mypyc` compiling `__init__.py` files, which broke subpackage imports. +* Simplified CI workflows to use `pip`'s build isolation instead of manually specifying build dependencies. +* Fixed a small bug in `ProgressBar`, where it would only overwrite and not actually clear the previous line. +* Added a new constant `ANSI.COLOR_VARIANTS_MAP`, which contains all possible color variants that can be used in formatting. +* Made it possible to also pass console default colors to `title_bg_color` in `Console.log()`, instead of only custom RGBA or HEXA colors. +* Added a new format key `link:…` to `FormatCodes`, which allows you to create hyperlinks in the console output with the syntax `[link:URL](display text)`. **BREAKING CHANGES:** -* The `ANSI.COLOR_MAP` constant is now a set for better lookup performance, as the color order doesn't matter there. -* All `Console` methods that allow console default colors as input for their color params, now actually validate the given color, raising an error if it's not valid. -* The default for `box_bg_color` in `Console.log_box_filled()` is now the console foreground color (`None`) instead of `br:green`. + +* The `ANSI.COLOR_MAP` constant is now a set for better lookup performance, as the color order doesn't matter there. +* All `Console` methods that allow console default colors as input for their color params, now actually validate the given color, raising an error if it's not valid. +* The default for `box_bg_color` in `Console.log_box_filled()` is now the console foreground color (`None`) instead of `br:green`. ## 13.04.2026 `v1.9.6` -* The compiled version of the library now includes the type stub files (`.pyi`), so type checkers can properly check types. -* Made all type hints in the whole library way more strict and accurate. -* Removed leftover unnecessary runtime type-checks in several methods throughout the whole library. +* The compiled version of the library now includes the type stub files (`.pyi`), so type checkers can properly check types. +* Made all type hints in the whole library way more strict and accurate. +* Removed leftover unnecessary runtime type-checks in several methods throughout the whole library. **BREAKING CHANGES:** -* All methods that should use positional-only and/or keyword-only params, now actually enforce that by using the `/` and `*` syntax in the method definitions. -* Renamed the `Spinner` class from the `console` module to `Throbber`, since that name is closer to what it's actually used for. -* Changed the name of the TypeAlias `DataStructure` to `DataObj` because that name is shorter and more general. -* Changed both names `DataStructureTypes` and `IndexIterableTypes` to `DataObjTT` and `IndexIterableTT` respectively (`TT` *stands for type-tuple*). -* Made the return value of `String.single_char_repeats()` always be *`int`* and not *int* | *bool*. + +* All methods that should use positional-only and/or keyword-only params, now actually enforce that by using the `/` and `*` syntax in the method definitions. +* Renamed the `Spinner` class from the `console` module to `Throbber`, since that name is closer to what it's actually used for. +* Changed the name of the TypeAlias `DataStructure` to `DataObj` because that name is shorter and more general. +* Changed both names `DataStructureTypes` and `IndexIterableTypes` to `DataObjTT` and `IndexIterableTT` respectively (`TT` *stands for type-tuple*). +* Made the return value of `String.single_char_repeats()` always be *`int`* and not *int* | *bool*. ## 25.01.2026 `v1.9.5` -* Added a new class property `Console.encoding`, which returns the encoding used by the console (*e.g.* `utf-8`*,* `cp1252`*, …*). -* Added multiple new class properties to the `System` class: - - `is_linux` Whether the current OS is Linux or not. - - `is_mac` Whether the current OS is macOS or not. - - `is_unix` Whether the current OS is a Unix-like OS (Linux, macOS, BSD, …) or not. - - `hostname` The network hostname of the current machine. - - `username` The current user's username. - - `os_name` The name of the operating system (*e.g.* `Windows`*,* `Linux`*, …*). - - `os_version` The version of the operating system. - - `architecture` The CPU architecture (*e.g.* `x86_64`*,* `ARM`*, …*). - - `cpu_count` The number of CPU cores available. - - `python_version` The Python version string (*e.g.* `3.10.4`). -* Created a two new TypeAliases: - - `ArgParseConfig` Matches the command-line-parsing configuration of a single argument. - - `ArgParseConfigs` Matches the command-line-parsing configurations of multiple arguments, packed in a dictionary. -* Added a new attribute `flag` to the `ArgData` TypedDict and the `ArgResult` class, which contains the specific flag that was found or `None` for positional args. +* Added a new class property `Console.encoding`, which returns the encoding used by the console (*e.g.* `utf-8`*,* `cp1252`*, …*). +* Added multiple new class properties to the `System` class: + - `is_linux` Whether the current OS is Linux or not. + - `is_mac` Whether the current OS is macOS or not. + - `is_unix` Whether the current OS is a Unix-like OS (Linux, macOS, BSD, …) or not. + - `hostname` The network hostname of the current machine. + - `username` The current user's username. + - `os_name` The name of the operating system (*e.g.* `Windows`*,* `Linux`*, …*). + - `os_version` The version of the operating system. + - `architecture` The CPU architecture (*e.g.* `x86_64`*,* `ARM`*, …*). + - `cpu_count` The number of CPU cores available. + - `python_version` The Python version string (*e.g.* `3.10.4`). +* Created a two new TypeAliases: + - `ArgParseConfig` Matches the command-line-parsing configuration of a single argument. + - `ArgParseConfigs` Matches the command-line-parsing configurations of multiple arguments, packed in a dictionary. +* Added a new attribute `flag` to the `ArgData` TypedDict and the `ArgResult` class, which contains the specific flag that was found or `None` for positional args. **BREAKING CHANGES:** -* Rewrote `Console.get_args()` for a different parsing functionality: - - Flagged values are now too saved to lists, so now only the `values` attribute is used for all argument types. - - The results of parsed command-line arguments are also no longer differentiated between regular flagged arguments and positional `"before"`/`"after"` arguments. - - The param `allow_spaces` was removed, and therefore a new param `flag_value_sep` was added, which specifies the character/s used to separate flags from their values.
- This means, flags can new **only** receive values when the separator is present (*e.g.* `--flag=value` *or* `--flag = value`). -* Combined the custom TypedDict classes `ArgResultRegular` and `ArgResultPositional` into a single TypedDict class `ArgData`, which is now used for all parsed command-line arguments. -* Renamed the classes `Args` and `ArgResult` to `ParsedArgs` and `ParsedArgData`, to better describe their purpose. -* Renamed the attribute `is_positional` to `is_pos` everywhere, so its name isn't that long. + +* Rewrote `Console.get_args()` for a different parsing functionality: + - Flagged values are now too saved to lists, so now only the `values` attribute is used for all argument types. + - The results of parsed command-line arguments are also no longer differentiated between regular flagged arguments and positional `"before"`/`"after"` arguments. + - The param `allow_spaces` was removed, and therefore a new param `flag_value_sep` was added, which specifies the character/s used to separate flags from their values.
+ This means, flags can new **only** receive values when the separator is present (*e.g.* `--flag=value` *or* `--flag = value`). +* Combined the custom TypedDict classes `ArgResultRegular` and `ArgResultPositional` into a single TypedDict class `ArgData`, which is now used for all parsed command-line arguments. +* Renamed the classes `Args` and `ArgResult` to `ParsedArgs` and `ParsedArgData`, to better describe their purpose. +* Renamed the attribute `is_positional` to `is_pos` everywhere, so its name isn't that long. ## 06.01.2026 `v1.9.4` -* Added a new base module `base.decorators` which contains custom decorators used throughout the library. -* Made `mypy_extensions` an optional dependency by wrapping all uses of `mypy_extensions.mypyc_attr` in a custom decorator that acts as a no-op if `mypy_extensions` is not installed. -* The methods from the `env_path` module that modify the PATH environment variable, no longer sort all paths alphabetically, but keep the original order, to not mess with the user's intended PATH order. -* Added a new TypeAlias `PathsList` to the `base.types` module, which matches a list of paths as strings or `pathlib.Path` objects. +* Added a new base module `base.decorators` which contains custom decorators used throughout the library. +* Made `mypy_extensions` an optional dependency by wrapping all uses of `mypy_extensions.mypyc_attr` in a custom decorator that acts as a no-op if `mypy_extensions` is not installed. +* The methods from the `env_path` module that modify the PATH environment variable, no longer sort all paths alphabetically, but keep the original order, to not mess with the user's intended PATH order. +* Added a new TypeAlias `PathsList` to the `base.types` module, which matches a list of paths as strings or `pathlib.Path` objects. **BREAKING CHANGES:** -* Renamed the module `path` to `file_sys` and its main class `Path` to `FileSys`, so you can better use it alongside the built-in `pathlib.Path` class without always needing to import one of them under an alias. -* Renamed most `FileSys` methods to better describe their functionality: - - `Path.extend()` is now `FileSys.extend_path()` - - `Path.extend_or_make()` is now `FileSys.extend_or_make_path()` -* Renamed the param `use_closest_match` in `FileSys.extend_path()` and `FileSys.extend_or_make_path()` to `fuzzy_match`, since that name is more commonly used for that functionality. -* Updated all library methods that work with paths to accept `pathlib.Path` objects additionally to strings, as path inputs. -* Also, all library methods that return paths now return `pathlib.Path` objects instead of strings. + +* Renamed the module `path` to `file_sys` and its main class `Path` to `FileSys`, so you can better use it alongside the built-in `pathlib.Path` class without always needing to import one of them under an alias. +* Renamed most `FileSys` methods to better describe their functionality: + - `Path.extend()` is now `FileSys.extend_path()` + - `Path.extend_or_make()` is now `FileSys.extend_or_make_path()` +* Renamed the param `use_closest_match` in `FileSys.extend_path()` and `FileSys.extend_or_make_path()` to `fuzzy_match`, since that name is more commonly used for that functionality. +* Updated all library methods that work with paths to accept `pathlib.Path` objects additionally to strings, as path inputs. +* Also, all library methods that return paths now return `pathlib.Path` objects instead of strings. @@ -135,110 +143,115 @@ **𝓗𝓪𝓹𝓹𝔂 𝟚𝟘𝟚𝟞 🎉** -* Added a new method `Color.str_to_hsla()` to parse HSLA colors from strings. -* Changed the default syntax highlighting for `Data.to_str()` and therefore also `Data.print()` to use console default colors. -* Added the missing but needed dunder methods to the `Args` and `ArgResult` classes and the `rgba`, `hsla` and `hexa` color objects for better usability and type checking. -* Added three new methods to `Args`: - - `get()` returns the argument result for a given alias, or a default value if not found - - `existing()` yields only the existing arguments as tuples of `(alias, ArgResult)` - - `missing()` yields only the missing arguments as tuples of `(alias, ArgResult)` -* Added a new attribute `is_positional` to `ArgResult`, which indicates whether the argument is a positional argument or not. -* The `ArgResult` class now also has a `dict()` method, which returns the argument result as a dictionary. -* Added new properties `is_tty` and `supports_color` to the `Console` class, `home` to the `Path` class and `is_win` to the `System` class. -* Added the option to add format specifiers to the `{current}`, `{total}` and `{percentage}` placeholders in the `bar_format` and `limited_bar_format` of `ProgressBar`. -* Finally fixed the `C901 'Console.get_args' is too complex (39)` linting error by refactoring the method into its own helper class. -* Changed the string- and repr-representations of the `rgba` and `hsla` color objects and newly implemented it for the `Args` and `ArgResult` classes. -* Made internal, global constants, which's values never change, into `Final` constants for better type checking. -* The names of all internal classes and methods are all no longer prefixed with a double underscore (`__`), but a single underscore (`_`) instead. -* Changed all methods defined as `@staticmethod` to `@classmethod` where applicable, to improve inheritance capabilities. -* Adjusted the whole library's type hints to be way more strict and accurate, using `mypy` as static type checker. -* Change the class-property definitions to be defined via `metaclass` and using `@property` decorators, to make them compatible with `mypyc`. -* Unnest all the nested methods in the whole library for compatibility with `mypyc`. -* The library is now compiled using `mypyc` when installing, which makes it run significantly faster. Benchmarking results: - - Simple methods like data and color operations had a speed improvement of around 50%. - - Complex methods like console logging had a speed improvement of up to 230%! +* Added a new method `Color.str_to_hsla()` to parse HSLA colors from strings. +* Changed the default syntax highlighting for `Data.to_str()` and therefore also `Data.print()` to use console default colors. +* Added the missing but needed dunder methods to the `Args` and `ArgResult` classes and the `rgba`, `hsla` and `hexa` color objects for better usability and type checking. +* Added three new methods to `Args`: + - `get()` returns the argument result for a given alias, or a default value if not found + - `existing()` yields only the existing arguments as tuples of `(alias, ArgResult)` + - `missing()` yields only the missing arguments as tuples of `(alias, ArgResult)` +* Added a new attribute `is_positional` to `ArgResult`, which indicates whether the argument is a positional argument or not. +* The `ArgResult` class now also has a `dict()` method, which returns the argument result as a dictionary. +* Added new properties `is_tty` and `supports_color` to the `Console` class, `home` to the `Path` class and `is_win` to the `System` class. +* Added the option to add format specifiers to the `{current}`, `{total}` and `{percentage}` placeholders in the `bar_format` and `limited_bar_format` of `ProgressBar`. +* Finally fixed the `C901 'Console.get_args' is too complex (39)` linting error by refactoring the method into its own helper class. +* Changed the string- and repr-representations of the `rgba` and `hsla` color objects and newly implemented it for the `Args` and `ArgResult` classes. +* Made internal, global constants, which's values never change, into `Final` constants for better type checking. +* The names of all internal classes and methods are all no longer prefixed with a double underscore (`__`), but a single underscore (`_`) instead. +* Changed all methods defined as `@staticmethod` to `@classmethod` where applicable, to improve inheritance capabilities. +* Adjusted the whole library's type hints to be way more strict and accurate, using `mypy` as static type checker. +* Change the class-property definitions to be defined via `metaclass` and using `@property` decorators, to make them compatible with `mypyc`. +* Unnest all the nested methods in the whole library for compatibility with `mypyc`. +* The library is now compiled using `mypyc` when installing, which makes it run significantly faster. Benchmarking results: + - Simple methods like data and color operations had a speed improvement of around 50%. + - Complex methods like console logging had a speed improvement of up to 230%! **BREAKING CHANGES:** -* Renamed `Data.to_str()` to `Data.render()`, since that describes its functionality better (*especially with the syntax highlighting option*). -* Renamed the constant `ANSI.ESCAPED_CHAR` to `ANSI.CHAR_ESCAPED` for better consistency with the other constant names. -* Removed the general `Pattern` and `Match` type aliases from the `base.types` module (*they are pointless since you should always use a specific type and not "type1 OR typeB"*). -* Removed the `_` prefix from the param `_syntax_highlighting` in `Data.render()`, since it's no longer just for internal use. + +* Renamed `Data.to_str()` to `Data.render()`, since that describes its functionality better (*especially with the syntax highlighting option*). +* Renamed the constant `ANSI.ESCAPED_CHAR` to `ANSI.CHAR_ESCAPED` for better consistency with the other constant names. +* Removed the general `Pattern` and `Match` type aliases from the `base.types` module (*they are pointless since you should always use a specific type and not "type1 OR typeB"*). +* Removed the `_` prefix from the param `_syntax_highlighting` in `Data.render()`, since it's no longer just for internal use. ## 16.12.2025 `v1.9.2` -* Added a new class `LazyRegex` to the `regex` module, which is used to define regex patterns that are only compiled when they are used for the first time. -* Removed unnecessary character escaping in the precompiled regex patterns in the `console` module. -* Removed all the runtime type-checks that can also be checked using static type-checking tools, since you're supposed to use type checkers in modern python anyway, and to improve performance. -* Renamed the internal class method `FormatCodes.__config_console()` to `FormatCodes._config_console()` to make it callable, but still indicate that it's internal. -* Fixed a small bug where `Console.log_box_…()` would crash, when calling it without providing any `*values` (*content for inside the box*). +* Added a new class `LazyRegex` to the `regex` module, which is used to define regex patterns that are only compiled when they are used for the first time. +* Removed unnecessary character escaping in the precompiled regex patterns in the `console` module. +* Removed all the runtime type-checks that can also be checked using static type-checking tools, since you're supposed to use type checkers in modern python anyway, and to improve performance. +* Renamed the internal class method `FormatCodes.__config_console()` to `FormatCodes._config_console()` to make it callable, but still indicate that it's internal. +* Fixed a small bug where `Console.log_box_…()` would crash, when calling it without providing any `*values` (*content for inside the box*). **BREAKING CHANGES:** -* The arguments when calling `Console.get_args()` are no longer specified in a single dictionary, but now each argument is passed as a separate keyword argument.
- You can still use a dictionary just fine by simply unpacking it with `**`, like this: - ```python - Console.get_args(**{"arg": {"-a", "--arg"}}) - ``` -* Replaced the internal `_COMPILED` regex pattern dictionaries with `LazyRegex` objects so it won't compile all regex patterns on library import, but only when they are used for the first time, which improves the library's import time. -* Renamed the internal `_COMPILED` regex pattern dictionaries to `_PATTERNS` for better clarity. -* Removed the import of the `ProgressBar` class from the `__init__.py` file, since it's not an important main class that should be imported directly. -* Renamed the constant `CLR` to `CLI_COLORS` and the constant `HELP` to `CLI_HELP` in the `cli.help` module. -* Changed the default value of the `strip_spaces` param in `Regex.brackets()` from `True` to `False`, since this is more intuitive behavior. + +* The arguments when calling `Console.get_args()` are no longer specified in a single dictionary, but now each argument is passed as a separate keyword argument.
+ You can still use a dictionary just fine by simply unpacking it with `**`, like this: + ```python + Console.get_args(**{"arg": {"-a", "--arg"}}) + ``` +* Replaced the internal `_COMPILED` regex pattern dictionaries with `LazyRegex` objects so it won't compile all regex patterns on library import, but only when they are used for the first time, which improves the library's import time. +* Renamed the internal `_COMPILED` regex pattern dictionaries to `_PATTERNS` for better clarity. +* Removed the import of the `ProgressBar` class from the `__init__.py` file, since it's not an important main class that should be imported directly. +* Renamed the constant `CLR` to `CLI_COLORS` and the constant `HELP` to `CLI_HELP` in the `cli.help` module. +* Changed the default value of the `strip_spaces` param in `Regex.brackets()` from `True` to `False`, since this is more intuitive behavior. ## 26.11.2025 `v1.9.1` -* Unified the module and class docstring styles throughout the whole library. -* Moved the Protocol `ProgressUpdater` from the `console` module to the `types` module. -* Added throttling to the `ProgressBar` update methods to impact the actual process' performance as little as possible. -* Added a new class `Spinner` to the `console` module, which is used to display a spinner animation in the console during an ongoing process. +* Unified the module and class docstring styles throughout the whole library. +* Moved the Protocol `ProgressUpdater` from the `console` module to the `types` module. +* Added throttling to the `ProgressBar` update methods to impact the actual process' performance as little as possible. +* Added a new class `Spinner` to the `console` module, which is used to display a spinner animation in the console during an ongoing process. **BREAKING CHANGES:** -* Made the value input into the params `bar_format` and `limited_bar_format` of `ProgressBar` be a list/tuple of strings instead of a single string, so the user can define multiple formats for different console widths. -* Added a new param sep: *str* = " " to the `ProgressBar` class, which is used to join multiple bar-format strings. -* Renamed the class property `Console.wh` to `Console.size`, since it describes the property better. -* Renamed the class property `Console.usr` to `Console.user`, since it describes the property better. -* Added missing type checking to methods in the `path` module. + +* Made the value input into the params `bar_format` and `limited_bar_format` of `ProgressBar` be a list/tuple of strings instead of a single string, so the user can define multiple formats for different console widths. +* Added a new param sep: *str* = " " to the `ProgressBar` class, which is used to join multiple bar-format strings. +* Renamed the class property `Console.wh` to `Console.size`, since it describes the property better. +* Renamed the class property `Console.usr` to `Console.user`, since it describes the property better. +* Added missing type checking to methods in the `path` module. ## 21.11.2025 `v1.9.0` Big Update 🚀 -* Standardized the docstrings for all public methods in the whole library to use the same style and structure. -* Replaced left over single quotes with double quotes for consistency. -* Fixed a bug inside `Data.remove_empty_items()`, where types other than strings where passed to `String.is_empty()`, which caused an exception. -* Refactored/reformatted the code of the whole library, to introduce more clear code structure with more room to breathe. -* Made the really complex regex patterns in the `Regex` class all multi-line for better readability. -* Added a new internal method `Regex._clean()`, which is used to clean up the regex patterns, defined as multi-line strings. -* Moved custom exception classes to their own file `base/exceptions.py`, so the user can easily import them all from the same place. -* Moved custom types to their own file `base/types.py`, so the user can easily import them all from the same place. -* Removed unnecessary duplicate code in several methods throughout the library. -* Introduced some minor performance improvements in a few methods, that might be called very often in a short time span. -* Added a small description to the docstrings of all modules and their main classes. +* Standardized the docstrings for all public methods in the whole library to use the same style and structure. +* Replaced left over single quotes with double quotes for consistency. +* Fixed a bug inside `Data.remove_empty_items()`, where types other than strings where passed to `String.is_empty()`, which caused an exception. +* Refactored/reformatted the code of the whole library, to introduce more clear code structure with more room to breathe. +* Made the really complex regex patterns in the `Regex` class all multi-line for better readability. +* Added a new internal method `Regex._clean()`, which is used to clean up the regex patterns, defined as multi-line strings. +* Moved custom exception classes to their own file `base/exceptions.py`, so the user can easily import them all from the same place. +* Moved custom types to their own file `base/types.py`, so the user can easily import them all from the same place. +* Removed unnecessary duplicate code in several methods throughout the library. +* Introduced some minor performance improvements in a few methods, that might be called very often in a short time span. +* Added a small description to the docstrings of all modules and their main classes. **BREAKING CHANGES:** -* The `find_args` param from the method `Console.get_args()` now only accepts sets for the flags instead of lists/tuples, since the order of flags doesn't matter and sets have better performance for lookups. -* Added missing type checking to all public methods in the whole library, so now they will all throw errors if the params aren't of the expected type. -* Removed the second definitions of constants in with lowercase names in the `ANSI` class inside the `consts` module, so now you can only access them with their uppercase names (*e.g.* `ANSI.CHAR` instead of `ANSI.char`). + +* The `find_args` param from the method `Console.get_args()` now only accepts sets for the flags instead of lists/tuples, since the order of flags doesn't matter and sets have better performance for lookups. +* Added missing type checking to all public methods in the whole library, so now they will all throw errors if the params aren't of the expected type. +* Removed the second definitions of constants in with lowercase names in the `ANSI` class inside the `consts` module, so now you can only access them with their uppercase names (*e.g.* `ANSI.CHAR` instead of `ANSI.char`). ## 14.11.2025 `v1.8.5` -* Made the help command `xulbux-help` new primarily use console default colors so it fits the user's console theme. -* Changed the default `box_bg_color` in `Console.log_box_filled()` from `green` to `br:green`. -* Fixed a bug in all methods of `FormatCodes`, where as soon as you used more than a single modifier format code (*e.g.* `[ll]` *or* `[++]`), it was treated as invalid and ignored. -* Added a new method `FormatCodes.escape()` which will escape all valid formatting codes in a string. -* Again refactored the whole `CHANGELOG.md` to use actual sentences and add a `BREAKING CHANGES` section to more clearly highlight breaking changes. +* Made the help command `xulbux-help` new primarily use console default colors so it fits the user's console theme. +* Changed the default `box_bg_color` in `Console.log_box_filled()` from `green` to `br:green`. +* Fixed a bug in all methods of `FormatCodes`, where as soon as you used more than a single modifier format code (*e.g.* `[ll]` *or* `[++]`), it was treated as invalid and ignored. +* Added a new method `FormatCodes.escape()` which will escape all valid formatting codes in a string. +* Again refactored the whole `CHANGELOG.md` to use actual sentences and add a `BREAKING CHANGES` section to more clearly highlight breaking changes. **BREAKING CHANGES:** -* Removed the `*c` and `*color` formatting codes, since the user should just use the code `default` to achieve the exact same instead. -* Renamed the method `FormatCodes.remove_formatting()` to `FormatCodes.remove()`. + +* Removed the `*c` and `*color` formatting codes, since the user should just use the code `default` to achieve the exact same instead. +* Renamed the method `FormatCodes.remove_formatting()` to `FormatCodes.remove()`. @@ -247,348 +260,362 @@ **𝓢𝓲𝓷𝓰𝓵𝓮𝓼 𝓓𝓪𝔂 🥇😉** -* Adjusted `Regex.hsla_str()` to not include optional degree (`°`) and percent (`%`) symbols in the captured groups. -* Fixed that `Regex.hexa_str()` couldn't match HEXA colors anywhere inside a string, but only if the whole string was just the HEXA color. -* Added `_ArgResultRegular` and `_ArgResultPositional` TypedDict classes for better type hints in `Args.dict()` and `Args.items()` methods. +* Adjusted `Regex.hsla_str()` to not include optional degree (`°`) and percent (`%`) symbols in the captured groups. +* Fixed that `Regex.hexa_str()` couldn't match HEXA colors anywhere inside a string, but only if the whole string was just the HEXA color. +* Added `_ArgResultRegular` and `_ArgResultPositional` TypedDict classes for better type hints in `Args.dict()` and `Args.items()` methods. **BREAKING CHANGES:** -* The method `Console.get_args()` no longer tries to convert found arg values to their respective types, since that caused too many unwanted, wrong type conversions. -* `ArgResult` now has separate properties for different argument types to improve type safety and eliminate the need for type casting when accessing argument values: - - value: Optional[*str*] for regular flagged arguments - - values: list[*str*] for positional `"before"`/`"after"` arguments + +* The method `Console.get_args()` no longer tries to convert found arg values to their respective types, since that caused too many unwanted, wrong type conversions. +* `ArgResult` now has separate properties for different argument types to improve type safety and eliminate the need for type casting when accessing argument values: + - value: Optional[*str*] for regular flagged arguments + - values: list[*str*] for positional `"before"`/`"after"` arguments ## 08.10.2025 `v1.8.3` -* Adjusted the look of the prompts and inputs of the `System.check_libs()` method. -* Added a new param to `System.check_libs()`:
- missing_libs_msgs: tuple[str, str] = (…) two messages: The first one is displayed when missing libraries are found. The second one is the confirmation message before installing missing libraries. -* Adjusted error messages throughout the whole library to all be structured about the same. -* Fixed a small bug in `FormatCodes.__config_console()`, where it would cause an exception, because it tried to configure Windows specific console settings on non-Windows systems. -* The `Console.get_args()` method will now treat everything as values (*even if it starts with* `-` *or* `--`) unless it's specified in the `find_args` param. -* Added two new params to all the `Console.log()` presets:
- - exit_code: *int* = 0 the exit code to use if `exit` is true - - reset_ansi: *bool* = True whether to reset all ANSI formatting after pausing/exiting or not -* Made the type hints and value checks for `Console.get_args()` more strict. -* You can now insert horizontal rules inside a `Console.log_box_bordered()` by putting `{hr}` in the text. -* Made it possible to also update the title within a `ProgressBar.progress_context()` using the returned callable with the new kwarg `label`. +* Adjusted the look of the prompts and inputs of the `System.check_libs()` method. +* Added a new param to `System.check_libs()`:
+ missing_libs_msgs: tuple[str, str] = (…) two messages: The first one is displayed when missing libraries are found. The second one is the confirmation message before installing missing libraries. +* Adjusted error messages throughout the whole library to all be structured about the same. +* Fixed a small bug in `FormatCodes.__config_console()`, where it would cause an exception, because it tried to configure Windows specific console settings on non-Windows systems. +* The `Console.get_args()` method will now treat everything as values (*even if it starts with* `-` *or* `--`) unless it's specified in the `find_args` param. +* Added two new params to all the `Console.log()` presets:
+ - exit_code: *int* = 0 the exit code to use if `exit` is true + - reset_ansi: *bool* = True whether to reset all ANSI formatting after pausing/exiting or not +* Made the type hints and value checks for `Console.get_args()` more strict. +* You can now insert horizontal rules inside a `Console.log_box_bordered()` by putting `{hr}` in the text. +* Made it possible to also update the title within a `ProgressBar.progress_context()` using the returned callable with the new kwarg `label`. **BREAKING CHANGES:** -* Reordered the params of `Console.pause_exit()` to be more logical. + +* Reordered the params of `Console.pause_exit()` to be more logical. ## 11.09.2025 `v1.8.2` -* The client command `xulbux-help` now tells you that there's a newer version of the library available, if you're not using the latest version. -* Added two new params to `Console.input()`: - - default_val: Optional[T] = None the default value to return if the input is empty - - output_type: type[T] = *str* the type (*class*) to convert the input to before returning it -* Added a new class to `ProgressBar` to the `console` module. -* Made small performance improvement in `FormatCodes.to_ansi()`. -* Added missing docstrings to several public class variables. -* Added the missing tests for methods in the `console` module. -* Added test for the last two modules that didn't have test until now: `regex` and `system`. +* The client command `xulbux-help` now tells you that there's a newer version of the library available, if you're not using the latest version. +* Added two new params to `Console.input()`: + - default_val: Optional[T] = None the default value to return if the input is empty + - output_type: type[T] = *str* the type (*class*) to convert the input to before returning it +* Added a new class to `ProgressBar` to the `console` module. +* Made small performance improvement in `FormatCodes.to_ansi()`. +* Added missing docstrings to several public class variables. +* Added the missing tests for methods in the `console` module. +* Added test for the last two modules that didn't have test until now: `regex` and `system`. **BREAKING CHANGES:** -* Spaces between a format code and the auto-reset-brackets are no longer allowed, so `[red]␣(text)` will not be automatically reset and output as `␣(text)`. + +* Spaces between a format code and the auto-reset-brackets are no longer allowed, so `[red]␣(text)` will not be automatically reset and output as `␣(text)`. ## 20.08.2025 `v1.8.1` -* **HOTFIX: Fixed a critical bug which caused the package to not install properly and make the whole library not work.** -* Fixed several small bugs regarding the tabs and text wrapping inside `Console.log()`. -* Added two new params to `Console.log()`: - - title_px: *int* = 1 the horizontal padding (*in chars*) to the title (*if* `title_bg_color` *is set*) - - title_mx: *int* = 2 the horizontal margin (*in chars*) to the title +* **HOTFIX: Fixed a critical bug which caused the package to not install properly and make the whole library not work.** +* Fixed several small bugs regarding the tabs and text wrapping inside `Console.log()`. +* Added two new params to `Console.log()`: + - title_px: *int* = 1 the horizontal padding (*in chars*) to the title (*if* `title_bg_color` *is set*) + - title_mx: *int* = 2 the horizontal margin (*in chars*) to the title **BREAKING CHANGES:** -* Renamed the param `_console_tabsize` form the method `Console.log()` to `tab_size`, and it will now just set the size for the log directly instead of specifying, what the console's tab size is. + +* Renamed the param `_console_tabsize` form the method `Console.log()` to `tab_size`, and it will now just set the size for the log directly instead of specifying, what the console's tab size is. ## 28.08.2025 `v1.8.0` **⚠️ This release is broken!** -* New options for the param `find_args` from the method `Console.get_args()`:
- Previously you could only input a dictionary with items like `"alias_name": ["-f", "--flag"]` that specify an arg's alias and the flags that correspond to it.
- New, instead of flags, you can also once use the literal `"before"` and once `"after"`, which corresponds to all non-flagged values before or after all flagged values. -* Changed the default `default_color` for all `Console` class input methods to `None`. -* Now `prompt` from `Console.pause_exit()` also supports custom formatting codes and the method new pauses per default (*without exiting*). +* New options for the param `find_args` from the method `Console.get_args()`:
+ Previously you could only input a dictionary with items like `"alias_name": ["-f", "--flag"]` that specify an arg's alias and the flags that correspond to it.
+ New, instead of flags, you can also once use the literal `"before"` and once `"after"`, which corresponds to all non-flagged values before or after all flagged values. +* Changed the default `default_color` for all `Console` class input methods to `None`. +* Now `prompt` from `Console.pause_exit()` also supports custom formatting codes and the method new pauses per default (*without exiting*). **BREAKING CHANGES:** -* The method `Console.restricted_input()` now returns an empty string instead of `None` if the user didn't input anything. -* Completely rewrote `Console.restricted_input()`, so now it's actually usable, and renamed it to just `Console.input()`. -* Removed method `Console.pwd_input()`, since you can now simply use `Console.input(mask_char="*")` instead, which does the exact same thing. -* Removed the CLI command `xx-help`, since it was redundant because there's already the CLI command `xulbux-help`. -* Renamed the previously internal module `_consts_` to `consts` and made it accessible via `from xulbux.base.consts import …`, since you should be able to use library constants without them being «internal». -* Removed the `xx_` from all the library modules since it's redundant, and without it the imports look more professional and cleaner. -* The constants form inside the `consts` module are now all uppercase (*except the class methods*), to make clear that they're constants. -* Removed the wildcard imports from the `__init__.py` file, so now you can only access the main classes directly with `from xulbux import …` and for the rest you have to import the specific module first. + +* The method `Console.restricted_input()` now returns an empty string instead of `None` if the user didn't input anything. +* Completely rewrote `Console.restricted_input()`, so now it's actually usable, and renamed it to just `Console.input()`. +* Removed method `Console.pwd_input()`, since you can now simply use `Console.input(mask_char="*")` instead, which does the exact same thing. +* Removed the CLI command `xx-help`, since it was redundant because there's already the CLI command `xulbux-help`. +* Renamed the previously internal module `_consts_` to `consts` and made it accessible via `from xulbux.base.consts import …`, since you should be able to use library constants without them being «internal». +* Removed the `xx_` from all the library modules since it's redundant, and without it the imports look more professional and cleaner. +* The constants form inside the `consts` module are now all uppercase (*except the class methods*), to make clear that they're constants. +* Removed the wildcard imports from the `__init__.py` file, so now you can only access the main classes directly with `from xulbux import …` and for the rest you have to import the specific module first. ## 29.07.2025 `v1.7.3` -* Added a new param to the methods `Console.log_box_filled()` and `Console.log_box_bordered()`:
- indent: *int* = 0 the indentation of the box (in chars) -* Fixed a bug in `Console.log_box_filled()` where the box background color would sometimes not stop at the box's edge, but would continue to the end of the console line. +* Added a new param to the methods `Console.log_box_filled()` and `Console.log_box_bordered()`:
+ indent: *int* = 0 the indentation of the box (in chars) +* Fixed a bug in `Console.log_box_filled()` where the box background color would sometimes not stop at the box's edge, but would continue to the end of the console line. **BREAKING CHANGES:** -* Removed the param `title_bg_color` from the `Console.log()` preset methods, since that is part of the preset and doesn't need to be changed by the user. + +* Removed the param `title_bg_color` from the `Console.log()` preset methods, since that is part of the preset and doesn't need to be changed by the user. ## 17.06.2025 `v1.7.2` -* The `Console.w`, `Console.h` and `Console.wh` class properties now return a default size if there is no console, instead of throwing an error. -* It wasn't actually possible to use default console-colors (*e.g.* `"red"`, `"green"`, …) for the color params in `Console.log()` so that option was completely removed again. -* Upgraded the speed of `FormatCodes.to_ansi()` by adding the internal ability to skip the `default_color` validation. -* Fixed type hints for the whole library. -* Fixed a small bug in `Console.pause_exit()`, where the key, pressed to unpause wasn't suppressed, so it was written into the next console input after unpausing. +* The `Console.w`, `Console.h` and `Console.wh` class properties now return a default size if there is no console, instead of throwing an error. +* It wasn't actually possible to use default console-colors (*e.g.* `"red"`, `"green"`, …) for the color params in `Console.log()` so that option was completely removed again. +* Upgraded the speed of `FormatCodes.to_ansi()` by adding the internal ability to skip the `default_color` validation. +* Fixed type hints for the whole library. +* Fixed a small bug in `Console.pause_exit()`, where the key, pressed to unpause wasn't suppressed, so it was written into the next console input after unpausing. ## 11.06.2025 `v1.7.1` -* Fixed an issue with the `Color.is_valid_…()` and `Color.is_valid()` methods, where you were not able to input any color without a type mismatch. -* Added a new method `Console.log_box_bordered()`, which does the same as `Console.log_box_filled()`, but with a border instead of a background color. -* The module `xx_format_codes` now treats the `[*]` to-default-color-reset as a normal full-reset, when no `default_color` is set, instead of just counting it as an invalid format code. -* Fixed bug where entering a color as HEX integer in the color params of the methods `Console.log()`, `Console.log_box_filled()` and `Console.log_box_bordered()` would not work, because it was not properly converted to a format code. -* You can now use default console colors (*e.g.* `"red"`, `"green"`, …) for the color params in `Console.log()`. -* The methods `Console.log_box_filled()` and `Console.log_box_bordered()` no longer right-strip spaces, so you can make multiple log boxes the same width, by adding spaces to the end of the text. +* Fixed an issue with the `Color.is_valid_…()` and `Color.is_valid()` methods, where you were not able to input any color without a type mismatch. +* Added a new method `Console.log_box_bordered()`, which does the same as `Console.log_box_filled()`, but with a border instead of a background color. +* The module `xx_format_codes` now treats the `[*]` to-default-color-reset as a normal full-reset, when no `default_color` is set, instead of just counting it as an invalid format code. +* Fixed bug where entering a color as HEX integer in the color params of the methods `Console.log()`, `Console.log_box_filled()` and `Console.log_box_bordered()` would not work, because it was not properly converted to a format code. +* You can now use default console colors (*e.g.* `"red"`, `"green"`, …) for the color params in `Console.log()`. +* The methods `Console.log_box_filled()` and `Console.log_box_bordered()` no longer right-strip spaces, so you can make multiple log boxes the same width, by adding spaces to the end of the text. **BREAKING CHANGES:** -* Renamed the method `Console.log_box()` to `Console.log_box_filled()`. + +* Renamed the method `Console.log_box()` to `Console.log_box_filled()`. ## 28.05.2025 `v1.7.0` -* Fixed a small bug in `Console.log()` where empty linebreaks where removed. -* Corrected and added missing type hints for the whole library. -* Fixed possibly unbound variables for the whole library. -* Updated the client command `xulbux-help`. +* Fixed a small bug in `Console.log()` where empty linebreaks where removed. +* Corrected and added missing type hints for the whole library. +* Fixed possibly unbound variables for the whole library. +* Updated the client command `xulbux-help`. ## 30.04.2025 `v1.6.9` -* Added a new param to the methods `FormatCodes.remove_ansi()` and `FormatCodes.remove_formatting()`:
- _ignore_linebreaks: *bool* = False whether to include linebreaks in the removal positions or not -* Added a new param to method `Color.luminance()` and to the `.grayscale()` method of all color types: - method: *str* = "wcag2" the luminance calculation method to use -* Added a new param to the method `File.rename_extension()`: - full_extension: *bool* = False whether to treat everything behind the first `.` as the extension or everything behind the last `.` -* Fixed a small bug in `Console.log_box()` where the leading spaces where removed from the box content. -* You can now assign default values to args in `Console.get_args()`. +* Added a new param to the methods `FormatCodes.remove_ansi()` and `FormatCodes.remove_formatting()`:
+ _ignore_linebreaks: *bool* = False whether to include linebreaks in the removal positions or not +* Added a new param to method `Color.luminance()` and to the `.grayscale()` method of all color types: + method: *str* = "wcag2" the luminance calculation method to use +* Added a new param to the method `File.rename_extension()`: + full_extension: *bool* = False whether to treat everything behind the first `.` as the extension or everything behind the last `.` +* Fixed a small bug in `Console.log_box()` where the leading spaces where removed from the box content. +* You can now assign default values to args in `Console.get_args()`. **BREAKING CHANGES:** -* Changed the params in `Json.create()`: - - new_file: *str* = "config" is now the first param and content: *dict* the second one - - new_file: *str* = "config" is now called json_file: *str* with no default value -* The methods `Json.update()` and `Data.set_value_by_path_id()` now intake a dictionary as `update_values` param, instead of a list of strings. -* Renamed param `correct_path` in `Path.extend()` and param `correct_paths` in `File.extend_or_make_path()` to `use_closest_match`, since this name describes its functionality better. -* Moved the method `extend_or_make_path()` from the `xx_file` module to the `xx_path` module and renamed it to `extend_or_make()`. + +* Changed the params in `Json.create()`: + - new_file: *str* = "config" is now the first param and content: *dict* the second one + - new_file: *str* = "config" is now called json_file: *str* with no default value +* The methods `Json.update()` and `Data.set_value_by_path_id()` now intake a dictionary as `update_values` param, instead of a list of strings. +* Renamed param `correct_path` in `Path.extend()` and param `correct_paths` in `File.extend_or_make_path()` to `use_closest_match`, since this name describes its functionality better. +* Moved the method `extend_or_make_path()` from the `xx_file` module to the `xx_path` module and renamed it to `extend_or_make()`. ## 18.03.2025 `v1.6.8` -* Made it possible to escape formatting codes by putting a slash (`/` *or* `\\`) at the beginning inside the brackets (*e.g.* `[/red]`). -* New methods for `Args` (*the returned object from* `Console.get_args()`): - - The `len()` function can now be used on `Args` (*the returned object from* `Console.get_args()`). - - The `Args` object now also has the dict like methods `.keys()`, `.values()` and `.items()`. - - You can also get the args as a dict with the `.dict()` method. - - You can now use the `in` operator on `Args`. -* New methods for `ArgResult` (*a single arg-object from inside `Args`): - - You can now use the `bool()` function on `ArgResult` to directly see if the arg exists. -* The methods `FormatCodes.remove_ansi()` and `FormatCodes.remove_formatting()` now have a second param get_removals: *bool* = False:
- If this param is true additionally to the cleaned string, a list of tuples will be returned, where tuple contains the position of the removed formatting/ansi code and the removed code. -* Fixed a bug in the line wrapping in all logging methods from the `xx_console` module. -* Added a new param to the method `Console.get_args()`:
- allow_spaces: *bool* = False whether to take spaces as separator of arg values or as part of an arg value +* Made it possible to escape formatting codes by putting a slash (`/` *or* `\\`) at the beginning inside the brackets (*e.g.* `[/red]`). +* New methods for `Args` (*the returned object from* `Console.get_args()`): + - The `len()` function can now be used on `Args` (*the returned object from* `Console.get_args()`). + - The `Args` object now also has the dict like methods `.keys()`, `.values()` and `.items()`. + - You can also get the args as a dict with the `.dict()` method. + - You can now use the `in` operator on `Args`. +* New methods for `ArgResult` (*a single arg-object from inside `Args`): + - You can now use the `bool()` function on `ArgResult` to directly see if the arg exists. +* The methods `FormatCodes.remove_ansi()` and `FormatCodes.remove_formatting()` now have a second param get_removals: *bool* = False:
+ If this param is true additionally to the cleaned string, a list of tuples will be returned, where tuple contains the position of the removed formatting/ansi code and the removed code. +* Fixed a bug in the line wrapping in all logging methods from the `xx_console` module. +* Added a new param to the method `Console.get_args()`:
+ allow_spaces: *bool* = False whether to take spaces as separator of arg values or as part of an arg value ## 26.02.2025 `v1.6.7` -* Made the staticmethod `System.is_elevated()` into a class property, which now can be accessed as `System.is_elevated`. -* The method `File.create()` now throws a custom `SameContentFileExistsError` exception if a file with the same name and content already exists. -* Added a bunch more docstrings to class properties and library constants. +* Made the staticmethod `System.is_elevated()` into a class property, which now can be accessed as `System.is_elevated`. +* The method `File.create()` now throws a custom `SameContentFileExistsError` exception if a file with the same name and content already exists. +* Added a bunch more docstrings to class properties and library constants. **BREAKING CHANGES:** -* Restructured the object returned from `Console.get_args()`:
- Before, you accessed an arg's result with `args[""]["value"]` and `args[""]["exists"]`.
- Now, you can directly access the result with `args..value` and `args..exists`. -* Made the method Path.get(*cwd*=True) or Path.get(*base_dir*=True) into two class properties, which now can be accessed as `Path.cwd` and `Path.script_dir`. + +* Restructured the object returned from `Console.get_args()`:
+ Before, you accessed an arg's result with `args[""]["value"]` and `args[""]["exists"]`.
+ Now, you can directly access the result with `args..value` and `args..exists`. +* Made the method Path.get(*cwd*=True) or Path.get(*base_dir*=True) into two class properties, which now can be accessed as `Path.cwd` and `Path.script_dir`. ## 17.02.2025 `v1.6.6` -* Added a new method `Console.multiline_input()`. -* Added two new params to the method `Console.log_box()`:
- - w_padding: *int* = 2 the horizontal padding (*in chars*) to the box content
- - w_full: *bool* = False whether to make the box be the full console width or not -* Fixed a small bug in `Data.print()` where two params passed to `Data.to_str()` where swapped, which caused an error. -* The method `Data.print()` now per default syntax highlights the console output:
- The syntax highlighting colors and styles can now be customized via the new param syntax_highlighting: dict[*str*, *str*] = {…}. -* Added two new methods `Data.serialize_bytes()` and `Data.deserialize_bytes()`. -* Made the method `String.to_type()` be able to also interpret and convert large complex structures. -* Added the new parameter strip_spaces: *bool* = True to the method `Regex.brackets()` which makes it possible to not ignore spaces around the content inside the brackets. -* Adjusted the `Console.log_box()` method, so the box background can't be reset to nothing any more. -* Added a new param to `Console.log()` (*and therefore also to every* `Console.log()` *preset*):
- format_linebreaks: *bool* = True when true, indents the text after every linebreak to the level of the previous text +* Added a new method `Console.multiline_input()`. +* Added two new params to the method `Console.log_box()`:
+ - w_padding: *int* = 2 the horizontal padding (*in chars*) to the box content
+ - w_full: *bool* = False whether to make the box be the full console width or not +* Fixed a small bug in `Data.print()` where two params passed to `Data.to_str()` where swapped, which caused an error. +* The method `Data.print()` now per default syntax highlights the console output:
+ The syntax highlighting colors and styles can now be customized via the new param syntax_highlighting: dict[*str*, *str*] = {…}. +* Added two new methods `Data.serialize_bytes()` and `Data.deserialize_bytes()`. +* Made the method `String.to_type()` be able to also interpret and convert large complex structures. +* Added the new parameter strip_spaces: *bool* = True to the method `Regex.brackets()` which makes it possible to not ignore spaces around the content inside the brackets. +* Adjusted the `Console.log_box()` method, so the box background can't be reset to nothing any more. +* Added a new param to `Console.log()` (*and therefore also to every* `Console.log()` *preset*):
+ format_linebreaks: *bool* = True when true, indents the text after every linebreak to the level of the previous text **BREAKING CHANGES:** -* Restructured the `_consts_` library constants to use `@dataclass` classes (*and simpler structured classes*) as much as possible. -* Renamed the `DEFAULT` class from the `_consts_` to `COLOR`, whose colors are now directly accessible as variables (*e.g.* `COLOR.red`) and not through dictionary keys. -* Changed the methods `Console.w()`, `Console.h()`, `Console.wh()` and `Console.user()` to modern class properties instead:
- `Console.w` current console columns (*in text characters*)
- `Console.h` current console lines
- `Console.wh` a tuple with the console size as (columns, lines)
- `Console.usr` the current username + +* Restructured the `_consts_` library constants to use `@dataclass` classes (*and simpler structured classes*) as much as possible. +* Renamed the `DEFAULT` class from the `_consts_` to `COLOR`, whose colors are now directly accessible as variables (*e.g.* `COLOR.red`) and not through dictionary keys. +* Changed the methods `Console.w()`, `Console.h()`, `Console.wh()` and `Console.user()` to modern class properties instead:
+ `Console.w` current console columns (*in text characters*)
+ `Console.h` current console lines
+ `Console.wh` a tuple with the console size as (columns, lines)
+ `Console.usr` the current username ## 29.01.2025 `v1.6.5` -* Now the method `FormatCodes.to_ansi()` automatically converts the param `string` to a *`str`* if it isn't one already. -* Added a new method `FormatCodes.remove_codes()`. -* Added a new method `FormatCodes.remove_ansi()`. -* Added a new method `Console.log_box()`. -* Changed the default values of two params in the `Console.log()` method and every log preset:
- - start: *str* = "\n" changed to start: *str* = ""
- - end: *str* = "\n\n" changed to end: *str* = "\n" -* Added the params start: *str* = "", end: *str* = "\n" and default_color: *rgba* | *hexa* = DEFAULT.color["cyan"] to `Console.restricted_input()` and `Console.pwd_input()`. +* Now the method `FormatCodes.to_ansi()` automatically converts the param `string` to a *`str`* if it isn't one already. +* Added a new method `FormatCodes.remove_codes()`. +* Added a new method `FormatCodes.remove_ansi()`. +* Added a new method `Console.log_box()`. +* Changed the default values of two params in the `Console.log()` method and every log preset:
+ - start: *str* = "\n" changed to start: *str* = ""
+ - end: *str* = "\n\n" changed to end: *str* = "\n" +* Added the params start: *str* = "", end: *str* = "\n" and default_color: *rgba* | *hexa* = DEFAULT.color["cyan"] to `Console.restricted_input()` and `Console.pwd_input()`. ## 22.01.2025 `v1.6.4` -* **HOTFIX: Fixed a heavy bug, where the library could not be imported after the last update, because of a bug in `xx_format_codes`.** +* **HOTFIX: Fixed a heavy bug, where the library could not be imported after the last update, because of a bug in `xx_format_codes`.** ## 22.01.2025 `v1.6.3` **⚠️ This release is broken!** -* Fixed a small bug in `xx_format_codes`:
- Inside print-strings, if there was a `'` or `"` inside an auto-reset-formatting (*e.g.* `[u](there's a quote)`), that caused it to not be recognized as valid, and therefore not be automatically reset.
- Now this is fixed and auto-reset-formatting works as expected. -* Added a new param ignore_in_strings: *bool* = True to `Regex.brackets()`:
- If this param is true and a bracket is inside a string (e.g. `'…'` or `"…"`), it will not be counted as the matching closing bracket. -* Removed `lru_cache` from the `xx_format_codes` module's internal methods, since it was unnecessary. -* Adjusted `FormatCodes.__config_console()` so it can only be called once per process. +* Fixed a small bug in `xx_format_codes`:
+ Inside print-strings, if there was a `'` or `"` inside an auto-reset-formatting (*e.g.* `[u](there's a quote)`), that caused it to not be recognized as valid, and therefore not be automatically reset.
+ Now this is fixed and auto-reset-formatting works as expected. +* Added a new param ignore_in_strings: *bool* = True to `Regex.brackets()`:
+ If this param is true and a bracket is inside a string (e.g. `'…'` or `"…"`), it will not be counted as the matching closing bracket. +* Removed `lru_cache` from the `xx_format_codes` module's internal methods, since it was unnecessary. +* Adjusted `FormatCodes.__config_console()` so it can only be called once per process. ## 20.01.2025 `v1.6.2` -* Added a new method `elevate()` to `xx_system`, which is used to request elevated privileges. -* Fixed a bug in `rgba()`, `hsla()` and `hexa()`:
- Previously, when initializing a color with the alpha channel set to `0.0` (*100% transparent*), it was saved correctly, but when converted to a different color type or when returned, the alpha channel got ignored, just like if it was `None` or `1.0` (*opaque*).
- Now when initializing a color with the alpha channel set to `0.0`, this doesn't happen and when converted or returned, the alpha channel is still `0.0`. -* Huge speed and efficiency improvements in `xx_color`, due to newly added option to initialize a color without validation, which saves time when initializing colors, when we know, that the values are valid. -* Added a new param reset_ansi: *bool* = False to `FormatCodes.input()`:
- If this param is true, all formatting will be reset after the user confirmed the input and the program continues. +* Added a new method `elevate()` to `xx_system`, which is used to request elevated privileges. +* Fixed a bug in `rgba()`, `hsla()` and `hexa()`:
+ Previously, when initializing a color with the alpha channel set to `0.0` (*100% transparent*), it was saved correctly, but when converted to a different color type or when returned, the alpha channel got ignored, just like if it was `None` or `1.0` (*opaque*).
+ Now when initializing a color with the alpha channel set to `0.0`, this doesn't happen and when converted or returned, the alpha channel is still `0.0`. +* Huge speed and efficiency improvements in `xx_color`, due to newly added option to initialize a color without validation, which saves time when initializing colors, when we know, that the values are valid. +* Added a new param reset_ansi: *bool* = False to `FormatCodes.input()`:
+ If this param is true, all formatting will be reset after the user confirmed the input and the program continues. **BREAKING CHANGES:** -* Moved the method `is_admin()` from `xx_console` to `xx_system`. -* Method `hex_int_to_rgba()` from `xx_color` now returns an `rgba()` object instead of the separate values `r`, `g`, `b` and `a`. + +* Moved the method `is_admin()` from `xx_console` to `xx_system`. +* Method `hex_int_to_rgba()` from `xx_color` now returns an `rgba()` object instead of the separate values `r`, `g`, `b` and `a`. ## 15.01.2025 `v1.6.1` -* Changed the params in `File.make_path()`:
- Previously there were the params filename: *str* and filetype: *str* = "" where `filename` didn't have to have the file extension included, as long as the `filetype` was set.
- Now these params have become one param file: *str* which is the file with file extension. -* Removed all line breaks and other Markdown formatting from docstrings, since not all IDEs support them. +* Changed the params in `File.make_path()`:
+ Previously there were the params filename: *str* and filetype: *str* = "" where `filename` didn't have to have the file extension included, as long as the `filetype` was set.
+ Now these params have become one param file: *str* which is the file with file extension. +* Removed all line breaks and other Markdown formatting from docstrings, since not all IDEs support them. **BREAKING CHANGES:** -* Changed the order the params in `File.create()`:
- Until now the param content: *str* = "" was the first param and file: *str* = "".
- New the param file: *str* = "" is the first param and content: *str* = "" is the second. -* Renamed `File.make_path()` to a more descriptive name `File.extend_or_make_path()` and adjusted the usages of `File.create()` and `File.make_path()` inside `xx_json` accordingly. + +* Changed the order the params in `File.create()`:
+ Until now the param content: *str* = "" was the first param and file: *str* = "".
+ New the param file: *str* = "" is the first param and content: *str* = "" is the second. +* Renamed `File.make_path()` to a more descriptive name `File.extend_or_make_path()` and adjusted the usages of `File.create()` and `File.make_path()` inside `xx_json` accordingly. ## 07.01.2025 `v1.6.0` -* Fixed a small bug in `to_camel_case()` in the `xx_string` module:
- Previously, it would return only the first part of the decomposed string.
- Now it correctly returns all decomposed string parts, joined in CamelCase. +* Fixed a small bug in `to_camel_case()` in the `xx_string` module:
+ Previously, it would return only the first part of the decomposed string.
+ Now it correctly returns all decomposed string parts, joined in CamelCase. ## 21.12.2024 `v1.5.9` -* Fixed bugs in method `to_ansi()` in module `xx_format_codes`:
- 1. The method always returned an empty string, because the color validation was broken, and it would identify all colors as invalid.
- Now the validation `Color.is_valid_rgba()` and `Color.is_valid_hexa()` are fixed and now, if a color is identified as invalid, the method returns the original string instead of an empty string. - 2. Previously the method `to_ansi()` couldn't handle formats inside `[]` because everything inside the brackets was recognized as an invalid format.
- Now you are able to use formats inside `[]` (*e.g.* `"[[red](Red text [b](inside) square brackets!)]"`). -* Introduced a new test for the `xx_format_codes` module. -* Fixed a small bug in the help client-command:
- Added back the default text color. +* Fixed bugs in method `to_ansi()` in module `xx_format_codes`:
+ 1. The method always returned an empty string, because the color validation was broken, and it would identify all colors as invalid.
+ Now the validation `Color.is_valid_rgba()` and `Color.is_valid_hexa()` are fixed and now, if a color is identified as invalid, the method returns the original string instead of an empty string. + 2. Previously the method `to_ansi()` couldn't handle formats inside `[]` because everything inside the brackets was recognized as an invalid format.
+ Now you are able to use formats inside `[]` (*e.g.* `"[[red](Red text [b](inside) square brackets!)]"`). +* Introduced a new test for the `xx_format_codes` module. +* Fixed a small bug in the help client-command:
+ Added back the default text color. ## 21.11.2024 `v1.5.8` -* Added method `String.is_empty()` to check if the string is empty. -* Added method `String.escape()` to escape special characters in a string. -* Introduced a new test for `xx_data` (*all methods*). -* Added doc-strings to all the methods in `xx_data`. -* Made all the methods from `xx_data` work wih all the types of data structures (*`list`, `tuple`, `set`, `frozenset`, `dict`*). -* Added method `EnvPath.remove_path()`. -* Introduced a new test for `xx_env_vars` (*all methods*). -* Added a `hexa_str()` preset to the `xx_regex` module. -* Introduced a new test for the methods from the `Color` class in `xx_color`. +* Added method `String.is_empty()` to check if the string is empty. +* Added method `String.escape()` to escape special characters in a string. +* Introduced a new test for `xx_data` (*all methods*). +* Added doc-strings to all the methods in `xx_data`. +* Made all the methods from `xx_data` work wih all the types of data structures (*`list`, `tuple`, `set`, `frozenset`, `dict`*). +* Added method `EnvPath.remove_path()`. +* Introduced a new test for `xx_env_vars` (*all methods*). +* Added a `hexa_str()` preset to the `xx_regex` module. +* Introduced a new test for the methods from the `Color` class in `xx_color`. **BREAKING CHANGES:** -* Renamed the library from `XulbuX` to `xulbux` for better naming conventions. -* Renamed the module `xx_cmd`, and it's class `Cmd` to `xx_console` and `Console`. -* Renamed the module `xx_env_vars`, and it's class `EnvVars` to `xx_env_path` and `EnvPath`. + +* Renamed the library from `XulbuX` to `xulbux` for better naming conventions. +* Renamed the module `xx_cmd`, and it's class `Cmd` to `xx_console` and `Console`. +* Renamed the module `xx_env_vars`, and it's class `EnvVars` to `xx_env_path` and `EnvPath`. ## 15.11.2024 `v1.5.7` -* Change the testing modules to be able to run together with the library `pytest`. -* Added formatting checks, using `black`, `isort` and `flake8`. -* Added the script (*command*) `xx-help` or `xulbux-help`. -* Structured `Cmd.restricted_input()` a bit nicer, so it appears less complex. -* Corrected code after `Lint with flake8` formatting suggestions. -* Added additional tests for the custom color types. -* Updated the whole `xx_format_codes` module for more efficiency and speed. +* Change the testing modules to be able to run together with the library `pytest`. +* Added formatting checks, using `black`, `isort` and `flake8`. +* Added the script (*command*) `xx-help` or `xulbux-help`. +* Structured `Cmd.restricted_input()` a bit nicer, so it appears less complex. +* Corrected code after `Lint with flake8` formatting suggestions. +* Added additional tests for the custom color types. +* Updated the whole `xx_format_codes` module for more efficiency and speed. **BREAKING CHANGES:** -* Moved the `help()` function to the file `_cli_.py`, because that's where all the scripts are located (*It also was renamed to* `help_command()`). -* Moved the method `normalize_spaces()` to `xx_string`. + +* Moved the `help()` function to the file `_cli_.py`, because that's where all the scripts are located (*It also was renamed to* `help_command()`). +* Moved the method `normalize_spaces()` to `xx_string`. @@ -597,8 +624,8 @@ **Again 𝓢𝓲𝓷𝓰𝓵𝓮𝓼 𝓓𝓪𝔂 🥇😉** -* Moved the whole library to its own repository: **[python-lib-xulbux](https://github.com/xulbux/python-lib-xulbux)** -* Updated all connections and links correspondingly. +* Moved the whole library to its own repository: **[python-lib-xulbux](https://github.com/xulbux/python-lib-xulbux)** +* Updated all connections and links correspondingly. @@ -607,216 +634,221 @@ **𝓢𝓲𝓷𝓰𝓵𝓮𝓼 𝓓𝓪𝔂 🥇😉** -* Added methods to get the width and height of the console (*in characters and lines*):
- - Cmd.w() -> *int* how many text characters the console is wide
- - Cmd.h() -> *int* how many lines the console is high
- - Cmd.wh() -> tuple[*int*, *int*] a tuple with width and height -* Added the method split_count(*string*, *count*) -> list[*str*] to `xx_string`. -* Added doc-strings to every method in `xx_string`. -* Updated the `Cmd.restricted_input()` method to be able to: - - paste text from the clipboard, - - select all text to delete everything at once, - - write and backspace over multiple lines and - - added support for custom formatting codes in the prompt. -* Added required non-standard libraries to the project file. -* Added more metadata to the project file. +* Added methods to get the width and height of the console (*in characters and lines*):
+ - Cmd.w() -> *int* how many text characters the console is wide
+ - Cmd.h() -> *int* how many lines the console is high
+ - Cmd.wh() -> tuple[*int*, *int*] a tuple with width and height +* Added the method split_count(*string*, *count*) -> list[*str*] to `xx_string`. +* Added doc-strings to every method in `xx_string`. +* Updated the `Cmd.restricted_input()` method to be able to: + - paste text from the clipboard, + - select all text to delete everything at once, + - write and backspace over multiple lines and + - added support for custom formatting codes in the prompt. +* Added required non-standard libraries to the project file. +* Added more metadata to the project file. ## 06.11.2024 `v1.5.4` -* Added a new method normalize_spaces(*code*) -> *str* to `Code`. -* Added new doc-strings to `xx_code` and `xx_cmd`. -* Added a custom `input()` method to `Cmd`, which lets you specify the allowed text characters the user can type, as well as the minimum and maximum length of the input. -* Added the method `pwd_input()` to `Cmd`, which works just like the `Cmd.restricted_input()` but masks the input characters with `*`. -* Restructured the whole library's imports, so the custom types won't get displayed as `Any` when hovering over a method/function. -* Fixed bug when trying to get the base directory from a compiled script (*EXE*):
- Would previously get the path to the temporary extracted directory, which is created when running the EXE file.
- Now it gets the actual base directory of the currently running file. +* Added a new method normalize_spaces(*code*) -> *str* to `Code`. +* Added new doc-strings to `xx_code` and `xx_cmd`. +* Added a custom `input()` method to `Cmd`, which lets you specify the allowed text characters the user can type, as well as the minimum and maximum length of the input. +* Added the method `pwd_input()` to `Cmd`, which works just like the `Cmd.restricted_input()` but masks the input characters with `*`. +* Restructured the whole library's imports, so the custom types won't get displayed as `Any` when hovering over a method/function. +* Fixed bug when trying to get the base directory from a compiled script (*EXE*):
+ Would previously get the path to the temporary extracted directory, which is created when running the EXE file.
+ Now it gets the actual base directory of the currently running file. **BREAKING CHANGES:** -* Made the `blend()` method from all the color types modify the *`self`* object as well as returning the result. + +* Made the `blend()` method from all the color types modify the *`self`* object as well as returning the result. ## 30.10.2024 `v1.5.3` -* Added the default text color to the `_consts_.py` so it's easier to change it (*and used it in the library*). -* Added a bunch of other default colors to the `_consts_.py` (*and used them in the library*). -* Refactored the whole library's code after the **[PEPs](https://peps.python.org)** and **[The Zen of Python](https://peps.python.org/pep-0020/#the-zen-of-python)** 🫡: - - Changed the indent to 4 spaces. - - No more inline control statements. -* Added new methods to `Color`:
- - rgba_to_hex(*r*, *g*, *b*, *a*) -> *int*
- - hex_to_rgba(*hex_int*) -> *tuple*
- - luminance(*r*, *g*, *b*, *precision*, *round_to*) -> *float*|*int* -* Fixed the `grayscale()` method of `rgba()`, `hsla()` and `hexa()`:
- The method would previously just return the color, fully desaturated (*not grayscale*).
- Now this is fixed, and the method uses the luminance formula, to get the actual grayscale value. -* All the methods in the `xx_color` module now support HEX integers (*e.g.* `0x8085FF` *instead of only strings:* `"#8085FF"` `"0x8085FF"`). +* Added the default text color to the `_consts_.py` so it's easier to change it (*and used it in the library*). +* Added a bunch of other default colors to the `_consts_.py` (*and used them in the library*). +* Refactored the whole library's code after the **[PEPs](https://peps.python.org)** and **[The Zen of Python](https://peps.python.org/pep-0020/#the-zen-of-python)** 🫡: + - Changed the indent to 4 spaces. + - No more inline control statements. +* Added new methods to `Color`:
+ - rgba_to_hex(*r*, *g*, *b*, *a*) -> *int*
+ - hex_to_rgba(*hex_int*) -> *tuple*
+ - luminance(*r*, *g*, *b*, *precision*, *round_to*) -> *float*|*int* +* Fixed the `grayscale()` method of `rgba()`, `hsla()` and `hexa()`:
+ The method would previously just return the color, fully desaturated (*not grayscale*).
+ Now this is fixed, and the method uses the luminance formula, to get the actual grayscale value. +* All the methods in the `xx_color` module now support HEX integers (*e.g.* `0x8085FF` *instead of only strings:* `"#8085FF"` `"0x8085FF"`). **BREAKING CHANGES:** -* Restructured the values in `_consts_.py`. + +* Restructured the values in `_consts_.py`. ## 28.10.2024 `v1.5.2` -* New parameter correct_path:*bool* in `Path.extend()`: - This makes sure, that typos in the path will only be corrected if this parameter is true. -* Fixed bug in `Path.extend()`, where an empty string was taken as a valid path for the current directory `./`. -* Fixed color validation bug:
- `Color.is_valid_rgba()` and `Color.is_valid_hsla()` would not accept an alpha channel of `None`.
- `Color.is_valid_rgba()` was still checking for an alpha channel from `0` to `255` instead of `0` to `1`. -* Fixed bug for `Color.has_alpha()`:
- Previously, it would return `True` if the alpha channel was `None`.
- Now in such cases it will return `False`. +* New parameter correct_path:*bool* in `Path.extend()`: + This makes sure, that typos in the path will only be corrected if this parameter is true. +* Fixed bug in `Path.extend()`, where an empty string was taken as a valid path for the current directory `./`. +* Fixed color validation bug:
+ `Color.is_valid_rgba()` and `Color.is_valid_hsla()` would not accept an alpha channel of `None`.
+ `Color.is_valid_rgba()` was still checking for an alpha channel from `0` to `255` instead of `0` to `1`. +* Fixed bug for `Color.has_alpha()`:
+ Previously, it would return `True` if the alpha channel was `None`.
+ Now in such cases it will return `False`. ## 28.10.2024 `v1.5.1` -* Now all methods in `xx_color` support both HEX prefixes (`#` *and* `0x`). -* Added the default HEX prefix to `_consts_.py`. -* Fixed bug when initializing a `hexa()` object:
- Would throw an error, even if the color was valid. +* Now all methods in `xx_color` support both HEX prefixes (`#` *and* `0x`). +* Added the default HEX prefix to `_consts_.py`. +* Fixed bug when initializing a `hexa()` object:
+ Would throw an error, even if the color was valid. **BREAKING CHANGES:** -* Renamed all library files for a better naming convention. + +* Renamed all library files for a better naming convention. ## 27.10.2024 `v1.5.0` Big Update 🚀 -* Added a `__help__.py` file, which will show some information about the library and how to use it, when it's run as a script or when the `help()` function is called. -* Added a lot more metadata to the library:
- `__version__` (*was already added in update [v1.4.2](#v1-4-2)*)
- `__author__`
- `__email__`
- `__license__`
- `__copyright__`
- `__url__`
- `__description__`
- `__all__` +* Added a `__help__.py` file, which will show some information about the library and how to use it, when it's run as a script or when the `help()` function is called. +* Added a lot more metadata to the library:
+ `__version__` (*was already added in update [v1.4.2](#v1-4-2)*)
+ `__author__`
+ `__email__`
+ `__license__`
+ `__copyright__`
+ `__url__`
+ `__description__`
+ `__all__` **BREAKING CHANGES:** -* Split all classes into separate files, so users can download only parts of the library more easily. + +* Split all classes into separate files, so users can download only parts of the library more easily. ## 27.10.2024 `v1.4.2` `v1.4.3` -* Path.extend(*rel_path*) -> *abs_path* now also extends system variables like `%USERPROFILE%` and `%APPDATA%`. -* Removed unnecessary parts when checking for missing required libraries. -* You can now get the libraries current version by accessing the attribute `XulbuX.__version__`. +* Path.extend(*rel_path*) -> *abs_path* now also extends system variables like `%USERPROFILE%` and `%APPDATA%`. +* Removed unnecessary parts when checking for missing required libraries. +* You can now get the libraries current version by accessing the attribute `XulbuX.__version__`. ## 26.10.2024 `v1.4.1` -* Added methods to each color type:
- - is_grayscale() -> *self*
- - is_opaque() -> *self* -* Added additional error checking to the color types. -* Made error messages for the color types clearer. -* Updated the blend(*other*, *ratio*) method of all color types to use additive blending except for the alpha-channel. -* Fixed problem with method-chaining for all color types. +* Added methods to each color type:
+ - is_grayscale() -> *self*
+ - is_opaque() -> *self* +* Added additional error checking to the color types. +* Made error messages for the color types clearer. +* Updated the blend(*other*, *ratio*) method of all color types to use additive blending except for the alpha-channel. +* Fixed problem with method-chaining for all color types. ## 25.10.2024 `v1.4.0` Big Update 🚀 -* Huge update to the custom color types: - - Now all type-methods support chaining. - - Added new methods to each type:
- lighten(*amount*) -> *self*
- darken(*amount*) -> *self*
- saturate(*amount*) -> *self*
- desaturate(*amount*) -> *self*
- rotate(*hue*) -> *self*
- invert() -> *self*
- grayscale() -> *self*
- blend(*other*, *ratio*) -> *self*
- is_dark() -> *bool*
- is_light() -> *bool*
- with_alpha(*alpha*) -> *self*
- complementary() -> *self* +* Huge update to the custom color types: + - Now all type-methods support chaining. + - Added new methods to each type:
+ lighten(*amount*) -> *self*
+ darken(*amount*) -> *self*
+ saturate(*amount*) -> *self*
+ desaturate(*amount*) -> *self*
+ rotate(*hue*) -> *self*
+ invert() -> *self*
+ grayscale() -> *self*
+ blend(*other*, *ratio*) -> *self*
+ is_dark() -> *bool*
+ is_light() -> *bool*
+ with_alpha(*alpha*) -> *self*
+ complementary() -> *self* ## 23.10.2024 `v1.3.1` -* Now the alpha channel will be rounded to maximal 2 decimals, if converting from `hexa()` to `rgba()` or `hsla()`. +* Now the alpha channel will be rounded to maximal 2 decimals, if converting from `hexa()` to `rgba()` or `hsla()`. ## 21.10.2024 `v1.3.0` Big Update 🚀 -* Fixed the custom types `rgba()`, `hsla()` and `hexa()`:
- - `rgba()`:
- Fixed `to_hsla()` and `to_hexa()`. - - `hsla()`:
- Fixed `to_rgba()` and `to_hexa()`. - - `hexa()`:
- Fixed `to_rgba()` and `to_hsla()`. -* Fixed methods from the `Color` class:
- `Color.has_alpha()`
- `Color.to_rgba()`
- `Color.to_hsla()`
- `Color.to_hexa()` -* Set default value for param allow_alpha: *bool* to `True` for methods:
- `Color.is_valid_rgba()`
- `Color.is_valid_hsla()`
- `Color.is_valid_hexa()`
- `Color.is_valid()` +* Fixed the custom types `rgba()`, `hsla()` and `hexa()`:
+ - `rgba()`:
+ Fixed `to_hsla()` and `to_hexa()`. + - `hsla()`:
+ Fixed `to_rgba()` and `to_hexa()`. + - `hexa()`:
+ Fixed `to_rgba()` and `to_hsla()`. +* Fixed methods from the `Color` class:
+ `Color.has_alpha()`
+ `Color.to_rgba()`
+ `Color.to_hsla()`
+ `Color.to_hexa()` +* Set default value for param allow_alpha: *bool* to `True` for methods:
+ `Color.is_valid_rgba()`
+ `Color.is_valid_hsla()`
+ `Color.is_valid_hexa()`
+ `Color.is_valid()` ## 18.10.2024 `v1.2.4` `v1.2.5` -* Added more info to the `README.md` as well as additional links. -* Adjusted the structure inside `CHANGELOG.md` for a better overview and readability. +* Added more info to the `README.md` as well as additional links. +* Adjusted the structure inside `CHANGELOG.md` for a better overview and readability. **BREAKING CHANGES:** -* Renamed the class `rgb()` to `rgba()` to communicate, more clearly, that it supports an alpha channel. -* Renamed the class `hsl()` to `hsla()` to communicate, more clearly, that it supports an alpha channel. + +* Renamed the class `rgb()` to `rgba()` to communicate, more clearly, that it supports an alpha channel. +* Renamed the class `hsl()` to `hsla()` to communicate, more clearly, that it supports an alpha channel. ## 18.10.2024 `v1.2.3` -* Added project links to the Python-project-file. -* Made some `CHANGELOG.md` improvements. -* Improved `README.md`. +* Added project links to the Python-project-file. +* Made some `CHANGELOG.md` improvements. +* Improved `README.md`. ## 18.10.2024 `v1.2.1` `v1.2.2` -* Fixed bug in method Path.get(*base_dir*=True):
- Previously, setting `base_dir` to `True` would not return the actual base directory or even cause an error.
- Setting `base_dir` to `True` now will return the actual base directory of the current program (*except if not running from a file*). +* Fixed bug in method Path.get(*base_dir*=True):
+ Previously, setting `base_dir` to `True` would not return the actual base directory or even cause an error.
+ Setting `base_dir` to `True` now will return the actual base directory of the current program (*except if not running from a file*). ## 17.10.2024 `v1.2.0` -* New method in the `Path` class:
- `Path.remove()` +* New method in the `Path` class:
+ `Path.remove()` @@ -824,96 +856,98 @@ ## 17.10.2024 `v1.1.9` **BREAKING CHANGES:** -* Corrected the naming of classes to comply with Python naming standards. + +* Corrected the naming of classes to comply with Python naming standards. ## 17.10.2024 `v1.1.8` -* Added support for all OSes to the OS-dependent methods. +* Added support for all OSes to the OS-dependent methods. ## 17.10.2024 `v1.1.6` `v1.1.7` -* Fixed the `Cmd.cls()` method:
- There was a bug where only on Windows 10, the ANSI formats weren't cleared. +* Fixed the `Cmd.cls()` method:
+ There was a bug where only on Windows 10, the ANSI formats weren't cleared. ## 17.10.2024 `v1.1.4` `v1.1.5` -* Added links to the `CHANGELOG.md` and `README.md` files. +* Added links to the `CHANGELOG.md` and `README.md` files. ## 17.10.2024 `v1.1.3` -* Changed the default value of the param compactness: *int* in the method `Data.print()` to `1` instead of `0`. +* Changed the default value of the param compactness: *int* in the method `Data.print()` to `1` instead of `0`. ## 17.10.2024 `v1.1.1` `v1.1.2` -* Adjusted the library's description. +* Adjusted the library's description. ## 16.10.2024 `v1.1.0` -* Made it possible to also auto-reset the color and not only the predefined formats, using the [auto-reset-format](#auto-reset-format) (`[format](Automatically resetting)`). +* Made it possible to also auto-reset the color and not only the predefined formats, using the [auto-reset-format](#auto-reset-format) (`[format](Automatically resetting)`). ## 16.10.2024 `v1.0.9` -* Added a library description, which gets shown if the library base-import is run directly. -* Made it possible to escape an auto-reset-format (`[format](Automatically resetting)`) with a slash, so you can still have `()` brackets behind a `[format]`: - ```python - FormatCodes.print('[u](Automatically resetting) following text') - ``` - prints: Automatically resetting following text +* Added a library description, which gets shown if the library base-import is run directly. +* Made it possible to escape an auto-reset-format (`[format](Automatically resetting)`) with a slash, so you can still have `()` brackets behind a `[format]`: + ```python + FormatCodes.print('[u](Automatically resetting) following text') + ``` + prints: Automatically resetting following text - ```python - FormatCodes.print('[u]/(Automatically resetting) following text') - ``` - prints: (Automatically resetting) following text + ```python + FormatCodes.print('[u]/(Automatically resetting) following text') + ``` + prints: (Automatically resetting) following text ## 16.10.2024 `v1.0.7` `v1.0.8` -* Added `input()` method to the `FormatCodes` class, so you can make pretty looking input prompts. -* Added warning for no network connection when trying to [install missing libraries](#improved-lib-importing). +* Added `input()` method to the `FormatCodes` class, so you can make pretty looking input prompts. +* Added warning for no network connection when trying to [install missing libraries](#improved-lib-importing). ## 15.10.2024 `v1.0.6` -* Improved **$\color{#8085FF}\textsf{XulbuX}$** library importing:
- Checks for missing required libraries and gives you the option to directly install them, if there are any. -* Fixed issue where configuration file wasn't created and loaded correctly. +* Improved **$\color{#8085FF}\textsf{XulbuX}$** library importing:
+ Checks for missing required libraries and gives you the option to directly install them, if there are any. +* Fixed issue where configuration file wasn't created and loaded correctly. **BREAKING CHANGES:** -* Moved constant variables into a separate file. + +* Moved constant variables into a separate file. ## 15.10.2024 `v1.0.1` `v1.0.2` `v1.0.3` `v1.0.4` `v1.0.5` -* Fixed `f-string` issues for Python 3.10: - 1. Not making use of same quotes inside f-strings any more. - 2. No backslash escaping in f-strings. +* Fixed `f-string` issues for Python 3.10: + 1. Not making use of same quotes inside f-strings any more. + 2. No backslash escaping in f-strings. @@ -930,71 +964,71 @@ from XulbuX import rgb, hsl, hexa ``` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Featuresclass, type, function, …
Custom Types: -rgb(int, int, int, float)
-hsl(int, int, int, float)
-hexa(str) -
Directory Operationsxx.Dir
File Operationsxx.File
JSON File Operationsxx.Json
System Actionsxx.System
Manage Environment Varsxx.EnvVars
CMD Log And Actionsxx.Cmd
Pretty Printingxx.FormatCodes
Color Operationsxx.Color
Data Operationsxx.Data
String Operationsxx.String
Code String Operationsxx.Code
Regex Pattern Templatesxx.Regex
Featuresclass, type, function, …
Custom Types: + rgb(int, int, int, float)
+ hsl(int, int, int, float)
+ hexa(str) +
Directory Operationsxx.Dir
File Operationsxx.File
JSON File Operationsxx.Json
System Actionsxx.System
Manage Environment Varsxx.EnvVars
CMD Log And Actionsxx.Cmd
Pretty Printingxx.FormatCodes
Color Operationsxx.Color
Data Operationsxx.Data
String Operationsxx.String
Code String Operationsxx.Code
Regex Pattern Templatesxx.Regex

diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py index d246dfa..e737f41 100644 --- a/src/xulbux/base/types.py +++ b/src/xulbux/base/types.py @@ -113,7 +113,7 @@ class ArgData(TypedDict): """Schema for the resulting data of parsing a single command-line argument.""" exists: bool is_pos: bool - values: list[str] + values: tuple[str, ...] flag: Optional[str] class RgbaDict(TypedDict): diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 52e4bf5..1932ef1 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -12,7 +12,7 @@ from .color import Color from .regex import LazyRegex -from typing import ValuesView, Generator, Callable, KeysView, Optional, Literal, TypeVar, TextIO, Any, overload, cast +from typing import ValuesView, Generator, Callable, KeysView, Optional, Literal, TypeVar, TextIO, Final, Any, overload, cast from prompt_toolkit.key_binding import KeyPressEvent, KeyBindings from prompt_toolkit.validation import ValidationError, Validator from prompt_toolkit.document import Document @@ -54,19 +54,19 @@ class ParsedArgData: ------------------------------------------------------------------------------------------------------------ * `exists` – Whether the argument was found in the command-line arguments or not. * `is_pos` – Whether the argument is a positional `"before"`/`"after"` argument or not. - * `values` – The list of values associated with the argument. + * `values` – The tuple of values associated with the argument. * `flag` – The specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args. ------------------------------------------------------------------------------------------------------------ When the `ParsedArgData` instance is accessed as a boolean it will correspond to the `exists` attribute.""" def __init__(self, *, exists: bool, values: list[str], is_pos: bool, flag: Optional[str] = None): - self.exists: bool = exists + self.exists: Final[bool] = exists """Whether the argument was found or not.""" - self.is_pos: bool = is_pos + self.is_pos: Final[bool] = is_pos """Whether the argument is a positional argument or not.""" - self.values: list[str] = values - """The list of values associated with the argument.""" - self.flag: Optional[str] = flag + self.values: Final[tuple[str, ...]] = tuple(values) + """The tuple of values associated with the argument.""" + self.flag: Final[Optional[str]] = flag """The specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args.""" def __bool__(self) -> bool: @@ -97,6 +97,12 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() + def _update(self, **kwargs: Any) -> None: + """Internal method to update the attributes of the `ParsedArgData` instance.""" + + for k, v in kwargs.items(): + object.__setattr__(self, k, v) + def dict(self) -> ArgData: """Returns the argument result as a dictionary.""" @@ -131,21 +137,37 @@ def get(self, index: int, /, default: Optional[str] = None) -> Optional[str]: class ParsedArgs: """Container for parsed command-line arguments, allowing attribute-style access.\n ------------------------------------------------------------------------------------- + * `unknown_flags` – A list of all found flags that were not defined in the config. * `**parsed_args` – A mapping of argument aliases to their corresponding data
saved in an `ParsedArgData` object. ------------------------------------------------------------------------------------- For example, if an argument `foo` was parsed, it can be accessed via `args.foo`.
Each such attribute (e.g. `args.foo`) is an instance of `ParsedArgData`.""" + # KEEP THESE ATTRS OUT OF __dict__ SO THAT vars(self) ONLY CONTAINS THE ParsedArgData INSTANCES + __slots__ = ('__dict__', 'all_exist', 'any_exist', 'is_empty', 'unknown_flags') + RESERVED_ALIASES: frozenset[str] = frozenset({ - "all_exist", "any_exist", "dict", "existing", "get", "is_empty", "items", "keys", "missing", "values" + "all_exist", "any_exist", "dict", "existing", "get", "is_empty", "items", "keys", "missing", "unknown_flags", "values" }) """Alias names that are reserved and cannot be used as argument aliases.""" - def __init__(self, **parsed_args: ParsedArgData): + def __init__(self, unknown_flags: Optional[list[str]] = None, **parsed_args: ParsedArgData): for alias_name, parsed_arg_data in parsed_args.items(): setattr(self, alias_name, parsed_arg_data) + _parsed_args = cast(dict[str, ParsedArgData], vars(self)).values() + + self.is_empty: Final[bool] = all(not arg.exists and not arg.values for arg in _parsed_args) + """Whether no argument was found and none have any values (not even defaults).""" + self.any_exist: Final[bool] = any(arg.exists for arg in _parsed_args) + """Whether at least one argument was explicitly found.""" + self.all_exist: Final[bool] = all(arg.exists for arg in _parsed_args) + """Whether all arguments were explicitly found.""" + self.unknown_flags: Final[frozenset[str]] = frozenset() if unknown_flags is None else frozenset(unknown_flags) + """Unknown flags found in the command-line arguments
+ (args that look like flags but are not defined in the config).""" + def __len__(self): """The number of arguments stored in the `ParsedArgs` object.""" @@ -157,9 +179,9 @@ def __contains__(self, key: str, /) -> bool: return key in vars(self) def __bool__(self) -> bool: - """Whether the `ParsedArgs` object contains any arguments.""" + """Whether the `ParsedArgs` object contains any arguments or unknown flags.""" - return len(self) > 0 + return len(self) > 0 or bool(self.unknown_flags) def __getattr__(self, name: str, /) -> ParsedArgData: raise AttributeError(f"'{type(self).__name__}' object has no attribute {name}") @@ -178,7 +200,7 @@ def __eq__(self, other: object, /) -> bool: if not isinstance(other, ParsedArgs): return False - return vars(self) == vars(other) + return vars(self) == vars(other) and self.unknown_flags == other.unknown_flags def __ne__(self, other: object, /) -> bool: """Check if two `ParsedArgs` objects are not equal by comparing their stored arguments.""" @@ -186,12 +208,17 @@ def __ne__(self, other: object, /) -> bool: return not self.__eq__(other) def __repr__(self) -> str: - if not self: - return "ParsedArgs()" - return "ParsedArgs(\n " + ",\n ".join( + items: list[str] = [ f"{key} = " + "\n ".join(repr(val).splitlines()) \ for key, val in self.__iter__() - ) + "\n)" + ] + + if self.unknown_flags: + items.append(f"unknown_flags = {self.unknown_flags!r}") + elif not items: + return "ParsedArgs()" + + return "ParsedArgs(\n " + ",\n ".join(items) + "\n)" def __str__(self) -> str: return self.__repr__() @@ -236,24 +263,6 @@ def missing(self) -> Generator[tuple[str, ParsedArgData], None, None]: if not val.exists: yield (key, val) - @property - def is_empty(self) -> bool: - """Whether no argument was found and none have any values (not even defaults).""" - - return all(not val.exists and not val.values for val in cast(dict[str, ParsedArgData], vars(self)).values()) - - @property - def any_exist(self) -> bool: - """Whether at least one argument was explicitly found.""" - - return any(val.exists for val in cast(dict[str, ParsedArgData], vars(self)).values()) - - @property - def all_exist(self) -> bool: - """Whether all arguments were explicitly found.""" - - return all(val.exists for val in cast(dict[str, ParsedArgData], vars(self)).values()) - @mypyc_attr(native_class=False) class _ConsoleMeta(type): @@ -1245,6 +1254,7 @@ def __init__( self.parsed_args: dict[str, ParsedArgData] = {} self.positional_configs: dict[str, str] = {} self.arg_lookup: dict[str, str] = {} + self.unknown_flags: list[str] = [] self.args = _sys.argv[1 + skip:] self.args_len = len(self.args) @@ -1259,7 +1269,7 @@ def __call__(self) -> ParsedArgs: self.process_flagged_args() self.process_positional_args() - return ParsedArgs(**self.parsed_args) + return ParsedArgs(self.unknown_flags, **self.parsed_args) def parse_arg_configs(self) -> None: """Parse the `arg_parse_configs` configuration and build lookup structures.""" @@ -1268,7 +1278,7 @@ def parse_arg_configs(self) -> None: if not alias.isidentifier(): raise ValueError(f"Invalid argument alias '{alias}'.\n" "Aliases must be valid Python identifiers.") - if alias in ParsedArgs.RESERVED_ALIASES: + elif alias in ParsedArgs.RESERVED_ALIASES: raise ValueError( f"Invalid argument alias '{alias}'.\n" f"The following names are reserved and cannot be used as aliases:\n" @@ -1401,8 +1411,7 @@ def _collect_before_arg(self, alias: str, /) -> None: before_args.append(arg) if before_args: - self.parsed_args[alias].values = before_args - self.parsed_args[alias].exists = len(before_args) > 0 + self.parsed_args[alias]._update(values=tuple(before_args), exists=True) def _collect_after_arg(self, alias: str, /) -> None: """Collect positional `"after"` arguments.""" @@ -1435,8 +1444,19 @@ def _collect_after_arg(self, alias: str, /) -> None: after_args.append(arg) if after_args: - self.parsed_args[alias].values = after_args - self.parsed_args[alias].exists = len(after_args) > 0 + self.parsed_args[alias]._update(values=tuple(after_args), exists=True) + + @staticmethod + def _looks_like_flag(arg: str, /) -> bool: + """Returns `True` if the arg resembles a flag (starts with `--` or `-`).
+ Arguments that look like negative numbers (e.g. `-42`, `-.5`) are not flags.""" + + if arg.startswith("--"): + return True + if len(arg) >= 2 and arg[0] == "-" and not arg[1].isdigit() and arg[1] != ".": + return True + + return False def _is_positional_arg(self, arg: str, /, *, allow_separator: bool = True) -> bool: """Check if an argument is positional (not a flag or separator).""" @@ -1444,14 +1464,20 @@ def _is_positional_arg(self, arg: str, /, *, allow_separator: bool = True) -> bo if (self.flag_value_sep \ and self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup): + if self._looks_like_flag(arg.split(self.flag_value_sep, 1)[0].strip()): + return False return True + if arg not in self.arg_lookup and (allow_separator or not self.flag_value_sep or arg != self.flag_value_sep): + if self._looks_like_flag(arg): + return False return True + return False def _is_flag_value(self, arg: str, /) -> bool: """Check if an argument can be treated as a space-separated flag value
- (i.e. it is not a known flag, not the separator, and not a `flag=value` token).""" + (i.e. it is not a known flag, not the separator, not a `flag=value` token, and does not look like a flag itself).""" if arg in self.arg_lookup: return False @@ -1461,6 +1487,9 @@ def _is_flag_value(self, arg: str, /) -> bool: and self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup): return False + if self._looks_like_flag(arg): + return False + return True def process_flagged_args(self) -> None: @@ -1474,29 +1503,34 @@ def process_flagged_args(self) -> None: # CASE 1: FLAG WITH INLINE SEPARATOR ('--flag=value') if self.flag_value_sep and self.flag_value_sep in arg: parts = arg.split(self.flag_value_sep, 1) + potential_flag = parts[0].strip() - if (potential_flag := (parts := arg.split(self.flag_value_sep, 1))[0].strip()) in self.arg_lookup: + if potential_flag in self.arg_lookup: alias = self.arg_lookup[potential_flag] - self.parsed_args[alias].exists = True - self.parsed_args[alias].flag = potential_flag + self.parsed_args[alias]._update(exists=True, flag=potential_flag) if len(parts) > 1 and (val := parts[1].strip()): - self.parsed_args[alias].values = [val] + self.parsed_args[alias]._update(values=(val, )) i += 1 continue - # CASE 2: STANDALONE FLAG + elif self._looks_like_flag(potential_flag): + # UNKNOWN FLAG WITH INLINE SEPARATOR (e.g. '--unknown=value') + self.unknown_flags.append(arg) + i += 1 + continue + + # CASE 2: STANDALONE KNOWN FLAG if arg in self.arg_lookup: alias = self.arg_lookup[arg] - self.parsed_args[alias].exists = True - self.parsed_args[alias].flag = arg + self.parsed_args[alias]._update(exists=True, flag=arg) # CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value') if self.flag_value_sep and i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep: if i + 2 < self.args_len: if (val := self.args[i + 2]) not in self.arg_lookup and val != self.flag_value_sep: - self.parsed_args[alias].values = [val] + self.parsed_args[alias]._update(values=(val, )) i += 3 continue i += 2 @@ -1504,11 +1538,15 @@ def process_flagged_args(self) -> None: # CHECK FOR SPACE-SEPARATED VALUE ('--flag value') if self.allow_space_value and i + 1 < self.args_len and self._is_flag_value(next_arg := self.args[i + 1]): - self.parsed_args[alias].values = [next_arg] + self.parsed_args[alias]._update(values=(next_arg, )) i += 2 continue # NO SEPARATOR = JUST A FLAG WITHOUT VALUE + elif self._looks_like_flag(arg): + # CASE 3: UNKNOWN STANDALONE FLAG (e.g. '--unknown', '-u') + self.unknown_flags.append(arg) + i += 1 diff --git a/tests/test_console.py b/tests/test_console.py index c1741eb..1fd9d14 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -99,38 +99,38 @@ def test_console_supports_color(): ["script.py", "-f=token with spaces", "-d"], {"file": {"-f"}, "debug": {"-d"}}, { - "file": {"exists": True, "is_pos": False, "values": ["token with spaces"], "flag": "-f"}, - "debug": {"exists": True, "is_pos": False, "values": [], "flag": "-d"}, + "file": {"exists": True, "is_pos": False, "values": ("token with spaces", ), "flag": "-f"}, + "debug": {"exists": True, "is_pos": False, "values": (), "flag": "-d"}, }, ), # FLAG VALUE PLUS OTHER TOKENS ( ["script.py", "--msg=hello", "world"], {"message": {"--msg"}}, - {"message": {"exists": True, "is_pos": False, "values": ["hello"], "flag": "--msg"}}, + {"message": {"exists": True, "is_pos": False, "values": ("hello", ), "flag": "--msg"}}, ), # VALUE SET IN SINGLE TOKEN FOLLOWED BY SECOND FLAG ( ["script.py", "--msg=this is a message", "--flag"], {"message": {"--msg"}, "flag": {"--flag"}}, { - "message": {"exists": True, "is_pos": False, "values": ["this is a message"], "flag": "--msg"}, - "flag": {"exists": True, "is_pos": False, "values": [], "flag": "--flag"}, + "message": {"exists": True, "is_pos": False, "values": ("this is a message", ), "flag": "--msg"}, + "flag": {"exists": True, "is_pos": False, "values": (), "flag": "--flag"}, }, ), # FLAG, SEPARATOR, AND VALUE SPREAD OVER MULTIPLE TOKENS ( ["script.py", "--msg", "=", "this is a message"], {"message": {"--msg"}}, - {"message": {"exists": True, "is_pos": False, "values": ["this is a message"], "flag": "--msg"}}, + {"message": {"exists": True, "is_pos": False, "values": ("this is a message", ), "flag": "--msg"}}, ), # CASE SENSITIVE FLAGS WITH SPACES ( ["script.py", "-t=this is some text", "-T=THIS IS A TITLE"], {"text": {"-t"}, "title": {"-T"}}, { - "text": {"exists": True, "is_pos": False, "values": ["this is some text"], "flag": "-t"}, - "title": {"exists": True, "is_pos": False, "values": ["THIS IS A TITLE"], "flag": "-T"}, + "text": {"exists": True, "is_pos": False, "values": ("this is some text", ), "flag": "-t"}, + "title": {"exists": True, "is_pos": False, "values": ("THIS IS A TITLE", ), "flag": "-T"}, }, ), @@ -140,8 +140,8 @@ def test_console_supports_color(): ["script.py", "--msg=given message"], {"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}}, { - "msg": {"exists": True, "is_pos": False, "values": ["given message"], "flag": "--msg"}, - "other": {"exists": False, "is_pos": False, "values": [], "flag": None}, + "msg": {"exists": True, "is_pos": False, "values": ("given message", ), "flag": "--msg"}, + "other": {"exists": False, "is_pos": False, "values": (), "flag": None}, }, ), # DEFAULT USED WHEN FLAG PRESENT BUT NO VALUE GIVEN @@ -149,8 +149,8 @@ def test_console_supports_color(): ["script.py", "-o", "--msg"], {"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}}, { - "msg": {"exists": True, "is_pos": False, "values": ["no message"], "flag": "--msg"}, - "other": {"exists": True, "is_pos": False, "values": [], "flag": "-o"}, + "msg": {"exists": True, "is_pos": False, "values": ("no message", ), "flag": "--msg"}, + "other": {"exists": True, "is_pos": False, "values": (), "flag": "-o"}, }, ), # DEFAULT USED WHEN FLAG ABSENT @@ -158,8 +158,8 @@ def test_console_supports_color(): ["script.py", "-o"], {"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}}, { - "msg": {"exists": False, "is_pos": False, "values": ["no message"], "flag": None}, - "other": {"exists": True, "is_pos": False, "values": [], "flag": "-o"}, + "msg": {"exists": False, "is_pos": False, "values": ("no message", ), "flag": None}, + "other": {"exists": True, "is_pos": False, "values": (), "flag": "-o"}, }, ), @@ -169,24 +169,24 @@ def test_console_supports_color(): ["script.py", "arg1", "arg2.1 arg2.2"], {"before": "before", "file": {"-f"}}, { - "before": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None}, - "file": {"exists": False, "is_pos": False, "values": [], "flag": None}, + "before": {"exists": True, "is_pos": True, "values": ("arg1", "arg2.1 arg2.2"), "flag": None}, + "file": {"exists": False, "is_pos": False, "values": (), "flag": None}, }, ), ( ["script.py", "arg1", "arg2.1 arg2.2", "-f=file.txt", "arg3"], {"before": "before", "file": {"-f"}}, { - "before": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None}, - "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}, + "before": {"exists": True, "is_pos": True, "values": ("arg1", "arg2.1 arg2.2"), "flag": None}, + "file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"}, }, ), ( ["script.py", "-f=file.txt", "arg1"], {"before": "before", "file": {"-f"}}, { - "before": {"exists": False, "is_pos": True, "values": [], "flag": None}, - "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}, + "before": {"exists": False, "is_pos": True, "values": (), "flag": None}, + "file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"}, }, ), # POSITIONAL "after" @@ -194,24 +194,24 @@ def test_console_supports_color(): ["script.py", "arg1", "arg2.1 arg2.2"], {"after": "after", "file": {"-f"}}, { - "file": {"exists": False, "is_pos": False, "values": [], "flag": None}, - "after": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None}, + "file": {"exists": False, "is_pos": False, "values": (), "flag": None}, + "after": {"exists": True, "is_pos": True, "values": ("arg1", "arg2.1 arg2.2"), "flag": None}, }, ), ( ["script.py", "arg1", "-f=file.txt", "arg2", "arg3.1 arg3.2"], {"after": "after", "file": {"-f"}}, { - "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}, - "after": {"exists": True, "is_pos": True, "values": ["arg2", "arg3.1 arg3.2"], "flag": None}, + "file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"}, + "after": {"exists": True, "is_pos": True, "values": ("arg2", "arg3.1 arg3.2"), "flag": None}, }, ), ( ["script.py", "arg1", "-f=file.txt"], {"after": "after", "file": {"-f"}}, { - "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}, - "after": {"exists": False, "is_pos": True, "values": [], "flag": None}, + "file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"}, + "after": {"exists": False, "is_pos": True, "values": (), "flag": None}, }, ), @@ -221,8 +221,8 @@ def test_console_supports_color(): ["script.py", "?help = show detailed info", "++mode=test"], {"help": {"?help"}, "mode": {"++mode"}}, { - "help": {"exists": True, "is_pos": False, "values": ["show detailed info"], "flag": "?help"}, - "mode": {"exists": True, "is_pos": False, "values": ["test"], "flag": "++mode"}, + "help": {"exists": True, "is_pos": False, "values": ("show detailed info", ), "flag": "?help"}, + "mode": {"exists": True, "is_pos": False, "values": ("test", ), "flag": "++mode"}, }, ), # AT SYMBOL PREFIX WITH POSITIONAL ARGUMENTS @@ -230,21 +230,21 @@ def test_console_supports_color(): ["script.py", "@msg = Hello, world!", "How are you?"], {"before": "before", "message": {"@msg"}, "after": "after"}, { - "before": {"exists": False, "is_pos": True, "values": [], "flag": None}, - "message": {"exists": True, "is_pos": False, "values": ["Hello, world!"], "flag": "@msg"}, - "after": {"exists": True, "is_pos": True, "values": ["How are you?"], "flag": None}, + "before": {"exists": False, "is_pos": True, "values": (), "flag": None}, + "message": {"exists": True, "is_pos": False, "values": ("Hello, world!", ), "flag": "@msg"}, + "after": {"exists": True, "is_pos": True, "values": ("How are you?", ), "flag": None}, }, ), - # --- DON'T TREAT VALUES STARTING WITH SPECIFIED FLAG PREFIXES AS FLAGS --- + # --- NEGATIVE NUMBERS ARE TREATED AS VALUES; UNKNOWN FLAGS ARE COLLECTED SEPARATELY --- ( ["script.py", "-42", "-d=-256", "--file=--not-a-flag", "--also-no-flag"], {"before": "before", "data": {"-d"}, "file": {"--file"}, "after": "after"}, { - "before": {"exists": True, "is_pos": True, "values": ["-42"], "flag": None}, - "data": {"exists": True, "is_pos": False, "values": ["-256"], "flag": "-d"}, - "file": {"exists": True, "is_pos": False, "values": ["--not-a-flag"], "flag": "--file"}, - "after": {"exists": True, "is_pos": True, "values": ["--also-no-flag"], "flag": None}, + "before": {"exists": True, "is_pos": True, "values": ("-42", ), "flag": None}, + "data": {"exists": True, "is_pos": False, "values": ("-256", ), "flag": "-d"}, + "file": {"exists": True, "is_pos": False, "values": ("--not-a-flag", ), "flag": "--file"}, + "after": {"exists": False, "is_pos": True, "values": (), "flag": None}, }, ), @@ -253,21 +253,21 @@ def test_console_supports_color(): ( ["script.py", "--flag", "myValue"], {"flag": {"--flag"}}, - {"flag": {"exists": True, "is_pos": False, "values": ["myValue"], "flag": "--flag"}}, + {"flag": {"exists": True, "is_pos": False, "values": ("myValue", ), "flag": "--flag"}}, ), # SHORT FLAG WITH SPACE-SEPARATED VALUE ( ["script.py", "-f", "file.txt"], {"file": {"-f", "--file"}}, - {"file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"}}, + {"file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"}}, ), # KNOWN FLAG FOLLOWING A FLAG IS NOT CONSUMED AS VALUE ( ["script.py", "--msg", "--flag"], {"message": {"--msg"}, "flag": {"--flag"}}, { - "message": {"exists": True, "is_pos": False, "values": [], "flag": "--msg"}, - "flag": {"exists": True, "is_pos": False, "values": [], "flag": "--flag"}, + "message": {"exists": True, "is_pos": False, "values": (), "flag": "--msg"}, + "flag": {"exists": True, "is_pos": False, "values": (), "flag": "--flag"}, }, ), # MULTIPLE FLAGS WITH SPACE-SEPARATED VALUES @@ -275,8 +275,8 @@ def test_console_supports_color(): ["script.py", "--msg", "hello", "-n", "42"], {"message": {"--msg"}, "number": {"-n"}}, { - "message": {"exists": True, "is_pos": False, "values": ["hello"], "flag": "--msg"}, - "number": {"exists": True, "is_pos": False, "values": ["42"], "flag": "-n"}, + "message": {"exists": True, "is_pos": False, "values": ("hello", ), "flag": "--msg"}, + "number": {"exists": True, "is_pos": False, "values": ("42", ), "flag": "-n"}, }, ), # SPACE-SEPARATED VALUE CONSUMED BY FLAG, REMAINING TOKEN IS "after" POSITIONAL @@ -284,8 +284,8 @@ def test_console_supports_color(): ["script.py", "--flag", "val", "after1"], {"flag": {"--flag"}, "after": "after"}, { - "flag": {"exists": True, "is_pos": False, "values": ["val"], "flag": "--flag"}, - "after": {"exists": True, "is_pos": True, "values": ["after1"], "flag": None}, + "flag": {"exists": True, "is_pos": False, "values": ("val", ), "flag": "--flag"}, + "after": {"exists": True, "is_pos": True, "values": ("after1", ), "flag": None}, }, ), # SPACE-SEPARATED VALUE WITH BOTH "before" AND "after" POSITIONALS @@ -293,9 +293,9 @@ def test_console_supports_color(): ["script.py", "pre", "--flag", "val", "post"], {"before": "before", "flag": {"--flag"}, "after": "after"}, { - "before": {"exists": True, "is_pos": True, "values": ["pre"], "flag": None}, - "flag": {"exists": True, "is_pos": False, "values": ["val"], "flag": "--flag"}, - "after": {"exists": True, "is_pos": True, "values": ["post"], "flag": None}, + "before": {"exists": True, "is_pos": True, "values": ("pre", ), "flag": None}, + "flag": {"exists": True, "is_pos": False, "values": ("val", ), "flag": "--flag"}, + "after": {"exists": True, "is_pos": True, "values": ("post", ), "flag": None}, }, ), ] @@ -343,12 +343,12 @@ def test_get_args_custom_sep(monkeypatch: pytest.MonkeyPatch): assert result.message.exists is True assert result.message.is_pos is False - assert result.message.values == ["This is a message"] + assert result.message.values == ("This is a message", ) assert result.message.flag == "--msg" assert result.data.exists is True assert result.data.is_pos is False - assert result.data.values == ["42"] + assert result.data.values == ("42", ) assert result.data.flag == "-d" assert result.dict() == { @@ -366,17 +366,18 @@ def test_get_args_no_sep(monkeypatch: pytest.MonkeyPatch): flag_value_sep=None, ) - # '--flag' consumes 'space_val' via space-separated syntax + # '--flag' CONSUMES 'space_val' VIA SPACE-SEPARATED SYNTAX assert result.flag.exists is True - assert result.flag.values == ["space_val"] + assert result.flag.values == ("space_val", ) assert result.flag.flag == "--flag" - # '--other=ignored' is not recognized as a flag (no separator processing) – goes to "after" + # '--other=ignored' LOOKS LIKE A FLAG WITH NO SEPARATOR PROCESSING – TREATED AS UNKNOWN FLAG assert result.other.exists is False - assert result.other.values == [] + assert result.other.values == () - assert result.after.exists is True - assert result.after.values == ["--other=ignored"] + assert result.after.exists is False + assert result.after.values == () + assert result.unknown_flags == frozenset({"--other=ignored"}) def test_get_args_allow_space_value_false(monkeypatch: pytest.MonkeyPatch): @@ -389,12 +390,12 @@ def test_get_args_allow_space_value_false(monkeypatch: pytest.MonkeyPatch): # 'val' must NOT be consumed by --flag assert result.flag.exists is True - assert result.flag.values == [] + assert result.flag.values == () assert result.flag.flag == "--flag" # both 'val' and 'after1' are unclaimed and collected as "after" positionals assert result.after.exists is True - assert result.after.values == ["val", "after1"] + assert result.after.values == ("val", "after1") def test_get_args_mixed_dash_scenarios(monkeypatch: pytest.MonkeyPatch): @@ -417,34 +418,36 @@ def test_get_args_mixed_dash_scenarios(monkeypatch: pytest.MonkeyPatch): assert result.before.exists is True assert result.before.is_pos is True - assert result.before.values == ["before string", "-42"] + assert result.before.values == ("before string", "-42") assert result.before.flag is None assert result.data.exists is True assert result.data.is_pos is False - assert result.data.values == ["256"] + assert result.data.values == ("256", ) assert result.data.flag == "-d" assert result.file.exists is True assert result.file.is_pos is False - assert result.file.values == ["my-file.txt"] + assert result.file.values == ("my-file.txt", ) assert result.file.flag == "--file" assert result.verbose.exists is True assert result.verbose.is_pos is False - assert result.verbose.values == [] + assert result.verbose.values == () assert result.verbose.flag == "-vv" assert result.help.exists is False assert result.help.is_pos is False - assert result.help.values == [] + assert result.help.values == () assert result.help.flag is None assert result.after.exists is True assert result.after.is_pos is True - assert result.after.values == ["after string", "--also-no-flag"] + assert result.after.values == ("after string", ) assert result.after.flag is None + assert result.unknown_flags == frozenset({"--also-no-flag"}) + assert result.dict() == { "before": result.before.dict(), "data": result.data.dict(), @@ -455,30 +458,61 @@ def test_get_args_mixed_dash_scenarios(monkeypatch: pytest.MonkeyPatch): } +def test_get_args_unknown_flags(monkeypatch: pytest.MonkeyPatch): + """Unknown flags (not in config) are collected in unknown_flags and not consumed as values.""" + # STANDALONE UNKNOWN FLAGS AND UNKNOWN FLAG WITH INLINE SEPARATOR + monkeypatch.setattr(sys, "argv", ["script.py", "--known=val", "--unknown", "--unknown=extra", "-u"]) + result = Console.get_args({"known": {"--known"}, "after": "after"}) + + assert result.known.exists is True + assert result.known.values == ("val", ) + assert result.unknown_flags == frozenset({"--unknown", "--unknown=extra", "-u"}) + assert result.after.values == () + + +def test_get_args_unknown_flag_not_consumed_as_value(monkeypatch: pytest.MonkeyPatch): + """An unknown flag following a known flag is NOT consumed as that flag's space-separated value.""" + monkeypatch.setattr(sys, "argv", ["script.py", "--known", "--unknown"]) + result = Console.get_args({"known": {"--known"}}) + + assert result.known.exists is True + assert result.known.values == () + assert result.unknown_flags == frozenset({"--unknown"}) + + +def test_get_args_negative_number_not_unknown_flag(monkeypatch: pytest.MonkeyPatch): + """Negative numbers (e.g. -42, -.5) are not treated as unknown flags.""" + monkeypatch.setattr(sys, "argv", ["script.py", "-42", "-.5", "--known"]) + result = Console.get_args({"before": "before", "known": {"--known"}}) + + assert result.before.values == ("-42", "-.5") + assert result.unknown_flags == frozenset() + + def test_get_args_skip(monkeypatch: pytest.MonkeyPatch): """Test that skip=N drops the first N argv entries before any parsing.""" # WITH skip=1: argv[1] ('fc') IS SKIPPED, PARSING STARTS AT argv[2] monkeypatch.setattr(sys, "argv", ["script.py", "fc", "hello", "world"]) result = Console.get_args({"input": "before"}, skip=1) assert result.input.exists is True - assert result.input.values == ["hello", "world"] + assert result.input.values == ("hello", "world") # WITH skip=2: argv[1] AND argv[2] ARE SKIPPED, PARSING STARTS AT argv[3] monkeypatch.setattr(sys, "argv", ["script.py", "sub", "cmd", "--flag=val"]) result = Console.get_args({"flag": {"--flag"}}, skip=2) assert result.flag.exists is True - assert result.flag.values == ["val"] + assert result.flag.values == ("val", ) # WITH skip EXCEEDING ARGV LENGTH: NO ARGS ARE PARSED monkeypatch.setattr(sys, "argv", ["script.py", "only"]) result = Console.get_args({"flag": {"--flag"}}, skip=5) assert result.flag.exists is False - # WITH skip=0 (DEFAULT): BEHAVIOUR IS UNCHANGED + # WITH skip=0 (DEFAULT): BEHAVIOR IS UNCHANGED monkeypatch.setattr(sys, "argv", ["script.py", "--flag=val"]) result = Console.get_args({"flag": {"--flag"}}, skip=0) assert result.flag.exists is True - assert result.flag.values == ["val"] + assert result.flag.values == ("val", ) def test_parsed_args_is_empty(): @@ -549,9 +583,7 @@ def test_parsed_args_all_exist(): assert args_partial.all_exist is False # NONE EXIST - args_none = ParsedArgs( - a=ParsedArgData(exists=False, values=[], is_pos=False), - ) + args_none = ParsedArgs(a=ParsedArgData(exists=False, values=[], is_pos=False), ) assert args_none.all_exist is False # EMPTY ParsedArgs: all_exist IS True (vacuously) @@ -560,17 +592,15 @@ def test_parsed_args_all_exist(): def test_parsed_args_properties_not_in_iter(): """Properties is_empty, any_exist, all_exist must not appear when iterating.""" - args = ParsedArgs( - flag=ParsedArgData(exists=True, values=[], is_pos=False), - ) + args = ParsedArgs(flag=ParsedArgData(exists=True, values=[], is_pos=False), ) keys = [k for k, _ in args] assert "is_empty" not in keys assert "any_exist" not in keys assert "all_exist" not in keys + assert "unknown_flags" not in keys assert len(args) == 1 - def test_args_dunder_methods(): args = ParsedArgs( before=ParsedArgData(exists=True, values=["arg1", "arg2"], is_pos=True), From 2fc32b33618ff773716107bce810b40540d05e6a Mon Sep 17 00:00:00 2001 From: XulbuX Date: Thu, 14 May 2026 22:47:54 +0200 Subject: [PATCH 10/18] Exclude `./build` folder from linting --- .github/workflows/test-and-lint.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index b4e7a91..b0e979a 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -44,8 +44,8 @@ jobs: - name: Lint with flake8 run: | - python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - python -m flake8 . --exit-zero --max-complexity=12 --statistics + python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=build + python -m flake8 . --exit-zero --max-complexity=12 --statistics --exclude=build - name: Type check with pyright run: | From 04b236b3b348fb139604363e46f5fd8b629f0c5f Mon Sep 17 00:00:00 2001 From: XulbuX Date: Fri, 15 May 2026 14:54:20 +0200 Subject: [PATCH 11/18] No longer force the `Console.log()` title to be all uppercase --- CHANGELOG.md | 7 +++++++ README.md | 5 +++++ src/xulbux/console.py | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd77bd..b070b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ * Improved the performance of `Console.log()` and `FormatCodes.to_ansi()` by restructuring the way they process the formatting and output. * Improved the performance of `String.normalize_spaces()` by using `str.translate()` instead of multiple `str.replace()` calls. * Improved the performance of `Data.remove_duplicates()` for lists and tuples: hashable items now deduplicate in O(n) using `dict.fromkeys()`, with an O(n²) equality-check fallback only for unhashable items (*lists, dicts, sets*). +* The `Console.log()` method no longer forces the title to be all uppercase, giving the user a bit more freedom in how they want to format their title. **BREAKING CHANGES:** @@ -188,9 +189,11 @@ * The arguments when calling `Console.get_args()` are no longer specified in a single dictionary, but now each argument is passed as a separate keyword argument.
You can still use a dictionary just fine by simply unpacking it with `**`, like this: + ```python Console.get_args(**{"arg": {"-a", "--arg"}}) ``` + * Replaced the internal `_COMPILED` regex pattern dictionaries with `LazyRegex` objects so it won't compile all regex patterns on library import, but only when they are used for the first time, which improves the library's import time. * Renamed the internal `_COMPILED` regex pattern dictionaries to `_PATTERNS` for better clarity. * Removed the import of the `ProgressBar` class from the `__init__.py` file, since it's not an important main class that should be imported directly. @@ -909,14 +912,17 @@ * Added a library description, which gets shown if the library base-import is run directly. * Made it possible to escape an auto-reset-format (`[format](Automatically resetting)`) with a slash, so you can still have `()` brackets behind a `[format]`: + ```python FormatCodes.print('[u](Automatically resetting) following text') ``` + prints: Automatically resetting following text ```python FormatCodes.print('[u]/(Automatically resetting) following text') ``` + prints: (Automatically resetting) following text @@ -956,6 +962,7 @@ $\color{#F90}\Huge\textsf{INITIAL RELEASE!\ 🤩🎉}$
**At initial release**, the library **$\color{#8085FF}\textsf{XulbuX}$** looks like this: + ```python # GENERAL LIBRARY import XulbuX as xx diff --git a/README.md b/README.md index 289a48d..153b3db 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ For the libraries latest changes and updates, see the [**change log**](https://g Run the following commands in a terminal with administrator privileges, so the actions take effect for all users. Install the library and all its dependencies with the command: + ```shell pip install xulbux ``` Upgrade the library and all its dependencies to their latest available version with the command: + ```shell pip install --upgrade xulbux ``` @@ -45,11 +47,13 @@ When the library is installed, the following commands are available in the termi ## Usage Import the full library under the alias `xx`, so its modules and main classes are accessible with `xx.module.Class`, `xx.MainClass.method()`: + ```python import xulbux as xx ``` So you don't have to import the full library under an alias, you can also import only certain parts of the library's contents: + ```python # LIBRARY SUB MODULES from xulbux.base.consts import COLOR, CHARS, ANSI @@ -159,6 +163,7 @@ from xulbux.color import rgba, hsla, hexa ## Example Usage This is what it could look like using this library for a simple but ultra good-looking color converter: + ```python from xulbux.base.consts import COLOR, CHARS from xulbux.color import hexa diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 1932ef1..48889df 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -504,7 +504,7 @@ def log( raise ValueError(f"The 'title_mx' parameter must be a non-negative integer, got {title_mx!r}") title_fg: str = "_c" - title = "" if title is None else title.strip().upper() + title = "" if title is None else title.strip() if has_title_bg := title_bg_color is not None: if str(title_bg_color).replace(" ", "").lower() in ANSI.COLOR_VARIANTS_MAP: From 5819bb1e5fa5ff78ff314bb88009c44ada19a1a7 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Fri, 15 May 2026 14:59:43 +0200 Subject: [PATCH 12/18] Use the rendered title length for tab size calculation in `Console.log()` --- src/xulbux/console.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 48889df..401e076 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -524,7 +524,7 @@ def log( px, mx = " " * title_px, " " * title_mx # TITLE LENGTH INCLUDING PADDING AND MARGIN - title_len: int = len(title) + (title_px * 2) + (title_mx * 2) + title_len: int = len(FormatCodes.remove(title)) + (title_px * 2) + (title_mx * 2) # CALCULATE DISTANCE TO NEXT TAB STOP tab: str = " " * (-title_len % tab_size) From cc01edf3ee9e6fb250ffaba2901106c20f8141c8 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Fri, 15 May 2026 15:50:01 +0200 Subject: [PATCH 13/18] Improve inline `is not None` if-statements and start fixing `E501 line too long` --- pyproject.toml | 6 +++++- src/xulbux/color.py | 20 +++++++++++++------- src/xulbux/console.py | 35 ++++++++++++++++++++++++++--------- src/xulbux/file_sys.py | 2 +- src/xulbux/format_codes.py | 9 +++------ 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c9d0703..1a14ebc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -119,7 +119,11 @@ max-complexity = 12 max-line-length = 127 select = ["E", "F", "W", "C90"] extend-ignore = ["E124", "E203", "E266", "E502", "W503"] -per-file-ignores = ["__init__.py:F403,F405", "types.py:E302,E305"] +per-file-ignores = [ + "__init__.py:F403,F405", + "src/xulbux/base/types.py:E302,E305", + "src/xulbux/cli/help.py:E501", +] [tool.setuptools] package-dir = { "" = "src" } diff --git a/src/xulbux/color.py b/src/xulbux/color.py index b52e3a3..d8a3a0a 100644 --- a/src/xulbux/color.py +++ b/src/xulbux/color.py @@ -59,7 +59,8 @@ def __init__(self, red: int, green: int, blue: int, alpha: Optional[float] = Non if not all((0 <= ch <= 255) for ch in (red, green, blue)): raise ValueError( - f"The 'red', 'green' and 'blue' parameters must be integers in range [0, 255] inclusive, got {red=!r} {green=!r} {blue=!r}" + "The 'red', 'green' and 'blue' parameters must be integers " + f"in range [0, 255] inclusive, got {red=!r} {green=!r} {blue=!r}" ) if alpha is not None and not (0.0 <= alpha <= 1.0): raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") @@ -688,19 +689,22 @@ def __ne__(self, other: object, /) -> bool: return not self.__eq__(other) def __repr__(self) -> str: - return f"hexa(#{self.red:02X}{self.green:02X}{self.blue:02X}{'' if self.alpha is None else f'{int(self.alpha * 255):02X}'})" + alpha = "" if self.alpha is None else f"{int(self.alpha * 255):02X}" + return f"hexa(#{self.red:02X}{self.green:02X}{self.blue:02X}{alpha})" def __str__(self) -> str: - return f"#{self.red:02X}{self.green:02X}{self.blue:02X}{'' if self.alpha is None else f'{int(self.alpha * 255):02X}'}" + alpha = "" if self.alpha is None else f"{int(self.alpha * 255):02X}" + return f"#{self.red:02X}{self.green:02X}{self.blue:02X}{alpha}" def dict(self) -> HexaDict: - """Returns the color components as a dictionary with hex string values for keys `"red"`, `"green"`, `"blue"` and optionally `"alpha"`.""" + """Returns the color components as a dictionary with hex string values
+ for keys `"red"`, `"green"`, `"blue"` and optionally `"alpha"`.""" return HexaDict( red=f"{self.red:02X}", green=f"{self.green:02X}", blue=f"{self.blue:02X}", - alpha=(f"{int(self.alpha * 255):02X}" if self.alpha is not None else None), + alpha=(None if self.alpha is None else f"{int(self.alpha * 255):02X}"), ) def values(self, *, round_alpha: bool = True) -> tuple[int, int, int, Optional[float]]: @@ -1247,7 +1251,8 @@ def rgba_to_hex_int( if not all((0 <= ch <= 255) for ch in (red, green, blue)): raise ValueError( - f"The 'red', 'green' and 'blue' parameters must be integers in [0, 255], got {red=!r} {green=!r} {blue=!r}" + "The 'red', 'green' and 'blue' parameters must be integers " + f"in [0, 255], got {red=!r} {green=!r} {blue=!r}" ) if alpha is not None and not (0.0 <= alpha <= 1.0): raise ValueError(f"The 'alpha' parameter must be a float in [0.0, 1.0] or None, got {alpha!r}") @@ -1387,7 +1392,8 @@ def luminance( if not all(0 <= ch <= 255 for ch in (red, green, blue)): raise ValueError( - f"The 'red', 'green' and 'blue' parameters must be integers in [0, 255], got {red=!r} {green=!r} {blue=!r}" + "The 'red', 'green' and 'blue' parameters must be integers " + f"in [0, 255], got {red=!r} {green=!r} {blue=!r}" ) _red, _green, _blue = red / 255.0, green / 255.0, blue / 255.0 diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 401e076..ebec8b8 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -92,7 +92,14 @@ def __ne__(self, other: object, /) -> bool: return not self.__eq__(other) def __repr__(self) -> str: - return f"ParsedArgData(\n exists = {self.exists!r},\n is_pos = {self.is_pos!r},\n values = {self.values!r},\n flag = {self.flag!r}\n)" + return ( + "ParsedArgData(\n" + f" exists = {self.exists!r},\n" + f" is_pos = {self.is_pos!r},\n" + f" values = {self.values!r},\n" + f" flag = {self.flag!r}\n" + ")" + ) def __str__(self) -> str: return self.__repr__() @@ -313,7 +320,7 @@ def encoding(cls) -> str: try: encoding = _sys.stdout.encoding - return encoding if encoding is not None else "utf-8" + return "utf-8" if encoding is None else encoding except (AttributeError, Exception): return "utf-8" @@ -514,7 +521,8 @@ def log( title_fg = str(Color.text_color_for_on_bg(title_bg_color)) else: raise ValueError( - f"The 'title_bg_color' parameter must be a valid terminal color, RGBA value, or HEXA value, got {title_bg_color!r}" + "The 'title_bg_color' parameter must be a valid terminal color, " + f"RGBA value, or HEXA value, got {title_bg_color!r}" ) else: title_px = 0 # REMOVE PADDING IF TITLE HAS NO BG COLOR @@ -754,7 +762,8 @@ def log_box_filled( box_bg_color = Color.to_hexa(box_bg_color) else: raise ValueError( - f"The 'box_bg_color' parameter must be a valid terminal color, RGBA value, or HEXA value, got {box_bg_color!r}" + "The 'box_bg_color' parameter must be a valid terminal color, " + f"RGBA value, or HEXA value, got {box_bg_color!r}" ) lines, unfmt_lines, max_line_len = cls._prepare_log_box(values, default_color) @@ -863,12 +872,20 @@ def log_box_bordered( spaces_l = " " * indent pad_w_full = (cls.width - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0 + border_t_line = border_chars[1] * ( + cls.width - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding) + ) + border_b_line = border_chars[5] * ( + cls.width - (len(border_chars[5] * 2)) if w_full else max_line_len + (2 * w_padding) + ) + h_rule_line = border_chars[9] * (cls.width - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding)) + border_l = f"[{border_style}]{border_chars[7]}[*]" border_r = f"[{border_style}]{border_chars[3]}[_]" - border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_chars[1] * (cls.width - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]" - border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_chars[5] * (cls.width - (len(border_chars[5] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]" + border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_t_line}{border_chars[2]}[_]" + border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_b_line}{border_chars[4]}[_]" - h_rule = f"{spaces_l}[{border_style}]{border_chars[8]}{border_chars[9] * (cls.width - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[10]}[_]" + h_rule = f"{spaces_l}[{border_style}]{border_chars[8]}{h_rule_line}{border_chars[10]}[_]" lines = [( \ h_rule if _PATTERNS.hr.match(line) else f"{spaces_l}{border_l}{' ' * w_padding}{line}[_]" @@ -1404,7 +1421,7 @@ def _collect_before_arg(self, alias: str, /) -> None: """Collect positional `"before"` arguments.""" before_args: list[str] = [] - end_pos: int = self.first_flag_pos if self.first_flag_pos is not None else self.args_len + end_pos: int = self.args_len if self.first_flag_pos is None else self.first_flag_pos for i in range(end_pos): if self._is_positional_arg(arg := self.args[i], allow_separator=False): @@ -1417,7 +1434,7 @@ def _collect_after_arg(self, alias: str, /) -> None: """Collect positional `"after"` arguments.""" after_args: list[str] = [] - start_pos: int = (self.last_flag_pos + 1) if self.last_flag_pos is not None else 0 + start_pos: int = 0 if self.last_flag_pos is None else (self.last_flag_pos + 1) # SKIP THE VALUE AFTER THE LAST FLAG IF IT HAS A SEPARATOR if self.last_flag_pos is not None: diff --git a/src/xulbux/file_sys.py b/src/xulbux/file_sys.py index 868fd35..911f6e0 100644 --- a/src/xulbux/file_sys.py +++ b/src/xulbux/file_sys.py @@ -131,7 +131,7 @@ def extend_or_make_path( try: result = cls.extend_path(rel_path, search_in=search_in, raise_error=True, fuzzy_match=fuzzy_match) - return result if result is not None else Path() + return Path() if result is None else result except PathNotFoundError: path = Path(str(rel_path)) diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index d3b78d2..e28e8e2 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -672,10 +672,8 @@ def _get_replacement(cls, format_key: str, default_color: Optional[rgba], /, bri are reset or you can get lighter or darker version of `default_color` (also as BG)""" # FAST PATH WHEN NO DEFAULT COLOR: USE CACHED RESULTS - if default_color is None: - cached = _REPLACEMENT_CACHE.get(format_key) - if cached is not None: - return cached + if default_color is None and (cached := _REPLACEMENT_CACHE.get(format_key)) is not None: + return cached _format_key = format_key format_key = cls._normalize_key(format_key) # NORMALIZE KEY AND SAVE ORIGINAL @@ -769,8 +767,7 @@ def _get_default_ansi( def _normalize_key(format_key: str, /) -> str: """Internal method to normalize the given format key.""" - cached = _NORMALIZE_KEY_CACHE.get(format_key) - if cached is not None: + if (cached := _NORMALIZE_KEY_CACHE.get(format_key)) is not None: return cached k_parts = format_key.replace(" ", "").lower().split(":") From ba1a724b981ce44f909ce6cb6fd5533cbf10a9f7 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Fri, 15 May 2026 16:03:23 +0200 Subject: [PATCH 14/18] Continue fixing `E501 line too long` --- src/xulbux/console.py | 9 ++++-- src/xulbux/format_codes.py | 59 ++++++++++++++++++++++++-------------- src/xulbux/system.py | 6 +++- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/xulbux/console.py b/src/xulbux/console.py index ebec8b8..4631a8d 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -1862,7 +1862,8 @@ def set_bar_format( if bar_format is not None: if not any(_PATTERNS.bar.search(part) for part in bar_format): raise ValueError( - f"The 'bar_format' parameter value must contain the '{{bar}}' or '{{b}}' placeholder, got {bar_format!r}" + "The 'bar_format' parameter value must contain the " + f"'{{bar}}' or '{{b}}' placeholder, got {bar_format!r}" ) self.bar_format = bar_format @@ -1870,7 +1871,8 @@ def set_bar_format( if limited_bar_format is not None: if not any(_PATTERNS.bar.search(part) for part in limited_bar_format): raise ValueError( - f"The 'limited_bar_format' parameter value must contain the '{{bar}}' or '{{b}}' placeholder, got {limited_bar_format!r}" + "The 'limited_bar_format' parameter value must contain the " + f"'{{bar}}' or '{{b}}' placeholder, got {limited_bar_format!r}" ) self.limited_bar_format = limited_bar_format @@ -2210,7 +2212,8 @@ def set_format(self, throbber_format: list[str] | tuple[str, ...], *, sep: Optio if not any(_PATTERNS.animation.search(fmt) for fmt in throbber_format): raise ValueError( - f"At least one format string in 'throbber_format' must contain the '{{animation}}' or '{{a}}' placeholder, got {throbber_format!r}" + "At least one format string in 'throbber_format' must contain the " + f"'{{animation}}' or '{{a}}' placeholder, got {throbber_format!r}" ) self.throbber_format = throbber_format diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index e28e8e2..6a0bf94 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -2,10 +2,11 @@ This module provides the `FormatCodes` class, which includes methods to print and work with strings that contain special formatting codes, which are then converted to ANSI codes for pretty terminal output. ------------------------------------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------------------- ### The Easy Formatting -First, let's take a look at a small example of what a highly styled print string with formatting could look like using this module: +First, let's take a look at a small example of what a highly styled +print string with formatting could look like using this module: ``` This here is just unformatted text. [b|u|br:blue](Next we have text that is bright blue + bold + underlined.)\\n [#000|bg:#F67](Then there's also black text with a red background.) And finally the ([i](boring)) plain text again. @@ -13,49 +14,55 @@ How all of this exactly works is explained in the sections below. 🠫 ------------------------------------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------------------- #### Formatting Codes and Keys In this module, you can apply styles and colors using simple formatting codes. These formatting codes consist of one or multiple different formatting keys in between square brackets. -If a formatting code is placed in a print-string, the formatting of that code will be applied to everything behind it until its -formatting is reset. If applying multiple styles and colors in the same place, instead of writing the formatting keys all into -separate brackets (e.g. `[x][y][z]`), they can also be put in a single pair of brackets, separated by pipes (e.g. `[x|y|z]`). +If a formatting code is placed in a print-string, the formatting of that code +will be applied to everything behind it until its formatting is reset. +If applying multiple styles and colors in the same place, instead of writing +the formatting keys all into separate brackets (e.g. `[x][y][z]`), +they can also be put in a single pair of brackets, separated by pipes (e.g. `[x|y|z]`). A list of all possible formatting keys can be found under all possible formatting keys. ------------------------------------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------------------- #### Auto Resetting Formatting Codes -Certain formatting can automatically be reset, behind a certain amount of text, just like shown in the following example: +Certain formatting can automatically be reset, behind a certain +amount of text, just like shown in the following example: ``` This is plain text, [br:blue](which is bright blue now.) Now it was automatically reset to plain again. ``` This will only reset formatting codes, that have a specific reset listed below. -That means if you use it where another formatting is already applied, that formatting is still there after the automatic reset: +That means if you use it where another formatting is already applied, +that formatting is still there after the automatic reset: ``` [cyan]This is cyan text, [dim](which is dimmed now.) Now it's not dimmed any more but still cyan. ``` -If you want to ignore the auto-reset functionality of `()` brackets, you can put a `\\` or `/` between them and -the formatting code: +If you want to ignore the auto-reset functionality of `()` brackets, +you can put a `\\` or `/` between them and the formatting code: ``` [cyan]This is cyan text, [u]/(which is underlined now.) And now it is still underlined and cyan. ``` ------------------------------------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------------------- #### All possible Formatting Keys * RGB colors: - Change the text color directly with an RGB color inside the square brackets. (With or without `rgb()` brackets doesn't matter.) + Change the text color directly with an RGB color inside the square brackets. + (With or without `rgb()` brackets doesn't matter.) Examples: - `[rgb(115, 117, 255)]` - `[(255, 0, 136)]` - `[255, 0, 136]` * HEX colors: - Change the text color directly with a HEX color inside the square brackets. (Whether the `RGB` or `RRGGBB` HEX format is used, + Change the text color directly with a HEX color inside the square brackets. + (Whether the `RGB` or `RRGGBB` HEX format is used, and if there's a `#` or `0x` prefix, doesn't matter.) Examples: - `[0x7788FF]` @@ -65,7 +72,8 @@ - `[#78F]` - `[78F]` * Background RGB / HEX colors: - Change the background color directly with an RGB or HEX color inside the square brackets, using the `background:` `BG:` prefix. + Change the background color directly with an RGB or HEX color inside + the square brackets, using the `background:` `BG:` prefix. (Same RGB / HEX formatting code rules as for text color.) Examples: - `[bg:rgb(115, 117, 255)]` @@ -73,7 +81,8 @@ - `[bg:#7788FF]` - `[bg:#78F]` * Standard terminal colors: - Change the text color to one of the standard terminal colors by just writing the color name in the square brackets. + Change the text color to one of the standard terminal colors + by just writing the color name in the square brackets. - `[black]` - `[red]` - `[green]` @@ -101,7 +110,8 @@ - `[bg:br:red]` - … * Text styles: - Use the built-in text formatting to change the style of the text. There are long and short forms for each formatting code. + Use the built-in text formatting to change the style of the text. + There are long and short forms for each formatting code. (Not all terminals support all text styles.) - `[bold]` `[b]` - `[dim]` @@ -112,8 +122,8 @@ - `[strikethrough]` `[s]` - `[double-underline]` `[du]` * Specific reset: - Use these reset codes to remove a specific style, color or background. Again, there are long and - short forms for each reset code. + Use these reset codes to remove a specific style, color or background. + Again, there are long and short forms for each reset code. - `[_bold]` `[_b]` - `[_dim]` - `[_italic]` `[_i]` @@ -134,7 +144,7 @@ - `[link:file:///path/to/file.txt](open file)` - `[link:https://example.com|br:blue](click here)` ------------------------------------------------------------------------------------------------------------------------------------- +-------------------------------------------------------------------------------------------------------------------- #### Additional Formatting Codes when a `default_color` is set 1. `[*]` resets everything, just like `[_]`, but the text color will remain in `default_color` @@ -1045,7 +1055,14 @@ def build_output(self, match: _rx.Match[str], /) -> str: # ADD AUTO-RESET TEXT if self.auto_reset_escaped and self.auto_reset_txt: - output += f"({self.cls.to_ansi(self.auto_reset_txt, self.default_color, self.brightness_steps, _default_start=False, _validate_default=False)})" + output = self.cls.to_ansi( + self.auto_reset_txt, + self.default_color, + self.brightness_steps, + _default_start=False, + _validate_default=False, + ) + output += f"({output})" elif self.auto_reset_txt: output += self.auto_reset_txt diff --git a/src/xulbux/system.py b/src/xulbux/system.py index 2bf5daa..32e02c2 100644 --- a/src/xulbux/system.py +++ b/src/xulbux/system.py @@ -189,7 +189,11 @@ def elevate(cls, win_title: Optional[str] = None, args: Optional[list[str]] = No if _os.name == "nt": # WINDOWS if win_title: - args_str = f'-c "import ctypes; ctypes.windll.kernel32.SetConsoleTitleW(\\"{win_title}\\"); exec(open(\\"{_sys.argv[0]}\\").read())" {" ".join(args_list)}"' + args_str = ( + '-c "import ctypes; ' + f'ctypes.windll.kernel32.SetConsoleTitleW(\\"{win_title}\\"); ' + f'exec(open(\\"{_sys.argv[0]}\\").read())" ' + " ".join(args_list) + ) else: args_str = f'-c "exec(open(\\"{_sys.argv[0]}\\").read())" {" ".join(args_list)}' From 75b38021e00ecdbe5759c9798d536aedb5e4bd04 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Sat, 16 May 2026 00:05:22 +0200 Subject: [PATCH 15/18] wip: Completely rework the formatting API and mark the old one as deprecated (to be removed in a future commits) --- src/xulbux/{format_codes.py => depr_format_codes.py} | 0 tests/{test_format_codes.py => test_depr_format_codes.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/xulbux/{format_codes.py => depr_format_codes.py} (100%) rename tests/{test_format_codes.py => test_depr_format_codes.py} (100%) diff --git a/src/xulbux/format_codes.py b/src/xulbux/depr_format_codes.py similarity index 100% rename from src/xulbux/format_codes.py rename to src/xulbux/depr_format_codes.py diff --git a/tests/test_format_codes.py b/tests/test_depr_format_codes.py similarity index 100% rename from tests/test_format_codes.py rename to tests/test_depr_format_codes.py From 9901b2402d78adfb66a93c3ae9a601cdeb64666a Mon Sep 17 00:00:00 2001 From: XulbuX Date: Sat, 16 May 2026 00:05:40 +0200 Subject: [PATCH 16/18] wip: Completely rework the formatting API and mark the old one as deprecated (to be removed in a future commits) --- CHANGELOG.md | 7 + README.md | 8 +- src/xulbux/__init__.py | 2 + src/xulbux/base/consts.py | 30 +- src/xulbux/cli/help.py | 6 +- src/xulbux/cli/tools.py | 14 +- src/xulbux/console.py | 48 +- src/xulbux/data.py | 4 +- src/xulbux/depr_format_codes.py | 25 +- src/xulbux/format_codes.py | 837 ++++++++++++++++++++++++++++++++ src/xulbux/system.py | 6 +- tests/test_console.py | 6 +- tests/test_depr_format_codes.py | 92 ++-- tests/test_format_codes.py | 210 ++++++++ 14 files changed, 1185 insertions(+), 110 deletions(-) create mode 100644 src/xulbux/format_codes.py create mode 100644 tests/test_format_codes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b070b7e..6fe05b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ * Improved the performance of `String.normalize_spaces()` by using `str.translate()` instead of multiple `str.replace()` calls. * Improved the performance of `Data.remove_duplicates()` for lists and tuples: hashable items now deduplicate in O(n) using `dict.fromkeys()`, with an O(n²) equality-check fallback only for unhashable items (*lists, dicts, sets*). * The `Console.log()` method no longer forces the title to be all uppercase, giving the user a bit more freedom in how they want to format their title. +* Added a new, typed, operator-based formatting API in `format_codes`.
+ The `Format` class (*alias* `F`) exposes every ANSI style/color attribute and uses `|` to combine codes and `()` to apply them to text – e.g. `(F.BOLD | F.RED)("hi")` and `F.hex("#F67")("hi")`.
+ The new `FormatCodes(*segments, sep="\n")` class builds the ANSI string on construction and exposes `.ansi`, `.raw`, `.code_positions`, `.print()` and `.input()`.
+ A companion `Term` class provides commonly used cursor- and screen-control sequences (`Term.HIDE_CURSOR`, `Term.up(n)`, `Term.move(row, col)`, `Term.title(text)`, …). **BREAKING CHANGES:** @@ -43,6 +47,9 @@ * Removed the `format_linebreaks` param from `Console.log()`, as the whole point of the `log()` method is to get a nicely formatted log message. * The `Console.get_args()` method no longer treats unknown flags as values but therefore saves them to the new `unknown_flags` property of the returned `ParsedArgs` object. * Changed the type of `ParsedArgData.values` and `ArgData.values` from list[*str*] to tuple[*str*, ...], since the values of an argument should be immutable after parsing. +* The original bracket-syntax `FormatCodes` class has been renamed to `deprFormatCodes` and moved into a new `depr_format_codes` module.
+ All internal call sites in the library still use the deprecated implementation; both APIs are exported in parallel.
+ `deprFormatCodes` will be removed in a future release – migrate to the new operator-based API at your convenience. diff --git a/README.md b/README.md index 153b3db..71e7c34 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,12 @@ from xulbux.color import rgba, hsla, hexa color - rgbahslahexaColor classes, which include methods to work with
+ rgba hsla hexa Color classes, which include methods to work with
colors in various formats. console - ConsoleProgressBar classes, which include methods for logging
+ Console ProgressBar classes, which include methods for logging
and other actions within the console. @@ -134,8 +134,8 @@ from xulbux.color import rgba, hsla, hexa format_codes - FormatCodes class, which includes methods to print and work with strings that contain
- special formatting codes, which are then converted to ANSI codes for pretty terminal output. + Format (alias F) FormatCodes Term classes for building richly formatted terminal output
+ via a typed, operator-based syntax and for emitting cursor- and screen-control sequences. json diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index d524c47..e9efd12 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -25,6 +25,7 @@ "File", "FileSys", "FormatCodes", + "deprFormatCodes", "Json", "Regex", "String", @@ -39,6 +40,7 @@ from .file import File from .file_sys import FileSys from .format_codes import FormatCodes +from .depr_format_codes import deprFormatCodes from .json import Json from .regex import Regex from .string import String diff --git a/src/xulbux/base/consts.py b/src/xulbux/base/consts.py index df59581..800af84 100644 --- a/src/xulbux/base/consts.py +++ b/src/xulbux/base/consts.py @@ -77,26 +77,33 @@ class ANSI: """Constants and utilities for ANSI escape code sequences.""" CHAR_ESCAPED: Final = r"\x1b" - """Printable ANSI escape character.""" + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n + Printable ANSI escape character.""" CHAR: Final = "\x1b" """ANSI escape character.""" START: Final = "[" - """Start of an ANSI escape sequence.""" + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n + Start of an ANSI escape sequence.""" SEP: Final = ";" - """Separator between ANSI escape sequence parts.""" + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n + Separator between ANSI escape sequence parts.""" END: Final = "m" - """End of an ANSI escape sequence.""" + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n + End of an ANSI escape sequence.""" @classmethod def seq(cls, placeholders: int = 1, /) -> FormattableString: - """Generates an ANSI escape sequence with the specified number of placeholders.""" + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n + Generates an ANSI escape sequence with the specified number of placeholders.""" return cls.CHAR + cls.START + cls.SEP.join(["{}" for _ in range(placeholders)]) + cls.END SEQ_COLOR: Final[FormattableString] = CHAR + START + "38" + SEP + "2" + SEP + "{}" + SEP + "{}" + SEP + "{}" + END - """ANSI escape sequence with three placeholders for setting the RGB text color.""" + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n + ANSI escape sequence with three placeholders for setting the RGB text color.""" SEQ_BG_COLOR: Final[FormattableString] = CHAR + START + "48" + SEP + "2" + SEP + "{}" + SEP + "{}" + SEP + "{}" + END - """ANSI escape sequence with three placeholders for setting the RGB background color.""" + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n + ANSI escape sequence with three placeholders for setting the RGB background color.""" SEQ_LINK_OPEN: Final[FormattableString] = CHAR + "]8;;{}" + CHAR + "\\" """OSC 8 hyperlink opening sequence with a placeholder for the URL.""" @@ -113,7 +120,8 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString: "cyan", "white", } - """The standard terminal color names.""" + """**DEPRECATED** – only used by `depr_format_codes` and as the seed for `COLOR_VARIANTS_MAP`.\n + The standard terminal color names.""" COLOR_VARIANTS_MAP: Final[set[str]] = COLOR_MAP | { "br:black", @@ -125,7 +133,8 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString: "br:cyan", "br:white", } - """All color variants that can be used in formatting.""" + """**DEPRECATED** – only used by `depr_format_codes` and as the seed for `COLOR_VARIANTS_MAP`.\n + All color variants that can be used in formatting.""" CODES_MAP: Final[dict[str | tuple[str, ...], int]] = { ################# SPECIFIC RESETS ################## @@ -186,4 +195,5 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString: "bg:br:cyan": 106, "bg:br:white": 107, } - """Dictionary mapping format keys to their corresponding ANSI code numbers.""" + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n + Dictionary mapping format keys to their corresponding ANSI code numbers.""" diff --git a/src/xulbux/cli/help.py b/src/xulbux/cli/help.py index 5315e5f..f557123 100644 --- a/src/xulbux/cli/help.py +++ b/src/xulbux/cli/help.py @@ -1,5 +1,5 @@ from .. import __version__ -from ..format_codes import FormatCodes +from ..depr_format_codes import deprFormatCodes from ..console import Console from urllib.error import HTTPError @@ -52,7 +52,7 @@ def is_latest_version() -> Optional[bool]: "punctuator": "br:black", "text": "white", } -CLI_HELP = FormatCodes.to_ansi( +CLI_HELP = deprFormatCodes.to_ansi( rf"""[_] [b|#7075FF] __ __ [b|#7075FF] _ __ __ __/ / / /_ __ ___ __ @@ -89,6 +89,6 @@ def show_help() -> None: """CLI command function for `xulbux-lib` command,
which shows some information about the library.""" - FormatCodes._config_terminal() + deprFormatCodes._config_terminal() print(CLI_HELP) Console.pause_exit(" [dim](Press any key to exit...)\n\n", pause=True) diff --git a/src/xulbux/cli/tools.py b/src/xulbux/cli/tools.py index 9d14462..9e1e0d1 100644 --- a/src/xulbux/cli/tools.py +++ b/src/xulbux/cli/tools.py @@ -1,4 +1,4 @@ -from ..format_codes import FormatCodes +from ..depr_format_codes import deprFormatCodes from ..console import Console @@ -10,19 +10,19 @@ def render_format_codes(): vals = args.input.values if not vals: - FormatCodes.print( + deprFormatCodes.print( "\n[_|i|dim]Provide a string to parse and render\n" "its format codes as ANSI terminal output.[_]\n" ) else: - ansi = FormatCodes.to_ansi("".join(vals)) - ansi_escaped = FormatCodes.escape_ansi(ansi) - ansi_stripped = FormatCodes.remove_ansi(ansi) + ansi = deprFormatCodes.to_ansi("".join(vals)) + ansi_escaped = deprFormatCodes.escape_ansi(ansi) + ansi_stripped = deprFormatCodes.remove_ansi(ansi) print(f"\n{ansi}\n") if len(ansi) != len(ansi_stripped): - FormatCodes.print(f"[_|i|dim]{ansi_escaped}[_]\n") + deprFormatCodes.print(f"[_|i|dim]{ansi_escaped}[_]\n") else: - FormatCodes.print("[_|i|dim](The provided string doesn't contain any valid format codes.)\n") + deprFormatCodes.print("[_|i|dim](The provided string doesn't contain any valid format codes.)\n") diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 4631a8d..ff36d74 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -7,7 +7,7 @@ from .base.decorators import mypyc_attr from .base.consts import CHARS, ANSI -from .format_codes import _PATTERNS as _FC_PATTERNS, FormatCodes +from .depr_format_codes import _PATTERNS as _FC_PATTERNS, deprFormatCodes from .string import String from .color import Color from .regex import LazyRegex @@ -454,9 +454,9 @@ def pause_exit( * `exit_code` – The exit code to use when exiting the program. * `reset_ansi` – Whether to reset the ANSI formatting after printing the prompt.""" - FormatCodes.print(prompt, end="", flush=True) + deprFormatCodes.print(prompt, end="", flush=True) if reset_ansi: - FormatCodes.print("[_]", end="") + deprFormatCodes.print("[_]", end="") if pause: cls._read_single_key() if exit: @@ -532,7 +532,7 @@ def log( px, mx = " " * title_px, " " * title_mx # TITLE LENGTH INCLUDING PADDING AND MARGIN - title_len: int = len(FormatCodes.remove(title)) + (title_px * 2) + (title_mx * 2) + title_len: int = len(deprFormatCodes.remove(title)) + (title_px * 2) + (title_mx * 2) # CALCULATE DISTANCE TO NEXT TAB STOP tab: str = " " * (-title_len % tab_size) @@ -541,7 +541,7 @@ def log( wrap_len: int = cls.width - (title_len + len(tab)) # REMOVE ALL FORMAT CODES AS THEY WON'T AFFECT THE VISIBLE LENGTH OF THE PROMPT - clean_prompt, removals = (*FormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True), ) + clean_prompt, removals = (*deprFormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True), ) # SPLIT PROMPT INTO LINES AND THEN SPLIT EACH LINE INTO CHUNKS THAT FIT WITHIN THE WRAP LENGTH prompt_lst: list[str] = list(chain.from_iterable(cls._process_lines(clean_prompt, wrap_len))) @@ -557,7 +557,7 @@ def log( f"{tab}{f'[{default_color}]' if default_color else ''}{prompt}[_]" ) - FormatCodes.print(out, default_color=default_color, end=end) + deprFormatCodes.print(out, default_color=default_color, end=end) @classmethod def debug( @@ -782,7 +782,7 @@ def log_box_filled( + "[*]" ) for line, unfmt in zip(lines, unfmt_lines)] - FormatCodes.print( + deprFormatCodes.print( ( \ f"{start}{spaces_l}[{bg_fc}]{pady}[*]\n" + "\n".join(lines) @@ -893,7 +893,7 @@ def log_box_bordered( + border_r ) for line, unfmt in zip(lines, unfmt_lines)] - FormatCodes.print( + deprFormatCodes.print( ( \ f"{start}{border_t}[_]\n" + "\n".join(lines) @@ -928,14 +928,14 @@ def confirm( information about formatting codes, see the `format_codes` module documentation.""" confirmed = cls.input( - FormatCodes.to_ansi( + deprFormatCodes.to_ansi( f"{start}{str(prompt)} [_|dim](({'Y' if default_is_yes else 'y'}/{'n' if default_is_yes else 'N'}): )", default_color=default_color, ) ).strip().lower() in ({"", "y", "yes"} if default_is_yes else {"y", "yes"}) if end: - FormatCodes.print(end, end="") + deprFormatCodes.print(end, end="") return confirmed @classmethod @@ -967,11 +967,11 @@ def multiline_input( kb = KeyBindings() kb.add("c-d", eager=True)(cls._multiline_input_submit) - FormatCodes.print(start + str(prompt), default_color=default_color) + deprFormatCodes.print(start + str(prompt), default_color=default_color) if show_keybindings: - FormatCodes.print("[dim][[b](CTRL+D)[dim] : end of input][_dim]") + deprFormatCodes.print("[dim][[b](CTRL+D)[dim] : end of input][_dim]") input_string = _pt.prompt(input_prefix, multiline=True, wrap_lines=True, key_bindings=kb) - FormatCodes.print("[_]" if reset_ansi else "", end=end[1:] if end.startswith("\n") else end) + deprFormatCodes.print("[_]" if reset_ansi else "", end=end[1:] if end.startswith("\n") else end) return input_string @@ -1084,7 +1084,7 @@ def input( custom_style = Style.from_dict({"bottom-toolbar": "noreverse"}) session: _pt.PromptSession[str] = _pt.PromptSession( - message=_pt.formatted_text.ANSI(FormatCodes.to_ansi(str(prompt), default_color=default_color)), + message=_pt.formatted_text.ANSI(deprFormatCodes.to_ansi(str(prompt), default_color=default_color)), validator=_ConsoleInputValidator( helper.get_text, mask_char=mask_char, @@ -1094,13 +1094,13 @@ def input( validate_while_typing=True, key_bindings=kb, bottom_toolbar=helper.bottom_toolbar, - placeholder=_pt.formatted_text.ANSI(FormatCodes.to_ansi(f"[i|br:black]{placeholder}[_i|_c]")) + placeholder=_pt.formatted_text.ANSI(deprFormatCodes.to_ansi(f"[i|br:black]{placeholder}[_i|_c]")) if placeholder else "", style=custom_style, ) - FormatCodes.print(start, end="") + deprFormatCodes.print(start, end="") session.prompt() - FormatCodes.print(end, end="") + deprFormatCodes.print(end, end="") if (result_text := helper.get_text()) in {"", None}: if default_val is not None: @@ -1242,7 +1242,7 @@ def _prepare_log_box( else: lines = [line for val in values for line in str(val).splitlines()] - unfmt_lines = [FormatCodes.remove(line, default_color) for line in lines] + unfmt_lines = [deprFormatCodes.remove(line, default_color) for line in lines] max_line_len = max(len(line) for line in unfmt_lines) if unfmt_lines else 0 return lines, unfmt_lines, max_line_len @@ -1632,7 +1632,7 @@ def bottom_toolbar(self) -> _pt.formatted_text.ANSI: if self.max_len and len(text_to_check) == self.max_len: toolbar_msgs.append("[b|#000|bg:br:yellow]( Maximum length reached )") - return _pt.formatted_text.ANSI(FormatCodes.to_ansi(" ".join(toolbar_msgs))) + return _pt.formatted_text.ANSI(deprFormatCodes.to_ansi(" ".join(toolbar_msgs))) except Exception: return _pt.formatted_text.ANSI("") @@ -1987,7 +1987,7 @@ def _draw_progress_bar(self, current: int, total: int, /, label: Optional[str] = ) bar = f"{self._create_bar(current, total, max(1, bar_width))}[*]" - progress_text = _PATTERNS.bar.sub(FormatCodes.to_ansi(bar), formatted) + progress_text = _PATTERNS.bar.sub(deprFormatCodes.to_ansi(bar), formatted) self._current_progress_str = progress_text self._last_line_len = len(progress_text) @@ -2014,9 +2014,9 @@ def _get_formatted_info_and_bar_width( fmt_parts.append(fmt_part) fmt_str = self.sep.join(fmt_parts) - fmt_str = FormatCodes.to_ansi(fmt_str) + fmt_str = deprFormatCodes.to_ansi(fmt_str) - bar_space = Console.width - len(FormatCodes.remove_ansi(_PATTERNS.bar.sub("", fmt_str))) + bar_space = Console.width - len(deprFormatCodes.remove_ansi(_PATTERNS.bar.sub("", fmt_str))) bar_width = min(bar_space, self.max_width) if bar_space > 0 else 0 return fmt_str, bar_width @@ -2315,8 +2315,8 @@ def _animation_loop(self) -> None: self._flush_buffer() - frame = FormatCodes.to_ansi(f"{self.frames[self._frame_index % len(self.frames)]}[*]") - formatted = FormatCodes.to_ansi(self.sep.join( + frame = deprFormatCodes.to_ansi(f"{self.frames[self._frame_index % len(self.frames)]}[*]") + formatted = deprFormatCodes.to_ansi(self.sep.join( fmt_part for part in self.throbber_format if \ (fmt_part := _PATTERNS.animation.sub(frame, _PATTERNS.label.sub(self.label or "", part))) )) diff --git a/src/xulbux/data.py b/src/xulbux/data.py index 424468f..b2795a8 100644 --- a/src/xulbux/data.py +++ b/src/xulbux/data.py @@ -5,7 +5,7 @@ from .base.types import IndexIterableTT, IndexIterable, DataObjTT, DataObj as DataObjType -from .format_codes import FormatCodes +from .depr_format_codes import deprFormatCodes from .string import String from .regex import Regex @@ -541,7 +541,7 @@ def print( ---------------------------------------------------------------------------------------------------------------- For more detailed information about formatting codes, see the `format_codes` module documentation.""" - FormatCodes.print( + deprFormatCodes.print( cls.render( data, indent=indent, diff --git a/src/xulbux/depr_format_codes.py b/src/xulbux/depr_format_codes.py index 6a0bf94..b49aa17 100644 --- a/src/xulbux/depr_format_codes.py +++ b/src/xulbux/depr_format_codes.py @@ -1,5 +1,14 @@ """ -This module provides the `FormatCodes` class, which includes methods to print and work with strings that +**DEPRECATED MODULE** – use the operator-based API in `xulbux.format_codes` (`F`, `FormatCodes`, `Term`) instead. + +This module is kept temporarily so existing internal callers and downstream code +that relies on the string-based bracket-syntax (`"[b](Hello)"`) keep working +until they are migrated to the new operator API. It will be removed in a +future release. + +-------------------------------------------------------------------------------------------------------------------- + +This module provides the `deprFormatCodes` class, which includes methods to print and work with strings that contain special formatting codes, which are then converted to ANSI codes for pretty terminal output. -------------------------------------------------------------------------------------------------------------------- @@ -251,21 +260,21 @@ def _build_ansi_flat() -> dict[str, str]: """Precomputed direct-lookup table from format key to ANSI escape sequence.""" _NORMALIZE_KEY_CACHE: dict[str, str] = {} -"""Cache for `FormatCodes._normalize_key` results.""" +"""Cache for `deprFormatCodes._normalize_key` results.""" _NORMALIZE_KEY_CACHE_MAX: Final[int] = 4096 _REPLACEMENT_CACHE: dict[str, str] = {} -"""Cache for `FormatCodes._get_replacement` results when no `default_color` is set.""" +"""Cache for `deprFormatCodes._get_replacement` results when no `default_color` is set.""" _REPLACEMENT_CACHE_MAX: Final[int] = 4096 _TO_ANSI_CACHE: dict[tuple[str, Optional[tuple[int, int, int]], int], str] = {} -"""Cache for full `FormatCodes.to_ansi` results on the public entry path.""" +"""Cache for full `deprFormatCodes.to_ansi` results on the public entry path.""" _TO_ANSI_CACHE_MAX: Final[int] = 1024 _TO_ANSI_CACHE_MAX_LEN: Final[int] = 8192 """Strings longer than this are not cached end-to-end.""" -class FormatCodes: +class deprFormatCodes: """This class provides methods to print and work with strings that contain special formatting codes, which are then converted to ANSI codes for pretty terminal output.""" @@ -408,7 +417,7 @@ def escape( _escape_char: Literal["/", "\\"] = "/", ) -> str: """Escapes all valid formatting codes in the string, so they are visible when output
- to the terminal using `FormatCodes.print()`. Invalid formatting codes remain unchanged.\n + to the terminal using `deprFormatCodes.print()`. Invalid formatting codes remain unchanged.\n ----------------------------------------------------------------------------------------- * `string` – The string that contains the formatting codes to escape. * `default_color` – The default text color to use if no other text color was applied. @@ -804,7 +813,7 @@ class _EscapeFormatCodeHelper: def __init__( self, - cls: type[FormatCodes], + cls: type[deprFormatCodes], *, use_default: bool, default_color: Optional[rgba], @@ -878,7 +887,7 @@ class _ReplaceKeysHelper: def __init__( self, - cls: type[FormatCodes], + cls: type[deprFormatCodes], *, use_default: bool, default_color: Optional[rgba], diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py new file mode 100644 index 0000000..406573e --- /dev/null +++ b/src/xulbux/format_codes.py @@ -0,0 +1,837 @@ +""" +This module provides the `FormatCodes` class together with the `Format` (alias `F`) and `Term`
+classes for building richly formatted terminal output using a typed, operator-based syntax. + +----------------------------------------------------------------------------------------------------------- +### The Easy Formatting + +First, let's take a look at a small example of what a
+highly styled output could look like using this module: + +```python +FormatCodes( + "This here is just unformatted text. " + (F.BOLD | F.UNDERLINE | F.BR.BLUE)( + "Next we have text that is bright blue + bold + underlined." + ), + (F.hex("#000") | F.BG.hex("#F67"))( + "Then there's also black text with a red background." + ) + " And finally the " + F.ITALIC("(boring)") + " plain text again.", +).print() +``` + +How all of this exactly works is explained in the sections below. 🠫 + +----------------------------------------------------------------------------------------------------------- +#### Format Codes and Groups + +In this module, you apply styles and colors using `Format` (or its short alias `F`) attributes.
+Every format attribute supports two operators: + +* `|` combines two or more format codes into a single immutable group, e.g.
+ `F.BOLD | F.RED` → bold + red foreground +* `()` applies the format (or group) to the given text and auto-resets the formatting after it, e.g.
+ `F.BOLD("hello")` → bold "hello", reset back to normal afterwards
+ `(F.BOLD | F.RED)("hello")` → same idea, combined + +A list of all possible format attributes can be found below. + +----------------------------------------------------------------------------------------------------------- +#### Auto Resetting Formats + +Every `_Fmt`, `_FmtGroup`, `_ColorFmt` or `_LinkFmt` call automatically generates the matching
+reset sequence behind its text, just like shown in the following example: + +```python +FormatCodes( + "This is plain text, " + + F.BR.BLUE("which is bright blue now.") + + " Now it was automatically reset to plain again.", +).print() +``` + +Only the specific formats that were applied are reset; other formatting in scope is left intact: + +```python +FormatCodes( + F.CYAN("This is cyan text, ", F.DIM("which is dimmed now."), + " Now it's not dimmed any more but still cyan."), +).print() +``` + +----------------------------------------------------------------------------------------------------------- +#### Nesting and Multi-Segment Groups + +A format call accepts either a single piece of text or any number of mixed segments.
+Strings, nested `_Styled` calls, sequences built with `+` and even raw tuples can be mixed freely: + +* `F.X("text")` – Apply `X` to `"text"`, auto-reset after. +* `F.X | F.Y` – Combine `X` and `Y` into a single group. +* `(F.X | F.Y)("text")` – Apply the group to `"text"`. +* `F.X("a", F.Y("b"), "c")` – Nested multi-segment: `Y` is applied only to `"b"`. +* `("a", F.X("b"), "c")` – Same-line group – passed as a single tuple to `FormatCodes(…)`. +* `"a" + F.X("b") + "c"` – Same-line group built with `+` (yields a `_Seq`). + +Inside `FormatCodes(*segments, sep="\\n")`, every positional argument is treated as one
+logical line and joined by `sep`. An empty string argument `""` therefore produces a blank line. + +----------------------------------------------------------------------------------------------------------- +#### All Possible Format Attributes + +* Text styles: + - `F.BOLD` + - `F.DIM` + - `F.ITALIC` + - `F.UNDERLINE` + - `F.INVERSE` + - `F.HIDDEN` + - `F.STRIKE` + - `F.DOUBLE_UNDERLINE` +* Standard foreground colors: + - `F.BLACK`, `F.RED`, `F.GREEN`, `F.YELLOW`, + `F.BLUE`, `F.MAGENTA`, `F.CYAN`, `F.WHITE` +* Bright foreground colors (`F.BR.*`): + - `F.BR.BLACK`, `F.BR.RED`, `F.BR.GREEN`, … +* Standard background colors (`F.BG.*`): + - `F.BG.BLACK`, `F.BG.RED`, `F.BG.GREEN`, … +* Bright background colors (`F.BG.BR.*` or `F.BR.BG.*`): + - `F.BG.BR.RED`, `F.BR.BG.RED`, … +* 24-bit true-color (foreground / background): + - `F.rgb(255, 96, 112)` + - `F.hex("#FF6070")` or `F.hex("F67")` + - `F.BG.rgb(0, 0, 0)` + - `F.BG.hex("#000")` +* Hyperlinks (OSC 8): + - `F.link("https://example.com")("click here")` + - `(F.link("…") | F.BR.BLUE)("click here")` +* Specific resets (only needed in advanced use; auto-reset usually covers it): + - `F.RESET_BOLD`, `F.RESET_DIM`, `F.RESET_ITALIC`, `F.RESET_UNDERLINE`, + `F.RESET_INVERSE`, `F.RESET_HIDDEN`, `F.RESET_STRIKE`, + `F.RESET_COLOR`, `F.RESET_BG` +* Total reset (resets every previously applied formatting): + - `F.RESET` + +----------------------------------------------------------------------------------------------------------- +#### Terminal Control – the `Term` class + +`Term` exposes commonly used non-formatting ANSI sequences for cursor- and screen-control.
+These are plain strings (or string-returning helpers), so they can be passed directly into a
+`FormatCodes(…)` call or written to `sys.stdout`: + +* `Term.CLEAR_LINE` – Erase the entire current line. +* `Term.CLEAR_SCREEN` – Erase the whole screen. +* `Term.HIDE_CURSOR` – Hide the cursor. +* `Term.SHOW_CURSOR` – Show the cursor. +* `Term.ALT_SCREEN` – Enter the alternate screen buffer. +* `Term.MAIN_SCREEN` – Leave the alternate screen buffer. +* `Term.up(n)` – Move the cursor up by `n` rows. +* `Term.down(n)` – Move the cursor down by `n` rows. +* `Term.right(n)` – Move the cursor right by `n` columns. +* `Term.left(n)` – Move the cursor left by `n` columns. +* `Term.move(row, col)` – Move the cursor to an absolute `(row, col)` position. +* `Term.title(text)` – Set the terminal window / tab title (OSC 2). +* `Term.save()` – Save the current cursor position. +* `Term.restore()` – Restore the previously saved cursor position. +""" + +from __future__ import annotations + +from .base.consts import ANSI +from .base.decorators import mypyc_attr + +from typing import TypeAlias, ClassVar, Iterator, Optional, Final, Union, cast +import ctypes as _ctypes +import regex as _rx +import sys as _sys +import os as _os + + +_terminal_ansi_configured: bool = False +"""Whether the terminal was already configured to be able to interpret and render ANSI formatting.""" + + +def _config_terminal() -> None: + """Configure the terminal to be able to interpret and render ANSI formatting.\n + This function only does something the first time it is called. Subsequent calls are no-ops.""" + + global _terminal_ansi_configured + if _terminal_ansi_configured: + return + + _sys.stdout.flush() + + if _os.name == "nt": + try: + kernel32 = getattr(_ctypes, "windll").kernel32 + handle = kernel32.GetStdHandle(-11) + mode = _ctypes.c_ulong() + kernel32.GetConsoleMode(handle, _ctypes.byref(mode)) + kernel32.SetConsoleMode(handle, mode.value | 0x0004) + except Exception: + pass + + _terminal_ansi_configured = True + + +_ANSI_SEQ_RX: Final = _rx.compile(ANSI.CHAR + r"(?:\].*?(?:\x1b\\|\x07)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])") +"""Regex pattern matching any ANSI escape sequence (CSI, OSC, or single-character).""" + +_RESET_MAP: Final[dict[int, int]] = { + ######################### TEXT STYLES ######################### + 1: 22, 2: 22, 3: 23, 4: 24, 7: 27, 8: 28, 9: 29, 21: 24, + ########################## FG COLORS ########################## + 30: 39, 31: 39, 32: 39, 33: 39, 34: 39, 35: 39, 36: 39, 37: 39, + ########################## BG COLORS ########################## + 40: 49, 41: 49, 42: 49, 43: 49, 44: 49, 45: 49, 46: 49, 47: 49, + ####################### BRIGHT FG COLORS ###################### + 90: 39, 91: 39, 92: 39, 93: 39, 94: 39, 95: 39, 96: 39, 97: 39, + ####################### BRIGHT BG COLORS ###################### + 100: 49, 101: 49, 102: 49, 103: 49, 104: 49, 105: 49, 106: 49, 107: 49 +} +"""Mapping from format code integer to its matching reset integer.\n +Codes that fully reset everything (`0`) or have no useful specific reset are intentionally omitted.""" + +################################################## CORE TYPES ################################################## + + +class _FmtGroup: + """An immutable, ordered group of format codes produced by `|`.\n + ------------------------------------------------------------------ + Supports further `|` chaining and `()` application.""" + + __slots__ = ("_codes", ) + + def __init__(self, *codes: _AnyFmt) -> None: + self._codes: tuple[_AnyFmt, ...] = codes + + def __iter__(self) -> Iterator[_AnyFmt]: + """Iterating a `_FmtGroup` yields its individual format codes in order.""" + + return iter(self._codes) + + def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup: + """Combines this format group with another format or group via `|`.""" + + if isinstance(other, _FmtGroup): + return _FmtGroup(*self._codes, *other._codes) + + return _FmtGroup(*self._codes, other) + + def __ror__(self, other: _AnyFmt) -> _FmtGroup: + """Combines this format group with another format or group via `|`.""" + + return _FmtGroup(other, *self._codes) + + def __call__(self, *text: _Segment) -> _Styled: + """Applies this format group to the given text, auto-resetting after.""" + + return _Styled(self, text[0] if len(text) == 1 else text) + + def __matmul__(self, text: _Text) -> _Styled: + """Applies this format group to the given text, auto-resetting after.""" + + return _Styled(self, text) + + def __repr__(self) -> str: + """Returns a string representation of this format group, showing its individual codes.""" + + return f"_FmtGroup{self._codes!r}" + + +@mypyc_attr(native_class=False) +class _Fmt(int): + """A single ANSI format code integer.\n + ---------------------------------------------------------------------------- + Supports two operators: + * `|` combines two or more codes into a `_FmtGroup` → `F.BOLD | F.RED` + * `()` applies the code to text, auto-resetting after → `F.BOLD("hello")` + ---------------------------------------------------------------------------- + Marked `native_class=False` because MyPyC does not support
+ subclassing the built-in `int` type in a native class.""" + + def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup: # type: ignore[override] + """Combines this format code with another code or group via `|`.""" + + if isinstance(other, _FmtGroup): + return _FmtGroup(self, *other) + + return _FmtGroup(self, other) + + def __ror__(self, other: _AnyFmt) -> _FmtGroup: # type: ignore[override] + """Combines this format code with another code or group via `|`.""" + + return _FmtGroup(other, self) + + def __call__(self, *text: _Segment) -> _Styled: + """Applies this format code to the given text, auto-resetting after.""" + + return _Styled(_FmtGroup(self), text[0] if len(text) == 1 else text) + + def __matmul__(self, text: _Text) -> _Styled: + """Applies this format code to the given text, auto-resetting after.""" + + return _Styled(_FmtGroup(self), text) + + +class _ColorFmt: + """A 24-bit true-color format – foreground or background.\n + --------------------------------------------------------------------- + >>> F.rgb(255, 96, 112)("text") # CUSTOM FG COLOR + >>> F.BG.rgb(0, 0, 0)("text") # CUSTOM BG COLOR + >>> F.hex("#FF6070")("text") # HEX FG COLOR + >>> (F.BOLD | F.rgb(255, 96, 112))("text") # COMBINED WITH STYLE""" + + __slots__ = ("_red", "_green", "_blue", "_bg") + + def __init__(self, red: int, green: int, blue: int, /, *, bg: bool = False) -> None: + self._red, self._green, self._blue, self._bg = red, green, blue, bg + + @classmethod + def from_hex(cls, color: str, /, *, bg: bool = False) -> _ColorFmt: + """Create a `_ColorFmt` from a HEX color string (e.g. `#FF6070` or `F67`).""" + + if (hex_str := color.strip().lstrip("#")).lower().startswith("0x"): + hex_str = hex_str[2:] + if len(hex_str) == 3: + hex_str = hex_str[0] * 2 + hex_str[1] * 2 + hex_str[2] * 2 + + return cls(int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16), bg=bg) + + def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup: + """Combines this color format with another format or group via `|`.""" + + if isinstance(other, _FmtGroup): + return _FmtGroup(self, *other._codes) + + return _FmtGroup(self, other) + + def __ror__(self, other: _AnyFmt) -> _FmtGroup: + """Combines this color format with another format or group via `|`.""" + + return _FmtGroup(other, self) + + def __call__(self, *text: _Segment) -> _Styled: + """Applies this color format to the given text, auto-resetting after.""" + + return _Styled(_FmtGroup(self), text[0] if len(text) == 1 else text) + + def __matmul__(self, text: _Text) -> _Styled: + """Applies this color format to the given text, auto-resetting after.""" + + return _Styled(_FmtGroup(self), text) + + def __repr__(self) -> str: + """Returns a string representation of this color format, indicating
+ whether it's foreground or background and its RGB values.""" + + return f"_ColorFmt({'bg' if self._bg else 'fg'} {self._red},{self._green},{self._blue})" + + +class _LinkFmt: + """An OSC 8 hyperlink. Combine with other formats via `|` to add text styling.\n + --------------------------------------------------------------------------------- + >>> F.link("https://example.com")("click here") + >>> (F.link("https://example.com") | F.BR.BLUE)("click here")""" + + __slots__ = ("_url", ) + + def __init__(self, url: str, /) -> None: + self._url = url + + def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup: + """Combines this link format with another format or group via `|`.""" + + if isinstance(other, _FmtGroup): + return _FmtGroup(self, *other._codes) + + return _FmtGroup(self, other) + + def __ror__(self, other: _AnyFmt) -> _FmtGroup: + """Combines this link format with another format or group via `|`.""" + + return _FmtGroup(other, self) + + def __call__(self, *text: _Segment) -> _Styled: + """Applies this link format to the given text, auto-resetting after.""" + + return _Styled(_FmtGroup(self), text[0] if len(text) == 1 else text) + + def __matmul__(self, text: _Text) -> _Styled: + """Applies this link format to the given text, auto-resetting after.""" + + return _Styled(_FmtGroup(self), text) + + def __repr__(self) -> str: + """Returns a string representation of this link format, showing the URL it points to.""" + + return f"_LinkFmt({self._url!r})" + + +_AnyFmt: TypeAlias = Union["_Fmt", "_ColorFmt", "_LinkFmt"] +"""Any single format code, color format, or link format
+that can be combined via `|` and applied to text.""" +_Segment: TypeAlias = Union[str, "_Styled", "_Seq"] +"""A single segment of text with optional formatting, which can be a plain string,
+a nested styled segment, or a sequence of mixed segments.""" +_Text: TypeAlias = Union[str, "_Styled", "_Seq", "tuple[_Segment, ...]"] +"""Anything that can be passed to a `_Fmt`/`_FmtGroup`/`_ColorFmt`/`_LinkFmt` call.""" +_Renderable: TypeAlias = Union[str, "_Styled", "_Seq", "tuple[_Segment, ...]"] +"""Anything that can be passed as a positional argument to `FormatCodes(…)`.""" + + +class _Styled: + """A `_FmtGroup` applied to text – produced by calling a `_Fmt` or `_FmtGroup`.\n + ------------------------------------------------------------------------------------------- + The renderer emits the opening ANSI codes, then `text`, then the matching reset codes.
+ `text` may be a plain `str`, a `_Styled`, a `_Seq`, or a tuple of mixed segments for + nested formatting.""" + + __slots__ = ("codes", "text") + + def __init__(self, codes: _FmtGroup, text: _Text) -> None: + self.codes = codes + self.text = text + + def __add__(self, other: str | _Styled | _Seq) -> _Seq: + """Combines this styled segment with another styled
+ segment or string via `+`, yielding a `_Seq`.""" + + if isinstance(other, _Seq): + return _Seq(self, *other._parts) + + return _Seq(self, other) + + def __radd__(self, other: str | _Styled) -> _Seq: + """Combines this styled segment with another styled
+ segment or string via `+`, yielding a `_Seq`.""" + + return _Seq(other, self) + + def __repr__(self) -> str: + """Returns a string representation of this styled segment, showing its codes and text.""" + + return f"_Styled(codes={self.codes!r}, text={self.text!r})" + + +class _Seq: + """A flat sequence of segments produced by the `+` operator.\n + ---------------------------------------------------------------------------------- + Alternative to a plain `tuple` when building a multi-segment group;
+ `+` keeps the whole expression as one Python value + so a code formatter won't split it across lines. + + >>> " " + F.BR.BLUE("-f") + ", " + F.BR.BLUE("--fast") + " description\\n" """ + + __slots__ = ("_parts", ) + + def __init__(self, *parts: _Segment) -> None: + self._parts: tuple[_Segment, ...] = parts + + def __add__(self, other: str | _Styled | _Seq) -> _Seq: + """Combines this sequence with another styled segment,
+ string, or sequence via `+`, yielding a new `_Seq`.""" + + if isinstance(other, _Seq): + return _Seq(*self._parts, *other._parts) + + return _Seq(*self._parts, other) + + def __radd__(self, other: str | _Styled) -> _Seq: + """Combines this sequence with another styled
+ segment or string via `+`, yielding a new `_Seq`.""" + + return _Seq(other, *self._parts) + + def __iter__(self) -> Iterator[_Segment]: + """Iterating a `_Seq` yields its individual segments in order.""" + + return iter(self._parts) + + def __repr__(self) -> str: + """Returns a string representation of this sequence, showing its individual segments.""" + + return f"_Seq{self._parts!r}" + + +################################################## NAMESPACE HELPERS ################################################## + + +class _BrBgNS: + """Namespace for bright background colors, reachable as `F.BG.BR.*` or `F.BR.BG.*`.""" + + BLACK: ClassVar[_Fmt] = _Fmt(100) + RED: ClassVar[_Fmt] = _Fmt(101) + GREEN: ClassVar[_Fmt] = _Fmt(102) + YELLOW: ClassVar[_Fmt] = _Fmt(103) + BLUE: ClassVar[_Fmt] = _Fmt(104) + MAGENTA: ClassVar[_Fmt] = _Fmt(105) + CYAN: ClassVar[_Fmt] = _Fmt(106) + WHITE: ClassVar[_Fmt] = _Fmt(107) + + +class _BgNS: + """Namespace for background colors, reachable as `F.BG.*`.""" + + BLACK: ClassVar[_Fmt] = _Fmt(40) + RED: ClassVar[_Fmt] = _Fmt(41) + GREEN: ClassVar[_Fmt] = _Fmt(42) + YELLOW: ClassVar[_Fmt] = _Fmt(43) + BLUE: ClassVar[_Fmt] = _Fmt(44) + MAGENTA: ClassVar[_Fmt] = _Fmt(45) + CYAN: ClassVar[_Fmt] = _Fmt(46) + WHITE: ClassVar[_Fmt] = _Fmt(47) + BR: ClassVar[type[_BrBgNS]] = _BrBgNS + + @staticmethod + def rgb(red: int, green: int, blue: int, /) -> _ColorFmt: + """24-bit background color from RGB components.\n + `F.BG.rgb(0, 0, 0)("text")`""" + + return _ColorFmt(red, green, blue, bg=True) + + @staticmethod + def hex(color: str, /) -> _ColorFmt: + """24-bit background color from HEX string.\n + `F.BG.hex("#202020")("text")`""" + + return _ColorFmt.from_hex(color, bg=True) + + +class _BrNS: + """Namespace for bright foreground colors, reachable as `F.BR.*`.""" + + BLACK: ClassVar[_Fmt] = _Fmt(90) + RED: ClassVar[_Fmt] = _Fmt(91) + GREEN: ClassVar[_Fmt] = _Fmt(92) + YELLOW: ClassVar[_Fmt] = _Fmt(93) + BLUE: ClassVar[_Fmt] = _Fmt(94) + MAGENTA: ClassVar[_Fmt] = _Fmt(95) + CYAN: ClassVar[_Fmt] = _Fmt(96) + WHITE: ClassVar[_Fmt] = _Fmt(97) + BG: ClassVar[type[_BrBgNS]] = _BrBgNS + + +################################################## FORMAT CODES ################################################## + + +class Format: + """All available ANSI format codes.\n + ----------------------------------------------------------------------------------------- + Every attribute supports `|` for combining and `()` for applying to text: + + >>> F.BOLD("hello") # BOLD, AUTO-RESET AFTER + >>> (F.BOLD | F.RED)("hello") # BOLD + RED, AUTO-RESET AFTER + >>> F.BR.GREEN("hello") # BRIGHT GREEN + >>> F.BG.BLACK("hello") # BLACK BACKGROUND + >>> F.DIM("# ", F.ITALIC("comment")) # NESTED: DIM WRAPS ITALIC INSIDE + + For a full list of available attributes, see the `format_codes` module documentation.""" + + ######################### TOTAL RESET ######################### + RESET: ClassVar[_Fmt] = _Fmt(0) + + ####################### SPECIFIC RESETS ####################### + RESET_BOLD: ClassVar[_Fmt] = _Fmt(22) + RESET_DIM: ClassVar[_Fmt] = _Fmt(22) + RESET_ITALIC: ClassVar[_Fmt] = _Fmt(23) + RESET_UNDERLINE: ClassVar[_Fmt] = _Fmt(24) + RESET_INVERSE: ClassVar[_Fmt] = _Fmt(27) + RESET_HIDDEN: ClassVar[_Fmt] = _Fmt(28) + RESET_STRIKE: ClassVar[_Fmt] = _Fmt(29) + RESET_COLOR: ClassVar[_Fmt] = _Fmt(39) + RESET_BG: ClassVar[_Fmt] = _Fmt(49) + + ######################### TEXT STYLES ######################### + BOLD: ClassVar[_Fmt] = _Fmt(1) + DIM: ClassVar[_Fmt] = _Fmt(2) + ITALIC: ClassVar[_Fmt] = _Fmt(3) + UNDERLINE: ClassVar[_Fmt] = _Fmt(4) + INVERSE: ClassVar[_Fmt] = _Fmt(7) + HIDDEN: ClassVar[_Fmt] = _Fmt(8) + STRIKE: ClassVar[_Fmt] = _Fmt(9) + DOUBLE_UNDERLINE: ClassVar[_Fmt] = _Fmt(21) + + ###################### STANDARD FG COLORS ##################### + BLACK: ClassVar[_Fmt] = _Fmt(30) + RED: ClassVar[_Fmt] = _Fmt(31) + GREEN: ClassVar[_Fmt] = _Fmt(32) + YELLOW: ClassVar[_Fmt] = _Fmt(33) + BLUE: ClassVar[_Fmt] = _Fmt(34) + MAGENTA: ClassVar[_Fmt] = _Fmt(35) + CYAN: ClassVar[_Fmt] = _Fmt(36) + WHITE: ClassVar[_Fmt] = _Fmt(37) + + ######################### NAMESPACES ########################## + BR: ClassVar[type[_BrNS]] = _BrNS + BG: ClassVar[type[_BgNS]] = _BgNS + + #################### CUSTOM COLORS & LINKS #################### + @staticmethod + def rgb(red: int, green: int, blue: int, /) -> _ColorFmt: + """24-bit foreground color.\n + `F.rgb(255, 96, 112)("text")`""" + + return _ColorFmt(red, green, blue) + + @staticmethod + def hex(color: str, /) -> _ColorFmt: + """24-bit foreground color from HEX string.\n + `F.hex("#FF6070")("text")` or `F.hex("F67")`""" + + return _ColorFmt.from_hex(color) + + @staticmethod + def link(url: str, /) -> _LinkFmt: + """Clickable hyperlink.\n + `F.link("https://example.com")("click here")`""" + + return _LinkFmt(url) + + +F = Format # SHORT ALIAS + +################################################## TERMINAL CONTROL ################################################## + + +class Term: + """Common ANSI terminal control sequences (cursor, screen, title)
+ as plain strings or string-returning static methods.\n + ------------------------------------------------------------------------------------------ + Values can be passed straight into a `FormatCodes(…)` call or written to `sys.stdout`.""" + + CLEAR_LINE: ClassVar[str] = f"{ANSI.CHAR}[2K" + """Erase the entire current line.""" + CLEAR_SCREEN: ClassVar[str] = f"{ANSI.CHAR}[2J" + """Erase the whole screen.""" + HIDE_CURSOR: ClassVar[str] = f"{ANSI.CHAR}[?25l" + """Hide the cursor.""" + SHOW_CURSOR: ClassVar[str] = f"{ANSI.CHAR}[?25h" + """Show the cursor.""" + ALT_SCREEN: ClassVar[str] = f"{ANSI.CHAR}[?1049h" + """Enter the alternate screen buffer.""" + MAIN_SCREEN: ClassVar[str] = f"{ANSI.CHAR}[?1049l" + """Leave the alternate screen buffer.""" + + @staticmethod + def up(n: int = 1, /) -> str: + """Move the cursor up by `n` rows.""" + + return f"{ANSI.CHAR}[{n}A" + + @staticmethod + def down(n: int = 1, /) -> str: + """Move the cursor down by `n` rows.""" + + return f"{ANSI.CHAR}[{n}B" + + @staticmethod + def right(n: int = 1, /) -> str: + """Move the cursor right by `n` columns.""" + + return f"{ANSI.CHAR}[{n}C" + + @staticmethod + def left(n: int = 1, /) -> str: + """Move the cursor left by `n` columns.""" + + return f"{ANSI.CHAR}[{n}D" + + @staticmethod + def move(row: int, col: int, /) -> str: + """Move the cursor to absolute position `(row, col)` (1-based).""" + + return f"{ANSI.CHAR}[{row};{col}H" + + @staticmethod + def title(text: str, /) -> str: + """Set the terminal window / tab title (OSC 2).""" + + return f"{ANSI.CHAR}]2;{text}\x07" + + @staticmethod + def save() -> str: + """Save the current cursor position.""" + + return f"{ANSI.CHAR}[s" + + @staticmethod + def restore() -> str: + """Restore the previously saved cursor position.""" + + return f"{ANSI.CHAR}[u" + + +################################################## FORMATCODES ################################################## + + +def _build_open_close(group: _FmtGroup, /) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Build the opening and closing ANSI sequences for a `_FmtGroup`.\n + ------------------------------------------------------------------------------------ + Returns a `(opens, closes)` pair of tuples. Multiple opens / closes are emitted
+ only when both an OSC 8 hyperlink and SGR codes are present (OSC wraps SGR).""" + + sgr_open: list[str] = [] + sgr_close: list[str] = [] + link_url: Optional[str] = None + + for code in group: + if isinstance(code, _LinkFmt): + link_url = code._url + + elif isinstance(code, _ColorFmt): + if code._bg: + sgr_open.append(f"48;2;{code._red};{code._green};{code._blue}") + sgr_close.append("49") + else: + sgr_open.append(f"38;2;{code._red};{code._green};{code._blue}") + sgr_close.append("39") + + else: + cid = int(code) + sgr_open.append(str(cid)) + if (reset := _RESET_MAP.get(cid)) is not None: + sgr_close.append(str(reset)) + + # DE-DUPE WHILE PRESERVING ORDER FOR CLEANER OUTPUT + seen: set[str] = set() + dedup_close: list[str] = [] + + for close_code in sgr_close: + if close_code not in seen: + seen.add(close_code) + dedup_close.append(close_code) + + opens: list[str] = [] + closes: list[str] = [] + + if link_url is not None: + opens.append(ANSI.SEQ_LINK_OPEN.format(link_url)) + if sgr_open: + opens.append(f"{ANSI.CHAR}[{';'.join(sgr_open)}m") + if dedup_close: + closes.append(f"{ANSI.CHAR}[{';'.join(dedup_close)}m") + if link_url is not None: + closes.append(ANSI.SEQ_LINK_CLOSE) + + return tuple(opens), tuple(closes) + + +class FormatCodes: + """Build a formatted string from a sequence of segments
+ (strings, `_Styled` calls, `_Seq` chains built with `+`, or raw tuples).\n + ------------------------------------------------------------------------------------------------------ + * `segments` – Any number of segments to render. Each positional argument represents one logical line. + * `sep` – The separator inserted between two adjacent positional arguments (default `"\\n"`). + ------------------------------------------------------------------------------------------------------ + After construction the instance exposes: + * `ansi` – The fully rendered ANSI escape string, ready to be written to a terminal. + * `raw` – The same content with every ANSI escape sequence stripped (the "plain" text). + * `code_positions` – A tuple of `(position, sequence)` pairs giving
+ the start offset of every ANSI escape sequence inside `ansi`.
+ The same data can be used to map indices between `raw` and `ansi`. + ------------------------------------------------------------------------------------------------------ + For exact information about how to use the operator syntax,
+ see the `format_codes` module documentation.""" + + def __init__(self, /, *segments: _Renderable, sep: str = "\n") -> None: + self._ansi_parts: list[str] = [] + self._raw_parts: list[str] = [] + self._code_positions: list[tuple[int, str]] = [] + self._offset: int = 0 + + for i, segment in enumerate(segments): + if i > 0: + self._emit_raw(sep) + self._render(segment) + + self.ansi: str = "".join(self._ansi_parts) + self.raw: str = "".join(self._raw_parts) + self.code_positions: tuple[tuple[int, str], ...] = tuple(self._code_positions) + + def __str__(self) -> str: + """Stringifying a `FormatCodes` instance yields its rendered
+ ANSI string, ready to be written to a terminal.""" + + return self.ansi + + def __repr__(self) -> str: + """Returns a string representation of this `FormatCodes` instance, showing its rendered ANSI string.""" + + return f"FormatCodes(ansi={self.ansi!r})" + + def print(self, /, *, end: str = "\n", flush: bool = True) -> None: + """Write the rendered ANSI string straight to `sys.stdout` (configuring the terminal
+ for ANSI on first use). Faster than the built-in `print()` for large outputs.\n + ----------------------------------------------------------------------------------------- + * `end` – The string to append at the end of the output (default `"\\n"`). + * `flush` – Whether to flush `sys.stdout` after writing (default `True`).""" + + _config_terminal() + _sys.stdout.write(self.ansi + end) + + if flush: + _sys.stdout.flush() + + def input(self, /, *, reset_ansi: bool = False) -> str: + """Use the rendered ANSI string as an input prompt and return the user's input.\n + ---------------------------------------------------------------------------------- + * `reset_ansi` – If true, all ANSI formatting will be reset after
+ the user confirmed the input and the program continues to run.""" + + _config_terminal() + user_input = input(self.ansi) + + if reset_ansi: + _sys.stdout.write(f"{ANSI.CHAR}[0m") + + return user_input + + @staticmethod + def remove_ansi(ansi_string: str, /) -> str: + """Remove every ANSI escape sequence from `ansi_string`, returning the plain text.\n + ------------------------------------------------------------------------------------- + * `ansi_string` – The string that contains the ANSI codes to remove.""" + + return _ANSI_SEQ_RX.sub("", ansi_string) + + def _emit_raw(self, text: str) -> None: + """Internal method to append `text` to both the ANSI and raw outputs and advance the running offset.""" + + self._ansi_parts.append(text) + self._raw_parts.append(text) + self._offset += len(text) + + def _emit_seq(self, sequence: str) -> None: + """Internal method to record a `sequence`'s position, then append it to the ANSI output only.""" + + self._code_positions.append((self._offset, sequence)) + self._ansi_parts.append(sequence) + self._offset += len(sequence) + + def _render(self, segment: object) -> None: + """Internal method to recursively render a `segment`, dispatching by runtime type.\n + ------------------------------------------------------------------------------------- + Strings are emitted as raw text; `_Styled` segments are wrapped in their opening
+ and closing ANSI sequences; `_Seq` and `tuple` segments are flattened in order.""" + + if isinstance(segment, str): + self._emit_raw(segment) + return + if isinstance(segment, _Styled): + opens, closes = _build_open_close(segment.codes) + for piece in opens: + self._emit_seq(piece) + self._render(segment.text) + for piece in closes: + self._emit_seq(piece) + return + if isinstance(segment, _Seq): + for seq_part in segment._parts: + self._render(seq_part) + return + if isinstance(segment, tuple): + for tuple_part in cast("tuple[object, ...]", segment): + self._render(tuple_part) + return + + # FALLBACK – COERCE UNKNOWN OBJECTS TO STR + self._emit_raw(str(segment)) diff --git a/src/xulbux/system.py b/src/xulbux/system.py index 32e02c2..26a93aa 100644 --- a/src/xulbux/system.py +++ b/src/xulbux/system.py @@ -6,7 +6,7 @@ from .base.types import MissingLibsMsgs from .base.decorators import mypyc_attr -from .format_codes import FormatCodes +from .depr_format_codes import deprFormatCodes from .console import Console from typing import Optional @@ -329,9 +329,9 @@ def find_missing_libs(self) -> list[str]: def confirm_installation(self, missing: list[str], /) -> bool: """Ask user for confirmation before installing libraries.""" - FormatCodes.print(f"[b]({self.missing_libs_msgs['found_missing']})") + deprFormatCodes.print(f"[b]({self.missing_libs_msgs['found_missing']})") for lib in missing: - FormatCodes.print(f" [dim](•) [i]{lib}[_i]") + deprFormatCodes.print(f" [dim](•) [i]{lib}[_i]") print() return Console.confirm(self.missing_libs_msgs["should_install"], end="\n") diff --git a/tests/test_console.py b/tests/test_console.py index 1fd9d14..aa4ba85 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -25,10 +25,10 @@ def mock_get_terminal_size(): def mock_formatcodes_print(monkeypatch: pytest.MonkeyPatch): mock = MagicMock() # PATCH IN THE ORIGINAL MODULE WHERE IT IS DEFINED - import xulbux.format_codes - monkeypatch.setattr(xulbux.format_codes.FormatCodes, "print", mock) + import xulbux.depr_format_codes + monkeypatch.setattr(xulbux.depr_format_codes.deprFormatCodes, "print", mock) # ALSO PATCH IN CONSOLE MODULE JUST IN CASE - monkeypatch.setattr("xulbux.console.FormatCodes.print", mock) + monkeypatch.setattr("xulbux.console.deprFormatCodes.print", mock) return mock diff --git a/tests/test_depr_format_codes.py b/tests/test_depr_format_codes.py index ec5acd6..3eeb99d 100644 --- a/tests/test_depr_format_codes.py +++ b/tests/test_depr_format_codes.py @@ -1,5 +1,5 @@ from xulbux.base.consts import ANSI -from xulbux.format_codes import FormatCodes +from xulbux.depr_format_codes import deprFormatCodes black = ANSI.SEQ_COLOR.format(0, 0, 0) @@ -21,13 +21,13 @@ reset_underline = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_underline', '_u')]}{ANSI.END}" # -################################################## FormatCodes TESTS ################################################## +################################################## deprFormatCodes TESTS ################################################## def test_to_ansi(): assert ( - FormatCodes.to_ansi("[b|#000|bg:red](He[in](l)lo) [[i|u|#F87](world)][default]![_]", - default_color="#FFF") == f"{default}{bold}{black}{bg_red}" + "He" + invert + "l" + reset_invert + deprFormatCodes.to_ansi("[b|#000|bg:red](He[in](l)lo) [[i|u|#F87](world)][default]![_]", + default_color="#FFF") == f"{default}{bold}{black}{bg_red}" + "He" + invert + "l" + reset_invert + "lo" + f"{reset_bold}{default}{reset_bg}" + " [" + f"{italic}{underline}{orange}" + "world" + f"{reset_italic}{reset_underline}{default}" + "]" + default + "!" + reset ) @@ -36,48 +36,48 @@ def test_to_ansi(): def test_escape_ansi(): ansi_string = f"{bold}Hello {orange}World!{reset}" escaped_string = ansi_string.replace(ANSI.CHAR, ANSI.CHAR_ESCAPED) - assert FormatCodes.escape_ansi(ansi_string) == escaped_string + assert deprFormatCodes.escape_ansi(ansi_string) == escaped_string def test_escape(): # TEST BASIC FORMATTING CODES - assert FormatCodes.escape("[b]Hello[_]") == "[/b]Hello[/_]" - assert FormatCodes.escape("[bold|italic]Text[_]") == "[/bold|italic]Text[/_]" + assert deprFormatCodes.escape("[b]Hello[_]") == "[/b]Hello[/_]" + assert deprFormatCodes.escape("[bold|italic]Text[_]") == "[/bold|italic]Text[/_]" # TEST WITH COLORS - assert FormatCodes.escape("[#F87]Hello[_]") == "[/#F87]Hello[/_]" - assert FormatCodes.escape("[rgb(255, 136, 119)]Hello[_]") == "[/rgb(255, 136, 119)]Hello[/_]" + assert deprFormatCodes.escape("[#F87]Hello[_]") == "[/#F87]Hello[/_]" + assert deprFormatCodes.escape("[rgb(255, 136, 119)]Hello[_]") == "[/rgb(255, 136, 119)]Hello[/_]" # TEST WITH DEFAULT COLOR - assert FormatCodes.escape("[default]Hello", default_color="#FFF") == "[/default]Hello" - assert FormatCodes.escape("[bg:default]Hello", default_color="#FFF") == "[/bg:default]Hello" + assert deprFormatCodes.escape("[default]Hello", default_color="#FFF") == "[/default]Hello" + assert deprFormatCodes.escape("[bg:default]Hello", default_color="#FFF") == "[/bg:default]Hello" # TEST WITH * FORMATTING CODE - assert FormatCodes.escape("[*]Hello", default_color="#FFF") == "[/*]Hello" - assert FormatCodes.escape("[b|*]Hello", default_color="#FFF") == "[/b|*]Hello" + assert deprFormatCodes.escape("[*]Hello", default_color="#FFF") == "[/*]Hello" + assert deprFormatCodes.escape("[b|*]Hello", default_color="#FFF") == "[/b|*]Hello" # TEST WITH AUTO-RESET - assert FormatCodes.escape("[b](Hello)") == "[/b](Hello)" - assert FormatCodes.escape("[*](Hello)", default_color="#FFF") == "[/*](Hello)" + assert deprFormatCodes.escape("[b](Hello)") == "[/b](Hello)" + assert deprFormatCodes.escape("[*](Hello)", default_color="#FFF") == "[/*](Hello)" # TEST INVALID FORMATTING CODES (SHOULD REMAIN UNCHANGED) - assert FormatCodes.escape("[invalid]Hello") == "[invalid]Hello" - assert FormatCodes.escape("[default]Hello") == "[default]Hello" # NO 'default_color' - assert FormatCodes.escape("[*]Hello") == "[/*]Hello" # NO 'default_color' + assert deprFormatCodes.escape("[invalid]Hello") == "[invalid]Hello" + assert deprFormatCodes.escape("[default]Hello") == "[default]Hello" # NO 'default_color' + assert deprFormatCodes.escape("[*]Hello") == "[/*]Hello" # NO 'default_color' # TEST ALREADY ESCAPED CODES - assert FormatCodes.escape("[/b]Hello") == "[/b]Hello" - assert FormatCodes.escape("[/*]Hello", default_color="#FFF") == "[/*]Hello" + assert deprFormatCodes.escape("[/b]Hello") == "[/b]Hello" + assert deprFormatCodes.escape("[/*]Hello", default_color="#FFF") == "[/*]Hello" # TEST WITH BRIGHTNESS MODIFIERS - assert FormatCodes.escape("[l]Hello", default_color="#FFF") == "[/l]Hello" - assert FormatCodes.escape("[ll]Hello", default_color="#FFF") == "[/ll]Hello" - assert FormatCodes.escape("[+]Hello", default_color="#FFF") == "[/+]Hello" - assert FormatCodes.escape("[++]Hello", default_color="#FFF") == "[/++]Hello" - assert FormatCodes.escape("[d]Hello", default_color="#FFF") == "[/d]Hello" - assert FormatCodes.escape("[dd]Hello", default_color="#FFF") == "[/dd]Hello" - assert FormatCodes.escape("[-]Hello", default_color="#FFF") == "[/-]Hello" - assert FormatCodes.escape("[--]Hello", default_color="#FFF") == "[/--]Hello" + assert deprFormatCodes.escape("[l]Hello", default_color="#FFF") == "[/l]Hello" + assert deprFormatCodes.escape("[ll]Hello", default_color="#FFF") == "[/ll]Hello" + assert deprFormatCodes.escape("[+]Hello", default_color="#FFF") == "[/+]Hello" + assert deprFormatCodes.escape("[++]Hello", default_color="#FFF") == "[/++]Hello" + assert deprFormatCodes.escape("[d]Hello", default_color="#FFF") == "[/d]Hello" + assert deprFormatCodes.escape("[dd]Hello", default_color="#FFF") == "[/dd]Hello" + assert deprFormatCodes.escape("[-]Hello", default_color="#FFF") == "[/-]Hello" + assert deprFormatCodes.escape("[--]Hello", default_color="#FFF") == "[/--]Hello" def test_hyperlinks(): @@ -88,63 +88,63 @@ def test_hyperlinks(): link_close = ANSI.SEQ_LINK_CLOSE # BASIC LINK - assert FormatCodes.to_ansi(f"[link:{url}](click here)") == f"{link_open}click here{link_close}" + assert deprFormatCodes.to_ansi(f"[link:{url}](click here)") == f"{link_open}click here{link_close}" # FILE URL - assert FormatCodes.to_ansi(f"[link:{file_url}](open file)") == f"{link_open_file}open file{link_close}" + assert deprFormatCodes.to_ansi(f"[link:{file_url}](open file)") == f"{link_open_file}open file{link_close}" # LINK WITH NESTED FORMATTING IN DISPLAY TEXT - assert FormatCodes.to_ansi(f"[link:{url}]([b](bold link))") == f"{link_open}{bold}bold link{reset_bold}{link_close}" + assert deprFormatCodes.to_ansi(f"[link:{url}]([b](bold link))") == f"{link_open}{bold}bold link{reset_bold}{link_close}" # LINK COMBINED WITH OTHER FORMAT KEYS - assert FormatCodes.to_ansi(f"[link:{url}|b](click here)") == f"{link_open}{bold}click here{reset_bold}{link_close}" + assert deprFormatCodes.to_ansi(f"[link:{url}|b](click here)") == f"{link_open}{bold}click here{reset_bold}{link_close}" bright_blue = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['br:blue']}{ANSI.END}" - assert FormatCodes.to_ansi(f"[link:{url}|br:blue](click here)" - ) == (f"{link_open}{bright_blue}click here{reset_color}{link_close}") + assert deprFormatCodes.to_ansi(f"[link:{url}|br:blue](click here)" + ) == (f"{link_open}{bright_blue}click here{reset_color}{link_close}") # LINK WITHOUT DISPLAY BRACES IS INVALID (LEFT AS-IS) - assert FormatCodes.to_ansi(f"[link:{url}]") == f"[link:{url}]" + assert deprFormatCodes.to_ansi(f"[link:{url}]") == f"[link:{url}]" # ESCAPE: LINK SHOULD BE ESCAPED - assert FormatCodes.escape(f"[link:{url}](click here)") == f"[/link:{url}](click here)" - assert FormatCodes.escape(f"[link:{url}|b](click here)") == f"[/link:{url}|b](click here)" + assert deprFormatCodes.escape(f"[link:{url}](click here)") == f"[/link:{url}](click here)" + assert deprFormatCodes.escape(f"[link:{url}|b](click here)") == f"[/link:{url}|b](click here)" # REMOVE: OSC SEQUENCES FROM LINK SHOULD BE STRIPPED, LEAVING ONLY DISPLAY TEXT - assert FormatCodes.remove(f"[link:{url}](click here)") == "click here" - assert FormatCodes.remove_ansi(f"{link_open}click here{link_close}") == "click here" + assert deprFormatCodes.remove(f"[link:{url}](click here)") == "click here" + assert deprFormatCodes.remove_ansi(f"{link_open}click here{link_close}") == "click here" def test_remove_ansi(): ansi_string = f"{bold}Hello {orange}World!{reset}" clean_string = "Hello World!" - assert FormatCodes.remove_ansi(ansi_string) == clean_string + assert deprFormatCodes.remove_ansi(ansi_string) == clean_string def test_remove_ansi_with_removals(): ansi_string = f"{bold}Hello\n{orange}World!{reset}" clean_string = "Hello\nWorld!" removals = ((0, bold), (6, orange), (12, reset)) - assert FormatCodes.remove_ansi(ansi_string, get_removals=True) == (clean_string, removals) + assert deprFormatCodes.remove_ansi(ansi_string, get_removals=True) == (clean_string, removals) removals = ((0, bold), (5, orange), (11, reset)) - assert FormatCodes.remove_ansi(ansi_string, get_removals=True, _ignore_linebreaks=True) == (clean_string, removals) + assert deprFormatCodes.remove_ansi(ansi_string, get_removals=True, _ignore_linebreaks=True) == (clean_string, removals) def test_remove_formatting(): format_string = "[b](Hello [#F87](World!))" clean_string = "Hello World!" - assert FormatCodes.remove(format_string) == clean_string + assert deprFormatCodes.remove(format_string) == clean_string def test_remove_formatting_with_removals(): format_string = "[b](Hello [#F87](World!))" clean_string = "Hello World!" removals = ((0, default), (0, bold), (6, orange), (12, default), (12, reset_bold)) - assert FormatCodes.remove(format_string, default_color="#FFF", get_removals=True) == (clean_string, removals) + assert deprFormatCodes.remove(format_string, default_color="#FFF", get_removals=True) == (clean_string, removals) format_string = "[b](Hello)\n[#F87](World!)" clean_string = "Hello\nWorld!" removals = ((0, default), (0, bold), (5, reset_bold), (6, orange), (12, default)) - assert FormatCodes.remove(format_string, default_color="#FFF", get_removals=True) == (clean_string, removals) + assert deprFormatCodes.remove(format_string, default_color="#FFF", get_removals=True) == (clean_string, removals) removals = ((0, default), (0, bold), (5, reset_bold), (5, orange), (11, default)) - assert FormatCodes.remove( + assert deprFormatCodes.remove( format_string, default_color="#FFF", get_removals=True, _ignore_linebreaks=True ) == (clean_string, removals) diff --git a/tests/test_format_codes.py b/tests/test_format_codes.py new file mode 100644 index 0000000..64c7773 --- /dev/null +++ b/tests/test_format_codes.py @@ -0,0 +1,210 @@ +from xulbux.format_codes import FormatCodes, Format, F, Term, _build_open_close, _FmtGroup +from xulbux.base.consts import ANSI + +import pytest +import sys +import io + + +ESC = ANSI.CHAR + +# +################################################## FormatCodes TESTS ################################################## + + +def test_plain_string_passes_through(): + result = FormatCodes("hello world") + assert result.ansi == "hello world" + assert result.raw == "hello world" + assert result.code_positions == () + + +def test_single_style_wraps_text_with_open_and_reset(): + result = FormatCodes(F.BOLD("hi")) + assert result.ansi == f"{ESC}[1mhi{ESC}[22m" + assert result.raw == "hi" + + +def test_combined_group_emits_single_sgr(): + result = FormatCodes((F.BOLD | F.RED)("hi")) + assert result.ansi == f"{ESC}[1;31mhi{ESC}[22;39m" + assert result.raw == "hi" + + +def test_default_separator_is_newline(): + result = FormatCodes("a", "b", "c") + assert result.ansi == "a\nb\nc" + assert result.raw == "a\nb\nc" + + +def test_custom_separator(): + result = FormatCodes("a", "b", sep=" | ") + assert result.ansi == "a | b" + + +def test_nested_styled_keeps_outer_style_after_inner_reset(): + result = FormatCodes(F.CYAN("outer ", F.DIM("inner"), " outer")) + expected = f"{ESC}[36mouter {ESC}[2minner{ESC}[22m outer{ESC}[39m" + assert result.ansi == expected + assert result.raw == "outer inner outer" + + +def test_seq_via_plus_operator(): + result = FormatCodes("a" + F.BOLD("b") + "c") + assert result.ansi == f"a{ESC}[1mb{ESC}[22mc" + assert result.raw == "abc" + + +def test_tuple_as_multi_segment_group(): + result = FormatCodes(("a", F.BOLD("b"), "c")) + assert result.ansi == f"a{ESC}[1mb{ESC}[22mc" + assert result.raw == "abc" + + +def test_multi_text_args_in_call(): + result = FormatCodes(F.BOLD("a", F.RED("b"), "c")) + expected = f"{ESC}[1ma{ESC}[31mb{ESC}[39mc{ESC}[22m" + assert result.ansi == expected + assert result.raw == "abc" + + +def test_bright_fg_color(): + result = FormatCodes(F.BR.BLUE("x")) + assert result.ansi == f"{ESC}[94mx{ESC}[39m" + + +def test_bg_color(): + result = FormatCodes(F.BG.RED("x")) + assert result.ansi == f"{ESC}[41mx{ESC}[49m" + + +def test_bright_bg_via_bg_br_and_br_bg_are_equivalent(): + via_bg_br = FormatCodes(F.BG.BR.GREEN("x")).ansi + via_br_bg = FormatCodes(F.BR.BG.GREEN("x")).ansi + assert via_bg_br == via_br_bg == f"{ESC}[102mx{ESC}[49m" + + +def test_rgb_fg(): + result = FormatCodes(F.rgb(10, 20, 30)("x")) + assert result.ansi == f"{ESC}[38;2;10;20;30mx{ESC}[39m" + + +def test_rgb_bg(): + result = FormatCodes(F.BG.rgb(10, 20, 30)("x")) + assert result.ansi == f"{ESC}[48;2;10;20;30mx{ESC}[49m" + + +def test_hex_fg_short_and_long(): + short_form = FormatCodes(F.hex("#abc")("x")).ansi + long_form = FormatCodes(F.hex("aabbcc")("x")).ansi + assert short_form == long_form == f"{ESC}[38;2;170;187;204mx{ESC}[39m" + + +def test_hex_bg(): + result = FormatCodes(F.BG.hex("#102030")("x")) + assert result.ansi == f"{ESC}[48;2;16;32;48mx{ESC}[49m" + + +def test_link_alone_wraps_text(): + result = FormatCodes(F.link("https://example.com")("click")) + assert result.ansi == f"{ESC}]8;;https://example.com{ESC}\\click{ESC}]8;;{ESC}\\" + assert result.raw == "click" + + +def test_link_combined_with_style(): + result = FormatCodes((F.link("https://x") | F.BOLD)("click")) + expected = (f"{ESC}]8;;https://x{ESC}\\" + f"{ESC}[1m" + f"click" + f"{ESC}[22m" + f"{ESC}]8;;{ESC}\\") + assert result.ansi == expected + + +def test_code_positions_match_offsets_in_ansi(): + result = FormatCodes(F.BOLD("hi")) + # ESC[1m THEN "hi" THEN ESC[22m + assert result.code_positions == ((0, f"{ESC}[1m"), (len(f"{ESC}[1m") + 2, f"{ESC}[22m")) + # OFFSETS MUST BE VALID SLICE POINTS INTO THE ANSI STRING + for position, sequence in result.code_positions: + assert result.ansi[position:position + len(sequence)] == sequence + + +def test_raw_equals_ansi_minus_sequences(): + result = FormatCodes(F.CYAN("a"), (F.BOLD | F.RED)("b"), "plain") + stripped = result.ansi + for _, sequence in result.code_positions: + stripped = stripped.replace(sequence, "", 1) + assert stripped == result.raw + + +def test_remove_ansi_strips_csi_and_osc(): + mixed = f"{ESC}[1mhi{ESC}[0m and {ESC}]8;;u{ESC}\\link{ESC}]8;;{ESC}\\" + assert FormatCodes.remove_ansi(mixed) == "hi and link" + + +def test_print_writes_ansi_plus_end_to_stdout(monkeypatch: pytest.MonkeyPatch): + buffer = io.StringIO() + monkeypatch.setattr(sys, "stdout", buffer) + FormatCodes(F.BOLD("hi")).print(end="!") + assert buffer.getvalue() == f"{ESC}[1mhi{ESC}[22m!" + + +def test_input_uses_ansi_as_prompt(monkeypatch: pytest.MonkeyPatch): + captured: dict[str, str] = {} + + def fake_input(prompt: str = "") -> str: + captured["prompt"] = prompt + return "answer" + + monkeypatch.setattr("builtins.input", fake_input) + result = FormatCodes(F.BOLD("Name: ")).input() + assert result == "answer" + assert captured["prompt"] == f"{ESC}[1mName: {ESC}[22m" + + +def test_or_chains_produce_fmtgroup(): + group = F.BOLD | F.RED | F.UNDERLINE + assert isinstance(group, _FmtGroup) + # `_Fmt` IS AN `int` SUBCLASS, SO DIRECT INT COMPARISON WORKS + assert list(group) == [1, 31, 4] + + +def test_pipe_with_fmtgroup_left_and_right(): + left_assoc = (F.BOLD | F.RED) | F.UNDERLINE + right_assoc = F.BOLD | (F.RED | F.UNDERLINE) + assert list(left_assoc) == list(right_assoc) == [1, 31, 4] + + +def test_build_open_close_dedupes_close_codes(): + # BOLD + DIM BOTH RESET TO 22 -> ONLY ONE 22 IN CLOSE + opens, closes = _build_open_close(F.BOLD | F.DIM) + assert opens == (f"{ESC}[1;2m", ) + assert closes == (f"{ESC}[22m", ) + + +def test_term_constants(): + assert Term.CLEAR_LINE == f"{ESC}[2K" + assert Term.CLEAR_SCREEN == f"{ESC}[2J" + assert Term.HIDE_CURSOR == f"{ESC}[?25l" + assert Term.SHOW_CURSOR == f"{ESC}[?25h" + assert Term.ALT_SCREEN == f"{ESC}[?1049h" + assert Term.MAIN_SCREEN == f"{ESC}[?1049l" + + +def test_term_cursor_movement(): + assert Term.up(3) == f"{ESC}[3A" + assert Term.down() == f"{ESC}[1B" + assert Term.right(5) == f"{ESC}[5C" + assert Term.left(2) == f"{ESC}[2D" + assert Term.move(4, 7) == f"{ESC}[4;7H" + + +def test_term_save_restore_and_title(): + assert Term.save() == f"{ESC}[s" + assert Term.restore() == f"{ESC}[u" + assert Term.title("hi") == f"{ESC}]2;hi\x07" + + +def test_format_and_F_are_same_object(): + assert F is Format From 6508f2aefd7f1b7cb6aeb29f62dba3ab0e76cb98 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Sat, 16 May 2026 22:16:19 +0200 Subject: [PATCH 17/18] Remove `xulbux-lib fc` CLI command, use the new `FC` API inside the lib help command and improve the `FC` API performance --- CHANGELOG.md | 4 +- README.md | 12 +- pyproject.toml | 17 +- src/xulbux/base/consts.py | 22 +-- src/xulbux/cli/__init__.py | 3 - src/xulbux/cli/help.py | 105 +++++----- src/xulbux/cli/tools.py | 28 --- src/xulbux/depr_format_codes.py | 9 +- src/xulbux/format_codes.py | 327 +++++++++++++++++++------------- tests/test_cli.py | 97 ++-------- tests/test_depr_format_codes.py | 6 +- tests/test_format_codes.py | 114 +++++++---- 12 files changed, 375 insertions(+), 369 deletions(-) delete mode 100644 src/xulbux/cli/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fe05b8..b22a787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,14 +35,16 @@ * The `Console.log()` method no longer forces the title to be all uppercase, giving the user a bit more freedom in how they want to format their title. * Added a new, typed, operator-based formatting API in `format_codes`.
The `Format` class (*alias* `F`) exposes every ANSI style/color attribute and uses `|` to combine codes and `()` to apply them to text – e.g. `(F.BOLD | F.RED)("hi")` and `F.hex("#F67")("hi")`.
- The new `FormatCodes(*segments, sep="\n")` class builds the ANSI string on construction and exposes `.ansi`, `.raw`, `.code_positions`, `.print()` and `.input()`.
+ The new `FormatCodes(*segments, sep="\n")` (*alias* `FC`) class builds the ANSI string on construction and exposes `.ansi`, `.raw`, `.code_positions`, `.print()` and `.input()`.
A companion `Term` class provides commonly used cursor- and screen-control sequences (`Term.HIDE_CURSOR`, `Term.up(n)`, `Term.move(row, col)`, `Term.title(text)`, …). +* Removed the `xulbux-lib fc` CLI command, since the new formatting API doesn't support its old format string syntax. **BREAKING CHANGES:** * Renamed `r`, `g`, `b` and `a` to `red`, `green`, `blue` and `alpha` everywhere in the library, to follow the no-single-letter-names convention. * Renamed `h`, `s` and `l` to `hue`, `sat` and `light` everywhere in the library, to follow the no-single-letter-names convention. * Renamed the `Console.w` and `Console.h` properties to `Console.width` and `Console.height`, to follow the no-single-letter-names convention. +* Renamed the `ANSI.SEQ_COLOR` lib constant to `ANSI.SEQ_FG_COLOR` to match the naming pattern of all other `ANSI` constants. * Removed the `background:` and `bright:` prefixes from the library, so now you can just use the `bg:` and `br:` ones, for consistency. * Removed the `format_linebreaks` param from `Console.log()`, as the whole point of the `log()` method is to get a nicely formatted log message. * The `Console.get_args()` method no longer treats unknown flags as values but therefore saves them to the new `unknown_flags` property of the returned `ParsedArgs` object. diff --git a/README.md b/README.md index 71e7c34..368ee72 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,9 @@ pip install --upgrade xulbux When the library is installed, the following commands are available in the terminal: -| Command | Description | -| :---------------- | :---------------------------------------------------------------- | -| `xulbux-lib` | Show some information about the library. | -| `xulbux-lib fc` | Parse and render a string's format codes as ANSI terminal output. | +| Command | Description | +| :----------- | :--------------------------------------- | +| `xulbux-lib` | Show some information about the library. |
@@ -134,8 +133,9 @@ from xulbux.color import rgba, hsla, hexa format_codes - Format (alias F) FormatCodes Term classes for building richly formatted terminal output
- via a typed, operator-based syntax and for emitting cursor- and screen-control sequences. + Format (alias F) FormatCodes (alias FC) Term classes for building richly formatted
+ terminal output via a typed, operator-based syntax and for emitting common cursor- and
+ screen-control sequences. json diff --git a/pyproject.toml b/pyproject.toml index 1a14ebc..326a173 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -138,22 +138,7 @@ xulbux = ["py.typed", "*.pyi", "**/*.pyi"] minversion = "7.0" addopts = "-ra -q" pythonpath = ["src"] -testpaths = [ - "tests/test_code.py", - "tests/test_color_types.py", - "tests/test_color.py", - "tests/test_console.py", - "tests/test_data.py", - "tests/test_env_path.py", - "tests/test_file_sys.py", - "tests/test_file.py", - "tests/test_format_codes.py", - "tests/test_json.py", - "tests/test_metadata_consistency.py", - "tests/test_regex.py", - "tests/test_string.py", - "tests/test_system.py", -] +testpaths = ["tests/test_*.py"] [tool.pyright] include = ["src", "tests"] diff --git a/src/xulbux/base/consts.py b/src/xulbux/base/consts.py index 800af84..ff36a61 100644 --- a/src/xulbux/base/consts.py +++ b/src/xulbux/base/consts.py @@ -77,10 +77,10 @@ class ANSI: """Constants and utilities for ANSI escape code sequences.""" CHAR_ESCAPED: Final = r"\x1b" - """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n - Printable ANSI escape character.""" + """Printable ANSI escape character.""" CHAR: Final = "\x1b" """ANSI escape character.""" + START: Final = "[" """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n Start of an ANSI escape sequence.""" @@ -98,16 +98,14 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString: return cls.CHAR + cls.START + cls.SEP.join(["{}" for _ in range(placeholders)]) + cls.END - SEQ_COLOR: Final[FormattableString] = CHAR + START + "38" + SEP + "2" + SEP + "{}" + SEP + "{}" + SEP + "{}" + END - """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n - ANSI escape sequence with three placeholders for setting the RGB text color.""" - SEQ_BG_COLOR: Final[FormattableString] = CHAR + START + "48" + SEP + "2" + SEP + "{}" + SEP + "{}" + SEP + "{}" + END - """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n - ANSI escape sequence with three placeholders for setting the RGB background color.""" + SEQ_FG_COLOR: Final[FormattableString] = f"{CHAR}[38;2;{{}};{{}};{{}}m" + """RGB foreground color sequence with placeholders for red, green, and blue values.""" + SEQ_BG_COLOR: Final[FormattableString] = f"{CHAR}[48;2;{{}};{{}};{{}}m" + """RGB background color sequence with placeholders for red, green, and blue values.""" - SEQ_LINK_OPEN: Final[FormattableString] = CHAR + "]8;;{}" + CHAR + "\\" + SEQ_LINK_OPEN: Final[FormattableString] = f"{CHAR}]8;;{{}}{CHAR}\\" """OSC 8 hyperlink opening sequence with a placeholder for the URL.""" - SEQ_LINK_CLOSE: Final[str] = CHAR + "]8;;" + CHAR + "\\" + SEQ_LINK_CLOSE: Final[str] = f"{CHAR}]8;;{CHAR}\\" """OSC 8 hyperlink closing sequence.""" COLOR_MAP: Final[set[str]] = { @@ -120,7 +118,7 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString: "cyan", "white", } - """**DEPRECATED** – only used by `depr_format_codes` and as the seed for `COLOR_VARIANTS_MAP`.\n + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n The standard terminal color names.""" COLOR_VARIANTS_MAP: Final[set[str]] = COLOR_MAP | { @@ -133,7 +131,7 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString: "br:cyan", "br:white", } - """**DEPRECATED** – only used by `depr_format_codes` and as the seed for `COLOR_VARIANTS_MAP`.\n + """**DEPRECATED** – only used by `depr_format_codes`. Will be removed together with that module.\n All color variants that can be used in formatting.""" CODES_MAP: Final[dict[str | tuple[str, ...], int]] = { diff --git a/src/xulbux/cli/__init__.py b/src/xulbux/cli/__init__.py index 54b69dc..28b83c8 100644 --- a/src/xulbux/cli/__init__.py +++ b/src/xulbux/cli/__init__.py @@ -5,9 +5,6 @@ def main() -> None: """Main entry point for the `xulbux-lib` CLI command.""" match sys.argv[1] if len(sys.argv) > 1 else "": - case "fc": - from .tools import render_format_codes - render_format_codes() case _: from .help import show_help show_help() diff --git a/src/xulbux/cli/help.py b/src/xulbux/cli/help.py index f557123..7164535 100644 --- a/src/xulbux/cli/help.py +++ b/src/xulbux/cli/help.py @@ -1,5 +1,6 @@ from .. import __version__ -from ..depr_format_codes import deprFormatCodes +from ..base.decorators import mypyc_attr +from ..format_codes import FC, F from ..console import Console from urllib.error import HTTPError @@ -38,57 +39,67 @@ def is_latest_version() -> Optional[bool]: URL = "https://pypi.org/pypi/xulbux/json" IS_LATEST_VERSION = is_latest_version() -CLI_COLORS = { - "border": "dim|br:black", - "class": "br:cyan", - "cmd": "green", - "const": "br:blue", - "fn": "br:green", - "heading": "br:white", - "import": "magenta", - "lib": "br:magenta", - "link": "br:blue", - "notice": "br:yellow", - "punctuator": "br:black", - "text": "white", -} -CLI_HELP = deprFormatCodes.to_ansi( - rf"""[_] - [b|#7075FF] __ __ - [b|#7075FF] _ __ __ __/ / / /_ __ ___ __ - [b|#7075FF] | |/ // / / / / / __ \/ / / | |/ / - [b|#7075FF] > , , < - [b|#7075FF]/_/|_|\____/\__/\____/\____//_/|_| [*|#000|BG:#8085FF] v[b]{__version__} [*|dim|{CLI_COLORS["notice"]}]({"" if IS_LATEST_VERSION else " (newer available)"})[*] - - [i|#9095FF]Simplify common programming tasks![*] - - [b|{CLI_COLORS["heading"]}](Commands:)[*] - [{CLI_COLORS["border"]}](╭───────────────────────────────────────────────────╮)[*] - [{CLI_COLORS["border"]}](│) [{CLI_COLORS["cmd"]}]xulbux-lib[*] [{CLI_COLORS["text"]}]Show library info and usage[*] [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](│) [{CLI_COLORS["cmd"]}]xulbux-lib [b|{CLI_COLORS["fn"]}](fc) [{CLI_COLORS["text"]}]Render a string's format codes[*] [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](╰───────────────────────────────────────────────────╯)[*] - [b|{CLI_COLORS["heading"]}](Usage:)[*] - [{CLI_COLORS["border"]}](╭───────────────────────────────────────────────────╮)[*] - [{CLI_COLORS["border"]}](│) [i|{CLI_COLORS["punctuator"]}](# LIBRARY CONSTANTS)[*] [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](│) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]base[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]consts [{CLI_COLORS["import"]}]import [{CLI_COLORS["const"]}]COLOR[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["const"]}]CHARS[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["const"]}]ANSI[*] [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](│) [i|{CLI_COLORS["punctuator"]}](# Main Classes)[*] [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](│) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux [{CLI_COLORS["import"]}]import [{CLI_COLORS["class"]}]Code[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["class"]}]Color[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["class"]}]Console[{CLI_COLORS["punctuator"]}], ...[*] [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](│) [i|{CLI_COLORS["punctuator"]}](# module specific imports)[*] [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](│) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]color [{CLI_COLORS["import"]}]import [{CLI_COLORS["fn"]}]rgba[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["fn"]}]hsla[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["fn"]}]hexa[*] [{CLI_COLORS["border"]}](│) - [{CLI_COLORS["border"]}](╰───────────────────────────────────────────────────╯)[*] - [b|{CLI_COLORS["heading"]}](Documentation:)[*] - [{CLI_COLORS["border"]}](╭───────────────────────────────────────────────────╮)[*] - [{CLI_COLORS["border"]}](│) [{CLI_COLORS["text"]}]For more information see the GitHub wiki page: [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](│) [{CLI_COLORS["link"]}|link:https://github.com/xulbux/python-lib-xulbux/wiki](github.com/xulbux/python-lib-xulbux/wiki) [{CLI_COLORS["border"]}](│)[*] - [{CLI_COLORS["border"]}](╰───────────────────────────────────────────────────╯)[*] - [_]""" + +@mypyc_attr(native_class=False) +class S: + """Styling constants for the CLI help message.""" + + BORDER = F.DIM | F.BR.BLACK + CLS = F.BR.CYAN + CMD = F.GREEN + CONST = F.BR.BLUE + FN = F.BR.GREEN + HEADING = F.BOLD | F.BR.WHITE + IMPORT = F.MAGENTA + LIB = F.BR.MAGENTA + META = F.DIM | F.BR.WHITE + PUNCT = F.BR.BLACK + TEXT = F.WHITE + + +# fmt: OFF +CLI_HELP = FC( + F.RESET, + ( + (F.BOLD | F.hex("#7075FF"))( + " __ __ \n" + " _ __ __ __/ / / /_ __ ___ __\n" + " | |/ // / / / / / __ \\/ / / | |/ /\n" + " > , , < \n" + " /_/|_|\\____/\\__/\\____/\\____//_/|_| ", + (F.hex("#000") | F.BG.hex("#8085FF"))(f" v{__version__} "), + ), + "" if IS_LATEST_VERSION else (F.DIM | F.YELLOW)(" (", F.ITALIC("newer available"), ")"), + ), + "", + (F.ITALIC | F.hex("#9095FF"))(" Simplify common programming tasks!"), + "", + S.HEADING(" Commands:"), + S.BORDER(" ╭───────────────────────────────────────────────────╮"), + (S.BORDER(" │ "), S.CMD("xulbux-lib "), S.TEXT("Show library info and usage "), S.BORDER("│")), + S.BORDER(" ╰───────────────────────────────────────────────────╯"), + S.HEADING(" Usage:"), + S.BORDER(" ╭───────────────────────────────────────────────────╮"), + (S.BORDER(" │ "), S.PUNCT("# ", F.ITALIC("LIBRARY CONSTANTS ")), S.BORDER("│")), + (S.BORDER(" │ "), S.IMPORT("from "), S.LIB("xulbux"), (F.DIM | S.LIB)("."), S.LIB("base"), (F.DIM | S.LIB)("."), S.LIB("consts "), S.IMPORT("import "), S.CONST("COLOR"), S.PUNCT(", "), S.CONST("CHARS"), S.PUNCT(", "), S.CONST("ANSI "), S.BORDER("│")), + (S.BORDER(" │ "), S.PUNCT("# ", F.ITALIC("Main Classes ")), S.BORDER("│")), + (S.BORDER(" │ "), S.IMPORT("from "), S.LIB("xulbux "), S.IMPORT("import "), S.CLS("Code"), S.PUNCT(", "), S.CLS("Color"), S.PUNCT(", "), S.CLS("Console"), S.PUNCT(", "), S.META("... "), S.BORDER("│")), + (S.BORDER(" │ "), S.PUNCT("# ", F.ITALIC("module specific imports ")), S.BORDER("│")), + (S.BORDER(" │ "), S.IMPORT("from "), S.LIB("xulbux"), (F.DIM | S.LIB)("."), S.LIB("color "), S.IMPORT("import "), S.FN("rgba"), S.PUNCT(", "), S.FN("hsla"), S.PUNCT(", "), S.FN("hexa "), S.BORDER("│")), + S.BORDER(" ╰───────────────────────────────────────────────────╯"), + S.HEADING(" Documentation:"), + S.BORDER(" ╭───────────────────────────────────────────────────╮"), + (S.BORDER(" │ "), S.TEXT("For more information see the GitHub wiki page: "), S.BORDER("│")), + (S.BORDER(" │ "), (F.BR.BLUE | F.link("https://github.com/xulbux/python-lib-xulbux/wiki"))("github.com/xulbux/python-lib-xulbux/wiki"), " ", S.BORDER("│")), + S.BORDER(" ╰───────────────────────────────────────────────────╯"), + "", ) +# fmt: ON def show_help() -> None: """CLI command function for `xulbux-lib` command,
which shows some information about the library.""" - deprFormatCodes._config_terminal() - print(CLI_HELP) + CLI_HELP.print() Console.pause_exit(" [dim](Press any key to exit...)\n\n", pause=True) diff --git a/src/xulbux/cli/tools.py b/src/xulbux/cli/tools.py deleted file mode 100644 index 9e1e0d1..0000000 --- a/src/xulbux/cli/tools.py +++ /dev/null @@ -1,28 +0,0 @@ -from ..depr_format_codes import deprFormatCodes -from ..console import Console - - -def render_format_codes(): - """CLI command function for `xulbux-lib fc` command, which allows you to parse
- and render a given string's format codes as ANSI terminal output.""" - - args = Console.get_args({"input": "before"}, skip=1) - vals = args.input.values - - if not vals: - deprFormatCodes.print( - "\n[_|i|dim]Provide a string to parse and render\n" - "its format codes as ANSI terminal output.[_]\n" - ) - - else: - ansi = deprFormatCodes.to_ansi("".join(vals)) - ansi_escaped = deprFormatCodes.escape_ansi(ansi) - ansi_stripped = deprFormatCodes.remove_ansi(ansi) - - print(f"\n{ansi}\n") - - if len(ansi) != len(ansi_stripped): - deprFormatCodes.print(f"[_|i|dim]{ansi_escaped}[_]\n") - else: - deprFormatCodes.print("[_|i|dim](The provided string doesn't contain any valid format codes.)\n") diff --git a/src/xulbux/depr_format_codes.py b/src/xulbux/depr_format_codes.py index b49aa17..e9f7a8b 100644 --- a/src/xulbux/depr_format_codes.py +++ b/src/xulbux/depr_format_codes.py @@ -717,14 +717,15 @@ def _get_replacement(cls, format_key: str, default_color: Optional[rgba], /, bri is_bg = rgb_match.group(1) red, green, blue = map(int, rgb_match.groups()[1:]) if Color.is_valid_rgba((red, green, blue)): - result = ANSI.SEQ_BG_COLOR.format(red, green, blue) if is_bg else ANSI.SEQ_COLOR.format(red, green, blue) + result = ANSI.SEQ_BG_COLOR.format(red, green, + blue) if is_bg else ANSI.SEQ_FG_COLOR.format(red, green, blue) elif hex_match: is_bg = hex_match.group(1) rgb = Color.to_rgba(hex_match.group(2)) result = ( ANSI.SEQ_BG_COLOR.format(rgb[0], rgb[1], rgb[2]) - if is_bg else ANSI.SEQ_COLOR.format(rgb[0], rgb[1], rgb[2]) + if is_bg else ANSI.SEQ_FG_COLOR.format(rgb[0], rgb[1], rgb[2]) ) except Exception: @@ -751,7 +752,7 @@ def _get_default_ansi( _default_color: tuple[int, int, int] = (default_color[0], default_color[1], default_color[2]) if brightness_steps is None or (format_key and _PATTERNS.bg_opt_default.search(format_key)): - return (ANSI.SEQ_BG_COLOR if format_key and _PATTERNS.bg_default.search(format_key) else ANSI.SEQ_COLOR).format( + return (ANSI.SEQ_BG_COLOR if format_key and _PATTERNS.bg_default.search(format_key) else ANSI.SEQ_FG_COLOR).format( *_default_color ) @@ -780,7 +781,7 @@ def _get_default_ansi( adjusted_rgb = Color.adjust_lightness(default_color, -(brightness_steps / 100) * adjust) new_rgb = (adjusted_rgb[0], adjusted_rgb[1], adjusted_rgb[2]) - return (ANSI.SEQ_BG_COLOR if is_bg else ANSI.SEQ_COLOR).format(*new_rgb[:3]) + return (ANSI.SEQ_BG_COLOR if is_bg else ANSI.SEQ_FG_COLOR).format(*new_rgb[:3]) @staticmethod def _normalize_key(format_key: str, /) -> str: diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 406573e..86ce1e1 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -1,6 +1,6 @@ """ -This module provides the `FormatCodes` class together with the `Format` (alias `F`) and `Term`
-classes for building richly formatted terminal output using a typed, operator-based syntax. +This module provides the `FormatCodes` (alias `FC`) class together with the `Format` (alias `F`)
+and `Term` classes for building richly formatted terminal output using a typed, operator-based syntax. ----------------------------------------------------------------------------------------------------------- ### The Easy Formatting @@ -38,8 +38,8 @@ ----------------------------------------------------------------------------------------------------------- #### Auto Resetting Formats -Every `_Fmt`, `_FmtGroup`, `_ColorFmt` or `_LinkFmt` call automatically generates the matching
-reset sequence behind its text, just like shown in the following example: +Every `_Fmt`, `_FmtGroup`, `_ColorFmt` or `_LinkFmt` call automatically generates the
+matching reset sequence behind its text, just like shown in the following example: ```python FormatCodes( @@ -58,18 +58,42 @@ ).print() ``` +----------------------------------------------------------------------------------------------------------- +#### Bare (Open-Only) Formats + +Passing a format object *without calling it* emits only its opening ANSI sequence at that
+position, with no matching close/reset appended. This is the typed equivalent of `[…]`
+(open bracket without closing braces) from the legacy string syntax: + +```python +FormatCodes( + F.RED, "error: something went wrong ", F.RESET, + "back to normal", +).print() +``` + +Any format type supports bare usage: `F.RED` (`_Fmt`), `F.hex("#F67")` (`_ColorFmt`),
+`F.link("url")` (`_LinkFmt`), and `F.BOLD | F.RED` (`_FmtGroup`).
+Bare formats can also appear inside tuples and nested calls: + +```python +FormatCodes( + F.DIM("a", F.RED, "b", F.RESET_COLOR, "c"), +).print() +``` + ----------------------------------------------------------------------------------------------------------- #### Nesting and Multi-Segment Groups A format call accepts either a single piece of text or any number of mixed segments.
-Strings, nested `_Styled` calls, sequences built with `+` and even raw tuples can be mixed freely: +Strings, nested `_Styled` calls, bare format objects, and raw tuples can be mixed freely: * `F.X("text")` – Apply `X` to `"text"`, auto-reset after. * `F.X | F.Y` – Combine `X` and `Y` into a single group. * `(F.X | F.Y)("text")` – Apply the group to `"text"`. * `F.X("a", F.Y("b"), "c")` – Nested multi-segment: `Y` is applied only to `"b"`. +* `F.X` – Bare: emit only the opening sequence, no auto-reset. * `("a", F.X("b"), "c")` – Same-line group – passed as a single tuple to `FormatCodes(…)`. -* `"a" + F.X("b") + "c"` – Same-line group built with `+` (yields a `_Seq`). Inside `FormatCodes(*segments, sep="\\n")`, every positional argument is treated as one
logical line and joined by `sep`. An empty string argument `""` therefore produces a blank line. @@ -93,8 +117,8 @@ - `F.BR.BLACK`, `F.BR.RED`, `F.BR.GREEN`, … * Standard background colors (`F.BG.*`): - `F.BG.BLACK`, `F.BG.RED`, `F.BG.GREEN`, … -* Bright background colors (`F.BG.BR.*` or `F.BR.BG.*`): - - `F.BG.BR.RED`, `F.BR.BG.RED`, … +* Bright background colors (`F.BG.BR.*`): + - `F.BG.BR.RED`, `F.BG.BR.GREEN`, … * 24-bit true-color (foreground / background): - `F.rgb(255, 96, 112)` - `F.hex("#FF6070")` or `F.hex("F67")` @@ -190,6 +214,13 @@ def _config_terminal() -> None: """Mapping from format code integer to its matching reset integer.\n Codes that fully reset everything (`0`) or have no useful specific reset are intentionally omitted.""" +_STANDARD_SEQS: Final[ + dict[int, tuple[tuple[str, ...], tuple[str, ...]]], +] = {cid: ((f"{ANSI.CHAR}[{cid}m", ), (f"{ANSI.CHAR}[{reset}m", )) + for cid, reset in _RESET_MAP.items()} +"""Pre-computed `(opens, closes)` tuple pairs for every standard single-code SGR format.\n +Used as a fast path in `_build_open_close` to avoid per-call list and string allocations.""" + ################################################## CORE TYPES ################################################## @@ -224,12 +255,14 @@ def __ror__(self, other: _AnyFmt) -> _FmtGroup: def __call__(self, *text: _Segment) -> _Styled: """Applies this format group to the given text, auto-resetting after.""" - return _Styled(self, text[0] if len(text) == 1 else text) + opens, closes = _build_open_close(self) + return _Styled(opens, closes, text[0] if len(text) == 1 else text) def __matmul__(self, text: _Text) -> _Styled: """Applies this format group to the given text, auto-resetting after.""" - return _Styled(self, text) + opens, closes = _build_open_close(self) + return _Styled(opens, closes, text) def __repr__(self) -> str: """Returns a string representation of this format group, showing its individual codes.""" @@ -248,6 +281,8 @@ class _Fmt(int): Marked `native_class=False` because MyPyC does not support
subclassing the built-in `int` type in a native class.""" + _oc: tuple[tuple[str, ...], tuple[str, ...]] + def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup: # type: ignore[override] """Combines this format code with another code or group via `|`.""" @@ -264,12 +299,28 @@ def __ror__(self, other: _AnyFmt) -> _FmtGroup: # type: ignore[override] def __call__(self, *text: _Segment) -> _Styled: """Applies this format code to the given text, auto-resetting after.""" - return _Styled(_FmtGroup(self), text[0] if len(text) == 1 else text) + try: + oc = self._oc + + except AttributeError: + cached = _STANDARD_SEQS.get(int(self)) + oc = _build_open_close(_FmtGroup(self)) if cached is None else cached + self._oc = oc + + return _Styled(oc[0], oc[1], text[0] if len(text) == 1 else text) def __matmul__(self, text: _Text) -> _Styled: """Applies this format code to the given text, auto-resetting after.""" - return _Styled(_FmtGroup(self), text) + try: + oc = self._oc + + except AttributeError: + cached = _STANDARD_SEQS.get(int(self)) + oc = _build_open_close(_FmtGroup(self)) if cached is None else cached + self._oc = oc + + return _Styled(oc[0], oc[1], text) class _ColorFmt: @@ -280,10 +331,16 @@ class _ColorFmt: >>> F.hex("#FF6070")("text") # HEX FG COLOR >>> (F.BOLD | F.rgb(255, 96, 112))("text") # COMBINED WITH STYLE""" - __slots__ = ("_red", "_green", "_blue", "_bg") + __slots__ = ("_red", "_green", "_blue", "_bg", "_open_seq", "_close_seq") def __init__(self, red: int, green: int, blue: int, /, *, bg: bool = False) -> None: self._red, self._green, self._blue, self._bg = red, green, blue, bg + if bg: + self._open_seq = ANSI.SEQ_BG_COLOR.format(red, green, blue) + self._close_seq = f"{ANSI.CHAR}[{F.RESET_BG}m" + else: + self._open_seq = ANSI.SEQ_FG_COLOR.format(red, green, blue) + self._close_seq = f"{ANSI.CHAR}[{F.RESET_FG}m" @classmethod def from_hex(cls, color: str, /, *, bg: bool = False) -> _ColorFmt: @@ -312,12 +369,12 @@ def __ror__(self, other: _AnyFmt) -> _FmtGroup: def __call__(self, *text: _Segment) -> _Styled: """Applies this color format to the given text, auto-resetting after.""" - return _Styled(_FmtGroup(self), text[0] if len(text) == 1 else text) + return _Styled((self._open_seq, ), (self._close_seq, ), text[0] if len(text) == 1 else text) def __matmul__(self, text: _Text) -> _Styled: """Applies this color format to the given text, auto-resetting after.""" - return _Styled(_FmtGroup(self), text) + return _Styled((self._open_seq, ), (self._close_seq, ), text) def __repr__(self) -> str: """Returns a string representation of this color format, indicating
@@ -332,10 +389,12 @@ class _LinkFmt: >>> F.link("https://example.com")("click here") >>> (F.link("https://example.com") | F.BR.BLUE)("click here")""" - __slots__ = ("_url", ) + __slots__ = ("_url", "_open_seq") def __init__(self, url: str, /) -> None: self._url = url + self._open_seq = ANSI.SEQ_LINK_OPEN.format(url) + self._open_seq = ANSI.SEQ_LINK_CLOSE def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup: """Combines this link format with another format or group via `|`.""" @@ -353,12 +412,12 @@ def __ror__(self, other: _AnyFmt) -> _FmtGroup: def __call__(self, *text: _Segment) -> _Styled: """Applies this link format to the given text, auto-resetting after.""" - return _Styled(_FmtGroup(self), text[0] if len(text) == 1 else text) + return _Styled((self._open_seq, ), (self._open_seq, ), text[0] if len(text) == 1 else text) def __matmul__(self, text: _Text) -> _Styled: """Applies this link format to the given text, auto-resetting after.""" - return _Styled(_FmtGroup(self), text) + return _Styled((self._open_seq, ), (self._open_seq, ), text) def __repr__(self) -> str: """Returns a string representation of this link format, showing the URL it points to.""" @@ -369,117 +428,77 @@ def __repr__(self) -> str: _AnyFmt: TypeAlias = Union["_Fmt", "_ColorFmt", "_LinkFmt"] """Any single format code, color format, or link format
that can be combined via `|` and applied to text.""" -_Segment: TypeAlias = Union[str, "_Styled", "_Seq"] -"""A single segment of text with optional formatting, which can be a plain string,
-a nested styled segment, or a sequence of mixed segments.""" -_Text: TypeAlias = Union[str, "_Styled", "_Seq", "tuple[_Segment, ...]"] +_Segment: TypeAlias = Union[str, "_Styled", _AnyFmt, _FmtGroup] +"""A single segment: a plain string, a nested styled segment, or a bare format object (open-only).""" +_Text: TypeAlias = Union[str, "_Styled", _AnyFmt, _FmtGroup, "tuple[_Segment, ...]"] """Anything that can be passed to a `_Fmt`/`_FmtGroup`/`_ColorFmt`/`_LinkFmt` call.""" -_Renderable: TypeAlias = Union[str, "_Styled", "_Seq", "tuple[_Segment, ...]"] +_Renderable: TypeAlias = Union[str, "_Styled", _AnyFmt, _FmtGroup, "tuple[_Segment, ...]"] """Anything that can be passed as a positional argument to `FormatCodes(…)`.""" class _Styled: - """A `_FmtGroup` applied to text – produced by calling a `_Fmt` or `_FmtGroup`.\n + """Pre-computed ANSI open/close sequences applied to text.\n ------------------------------------------------------------------------------------------- The renderer emits the opening ANSI codes, then `text`, then the matching reset codes.
- `text` may be a plain `str`, a `_Styled`, a `_Seq`, or a tuple of mixed segments for - nested formatting.""" + `text` may be a plain `str`, a nested `_Styled`, or a tuple of mixed segments.""" - __slots__ = ("codes", "text") + __slots__ = ("_opens", "_closes", "text") - def __init__(self, codes: _FmtGroup, text: _Text) -> None: - self.codes = codes + def __init__(self, opens: tuple[str, ...], closes: tuple[str, ...], text: _Text) -> None: + self._opens = opens + self._closes = closes self.text = text - def __add__(self, other: str | _Styled | _Seq) -> _Seq: - """Combines this styled segment with another styled
- segment or string via `+`, yielding a `_Seq`.""" - - if isinstance(other, _Seq): - return _Seq(self, *other._parts) - - return _Seq(self, other) - - def __radd__(self, other: str | _Styled) -> _Seq: - """Combines this styled segment with another styled
- segment or string via `+`, yielding a `_Seq`.""" - - return _Seq(other, self) - def __repr__(self) -> str: - """Returns a string representation of this styled segment, showing its codes and text.""" - - return f"_Styled(codes={self.codes!r}, text={self.text!r})" - - -class _Seq: - """A flat sequence of segments produced by the `+` operator.\n - ---------------------------------------------------------------------------------- - Alternative to a plain `tuple` when building a multi-segment group;
- `+` keeps the whole expression as one Python value - so a code formatter won't split it across lines. + """Returns a string representation of this styled segment, showing its opens and text.""" - >>> " " + F.BR.BLUE("-f") + ", " + F.BR.BLUE("--fast") + " description\\n" """ - - __slots__ = ("_parts", ) - - def __init__(self, *parts: _Segment) -> None: - self._parts: tuple[_Segment, ...] = parts - - def __add__(self, other: str | _Styled | _Seq) -> _Seq: - """Combines this sequence with another styled segment,
- string, or sequence via `+`, yielding a new `_Seq`.""" - - if isinstance(other, _Seq): - return _Seq(*self._parts, *other._parts) - - return _Seq(*self._parts, other) - - def __radd__(self, other: str | _Styled) -> _Seq: - """Combines this sequence with another styled
- segment or string via `+`, yielding a new `_Seq`.""" - - return _Seq(other, *self._parts) - - def __iter__(self) -> Iterator[_Segment]: - """Iterating a `_Seq` yields its individual segments in order.""" - - return iter(self._parts) - - def __repr__(self) -> str: - """Returns a string representation of this sequence, showing its individual segments.""" - - return f"_Seq{self._parts!r}" + return f"_Styled(opens={self._opens!r}, text={self.text!r})" ################################################## NAMESPACE HELPERS ################################################## -class _BrBgNS: - """Namespace for bright background colors, reachable as `F.BG.BR.*` or `F.BR.BG.*`.""" +class _BgBrNS: + """Namespace for bright background colors, reachable as `F.BG.BR.*`.""" BLACK: ClassVar[_Fmt] = _Fmt(100) + """Bright black background.""" RED: ClassVar[_Fmt] = _Fmt(101) + """Bright red background.""" GREEN: ClassVar[_Fmt] = _Fmt(102) + """Bright green background.""" YELLOW: ClassVar[_Fmt] = _Fmt(103) + """Bright yellow background.""" BLUE: ClassVar[_Fmt] = _Fmt(104) + """Bright blue background.""" MAGENTA: ClassVar[_Fmt] = _Fmt(105) + """Bright magenta background.""" CYAN: ClassVar[_Fmt] = _Fmt(106) + """Bright cyan background.""" WHITE: ClassVar[_Fmt] = _Fmt(107) + """Bright white background.""" class _BgNS: """Namespace for background colors, reachable as `F.BG.*`.""" BLACK: ClassVar[_Fmt] = _Fmt(40) + """Black background.""" RED: ClassVar[_Fmt] = _Fmt(41) + """Red background.""" GREEN: ClassVar[_Fmt] = _Fmt(42) + """Green background.""" YELLOW: ClassVar[_Fmt] = _Fmt(43) + """Yellow background.""" BLUE: ClassVar[_Fmt] = _Fmt(44) + """Blue background.""" MAGENTA: ClassVar[_Fmt] = _Fmt(45) + """Magenta background.""" CYAN: ClassVar[_Fmt] = _Fmt(46) + """Cyan background.""" WHITE: ClassVar[_Fmt] = _Fmt(47) - BR: ClassVar[type[_BrBgNS]] = _BrBgNS + """White background.""" + BR: ClassVar[type[_BgBrNS]] = _BgBrNS @staticmethod def rgb(red: int, green: int, blue: int, /) -> _ColorFmt: @@ -500,14 +519,21 @@ class _BrNS: """Namespace for bright foreground colors, reachable as `F.BR.*`.""" BLACK: ClassVar[_Fmt] = _Fmt(90) + """Bright black foreground.""" RED: ClassVar[_Fmt] = _Fmt(91) + """Bright red foreground.""" GREEN: ClassVar[_Fmt] = _Fmt(92) + """Bright green foreground.""" YELLOW: ClassVar[_Fmt] = _Fmt(93) + """Bright yellow foreground.""" BLUE: ClassVar[_Fmt] = _Fmt(94) + """Bright blue foreground.""" MAGENTA: ClassVar[_Fmt] = _Fmt(95) + """Bright magenta foreground.""" CYAN: ClassVar[_Fmt] = _Fmt(96) + """Bright cyan foreground.""" WHITE: ClassVar[_Fmt] = _Fmt(97) - BG: ClassVar[type[_BrBgNS]] = _BrBgNS + """Bright white foreground.""" ################################################## FORMAT CODES ################################################## @@ -528,37 +554,65 @@ class Format: ######################### TOTAL RESET ######################### RESET: ClassVar[_Fmt] = _Fmt(0) + """Reset all formatting to default.""" ####################### SPECIFIC RESETS ####################### RESET_BOLD: ClassVar[_Fmt] = _Fmt(22) + """Reset bold (also resets dim, as they share the same code).""" RESET_DIM: ClassVar[_Fmt] = _Fmt(22) + """Reset dim (also resets bold, as they share the same code).""" RESET_ITALIC: ClassVar[_Fmt] = _Fmt(23) + """Reset italic.""" RESET_UNDERLINE: ClassVar[_Fmt] = _Fmt(24) + """Reset underline and double underline.""" RESET_INVERSE: ClassVar[_Fmt] = _Fmt(27) + """Reset inverse.""" RESET_HIDDEN: ClassVar[_Fmt] = _Fmt(28) - RESET_STRIKE: ClassVar[_Fmt] = _Fmt(29) - RESET_COLOR: ClassVar[_Fmt] = _Fmt(39) + """Reset hidden.""" + RESET_STRIKETHROUGH: ClassVar[_Fmt] = _Fmt(29) + """Reset strikethrough.""" + RESET_FG: ClassVar[_Fmt] = _Fmt(39) + """Reset foreground color.""" RESET_BG: ClassVar[_Fmt] = _Fmt(49) + """Reset background color.""" ######################### TEXT STYLES ######################### BOLD: ClassVar[_Fmt] = _Fmt(1) + """Bold text.\n + Note that this is also reset by `RESET_DIM`.""" DIM: ClassVar[_Fmt] = _Fmt(2) + """Dim text.\n + Note that this is also reset by `RESET_BOLD`.""" ITALIC: ClassVar[_Fmt] = _Fmt(3) + """Italic text.""" UNDERLINE: ClassVar[_Fmt] = _Fmt(4) + """Underline text.""" INVERSE: ClassVar[_Fmt] = _Fmt(7) + """Inverse colors (swap foreground and background colors).""" HIDDEN: ClassVar[_Fmt] = _Fmt(8) - STRIKE: ClassVar[_Fmt] = _Fmt(9) + """Hidden (invisible) text.""" + STRIKETHROUGH: ClassVar[_Fmt] = _Fmt(9) + """Strikethrough text.""" DOUBLE_UNDERLINE: ClassVar[_Fmt] = _Fmt(21) + """Double underline text.""" ###################### STANDARD FG COLORS ##################### BLACK: ClassVar[_Fmt] = _Fmt(30) + """Black foreground.""" RED: ClassVar[_Fmt] = _Fmt(31) + """Red foreground.""" GREEN: ClassVar[_Fmt] = _Fmt(32) + """Green foreground.""" YELLOW: ClassVar[_Fmt] = _Fmt(33) + """Yellow foreground.""" BLUE: ClassVar[_Fmt] = _Fmt(34) + """Blue foreground.""" MAGENTA: ClassVar[_Fmt] = _Fmt(35) + """Magenta foreground.""" CYAN: ClassVar[_Fmt] = _Fmt(36) + """Cyan foreground.""" WHITE: ClassVar[_Fmt] = _Fmt(37) + """White foreground.""" ######################### NAMESPACES ########################## BR: ClassVar[type[_BrNS]] = _BrNS @@ -669,6 +723,11 @@ def _build_open_close(group: _FmtGroup, /) -> tuple[tuple[str, ...], tuple[str, Returns a `(opens, closes)` pair of tuples. Multiple opens / closes are emitted
only when both an OSC 8 hyperlink and SGR codes are present (OSC wraps SGR).""" + # FAST PATH: SINGLE STANDARD _Fmt CODE (REALLY COMMON) + if len(codes := group._codes) == 1 and type(codes[0]) is _Fmt: + if (cached := _STANDARD_SEQS.get(int(codes[0]))) is not None: + return cached + sgr_open: list[str] = [] sgr_close: list[str] = [] link_url: Optional[str] = None @@ -717,35 +776,44 @@ def _build_open_close(group: _FmtGroup, /) -> tuple[tuple[str, ...], tuple[str, class FormatCodes: """Build a formatted string from a sequence of segments
- (strings, `_Styled` calls, `_Seq` chains built with `+`, or raw tuples).\n + (strings, `_Styled` calls, or raw tuples), joined by `sep`.\n ------------------------------------------------------------------------------------------------------ * `segments` – Any number of segments to render. Each positional argument represents one logical line. * `sep` – The separator inserted between two adjacent positional arguments (default `"\\n"`). ------------------------------------------------------------------------------------------------------ After construction the instance exposes: * `ansi` – The fully rendered ANSI escape string, ready to be written to a terminal. - * `raw` – The same content with every ANSI escape sequence stripped (the "plain" text). + * `raw` – `ansi` with every ANSI escape sequence stripped; computed on demand. * `code_positions` – A tuple of `(position, sequence)` pairs giving
- the start offset of every ANSI escape sequence inside `ansi`.
- The same data can be used to map indices between `raw` and `ansi`. + the start offset of every ANSI escape sequence inside `ansi`; computed on demand. ------------------------------------------------------------------------------------------------------ For exact information about how to use the operator syntax,
see the `format_codes` module documentation.""" + __slots__ = ("_ansi_parts", "ansi") + def __init__(self, /, *segments: _Renderable, sep: str = "\n") -> None: self._ansi_parts: list[str] = [] - self._raw_parts: list[str] = [] - self._code_positions: list[tuple[int, str]] = [] - self._offset: int = 0 for i, segment in enumerate(segments): if i > 0: - self._emit_raw(sep) + self._ansi_parts.append(sep) self._render(segment) self.ansi: str = "".join(self._ansi_parts) - self.raw: str = "".join(self._raw_parts) - self.code_positions: tuple[tuple[int, str], ...] = tuple(self._code_positions) + + @property + def raw(self) -> str: + """The rendered output with every ANSI escape sequence stripped (the "plain" text).""" + + return _ANSI_SEQ_RX.sub("", self.ansi) + + @property + def code_positions(self) -> tuple[tuple[int, str], ...]: + """A tuple of `(position, sequence)` pairs giving the
+ start offset of every ANSI escape sequence inside `ansi`.""" + + return tuple((match.start(), match.group()) for match in _ANSI_SEQ_RX.finditer(self.ansi)) def __str__(self) -> str: """Stringifying a `FormatCodes` instance yields its rendered
@@ -793,45 +861,44 @@ def remove_ansi(ansi_string: str, /) -> str: return _ANSI_SEQ_RX.sub("", ansi_string) - def _emit_raw(self, text: str) -> None: - """Internal method to append `text` to both the ANSI and raw outputs and advance the running offset.""" - - self._ansi_parts.append(text) - self._raw_parts.append(text) - self._offset += len(text) - - def _emit_seq(self, sequence: str) -> None: - """Internal method to record a `sequence`'s position, then append it to the ANSI output only.""" - - self._code_positions.append((self._offset, sequence)) - self._ansi_parts.append(sequence) - self._offset += len(sequence) - def _render(self, segment: object) -> None: """Internal method to recursively render a `segment`, dispatching by runtime type.\n - ------------------------------------------------------------------------------------- + ------------------------------------------------------------------------------------ Strings are emitted as raw text; `_Styled` segments are wrapped in their opening
- and closing ANSI sequences; `_Seq` and `tuple` segments are flattened in order.""" + and closing ANSI sequences; `tuple` segments are flattened in order.
+ Bare format objects (`_Fmt`, `_ColorFmt`, `_LinkFmt`, `_FmtGroup`) emit only
+ their opening sequence with no matching close.""" if isinstance(segment, str): - self._emit_raw(segment) + self._ansi_parts.append(segment) return if isinstance(segment, _Styled): - opens, closes = _build_open_close(segment.codes) - for piece in opens: - self._emit_seq(piece) + for piece in segment._opens: + self._ansi_parts.append(piece) self._render(segment.text) - for piece in closes: - self._emit_seq(piece) - return - if isinstance(segment, _Seq): - for seq_part in segment._parts: - self._render(seq_part) + for piece in segment._closes: + self._ansi_parts.append(piece) return if isinstance(segment, tuple): for tuple_part in cast("tuple[object, ...]", segment): self._render(tuple_part) return + if isinstance(segment, _Fmt): + self._ansi_parts.append(f"{ANSI.CHAR}[{int(segment)}m") + return + if isinstance(segment, _ColorFmt): + self._ansi_parts.append(segment._open_seq) + return + if isinstance(segment, _LinkFmt): + self._ansi_parts.append(segment._open_seq) + return + if isinstance(segment, _FmtGroup): + for piece in _build_open_close(segment)[0]: + self._ansi_parts.append(piece) + return # FALLBACK – COERCE UNKNOWN OBJECTS TO STR - self._emit_raw(str(segment)) + self._ansi_parts.append(str(segment)) + + +FC = FormatCodes # SHORT ALIAS diff --git a/tests/test_cli.py b/tests/test_cli.py index ecb77d2..8a36121 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,44 +1,32 @@ -from xulbux.cli.tools import render_format_codes from xulbux.cli.help import show_help from unittest.mock import MagicMock from pathlib import Path import pytest import toml -import sys ROOT_DIR = Path(__file__).parent.parent PYPROJECT_PATH = ROOT_DIR / "pyproject.toml" - ################################################## ENTRYPOINT REGISTRATION TESTS ################################################## -def test_xulbux_help_entrypoint_registered(): - """Verifies that the `xulbux-help` script is registered in pyproject.toml.""" - with open(PYPROJECT_PATH, "r", encoding="utf-8") as file: - pyproject_data = toml.load(file) - scripts = pyproject_data.get("project", {}).get("scripts", {}) - assert "xulbux-help" in scripts, "`xulbux-help` not found in [project.scripts] in pyproject.toml" - assert scripts["xulbux-help"] == "xulbux.cli.help:show_help" - - -def test_xulbux_fc_entrypoint_registered(): - """Verifies that the `xulbux-fc` script is registered in pyproject.toml.""" +def test_xulbux_lib_entrypoint_registered(): + """Verifies that the `xulbux-lib` script is registered in pyproject.toml pointing to the CLI main().""" with open(PYPROJECT_PATH, "r", encoding="utf-8") as file: pyproject_data = toml.load(file) scripts = pyproject_data.get("project", {}).get("scripts", {}) - assert "xulbux-fc" in scripts, "`xulbux-fc` not found in [project.scripts] in pyproject.toml" - assert scripts["xulbux-fc"] == "xulbux.cli.tools:render_format_codes" + assert "xulbux-lib" in scripts, "`xulbux-lib` not found in [project.scripts] in pyproject.toml" + assert scripts["xulbux-lib"] == "xulbux.cli:main" -################################################## xulbux-help TESTS ################################################## +################################################## xulbux-lib TESTS ################################################## def test_show_help_prints_output(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]): """show_help() must print the ANSI help banner to stdout.""" - monkeypatch.setattr("xulbux.console._read_single_key", MagicMock()) + monkeypatch.setattr("xulbux.console.Console._read_single_key", MagicMock()) show_help() @@ -50,7 +38,7 @@ def test_show_help_contains_version(monkeypatch: pytest.MonkeyPatch, capsys: pyt """The help banner must contain the installed package version.""" from xulbux import __version__ - monkeypatch.setattr("xulbux.console._read_single_key", MagicMock()) + monkeypatch.setattr("xulbux.console.Console._read_single_key", MagicMock()) show_help() @@ -67,73 +55,12 @@ def test_show_help_calls_pause_exit(monkeypatch: pytest.MonkeyPatch): mock_pause_exit.assert_called_once() call_kwargs = mock_pause_exit.call_args - # pause=True must be passed so the user sees the prompt + # pause=True MUST BE PASSED SO THE USER SEES THE PROMPT assert call_kwargs.kwargs.get("pause", True) is True -def test_show_help_does_not_require_elevated_privileges(monkeypatch: pytest.MonkeyPatch): - """show_help() must not raise when the keyboard library is unavailable or unprivileged. - This guards against regressions where elevated privileges are accidentally required - (the original bug on macOS and Linux).""" - mock_read_key = MagicMock(side_effect=OSError("Error 13 - Must be run as administrator")) - # Simulate the failure mode that was reported on macOS/Linux - monkeypatch.setattr("xulbux.console._read_single_key", mock_read_key) - - with pytest.raises(OSError): - show_help() - - -def test_show_help_no_privileges_needed_when_properly_implemented(monkeypatch: pytest.MonkeyPatch): - """With the cross-platform _read_single_key implementation, show_help() - must complete without errors – no elevated privileges required.""" - monkeypatch.setattr("xulbux.console._read_single_key", MagicMock()) - - # Must not raise at all - show_help() - - -################################################## xulbux-fc TESTS ################################################## - - -def test_render_format_codes_no_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]): - """When no positional arguments are provided, render_format_codes() prints a usage hint.""" - monkeypatch.setattr(sys, "argv", ["xulbux-fc"]) - - render_format_codes() +def test_show_help_does_not_raise(monkeypatch: pytest.MonkeyPatch): + """show_help() must complete without errors.""" + monkeypatch.setattr("xulbux.console.Console._read_single_key", MagicMock()) - captured = capsys.readouterr() - assert "Provide a string" in captured.out - - -def test_render_format_codes_with_plain_string(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]): - """A plain string with no format codes must print the 'no valid format codes' notice.""" - monkeypatch.setattr(sys, "argv", ["xulbux-fc", "hello world"]) - - render_format_codes() - - captured = capsys.readouterr() - assert "hello world" in captured.out - assert "doesn't contain any valid format codes" in captured.out - - -def test_render_format_codes_with_format_codes(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]): - """A string containing valid format codes must render the ANSI output and the escaped form.""" - monkeypatch.setattr(sys, "argv", ["xulbux-fc", "[b]bold[_]"]) - - render_format_codes() - - captured = capsys.readouterr() - # The rendered ANSI output must be non-empty - assert len(captured.out.strip()) > 0 - # The "doesn't contain any valid format codes" notice must NOT appear - assert "doesn't contain any valid format codes" not in captured.out - - -def test_render_format_codes_multiple_tokens(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]): - """Multiple positional tokens are joined and rendered as one string.""" - monkeypatch.setattr(sys, "argv", ["xulbux-fc", "hello", "world"]) - - render_format_codes() - - captured = capsys.readouterr() - assert "helloworld" in captured.out or "hello" in captured.out + show_help() # MUST NOT RAISE diff --git a/tests/test_depr_format_codes.py b/tests/test_depr_format_codes.py index 3eeb99d..d164a80 100644 --- a/tests/test_depr_format_codes.py +++ b/tests/test_depr_format_codes.py @@ -2,10 +2,10 @@ from xulbux.depr_format_codes import deprFormatCodes -black = ANSI.SEQ_COLOR.format(0, 0, 0) +black = ANSI.SEQ_FG_COLOR.format(0, 0, 0) bg_red = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['bg:red']}{ANSI.END}" -default = ANSI.SEQ_COLOR.format(255, 255, 255) -orange = ANSI.SEQ_COLOR.format(255, 136, 119) +default = ANSI.SEQ_FG_COLOR.format(255, 255, 255) +orange = ANSI.SEQ_FG_COLOR.format(255, 136, 119) bold = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('bold', 'b')]}{ANSI.END}" invert = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('inverse', 'invert', 'in')]}{ANSI.END}" diff --git a/tests/test_format_codes.py b/tests/test_format_codes.py index 64c7773..f2ea38b 100644 --- a/tests/test_format_codes.py +++ b/tests/test_format_codes.py @@ -1,4 +1,4 @@ -from xulbux.format_codes import FormatCodes, Format, F, Term, _build_open_close, _FmtGroup +from xulbux.format_codes import FormatCodes, FC, Format, F, Term, _FmtGroup, _build_open_close from xulbux.base.consts import ANSI import pytest @@ -12,107 +12,149 @@ ################################################## FormatCodes TESTS ################################################## +def test_bare_fmt_emits_only_open_sequence(): + result = FC(F.RED) + assert result.ansi == f"{ESC}[31m" + assert result.raw == "" + + +def test_bare_reset_fmt(): + result = FC(F.RESET) + assert result.ansi == f"{ESC}[0m" + assert result.raw == "" + + +def test_bare_fmt_sequence_with_explicit_reset(): + result = FC(F.RED, "hello", F.RESET) + assert result.ansi == f"{ESC}[31m\nhello\n{ESC}[0m" + assert result.raw == "\nhello\n" + + +def test_bare_fmt_inside_tuple(): + result = FC((F.RED, "hello", F.RESET)) + assert result.ansi == f"{ESC}[31mhello{ESC}[0m" + assert result.raw == "hello" + + +def test_bare_colorfmt_emits_open_sequence(): + result = FC(F.hex("#ff6070")) + assert result.ansi == f"{ESC}[38;2;255;96;112m" + assert result.raw == "" + + +def test_bare_linkfmt_emits_open_sequence(): + result = FC(F.link("https://example.com")) + assert result.ansi == f"{ESC}]8;;https://example.com{ESC}\\" + assert result.raw == "" + + +def test_bare_fmtgroup_emits_open_sequence(): + result = FC(F.BOLD | F.RED) + assert result.ansi == f"{ESC}[1;31m" + assert result.raw == "" + + +def test_bare_fmt_inside_nested_styled_call(): + result = FC(F.DIM("a", F.RED, "b", F.RESET_FG, "c")) + expected = f"{ESC}[2ma{ESC}[31mb{ESC}[39mc{ESC}[22m" + assert result.ansi == expected + assert result.raw == "abc" + + def test_plain_string_passes_through(): - result = FormatCodes("hello world") + result = FC("hello world") assert result.ansi == "hello world" assert result.raw == "hello world" assert result.code_positions == () def test_single_style_wraps_text_with_open_and_reset(): - result = FormatCodes(F.BOLD("hi")) + result = FC(F.BOLD("hi")) assert result.ansi == f"{ESC}[1mhi{ESC}[22m" assert result.raw == "hi" def test_combined_group_emits_single_sgr(): - result = FormatCodes((F.BOLD | F.RED)("hi")) + result = FC((F.BOLD | F.RED)("hi")) assert result.ansi == f"{ESC}[1;31mhi{ESC}[22;39m" assert result.raw == "hi" def test_default_separator_is_newline(): - result = FormatCodes("a", "b", "c") + result = FC("a", "b", "c") assert result.ansi == "a\nb\nc" assert result.raw == "a\nb\nc" def test_custom_separator(): - result = FormatCodes("a", "b", sep=" | ") + result = FC("a", "b", sep=" | ") assert result.ansi == "a | b" def test_nested_styled_keeps_outer_style_after_inner_reset(): - result = FormatCodes(F.CYAN("outer ", F.DIM("inner"), " outer")) + result = FC(F.CYAN("outer ", F.DIM("inner"), " outer")) expected = f"{ESC}[36mouter {ESC}[2minner{ESC}[22m outer{ESC}[39m" assert result.ansi == expected assert result.raw == "outer inner outer" -def test_seq_via_plus_operator(): - result = FormatCodes("a" + F.BOLD("b") + "c") - assert result.ansi == f"a{ESC}[1mb{ESC}[22mc" - assert result.raw == "abc" - - def test_tuple_as_multi_segment_group(): - result = FormatCodes(("a", F.BOLD("b"), "c")) + result = FC(("a", F.BOLD("b"), "c")) assert result.ansi == f"a{ESC}[1mb{ESC}[22mc" assert result.raw == "abc" def test_multi_text_args_in_call(): - result = FormatCodes(F.BOLD("a", F.RED("b"), "c")) + result = FC(F.BOLD("a", F.RED("b"), "c")) expected = f"{ESC}[1ma{ESC}[31mb{ESC}[39mc{ESC}[22m" assert result.ansi == expected assert result.raw == "abc" def test_bright_fg_color(): - result = FormatCodes(F.BR.BLUE("x")) + result = FC(F.BR.BLUE("x")) assert result.ansi == f"{ESC}[94mx{ESC}[39m" def test_bg_color(): - result = FormatCodes(F.BG.RED("x")) + result = FC(F.BG.RED("x")) assert result.ansi == f"{ESC}[41mx{ESC}[49m" -def test_bright_bg_via_bg_br_and_br_bg_are_equivalent(): - via_bg_br = FormatCodes(F.BG.BR.GREEN("x")).ansi - via_br_bg = FormatCodes(F.BR.BG.GREEN("x")).ansi - assert via_bg_br == via_br_bg == f"{ESC}[102mx{ESC}[49m" +def test_bright_bg_color(): + result = FC(F.BG.BR.GREEN("x")) + assert result.ansi == f"{ESC}[102mx{ESC}[49m" def test_rgb_fg(): - result = FormatCodes(F.rgb(10, 20, 30)("x")) + result = FC(F.rgb(10, 20, 30)("x")) assert result.ansi == f"{ESC}[38;2;10;20;30mx{ESC}[39m" def test_rgb_bg(): - result = FormatCodes(F.BG.rgb(10, 20, 30)("x")) + result = FC(F.BG.rgb(10, 20, 30)("x")) assert result.ansi == f"{ESC}[48;2;10;20;30mx{ESC}[49m" def test_hex_fg_short_and_long(): - short_form = FormatCodes(F.hex("#abc")("x")).ansi - long_form = FormatCodes(F.hex("aabbcc")("x")).ansi + short_form = FC(F.hex("#abc")("x")).ansi + long_form = FC(F.hex("aabbcc")("x")).ansi assert short_form == long_form == f"{ESC}[38;2;170;187;204mx{ESC}[39m" def test_hex_bg(): - result = FormatCodes(F.BG.hex("#102030")("x")) + result = FC(F.BG.hex("#102030")("x")) assert result.ansi == f"{ESC}[48;2;16;32;48mx{ESC}[49m" def test_link_alone_wraps_text(): - result = FormatCodes(F.link("https://example.com")("click")) + result = FC(F.link("https://example.com")("click")) assert result.ansi == f"{ESC}]8;;https://example.com{ESC}\\click{ESC}]8;;{ESC}\\" assert result.raw == "click" def test_link_combined_with_style(): - result = FormatCodes((F.link("https://x") | F.BOLD)("click")) + result = FC((F.link("https://x") | F.BOLD)("click")) expected = (f"{ESC}]8;;https://x{ESC}\\" f"{ESC}[1m" f"click" @@ -122,7 +164,7 @@ def test_link_combined_with_style(): def test_code_positions_match_offsets_in_ansi(): - result = FormatCodes(F.BOLD("hi")) + result = FC(F.BOLD("hi")) # ESC[1m THEN "hi" THEN ESC[22m assert result.code_positions == ((0, f"{ESC}[1m"), (len(f"{ESC}[1m") + 2, f"{ESC}[22m")) # OFFSETS MUST BE VALID SLICE POINTS INTO THE ANSI STRING @@ -131,7 +173,7 @@ def test_code_positions_match_offsets_in_ansi(): def test_raw_equals_ansi_minus_sequences(): - result = FormatCodes(F.CYAN("a"), (F.BOLD | F.RED)("b"), "plain") + result = FC(F.CYAN("a"), (F.BOLD | F.RED)("b"), "plain") stripped = result.ansi for _, sequence in result.code_positions: stripped = stripped.replace(sequence, "", 1) @@ -140,13 +182,13 @@ def test_raw_equals_ansi_minus_sequences(): def test_remove_ansi_strips_csi_and_osc(): mixed = f"{ESC}[1mhi{ESC}[0m and {ESC}]8;;u{ESC}\\link{ESC}]8;;{ESC}\\" - assert FormatCodes.remove_ansi(mixed) == "hi and link" + assert FC.remove_ansi(mixed) == "hi and link" def test_print_writes_ansi_plus_end_to_stdout(monkeypatch: pytest.MonkeyPatch): buffer = io.StringIO() monkeypatch.setattr(sys, "stdout", buffer) - FormatCodes(F.BOLD("hi")).print(end="!") + FC(F.BOLD("hi")).print(end="!") assert buffer.getvalue() == f"{ESC}[1mhi{ESC}[22m!" @@ -158,7 +200,7 @@ def fake_input(prompt: str = "") -> str: return "answer" monkeypatch.setattr("builtins.input", fake_input) - result = FormatCodes(F.BOLD("Name: ")).input() + result = FC(F.BOLD("Name: ")).input() assert result == "answer" assert captured["prompt"] == f"{ESC}[1mName: {ESC}[22m" @@ -206,5 +248,9 @@ def test_term_save_restore_and_title(): assert Term.title("hi") == f"{ESC}]2;hi\x07" -def test_format_and_F_are_same_object(): +def test_FormatCodes_and_FC_are_same_object(): + assert FC is FormatCodes + + +def test_Format_and_F_are_same_object(): assert F is Format From 9b650adaf87d631fc04551312b212b82b1ad2b1e Mon Sep 17 00:00:00 2001 From: XulbuX Date: Sat, 16 May 2026 22:26:15 +0200 Subject: [PATCH 18/18] =?UTF-8?q?`F.link(=E2=80=A6)`=20bugfix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/xulbux/format_codes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 86ce1e1..82c3902 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -389,12 +389,12 @@ class _LinkFmt: >>> F.link("https://example.com")("click here") >>> (F.link("https://example.com") | F.BR.BLUE)("click here")""" - __slots__ = ("_url", "_open_seq") + __slots__ = ("_url", "_open_seq", "_close_seq") def __init__(self, url: str, /) -> None: self._url = url self._open_seq = ANSI.SEQ_LINK_OPEN.format(url) - self._open_seq = ANSI.SEQ_LINK_CLOSE + self._close_seq = ANSI.SEQ_LINK_CLOSE def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup: """Combines this link format with another format or group via `|`.""" @@ -412,12 +412,12 @@ def __ror__(self, other: _AnyFmt) -> _FmtGroup: def __call__(self, *text: _Segment) -> _Styled: """Applies this link format to the given text, auto-resetting after.""" - return _Styled((self._open_seq, ), (self._open_seq, ), text[0] if len(text) == 1 else text) + return _Styled((self._open_seq, ), (self._close_seq, ), text[0] if len(text) == 1 else text) def __matmul__(self, text: _Text) -> _Styled: """Applies this link format to the given text, auto-resetting after.""" - return _Styled((self._open_seq, ), (self._open_seq, ), text) + return _Styled((self._open_seq, ), (self._close_seq, ), text) def __repr__(self) -> str: """Returns a string representation of this link format, showing the URL it points to."""