diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddf19a..a343ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,24 @@ #
Changelog
- + + +## ... `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. -## ... `v1.8.6` -* Standardized the docstrings for all public methods in the whole library to use the same style and format. +**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. diff --git a/README.md b/README.md index cec0f89..e2244ce 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # **xulbux** -[![](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/PythonLibraryXulbuX?style=flat&labelColor=405555&color=70FFEE)](https://github.com/XulbuX/PythonLibraryXulbuX/blob/main/LICENSE) [![](https://img.shields.io/github/last-commit/XulbuX/PythonLibraryXulbuX?style=flat&labelColor=554045&color=FF6065)](https://github.com/XulbuX/PythonLibraryXulbuX/commits) [![](https://img.shields.io/github/issues/XulbuX/PythonLibraryXulbuX?style=flat&labelColor=554045&color=FF6065)](https://github.com/XulbuX/PythonLibraryXulbuX/issues) [![](https://img.shields.io/github/stars/XulbuX/PythonLibraryXulbuX?label=★&style=flat&labelColor=604A40&color=FF9673) -](https://github.com/XulbuX/PythonLibraryXulbuX/stargazers) +[![](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/PythonLibraryXulbuX?style=flat&labelColor=405555&color=70FFEE)](https://github.com/XulbuX/PythonLibraryXulbuX/blob/main/LICENSE) [![](https://img.shields.io/github/last-commit/XulbuX/PythonLibraryXulbuX?style=flat&labelColor=554045&color=FF6065)](https://github.com/XulbuX/PythonLibraryXulbuX/commits) [![](https://img.shields.io/github/issues/XulbuX/PythonLibraryXulbuX?style=flat&labelColor=554045&color=FF6065)](https://github.com/XulbuX/PythonLibraryXulbuX/issues) [![](https://img.shields.io/github/stars/XulbuX/PythonLibraryXulbuX?label=★&style=flat&labelColor=604A40&color=FF9673)](https://github.com/XulbuX/PythonLibraryXulbuX/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. diff --git a/pyproject.toml b/pyproject.toml index a1774e8..f1c6bd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "xulbux" -version = "1.8.5" +version = "1.9.0" authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] maintainers = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] description = "A Python library which includes lots of helpful classes, types, and functions aiming to make common programming tasks simpler." diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index 2b244d0..a428843 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.8.5" +__version__ = "1.9.0" __author__ = "XulbuX" __email__ = "xulbux.real@gmail.com" diff --git a/src/xulbux/base/exceptions.py b/src/xulbux/base/exceptions.py new file mode 100644 index 0000000..04b1da1 --- /dev/null +++ b/src/xulbux/base/exceptions.py @@ -0,0 +1,14 @@ +################################################## FILE ################################################## + + +class SameContentFileExistsError(FileExistsError): + """Exception raised when a file with the same name and content already exists.""" + ... + + +################################################## PATH ################################################## + + +class PathNotFoundError(FileNotFoundError): + """Exception raised when a specified path could not be found.""" + ... diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py new file mode 100644 index 0000000..1248597 --- /dev/null +++ b/src/xulbux/base/types.py @@ -0,0 +1,81 @@ +from typing import TYPE_CHECKING, Annotated, TypeAlias, TypedDict, Optional, Union, Any +import regex as _rx +import re as _re + +# PREVENT CIRCULAR IMPORTS +if TYPE_CHECKING: + from ..color import rgba, hsla, hexa + +# +################################################## COLOR ################################################## + +Int_0_100 = Annotated[int, "An integer in range [0, 100]."] +Int_0_255 = Annotated[int, "An integer in range [0, 255]."] +Int_0_360 = Annotated[int, "An integer in range [0, 360]."] +Float_0_1 = Annotated[float, "A float in range [0.0, 1.0]."] + +AnyRgba: TypeAlias = Any +AnyHsla: TypeAlias = Any +AnyHexa: TypeAlias = Any + +Rgba: TypeAlias = Union[ + tuple[Int_0_255, Int_0_255, Int_0_255], + tuple[Int_0_255, Int_0_255, Int_0_255, Float_0_1], + list[Int_0_255], + list[Union[Int_0_255, Float_0_1]], + dict[str, Union[int, float]], + "rgba", + str, +] +Hsla: TypeAlias = Union[ + tuple[Int_0_360, Int_0_100, Int_0_100], + tuple[Int_0_360, Int_0_100, Int_0_100, Float_0_1], + list[Union[Int_0_360, Int_0_100]], + list[Union[Int_0_360, Int_0_100, Float_0_1]], + dict[str, Union[int, float]], + "hsla", + str, +] +Hexa: TypeAlias = Union[str, int, "hexa"] + +# +################################################## CONSOLE ################################################## + + +class ArgConfigWithDefault(TypedDict): + """TypedDict for flagged argument configuration with default value.""" + flags: set[str] + default: str + + +class ArgResultRegular(TypedDict): + """TypedDict for regular flagged argument results.""" + exists: bool + value: Optional[str] + + +class ArgResultPositional(TypedDict): + """TypedDict for positional `"before"`/`"after"` argument results.""" + exists: bool + values: list[str] + + +################################################## DATA ################################################## + +DataStructure: TypeAlias = Union[list, tuple, set, frozenset, dict] +IndexIterable: TypeAlias = Union[list, tuple, set, frozenset] + +# +################################################## REGEX ################################################## + +Pattern: TypeAlias = _re.Pattern[str] | _rx.Pattern[str] +Match: TypeAlias = _re.Match[str] | _rx.Match[str] + +# +################################################## SYSTEM ################################################## + + +class MissingLibsMsgs(TypedDict): + """TypedDict for the `missing_libs_msgs` parameter in `System.check_libs()`.""" + found_missing: str + should_install: str diff --git a/src/xulbux/cli/help.py b/src/xulbux/cli/help.py index 8364a97..55910a1 100644 --- a/src/xulbux/cli/help.py +++ b/src/xulbux/cli/help.py @@ -19,9 +19,9 @@ def get_latest_version() -> Optional[str]: def is_latest_version() -> Optional[bool]: try: - if (latest := get_latest_version()) in ("", None): + if (latest := get_latest_version()) in {"", None}: return None - latest_v_parts = tuple(int(part) for part in latest.lower().lstrip("v").split('.')) + 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: diff --git a/src/xulbux/code.py b/src/xulbux/code.py index 1ee0245..12752a9 100644 --- a/src/xulbux/code.py +++ b/src/xulbux/code.py @@ -1,3 +1,7 @@ +""" +This module provides the `Code` class, which offers methods to work with code strings. +""" + from .string import String from .regex import Regex from .data import Data @@ -6,62 +10,99 @@ class Code: + """This class includes methods to work with code strings.""" @staticmethod def add_indent(code: str, indent: int) -> str: - """Adds `indent` spaces at the beginning of each line.""" - indented_lines = [" " * indent + line for line in code.splitlines()] - return "\n".join(indented_lines) + """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""" + if not isinstance(code, str): + raise TypeError(f"The 'code' parameter must be a string, got {type(code)}") + if not isinstance(indent, int): + raise TypeError(f"The 'indent' parameter must be an integer, got {type(indent)}") + elif indent < 0: + raise ValueError(f"The 'indent' parameter must be non-negative, got {indent!r}") + + return "\n".join(" " * indent + line for line in code.splitlines()) @staticmethod def get_tab_spaces(code: str) -> int: - """Will try to get the amount of spaces used for indentation.""" - code_lines = String.get_lines(code, remove_empty_lines=True) - indents = [len(line) - len(line.lstrip()) for line in code_lines] - non_zero_indents = [i for i in indents if i > 0] - return min(non_zero_indents) if non_zero_indents else 0 + """Will try to get the amount of spaces used for indentation.\n + ---------------------------------------------------------------- + - `code` -⠀the code to analyze""" + if not isinstance(code, str): + raise TypeError(f"The 'code' parameter must be a string, got {type(code)}") + + 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 @staticmethod def change_tab_size(code: str, new_tab_size: int, remove_empty_lines: bool = False) -> str: """Replaces all tabs with `new_tab_size` spaces.\n - ---------------------------------------------------------------------------------- - If `remove_empty_lines` is `True`, empty lines will be removed in the process.""" - code_lines = String.get_lines(code, remove_empty_lines=True) - lines = code_lines if remove_empty_lines else String.get_lines(code) - tab_spaces = Code.get_tab_spaces(code) - if (tab_spaces == new_tab_size) or tab_spaces == 0: + -------------------------------------------------------------------------------- + - `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""" + if not isinstance(code, str): + raise TypeError(f"The 'code' parameter must be a string, got {type(code)}") + if not isinstance(new_tab_size, int): + raise TypeError(f"The 'new_tab_size' parameter must be an integer, got {type(new_tab_size)}") + elif new_tab_size < 0: + raise ValueError(f"The 'new_tab_size' parameter must be non-negative, got {new_tab_size!r}") + if not isinstance(remove_empty_lines, bool): + raise TypeError(f"The 'remove_empty_lines' parameter must be a boolean, got {type(remove_empty_lines)}") + + code_lines = String.get_lines(code, remove_empty_lines=remove_empty_lines) + + if ((tab_spaces := Code.get_tab_spaces(code)) == new_tab_size) or tab_spaces == 0: if remove_empty_lines: return "\n".join(code_lines) return code + result = [] - for line in lines: - stripped = line.lstrip() - indent_level = (len(line) - len(stripped)) // tab_spaces - new_indent = " " * (indent_level * new_tab_size) - result.append(new_indent + stripped) + for line in code_lines: + indent_level = (len(line) - len(stripped := line.lstrip())) // tab_spaces + result.append((" " * (indent_level * new_tab_size)) + stripped) + return "\n".join(result) @staticmethod def get_func_calls(code: str) -> list: - """Will try to get all function calls and return them as a list.""" - funcs = _rx.findall(r"(?i)" + Regex.func_call(), code) + """Will try to get all function calls and return them as a list.\n + ------------------------------------------------------------------- + - `code` -⠀the code to analyze""" + if not isinstance(code, str): + raise TypeError(f"The 'code' parameter must be a string, got {type(code)}") + nested_func_calls = [] - for _, func_attrs in funcs: - nested_calls = _rx.findall(r"(?i)" + Regex.func_call(), func_attrs) - if nested_calls: + + for _, func_attrs in (funcs := _rx.findall(r"(?i)" + Regex.func_call(), code)): + if (nested_calls := _rx.findall(r"(?i)" + Regex.func_call(), func_attrs)): nested_func_calls.extend(nested_calls) + return list(Data.remove_duplicates(funcs + nested_func_calls)) @staticmethod - def is_js(code: str, funcs: list[str] = ["__", "$t", "$lang"]) -> bool: - """Will check if the code is very likely to be JavaScript.""" - if not code or len(code.strip()) < 3: + def is_js(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""" + if not isinstance(code, str): + raise TypeError(f"The 'code' parameter must be a string, got {type(code)}") + elif len(code.strip()) < 3: return False + if not isinstance(funcs, set): + raise TypeError(f"The 'funcs' parameter must be a set, got {type(funcs)}") + for func in funcs: if _rx.match(r"^[\s\n]*" + _rx.escape(func) + r"\([^\)]*\)[\s\n]*$", code): return True + direct_js_patterns = [ - r"^[\s\n]*\$\(['\"][^'\"]+['\"]\)\.[\w]+\([^\)]*\);?[\s\n]*$", # jQuery calls + r"""^[\s\n]*\$\(["'][^"']+["']\)\.[\w]+\([^\)]*\);?[\s\n]*$""", # jQuery calls r"^[\s\n]*\$\.[a-zA-Z]\w*\([^\)]*\);?[\s\n]*$", # $.ajax(), etc. r"^[\s\n]*\(\s*function\s*\(\)\s*\{.*\}\s*\)\(\);?[\s\n]*$", # IIFE r"^[\s\n]*document\.[a-zA-Z]\w*\([^\)]*\);?[\s\n]*$", # document.getElementById() @@ -71,6 +112,7 @@ def is_js(code: str, funcs: list[str] = ["__", "$t", "$lang"]) -> bool: for pattern in direct_js_patterns: if _rx.match(pattern, code): return True + arrow_function_patterns = [ r"^[\s\n]*\b[\w_]+\s*=\s*\([^\)]*\)\s*=>\s*[^;{]*[;]?[\s\n]*$", # const x = (y) => y*2; r"^[\s\n]*\b[\w_]+\s*=\s*[\w_]+\s*=>\s*[^;{]*[;]?[\s\n]*$", # const x = y => y*2; @@ -80,6 +122,8 @@ def is_js(code: str, funcs: list[str] = ["__", "$t", "$lang"]) -> bool: for pattern in arrow_function_patterns: if _rx.match(pattern, code): return True + + js_score = 0 funcs_pattern = r"(" + "|".join(_rx.escape(f) for f in funcs) + r")" + Regex.brackets("()") js_indicators = [(r"\b(var|let|const)\s+[\w_$]+", 2), # JS variable declarations (r"\$[\w_$]+\s*=", 2), # jQuery-style variables @@ -98,18 +142,17 @@ def is_js(code: str, funcs: list[str] = ["__", "$t", "$lang"]) -> bool: (r"\btry\s*\{[^}]*\}\s*catch\s*\(", 1.5), # Try-catch (r";[\s\n]*$", 0.5), # Semicolon line endings ] - js_score = 0 + line_endings = [line.strip() for line in code.splitlines() if line.strip()] - semicolon_endings = sum(1 for line in line_endings if line.endswith(';')) - if semicolon_endings >= 1: + if (semicolon_endings := sum(1 for line in line_endings if line.endswith(";"))) >= 1: js_score += min(semicolon_endings, 2) - opening_braces = code.count('{') - closing_braces = code.count('}') - if opening_braces > 0 and opening_braces == closing_braces: + if (opening_braces := code.count("{")) > 0 and opening_braces == code.count("}"): js_score += 1 + for pattern, score in js_indicators: regex = _rx.compile(pattern, _rx.IGNORECASE) matches = regex.findall(code) if matches: js_score += len(matches) * score + return js_score >= 2 diff --git a/src/xulbux/color.py b/src/xulbux/color.py index 13ab145..c0d04f2 100644 --- a/src/xulbux/color.py +++ b/src/xulbux/color.py @@ -1,76 +1,26 @@ """ -`rgba`: - An RGB/RGBA color: is a tuple of 3 integers, representing the red (`0`-`255`), green (`0`-`255`), and blue (`0`-`255`). - Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`). -`hsla`: - An HSL/HSB color: is a tuple of 3 integers, representing the hue (`0`-`360`), saturation (`0`-`100`), and lightness (`0`-`100`). - Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`). -`hexa`: - A HEX color: is a string in the format `RGB`, `RGBA`, `RRGGBB` or `RRGGBBAA` (where `R` `G` `B` `A` are hexadecimal digits). - -------------------------------------------------------------------------------------------------------------------------------------- -The `Color` class, which contains all sorts of different color-related methods: -- validate colors: - - is valid rgba - - is valid hsla - - is valid hexa - - is valid in any format -- check if a color has an alpha channel -- convert between different color formats: - - color to rgba - - color to hsla - - color to hexa -- recognize colors inside strings and convert them to color types: - - string to rgba -- convert an RGBA color to a HEX integer -- convert a HEX integer to an RGBA color -- get a colors luminance from the RGB channels -- get the optimal text color for on a colored background -- adjust different color channels: - - brightness - - saturation +This module provides the `rgba`, `hsla` and `hexa` classes, which offer +methods to manipulate colors in their respective color spaces.
+ +This module also provides the `Color` class, which +includes methods to work with colors in various formats. """ +from .base.types import AnyRgba, AnyHsla, AnyHexa, Rgba, Hsla, Hexa from .regex import Regex -from typing import Annotated, TypeAlias, Iterator, Optional, Literal, Union, Any, cast +from typing import Iterator, Optional, Literal, cast import re as _re -Int_0_100 = Annotated[int, "An integer value between 0 and 100, inclusive."] -Int_0_255 = Annotated[int, "An integer value between 0 and 255, inclusive."] -Int_0_360 = Annotated[int, "An integer value between 0 and 360, inclusive."] -Float_0_1 = Annotated[float, "A float value between 0.0 and 1.0, inclusive."] - -AnyRgba: TypeAlias = Any -AnyHsla: TypeAlias = Any -AnyHexa: TypeAlias = Any - -Rgba: TypeAlias = Union[ - tuple[Int_0_255, Int_0_255, Int_0_255], - tuple[Int_0_255, Int_0_255, Int_0_255, Float_0_1], - list[Int_0_255], - list[Union[Int_0_255, Float_0_1]], - dict[str, Union[int, float]], - "rgba", - str, -] -Hsla: TypeAlias = Union[ - tuple[Int_0_360, Int_0_100, Int_0_100], - tuple[Int_0_360, Int_0_100, Int_0_100, Float_0_1], - list[Union[Int_0_360, Int_0_100]], - list[Union[Int_0_360, Int_0_100, Float_0_1]], - dict[str, Union[int, float]], - "hsla", - str, -] -Hexa: TypeAlias = Union[str, int, "hexa"] - - class rgba: - """An RGB/RGBA color: is a tuple of 3 integers, representing the red (`0`-`255`), green (`0`-`255`), and blue (`0`-`255`).\n - Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).\n - ----------------------------------------------------------------------------------------------------------------------------- + """An RGB/RGBA color object that includes a bunch of methods to manipulate the color.\n + -------------------------------------------------------------------------------------------- + - `r` -⠀the red channel in range [0, 255] + - `g` -⠀the green channel in range [0, 255] + - `b` -⠀the blue channel in range [0, 255] + - `a` -⠀the alpha channel in range [0.0, 1.0] or `None` if the color has no alpha channel\n + -------------------------------------------------------------------------------------------- Includes methods: - `to_hsla()` to convert to HSL color - `to_hexa()` to convert to HEX color @@ -92,25 +42,28 @@ class rgba: def __init__(self, r: int, g: int, b: int, a: Optional[float] = None, _validate: bool = True): self.r: int - """The red channel (`0`–`255`)""" + """The red channel in range [0, 255].""" self.g: int - """The green channel (`0`–`255`)""" + """The green channel in range [0, 255].""" self.b: int - """The blue channel (`0`–`255`)""" + """The blue channel in range [0, 255].""" self.a: Optional[float] - """The alpha channel (`0.0`–`1.0`) or `None` if not set""" + """The alpha channel in range [0.0, 1.0] or `None` if not set.""" + if not _validate: self.r, self.g, self.b, self.a = r, g, b, a return + if any(isinstance(x, rgba) for x in (r, g, b)): - raise ValueError("Color is already a rgba() color") - elif not all(isinstance(x, int) and 0 <= x <= 255 for x in (r, g, b)): - raise ValueError( - "RGBA color must have R G B as integers in [0, 255]: got", - (r, g, b), - ) - elif a is not None and not (isinstance(a, (int, float)) and 0 <= a <= 1): - raise ValueError(f"Alpha channel must be a float/int in [0.0, 1.0]: got '{a}'") + raise ValueError("Color is already an rgba() color object.") + if not all(isinstance(x, int) and (0 <= x <= 255) for x in (r, g, b)): + raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in range [0, 255], got {r=} {g=} {b=}") + if a is not None: + if not isinstance(a, float): + raise TypeError(f"The 'a' parameter must be a float, got {type(a)}") + elif not (0.0 <= a <= 1.0): + raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0], got {a!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)) @@ -124,142 +77,190 @@ def __getitem__(self, index: int) -> int | float: return ((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))[index] 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.r}, {self.g}, {self.b}{'' if self.a is None else f', {self.a}'})" def __str__(self) -> str: - return f'({self.r}, {self.g}, {self.b}{"" if self.a is None else f", {self.a}"})' + return f"({self.r}, {self.g}, {self.b}{'' if self.a is None else f', {self.a}'})" def __eq__(self, other: "rgba") -> bool: # type: ignore[override] if not isinstance(other, rgba): return False - return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a) + else: + return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a) def dict(self) -> dict: - """Returns the color components as a dictionary with keys `'r'`, `'g'`, `'b'` and optionally `'a'`""" + """Returns the color components as a dictionary with keys `"r"`, `"g"`, `"b"` and optionally `"a"`.""" return dict(r=self.r, g=self.g, b=self.b) if self.a is None else dict(r=self.r, g=self.g, b=self.b, a=self.a) def values(self) -> tuple: - """Returns the color components as separate values `r, g, b, a`""" + """Returns the color components as separate values `r, g, b, a`.""" return self.r, self.g, self.b, self.a def to_hsla(self) -> "hsla": - """Returns the color as a `hsla()` color""" + """Returns the color as `hsla()` color object.""" return hsla(*self._rgb_to_hsl(self.r, self.g, self.b), self.a, _validate=False) # type: ignore[positional-arguments] def to_hexa(self) -> "hexa": - """Returns the color as a `hexa()` color""" + """Returns the color as `hexa()` color object.""" return hexa("", self.r, self.g, self.b, self.a) def has_alpha(self) -> bool: - """Returns `True` if the color has an alpha channel and `False` otherwise""" + """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None def lighten(self, amount: float) -> "rgba": - """Increases the colors lightness by the specified amount (`0.0`-`1.0`)""" + """Increases the colors lightness by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], 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) def darken(self, amount: float) -> "rgba": - """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)""" + """Decreases the colors lightness by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], 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) def saturate(self, amount: float) -> "rgba": - """Increases the colors saturation by the specified amount (`0.0`-`1.0`)""" + """Increases the colors saturation by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], 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) def desaturate(self, amount: float) -> "rgba": - """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)""" + """Decreases the colors saturation by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], 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) def rotate(self, degrees: int) -> "rgba": - """Rotates the colors hue by the specified number of degrees""" + """Rotates the colors hue by the specified number of degrees.""" + if not isinstance(degrees, int): + raise TypeError(f"The 'degrees' parameter must be an integer, got {type(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) def invert(self, invert_alpha: bool = False) -> "rgba": - """Inverts the color by rotating hue by 180 degrees and inverting lightness""" + """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" + if not isinstance(invert_alpha, bool): + raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}") + 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) def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "rgba": """Converts the color to grayscale using the luminance formula.\n - ------------------------------------------------------------------ - The `method` is 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) 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 (`0.0`-`1.0`): - - 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)""" - if not (isinstance(ratio, (int, float)) and 0 <= ratio <= 1): - raise ValueError("'ratio' must be a float/int in [0.0, 1.0]") - elif not isinstance(other, rgba): + """Blends the current color with another color using the specified ratio in range [0.0, 1.0].\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""" + if not isinstance(other, rgba): if Color.is_valid_rgba(other): other = Color.to_rgba(other) else: - raise TypeError("'other' must be a valid RGBA color") + raise TypeError(f"The 'other' parameter must be a valid RGBA color, got {type(other)}") + if not isinstance(ratio, float): + raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}") + elif not (0.0 <= ratio <= 1.0): + raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0], got {ratio!r}") + if not isinstance(additive_alpha, bool): + raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}") + ratio *= 2 self.r = max(0, min(255, int(round((self.r * (2 - ratio)) + (other.r * ratio))))) self.g = max(0, min(255, int(round((self.g * (2 - ratio)) + (other.g * ratio))))) self.b = max(0, min(255, int(round((self.b * (2 - ratio)) + (other.b * ratio))))) none_alpha = self.a is None and (len(other) <= 3 or other[3] is None) + if not none_alpha: self_a = 1 if self.a is None else self.a other_a = (other[3] if other[3] is not None else 1) if len(other) > 3 else 1 + if additive_alpha: self.a = 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)))) + else: self.a = None + return rgba(self.r, self.g, self.b, None if none_alpha else self.a, _validate=False) def is_dark(self) -> bool: - """Returns `True` if the color is considered dark (`lightness < 50%`)""" + """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%`)""" + """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""" + """Returns `True` if the color is grayscale.""" return self.r == self.g == self.b def is_opaque(self) -> bool: - """Returns `True` if the color has no transparency""" + """Returns `True` if the color has no transparency.""" return self.a == 1 or self.a is None def with_alpha(self, alpha: float) -> "rgba": - """Returns a new color with the specified alpha value""" - if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1): - raise ValueError("'alpha' must be a float/int in [0.0, 1.0]") + """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], got {alpha!r}") + return rgba(self.r, self.g, self.b, alpha, _validate=False) def complementary(self) -> "rgba": - """Returns the complementary color (180 degrees on the color wheel)""" + """Returns the complementary color (180 degrees on the color wheel).""" return self.to_hsla().complementary().to_rgba() def _rgb_to_hsl(self, r: int, g: int, b: int) -> tuple: + """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 + if max_c == min_c: h = s = 0 else: delta = max_c - min_c s = delta / (1 - abs(2 * l - 1)) + if max_c == _r: h = ((_g - _b) / delta) % 6 elif max_c == _g: @@ -267,13 +268,18 @@ def _rgb_to_hsl(self, r: int, g: int, b: int) -> tuple: else: h = ((_r - _g) / delta) + 4 h /= 6 + return int(round(h * 360)), int(round(s * 100)), int(round(l * 100)) class hsla: - """A HSL/HSLA color: is a tuple of 3 integers, representing hue (`0`-`360`), saturation (`0`-`100`), and lightness (`0`-`100`).\n - Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).\n - ---------------------------------------------------------------------------------------------------------------------------------- + """A HSL/HSLA color object that includes a bunch of methods to manipulate the color.\n + -------------------------------------------------------------------------------------------- + - `h` -⠀the hue channel in range [0, 360] + - `s` -⠀the saturation channel in range [0, 100] + - `l` -⠀the lightness channel in range [0, 100] + - `a` -⠀the alpha channel in range [0.0, 1.0] or `None` if the color has no alpha channel\n + -------------------------------------------------------------------------------------------- Includes methods: - `to_rgba()` to convert to RGB color - `to_hexa()` to convert to HEX color @@ -295,25 +301,30 @@ class hsla: def __init__(self, h: int, s: int, l: int, a: Optional[float] = None, _validate: bool = True): self.h: int - """The hue channel (`0`–`360`)""" + """The hue channel in range [0, 360].""" self.s: int - """The saturation channel (`0`–`100`)""" + """The saturation channel in range [0, 100].""" self.l: int - """The lightness channel (`0`–`100`)""" + """The lightness channel in range [0, 100].""" self.a: Optional[float] - """The alpha channel (`0.0`–`1.0`) or `None` if not set""" + """The alpha channel in range [0.0, 1.0] or `None` if not set.""" + if not _validate: self.h, self.s, self.l, self.a = h, s, l, a return + if any(isinstance(x, hsla) for x in (h, s, l)): - raise ValueError("Color is already a hsla() color") - elif not (isinstance(h, int) and (0 <= h <= 360) and all(isinstance(x, int) and (0 <= x <= 100) for x in (s, l))): - raise ValueError( - "HSL color must have H as integer in [0, 360] and S L as integers in [0, 100]: got", - (h, s, l), - ) - elif a is not None and (not isinstance(a, (int, float)) or not 0 <= a <= 1): - raise ValueError(f"Alpha channel must be a float/int in [0.0, 1.0]: got '{a}'") + raise ValueError("Color is already a hsla() color object.") + if not (isinstance(h, int) and (0 <= h <= 360)): + raise ValueError(f"The 'h' parameter must be an integer in range [0, 360], got {h!r}") + if not all(isinstance(x, int) and (0 <= x <= 100) for x in (s, l)): + raise ValueError(f"The 's' and 'l' parameters must be integers in range [0, 100], got {s=} {l=}") + if a is not None: + if not isinstance(a, float): + raise TypeError(f"The 'a' parameter must be a float, got {type(a)}") + elif not (0.0 <= a <= 1.0): + raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0], got {a!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)) @@ -335,118 +346,157 @@ def __str__(self) -> str: def __eq__(self, other: "hsla") -> bool: # type: ignore[override] if not isinstance(other, hsla): return False - return (self.h, self.s, self.l, self.a) == (other.h, other.s, other.l, other.a) + else: + return (self.h, self.s, self.l, self.a) == (other.h, other.s, other.l, other.a) def dict(self) -> dict: - """Returns the color components as a dictionary with keys `'h'`, `'s'`, `'l'` and optionally `'a'`""" + """Returns the color components as a dictionary with keys `"h"`, `"s"`, `"l"` and optionally `"a"`.""" return dict(h=self.h, s=self.s, l=self.l) if self.a is None else dict(h=self.h, s=self.s, l=self.l, a=self.a) def values(self) -> tuple: - """Returns the color components as separate values `h, s, l, a`""" + """Returns the color components as separate values `h, s, l, a`.""" return self.h, self.s, self.l, self.a def to_rgba(self) -> "rgba": - """Returns the color as a `rgba()` color""" + """Returns the color as `rgba()` color object.""" return rgba(*self._hsl_to_rgb(self.h, self.s, self.l), self.a, _validate=False) # type: ignore[positional-arguments] def to_hexa(self) -> "hexa": - """Returns the color as a `hexa()` color""" + """Returns the color as `hexa()` color object.""" r, g, b = self._hsl_to_rgb(self.h, self.s, self.l) return hexa("", r, g, b, self.a) def has_alpha(self) -> bool: - """Returns `True` if the color has an alpha channel and `False` otherwise""" + """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None def lighten(self, amount: float) -> "hsla": - """Increases the colors lightness by the specified amount (`0.0`-`1.0`)""" - if not (isinstance(amount, (int, float)) and 0 <= amount <= 1): - raise ValueError("'amount' must be a float/int in [0.0, 1.0]") + """Increases the colors lightness by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], 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) def darken(self, amount: float) -> "hsla": - """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)""" - if not (isinstance(amount, (int, float)) and 0 <= amount <= 1): - raise ValueError("'amount' must be a float/int in [0.0, 1.0]") + """Decreases the colors lightness by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], got {amount!r}") + self.l = int(max(0, self.l * (1 - amount))) return hsla(self.h, self.s, self.l, self.a, _validate=False) def saturate(self, amount: float) -> "hsla": - """Increases the colors saturation by the specified amount (`0.0`-`1.0`)""" - if not (isinstance(amount, (int, float)) and 0 <= amount <= 1): - raise ValueError("'amount' must be a float/int in [0.0, 1.0]") + """Increases the colors saturation by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], 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) def desaturate(self, amount: float) -> "hsla": - """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)""" - if not (isinstance(amount, (int, float)) and 0 <= amount <= 1): - raise ValueError("'amount' must be a float/int in [0.0, 1.0]") + """Decreases the colors saturation by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], got {amount!r}") + self.s = int(max(0, self.s * (1 - amount))) return hsla(self.h, self.s, self.l, self.a, _validate=False) def rotate(self, degrees: int) -> "hsla": - """Rotates the colors hue by the specified number of degrees""" + """Rotates the colors hue by the specified number of degrees.""" + if not isinstance(degrees, int): + raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}") + self.h = (self.h + degrees) % 360 return hsla(self.h, self.s, self.l, self.a, _validate=False) def invert(self, invert_alpha: bool = False) -> "hsla": - """Inverts the color by rotating hue by 180 degrees and inverting lightness""" + """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" + if not isinstance(invert_alpha, bool): + raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}") + 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) def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "hsla": """Converts the color to grayscale using the luminance formula.\n - ------------------------------------------------------------------ - The `method` is 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()' l = int(Color.luminance(*self._hsl_to_rgb(self.h, self.s, self.l), 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) 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 (`0.0`-`1.0`): - - 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)""" + """Blends the current color with another color using the specified ratio in range [0.0, 1.0].\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""" + if not Color.is_valid_hsla(other): + raise TypeError(f"The 'other' parameter must be a valid HSLA color, got {type(other)}") + if not isinstance(ratio, float): + raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}") + elif not (0.0 <= ratio <= 1.0): + raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0], got {ratio!r}") + if not isinstance(additive_alpha, bool): + raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}") + self.h, self.s, self.l, self.a = self.to_rgba().blend(Color.to_rgba(other), ratio, additive_alpha).to_hsla().values() return hsla(self.h, self.s, self.l, self.a, _validate=False) def is_dark(self) -> bool: - """Returns `True` if the color is considered dark (`lightness < 50%`)""" + """Returns `True` if the color is considered dark (`lightness < 50%`).""" return self.l < 50 def is_light(self) -> bool: - """Returns `True` if the color is considered light (`lightness >= 50%`)""" + """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""" + """Returns `True` if the color is considered grayscale.""" return self.s == 0 def is_opaque(self) -> bool: - """Returns `True` if the color has no transparency""" + """Returns `True` if the color has no transparency.""" return self.a == 1 or self.a is None def with_alpha(self, alpha: float) -> "hsla": - """Returns a new color with the specified alpha value""" - if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1): - raise ValueError("'alpha' must be a float/int in [0.0, 1.0]") + """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], got {alpha!r}") + return hsla(self.h, self.s, self.l, alpha, _validate=False) def complementary(self) -> "hsla": - """Returns the complementary color (180 degrees on the color wheel)""" + """Returns the complementary color (180 degrees on the color wheel).""" return hsla((self.h + 180) % 360, self.s, self.l, self.a, _validate=False) def _hsl_to_rgb(self, h: int, s: int, l: int) -> tuple: + """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) else: @@ -469,13 +519,19 @@ def hue_to_rgb(p, q, t): r = int(round(hue_to_rgb(p, q, _h + 1 / 3) * 255)) g = int(round(hue_to_rgb(p, q, _h) * 255)) b = int(round(hue_to_rgb(p, q, _h - 1 / 3) * 255)) + return r, g, b class hexa: - """A HEX color: is a string representing a hexadecimal color code with optional alpha channel.\n - ------------------------------------------------------------------------------------------------- - Supports formats: RGB, RGBA, RRGGBB, RRGGBBAA (with or without prefix) + """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) + -------------------------------------------------------------------------------------------- Includes methods: - `to_rgba()` to convert to RGB color - `to_hsla()` to convert to HSL color @@ -504,23 +560,27 @@ def __init__( _a: Optional[float] = None, ): self.r: int - """The red channel (`0`–`255`)""" + """The red channel in range [0, 255].""" self.g: int - """The green channel (`0`–`255`)""" + """The green channel in range [0, 255].""" self.b: int - """The blue channel (`0`–`255`)""" + """The blue channel in range [0, 255].""" self.a: Optional[float] - """The alpha channel (`0.0`–`1.0`) or `None` if not set""" + """The alpha channel in range [0.0, 1.0] 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 return + if isinstance(color, hexa): - raise ValueError("Color is already a hexa() color") - if isinstance(color, str): + raise ValueError("Color is already a hexa() color object.") + + elif isinstance(color, str): if color.startswith("#"): color = color[1:].upper() elif color.startswith("0x"): color = color[2:].upper() + if len(color) == 3: # RGB self.r, self.g, self.b, self.a = ( int(color[0] * 2, 16), @@ -550,11 +610,12 @@ def __init__( int(color[6:8], 16) / 255.0, ) else: - raise ValueError(f"Invalid HEX format '{color}'") + 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() else: - raise TypeError(f"HEX color must be of type 'str' or 'int': got '{type(color)}'") + raise TypeError(f"The 'color' parameter must be a string or integer, got {type(color)}") def __len__(self) -> int: return 3 if self.a is None else 4 @@ -564,8 +625,8 @@ def __iter__(self) -> Iterator: + (() if self.a is None else (f"{int(self.a * 255):02X}", ))) def __getitem__(self, index: int) -> str | int: - 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.r:02X}", f"{self.g:02X}", f"{self.b:02X}") \ + + (() if self.a is None else (f"{int(self.a * 255):02X}", )))[index] 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}"})' @@ -577,10 +638,11 @@ def __eq__(self, other: "hexa") -> bool: # type: ignore[override] """Returns whether the other color is equal to this one.""" if not isinstance(other, hexa): return False - return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a) + else: + return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a) def dict(self) -> dict: - """Returns the color components as a dictionary with hex string values for keys `'r'`, `'g'`, `'b'` and optionally `'a'`""" + """Returns the color components as a dictionary with hex string values for keys `"r"`, `"g"`, `"b"` and optionally `"a"`.""" return ( dict(r=f"{self.r:02X}", g=f"{self.g:02X}", b=f"{self.b:02X}") if self.a is None else dict( r=f"{self.r:02X}", @@ -591,11 +653,17 @@ def dict(self) -> dict: ) def values(self, round_alpha: bool = True) -> tuple: - """Returns the color components as separate values `r, g, b, a`""" + """Returns the color components as separate values `r, g, b, a`.""" + if not isinstance(round_alpha, bool): + raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}") + return self.r, self.g, self.b, None if self.a is None else (round(self.a, 2) if round_alpha else self.a) def to_rgba(self, round_alpha: bool = True) -> "rgba": - """Returns the color as a `rgba()` color""" + """Returns the color as `rgba()` color object.""" + if not isinstance(round_alpha, bool): + raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}") + return rgba( self.r, self.g, @@ -605,98 +673,154 @@ def to_rgba(self, round_alpha: bool = True) -> "rgba": ) def to_hsla(self, round_alpha: bool = True) -> "hsla": - """Returns the color as a `hsla()` color""" + """Returns the color as `hsla()` color object.""" + if not isinstance(round_alpha, bool): + raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}") + return self.to_rgba(round_alpha).to_hsla() def has_alpha(self) -> bool: - """Returns `True` if the color has an alpha channel and `False` otherwise""" + """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None def lighten(self, amount: float) -> "hexa": - """Increases the colors lightness by the specified amount (`0.0`-`1.0`)""" + """Increases the colors lightness by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], got {amount!r}") + self.r, self.g, self.b, self.a = self.to_rgba(False).lighten(amount).values() return hexa("", self.r, self.g, self.b, self.a) def darken(self, amount: float) -> "hexa": - """Decreases the colors lightness by the specified amount (`0.0`-`1.0`)""" + """Decreases the colors lightness by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], got {amount!r}") + self.r, self.g, self.b, self.a = self.to_rgba(False).darken(amount).values() return hexa("", self.r, self.g, self.b, self.a) def saturate(self, amount: float) -> "hexa": - """Increases the colors saturation by the specified amount (`0.0`-`1.0`)""" + """Increases the colors saturation by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], got {amount!r}") + self.r, self.g, self.b, self.a = self.to_rgba(False).saturate(amount).values() return hexa("", self.r, self.g, self.b, self.a) def desaturate(self, amount: float) -> "hexa": - """Decreases the colors saturation by the specified amount (`0.0`-`1.0`)""" + """Decreases the colors saturation by the specified amount in range [0.0, 1.0].""" + if not isinstance(amount, float): + raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") + elif not (0.0 <= amount <= 1.0): + raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0], got {amount!r}") + self.r, self.g, self.b, self.a = self.to_rgba(False).desaturate(amount).values() return hexa("", self.r, self.g, self.b, self.a) def rotate(self, degrees: int) -> "hexa": - """Rotates the colors hue by the specified number of degrees""" + """Rotates the colors hue by the specified number of degrees.""" + if not isinstance(degrees, int): + raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}") + self.r, self.g, self.b, self.a = self.to_rgba(False).rotate(degrees).values() return hexa("", self.r, self.g, self.b, self.a) def invert(self, invert_alpha: bool = False) -> "hexa": - """Inverts the color by rotating hue by 180 degrees and inverting lightness""" + """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" + if not isinstance(invert_alpha, bool): + raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}") + self.r, self.g, self.b, self.a = self.to_rgba(False).invert().values() if invert_alpha and self.a is not None: self.a = 1 - self.a + return hexa("", self.r, self.g, self.b, self.a) def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "hexa": """Converts the color to grayscale using the luminance formula.\n - ------------------------------------------------------------------ - The `method` is 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("", self.r, self.g, self.b, self.a) 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 (`0.0`-`1.0`): - - 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)""" + """Blends the current color with another color using the specified ratio in range [0.0, 1.0].\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""" + if not Color.is_valid_hexa(other): + raise TypeError(f"The 'other' parameter must be a valid HEXA color, got {type(other)}") + if not isinstance(ratio, float): + raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}") + elif not (0.0 <= ratio <= 1.0): + raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0], got {ratio!r}") + if not isinstance(additive_alpha, bool): + raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}") + self.r, self.g, self.b, self.a = self.to_rgba(False).blend(Color.to_rgba(other), ratio, additive_alpha).values() return hexa("", self.r, self.g, self.b, self.a) def is_dark(self) -> bool: - """Returns `True` if the color is considered dark (`lightness < 50%`)""" + """Returns `True` if the color is considered dark (`lightness < 50%`).""" return self.to_hsla(False).is_dark() def is_light(self) -> bool: - """Returns `True` if the color is considered light (`lightness >= 50%`)""" + """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`)""" + """Returns `True` if the color is grayscale (`saturation == 0`).""" return self.to_hsla(False).is_grayscale() def is_opaque(self) -> bool: - """Returns `True` if the color has no transparency (`alpha == 1.0`)""" + """Returns `True` if the color has no transparency (`alpha == 1.0`).""" return self.a == 1 or self.a is None def with_alpha(self, alpha: float) -> "hexa": - """Returns a new color with the specified alpha value""" - if not (isinstance(alpha, (int, float)) and 0 <= alpha <= 1): - raise ValueError("'alpha' must be in [0.0, 1.0]") + """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], got {alpha!r}") + return hexa("", self.r, self.g, self.b, alpha) def complementary(self) -> "hexa": - """Returns the complementary color (180 degrees on the color wheel)""" + """Returns the complementary color (180 degrees on the color wheel).""" return self.to_hsla(False).complementary().to_hexa() class Color: + """This class includes methods to work with colors in different formats.""" @staticmethod def is_valid_rgba(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""" + if not isinstance(allow_alpha, bool): + raise TypeError(f"The 'new_tab_size' parameter must be an boolean, got {type(allow_alpha)}") + try: if isinstance(color, rgba): return True + elif isinstance(color, (list, tuple)): if allow_alpha and Color.has_alpha(color): return ( @@ -707,6 +831,7 @@ def is_valid_rgba(color: AnyRgba, allow_alpha: bool = True) -> bool: return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255 else: return False + elif isinstance(color, dict): if allow_alpha and Color.has_alpha(color): return ( @@ -717,17 +842,27 @@ def is_valid_rgba(color: AnyRgba, allow_alpha: bool = True) -> bool: return 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255 else: return False + elif isinstance(color, str): return bool(_re.fullmatch(Regex.rgba_str(allow_alpha=allow_alpha), color)) - return False + except Exception: - return False + pass + return False @staticmethod def is_valid_hsla(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""" + if not isinstance(allow_alpha, bool): + raise TypeError(f"The 'new_tab_size' parameter must be an boolean, got {type(allow_alpha)}") + try: if isinstance(color, hsla): return True + elif isinstance(color, (list, tuple)): if allow_alpha and Color.has_alpha(color): return ( @@ -738,6 +873,7 @@ def is_valid_hsla(color: AnyHsla, allow_alpha: bool = True) -> bool: return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100 else: return False + elif isinstance(color, dict): if allow_alpha and Color.has_alpha(color): return ( @@ -748,37 +884,62 @@ def is_valid_hsla(color: AnyHsla, allow_alpha: bool = True) -> bool: return 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100 else: return False + elif isinstance(color, str): return bool(_re.fullmatch(Regex.hsla_str(allow_alpha=allow_alpha), color)) - return False + except Exception: - return False + pass + return False @staticmethod def is_valid_hexa( color: AnyHexa, allow_alpha: bool = True, get_prefix: bool = False, - ) -> bool | tuple[bool, Optional[Literal['#', '0x']]]: + ) -> 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""" + if not isinstance(allow_alpha, bool): + raise TypeError(f"The 'new_tab_size' parameter must be an boolean, got {type(allow_alpha)}") + if not isinstance(get_prefix, bool): + raise TypeError(f"The 'get_prefix' parameter must be an boolean, got {type(get_prefix)}") + try: if isinstance(color, hexa): return (True, "#") if get_prefix else True + elif isinstance(color, int): is_valid = 0x000000 <= color <= (0xFFFFFFFF if allow_alpha else 0xFFFFFF) return (is_valid, "0x") if get_prefix else is_valid + elif isinstance(color, str): color, prefix = ((color[1:], "#") if color.startswith("#") else (color[2:], "0x") if color.startswith("0x") else (color, None)) - return ((bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color)), - prefix) if get_prefix else bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color))) - return False + return ( + (bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color)), prefix) \ + if get_prefix else bool(_re.fullmatch(Regex.hexa_str(allow_alpha=allow_alpha), color)) + ) + except Exception: - return (False, None) if get_prefix else False + pass + return (False, None) if get_prefix else False @staticmethod def is_valid(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""" + if not isinstance(allow_alpha, bool): + raise TypeError(f"The 'new_tab_size' parameter must be an boolean, got {type(allow_alpha)}") + return bool( - Color.is_valid_rgba(color, allow_alpha) or Color.is_valid_hsla(color, allow_alpha) + Color.is_valid_rgba(color, allow_alpha) \ + or Color.is_valid_hsla(color, allow_alpha) \ or Color.is_valid_hexa(color, allow_alpha) ) @@ -786,10 +947,10 @@ def is_valid(color: AnyRgba | AnyHsla | AnyHexa, allow_alpha: bool = True) -> bo def has_alpha(color: Rgba | Hsla | Hexa) -> bool: """Check if the given color has an alpha channel.\n --------------------------------------------------------------------------- - Input a RGBA, HSLA or HEXA color as `color`. - Returns `True` if the color has an alpha channel and `False` otherwise.""" + - `color` -⠀the color to check (can be in any supported format)""" if isinstance(color, (rgba, hsla, hexa)): return color.has_alpha() + if Color.is_valid_hexa(color): if isinstance(color, str): if color.startswith("#"): @@ -798,59 +959,72 @@ def has_alpha(color: Rgba | Hsla | Hexa) -> bool: if isinstance(color, int): hex_length = len(f"{color:X}") return hex_length == 4 or hex_length == 8 + elif isinstance(color, (list, tuple)) and len(color) == 4 and color[3] is not None: return True elif isinstance(color, dict) and len(color) == 4 and color["a"] is not None: return True + return False @staticmethod def to_rgba(color: Rgba | Hsla | Hexa) -> rgba: - """Will try to convert any color type to a color of type 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)""" if isinstance(color, (hsla, hexa)): return color.to_rgba() elif Color.is_valid_hsla(color): - return hsla(*color, _validate=False).to_rgba() # type: ignore[not-iterable] + return hsla(*cast(hsla, color), _validate=False).to_rgba() elif Color.is_valid_hexa(color): return hexa(cast(str | int, color)).to_rgba() elif Color.is_valid_rgba(color): - return color if isinstance(color, rgba) else (rgba(*color, _validate=False)) # type: ignore[not-iterable] - raise ValueError(f"Invalid color format '{color}'") + return color if isinstance(color, rgba) else (rgba(*cast(rgba, color), _validate=False)) + raise ValueError(f"Could not convert color '{color!r}' to RGBA.") @staticmethod def to_hsla(color: Rgba | Hsla | Hexa) -> hsla: - """Will try to convert any color type to a color of type 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)""" if isinstance(color, (rgba, hexa)): return color.to_hsla() elif Color.is_valid_rgba(color): - return rgba(*color, _validate=False).to_hsla() # type: ignore[not-iterable] + return rgba(*cast(rgba, color), _validate=False).to_hsla() elif Color.is_valid_hexa(color): return hexa(cast(str | int, color)).to_hsla() elif Color.is_valid_hsla(color): - return color if isinstance(color, hsla) else (hsla(*color, _validate=False)) # type: ignore[not-iterable] - raise ValueError(f"Invalid color format '{color}'") + return color if isinstance(color, hsla) else (hsla(*cast(hsla, color), _validate=False)) + raise ValueError(f"Could not convert color '{color!r}' to HSLA.") @staticmethod def to_hexa(color: Rgba | Hsla | Hexa) -> hexa: - """Will try to convert any color type to a color of type 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)""" if isinstance(color, (rgba, hsla)): return color.to_hexa() elif Color.is_valid_rgba(color): - return rgba(*color, _validate=False).to_hexa() # type: ignore[not-iterable] + return rgba(*cast(rgba, color), _validate=False).to_hexa() elif Color.is_valid_hsla(color): - return hsla(*color, _validate=False).to_hexa() # type: ignore[not-iterable] + return hsla(*cast(hsla, color), _validate=False).to_hexa() elif Color.is_valid_hexa(color): return color if isinstance(color, hexa) else hexa(cast(str | int, color)) - raise ValueError(f"Invalid color format '{color}'") + raise ValueError(f"Could not convert color '{color}' to HEXA") @staticmethod def str_to_rgba(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 - -------------------------------------------------------------------------------------------------- - If `only_first` is `True` only the first found color will be returned (not as a list).""" + --------------------------------------------------------------------------------------------------------------- + - `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 not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(only_first, bool): + raise TypeError(f"The 'only_first' parameter must be an boolean, got {type(only_first)}") + if only_first: - match = _re.search(Regex.rgba_str(allow_alpha=True), string) - if not match: + if not (match := _re.search(Regex.rgba_str(allow_alpha=True), string)): return None m = match.groups() return rgba( @@ -860,9 +1034,9 @@ def str_to_rgba(string: str, only_first: bool = False) -> Optional[rgba | list[r ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None), _validate=False, ) + else: - matches = _re.findall(Regex.rgba_str(allow_alpha=True), string) - if not matches: + if not (matches := _re.findall(Regex.rgba_str(allow_alpha=True), string)): return None return [ rgba( @@ -884,15 +1058,26 @@ def rgba_to_hex_int( ) -> 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 - back to RGBA using `Color.hex_int_to_rgba()`.\n - ⇾ You can disable this behavior by setting `preserve_original` to `True`""" + back to RGBA using `Color.hex_int_to_rgba()`.""" + if not all(isinstance(c, int) and 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 (isinstance(a, float) and 0 <= a <= 1): + raise ValueError(f"The 'a' parameter must be a float in [0.0, 1.0] or None, got {a!r}") + if not isinstance(preserve_original, bool): + raise TypeError(f"The 'preserve_original' parameter must be an boolean, got {type(preserve_original)}") + r = max(0, min(255, int(r))) g = max(0, min(255, int(g))) b = max(0, min(255, int(b))) + if a is None: hex_int = (r << 16) | (g << 8) | b if not preserve_original and (hex_int & 0xF00000) == 0: @@ -902,20 +1087,27 @@ def rgba_to_hex_int( hex_int = (r << 24) | (g << 16) | (b << 8) | a if not preserve_original and r == 0: hex_int |= 0x01000000 + return hex_int @staticmethod def hex_int_to_rgba(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. - This is the correction, so the color doesn't even look slightly different.\n - ⇾ You can disable this behavior by setting `preserve_original` to `True`""" + This is the correction, so the color doesn't even look slightly different.""" if not isinstance(hex_int, int): - raise ValueError("Input must be an integer") - hex_str = f"{hex_int:x}" - if len(hex_str) <= 6: + raise TypeError(f"The 'hex_int' parameter must be an integer, got {type(hex_int)}") + if not isinstance(preserve_original, bool): + raise TypeError(f"The 'preserve_original' parameter must be an boolean, got {type(preserve_original)}") + elif not 0 <= hex_int <= 0xFFFFFFFF: + raise ValueError(f"Expected HEX integer in range [0x000000, 0xFFFFFFFF], 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, @@ -924,6 +1116,7 @@ def hex_int_to_rgba(hex_int: int, preserve_original: bool = False) -> rgba: None, _validate=False, ) + elif len(hex_str) <= 8: hex_str = hex_str.zfill(8) return rgba( @@ -933,8 +1126,9 @@ def hex_int_to_rgba(hex_int: int, preserve_original: bool = False) -> rgba: int(hex_str[6:8], 16) / 255.0, _validate=False, ) + else: - raise ValueError(f"Invalid HEX integer '0x{hex_str}': expected in range [0x000000, 0xFFFFFF]") + raise ValueError(f"Could not convert HEX integer 0x{hex_int:X} to RGBA color.") @staticmethod def luminance( @@ -946,16 +1140,25 @@ def luminance( ) -> int | float: """Calculates the relative luminance of a color according to various standards.\n ---------------------------------------------------------------------------------- - The `output_type` controls the range of the returned luminance value: - - `int` returns integer in [0, 100] - - `float` returns float in [0.0, 1.0] - - `None` returns integer in [0, 255]\n - The `method` is 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)""" + - `r`, `g`, `b` -⠀the red, green and blue channels in range [0, 255] + - `output_type` -⠀the range of the returned luminance value: + * `int` returns integer in range [0, 100] + * `float` returns float in range [0.0, 1.0] + * `None` returns integer in range [0, 255] + - `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(isinstance(c, int) and 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 output_type not in {int, float, None}: + raise TypeError(f"The 'output_type' parameter must be either 'int', 'float' or 'None', got {output_type!r}") + if method not in {"wcag2", "wcag3", "simple", "bt601"}: + raise ValueError(f"The 'method' parameter must be one of 'wcag2', 'wcag3', 'simple' or 'bt601', got {method!r}") + _r, _g, _b = r / 255.0, g / 255.0, b / 255.0 + if method == "simple": luminance = (_r + _g + _b) / 3 elif method == "bt601": @@ -970,6 +1173,7 @@ def luminance( _g = Color._linearize_srgb(_g) _b = Color._linearize_srgb(_b) luminance = 0.2126 * _r + 0.7152 * _g + 0.0722 * _b + if output_type == int: return round(luminance * 100) elif output_type == float: @@ -979,7 +1183,14 @@ def luminance( @staticmethod def _linearize_srgb(c: float) -> float: - """Helper method to linearize sRGB component following the WCAG standard.""" + """Helper method to linearize sRGB component following the WCAG standard.\n + ---------------------------------------------------------------------------- + - `c` -⠀the sRGB component value in range [0.0, 1.0]""" + if not isinstance(c, float): + raise TypeError(f"The 'c' parameter must be a float, got {type(c)}") + elif not 0.0 <= c <= 1.0: + raise ValueError(f"The 'c' parameter must be in range [0.0, 1.0], got {c!r}") + if c <= 0.03928: return c / 12.92 else: @@ -987,37 +1198,78 @@ def _linearize_srgb(c: float) -> float: @staticmethod def text_color_for_on_bg(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)""" was_hexa, was_int = Color.is_valid_hexa(text_bg_color), isinstance(text_bg_color, int) + + if not (Color.is_valid_rgba(text_bg_color) or was_hexa): + raise ValueError(f"The 'text_bg_color' parameter must be a valid RGBA or HEXA color, got {text_bg_color!r}") + text_bg_color = Color.to_rgba(text_bg_color) brightness = 0.2126 * text_bg_color[0] + 0.7152 * text_bg_color[1] + 0.0722 * text_bg_color[2] - return (((0xFFFFFF if was_int else hexa("", 255, 255, 255)) if was_hexa else rgba(255, 255, 255, _validate=False)) - if brightness < 128 else - ((0x000 if was_int else hexa("", 0, 0, 0)) if was_hexa else rgba(0, 0, 0, _validate=False))) + + return ( + (0xFFFFFF if was_int else hexa("", 255, 255, 255)) if was_hexa \ + else rgba(255, 255, 255, _validate=False) + ) if brightness < 128 else ( + (0x000 if was_int else hexa("", 0, 0, 0)) if was_hexa \ + else rgba(0, 0, 0, _validate=False) + ) @staticmethod def adjust_lightness(color: Rgba | Hexa, lightness_change: float) -> rgba | hexa: """In- or decrease the lightness of the input color.\n - ----------------------------------------------------------------------------------------------------- - - color (rgba|hexa): HEX or RGBA color - - lightness_change (float): float between -1.0 (darken by `100%`) and 1.0 (lighten by `100%`)\n - ----------------------------------------------------------------------------------------------------- - returns (rgba|hexa): the adjusted color in the format of the input color""" + ------------------------------------------------------------------ + - `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%)""" was_hexa = Color.is_valid_hexa(color) - _color: hsla = Color.to_hsla(color) - h, s, l, a = (int(_color[0]), int(_color[1]), int(_color[2]), _color[3] if Color.has_alpha(_color) else None) + + if not (Color.is_valid_rgba(color) or was_hexa): + raise ValueError(f"The 'color' parameter must be a valid RGBA or HEXA color, got {color!r}") + if not isinstance(lightness_change, float): + raise TypeError(f"The 'lightness_change' parameter must be a float, got {type(lightness_change)}") + elif not -1.0 <= lightness_change <= 1.0: + raise ValueError(f"The 'lightness_change' parameter must be in range [-1.0, 1.0], got {lightness_change!r}") + + hsla_color: hsla = Color.to_hsla(color) + h, s, l, a = ( + int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \ + hsla_color[3] if Color.has_alpha(hsla_color) else None + ) l = int(max(0, min(100, l + lightness_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() + + return ( + hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \ + else hsla(h, s, l, a, _validate=False).to_rgba() + ) @staticmethod def adjust_saturation(color: Rgba | Hexa, saturation_change: float) -> rgba | hexa: """In- or decrease the saturation of the input color.\n - ----------------------------------------------------------------------------------------------------------- - - color (rgba|hexa): HEX or RGBA color - - saturation_change (float): float between -1.0 (saturate by `100%`) and 1.0 (desaturate by `100%`)\n - ----------------------------------------------------------------------------------------------------------- - returns (rgba|hexa): the adjusted color in the format of the input color""" + ----------------------------------------------------------------------- + - `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%)""" was_hexa = Color.is_valid_hexa(color) - _color: hsla = Color.to_hsla(color) - h, s, l, a = (int(_color[0]), int(_color[1]), int(_color[2]), _color[3] if Color.has_alpha(_color) else None) + + if not (Color.is_valid_rgba(color) or was_hexa): + raise ValueError(f"The 'color' parameter must be a valid RGBA or HEXA color, got {color!r}") + if not isinstance(saturation_change, float): + raise TypeError(f"The 'saturation_change' parameter must be a float, got {type(saturation_change)}") + elif not -1.0 <= saturation_change <= 1.0: + raise ValueError(f"The 'saturation_change' parameter must be in range [-1.0, 1.0], got {saturation_change!r}") + + hsla_color: hsla = Color.to_hsla(color) + + h, s, l, a = ( + int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \ + hsla_color[3] if Color.has_alpha(hsla_color) else None + ) s = int(max(0, min(100, s + saturation_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() + + return ( + hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \ + else hsla(h, s, l, a, _validate=False).to_rgba() + ) diff --git a/src/xulbux/console.py b/src/xulbux/console.py index da63f25..a07d0c5 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -1,16 +1,16 @@ """ -Functions for logging and other small actions within the console.\n ----------------------------------------------------------------------------------------------------------- -You can also use special formatting codes directly inside the log message to change their appearance. -For more detailed information about formatting codes, see the the `format_codes` module documentation. +This module provides the `Console` class, which offers +methods for logging and other actions within the console. """ +from .base.types import ArgConfigWithDefault, ArgResultRegular, ArgResultPositional, Rgba, Hexa from .base.consts import COLOR, CHARS, ANSI -from .format_codes import FormatCodes, _COMPILED as _FC_COMPILED + +from .format_codes import _COMPILED as _FC_COMPILED, FormatCodes from .string import String -from .color import Color, Rgba, Hexa +from .color import Color, hexa -from typing import Generator, TypedDict, Callable, Optional, Protocol, Literal, Mapping, Pattern, TypeVar, TextIO, Any, overload, cast +from typing import Generator, Callable, Optional, Protocol, Literal, Mapping, Pattern, TypeVar, TextIO, overload, cast from prompt_toolkit.key_binding import KeyPressEvent, KeyBindings from prompt_toolkit.validation import ValidationError, Validator from prompt_toolkit.styles import Style @@ -73,25 +73,8 @@ def __get__(self, obj, owner=None): return _os.getenv("USER") or _os.getenv("USERNAME") or _getpass.getuser() -class _ArgConfigWithDefault(TypedDict): - flags: list[str] | tuple[str, ...] - default: Any - - -class _ArgResultRegular(TypedDict): - """TypedDict for regular flagged argument results.""" - exists: bool - value: Optional[str] - - -class _ArgResultPositional(TypedDict): - """TypedDict for positional 'before'/'after' argument results.""" - exists: bool - values: list[str] - - class ArgResult: - """Represents the result of a parsed command-line argument and contains the following attributes: + """Represents the result of a parsed command-line argument, containing the following attributes: - `exists` -⠀if the argument was found or not - `value` -⠀the value given with the found argument as a string (only for regular flagged arguments) - `values` -⠀the list of values for positional arguments (only for `"before"`/`"after"` arguments)\n @@ -99,6 +82,15 @@ class ArgResult: When the `ArgResult` instance is accessed as a boolean it will correspond to the `exists` attribute.""" def __init__(self, exists: bool, value: Optional[str] = None, values: Optional[list[str]] = None): + if not isinstance(exists, bool): + raise TypeError(f"The 'exists' parameter must be a boolean, got {type(exists)}") + if not isinstance(value, (str, type(None))): + raise TypeError(f"The 'value' parameter must be a string or None, got {type(value)}") + if not isinstance(values, (list, type(None))): + raise TypeError(f"The 'values' parameter must be a list of strings or None, got {type(values)}") + if value is not None and values is not None: + raise ValueError("The 'value' and 'values' parameters are mutually exclusive. Only one can be set.") + self.exists: bool = exists """Whether the argument was found or not.""" self.value: Optional[str] = value @@ -111,14 +103,25 @@ def __bool__(self): class Args: - """Container for parsed command-line arguments, allowing attribute-style access. + """Container for parsed command-line arguments, allowing attribute-style access.\n + -------------------------------------------------------------------------------------- + - `kwargs` -⠀a mapping of argument aliases to their corresponding data dictionaries\n + -------------------------------------------------------------------------------------- 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 `ArgResult`.""" - def __init__(self, **kwargs: dict[str, str | list[str]]): + def __init__(self, **kwargs: Mapping[str, str | list[str]]): + if not kwargs: + raise ValueError("At least one argument must be provided to initialize Args.") + if not all(isinstance(data_dict, Mapping) for data_dict in kwargs.values()): + raise TypeError( + f"All argument values must be mappings (e.g. dict), got " + f"{[type(v) for v in kwargs.values() if not isinstance(v, Mapping)]}" + ) + for alias_name, data_dict in kwargs.items(): if not alias_name.isidentifier(): - raise TypeError(f"Argument alias '{alias_name}' is invalid. It must be a valid Python variable name.") + raise TypeError(f"Argument alias '{alias_name}' is invalid: It must be a valid Python variable name.") if "values" in data_dict: setattr( self, alias_name, @@ -137,28 +140,28 @@ def __contains__(self, key): return hasattr(self, key) def __getattr__(self, name: str) -> ArgResult: - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + raise AttributeError(f"'{type(self).__name__}' object has no attribute {name}") def __getitem__(self, key): if isinstance(key, int): return list(self.__iter__())[key] return getattr(self, key) - def __iter__(self) -> Generator[tuple[str, _ArgResultRegular | _ArgResultPositional], None, None]: - for key, value in vars(self).items(): - if value.values is not None: - yield (key, _ArgResultPositional(exists=value.exists, values=value.values)) + def __iter__(self) -> Generator[tuple[str, ArgResultRegular | ArgResultPositional], None, None]: + for key, val in vars(self).items(): + if val.values is not None: + yield (key, ArgResultPositional(exists=val.exists, values=val.values)) else: - yield (key, _ArgResultRegular(exists=value.exists, value=value.value)) + yield (key, ArgResultRegular(exists=val.exists, value=val.value)) - def dict(self) -> dict[str, _ArgResultRegular | _ArgResultPositional]: + def dict(self) -> dict[str, ArgResultRegular | ArgResultPositional]: """Returns the arguments as a dictionary.""" - result: dict[str, _ArgResultRegular | _ArgResultPositional] = {} - for k, v in vars(self).items(): - if v.values is not None: - result[k] = _ArgResultPositional(exists=v.exists, values=v.values) + result: dict[str, ArgResultRegular | ArgResultPositional] = {} + for key, val in vars(self).items(): + if val.values is not None: + result[key] = ArgResultPositional(exists=val.exists, values=val.values) else: - result[k] = _ArgResultRegular(exists=v.exists, value=v.value) + result[key] = ArgResultRegular(exists=val.exists, value=val.value) return result def keys(self): @@ -169,13 +172,14 @@ def values(self): """Returns the argument results as `dict_values([...])`.""" return vars(self).values() - def items(self) -> Generator[tuple[str, _ArgResultRegular | _ArgResultPositional], None, None]: + def items(self) -> Generator[tuple[str, ArgResultRegular | ArgResultPositional], None, None]: """Yields tuples of `(alias, _ArgResultRegular | _ArgResultPositional)`.""" for key, val in self.__iter__(): yield (key, val) class Console: + """This class provides methods for logging and other actions within the console.""" w: int = _ConsoleWidth() # type: ignore[assignment] """The width of the console in characters.""" @@ -188,28 +192,35 @@ class Console: @staticmethod def get_args( - find_args: Mapping[ - str, - list[str] | tuple[str, ...] | _ArgConfigWithDefault | Literal["before", "after"], - ], - allow_spaces: bool = False + find_args: Mapping[str, set[str] | ArgConfigWithDefault | Literal["before", "after"]], + allow_spaces: bool = False, ) -> Args: """Will search for the specified arguments in the command line arguments and return the results as a special `Args` object.\n - ---------------------------------------------------------------- + ----------------------------------------------------------------------------------------------------------- + - `find_args` -⠀a dictionary defining the argument aliases and their flags/configuration (explained below) + - `allow_spaces` -⠀if true , flagged argument values can span multiple space-separated tokens until the + next flag is encountered, otherwise only the immediate next token is captured as the value:
+ This allows passing multi-word values without quotes + (e.g. `-f hello world` instead of `-f "hello world"`).
+ * This setting does not affect `"before"`/`"after"` positional arguments, + which always treat each token separately.
+ * When `allow_spaces=True`, positional `"after"` arguments will always be empty if any flags + are present, as all tokens following the last flag are consumed as that flag's value.\n + ----------------------------------------------------------------------------------------------------------- The `find_args` dictionary can have the following structures for each alias: - 1. Simple list/tuple of flags (when no default value is needed): + 1. Simple set of flags (when no default value is needed): ```python - "alias_name": ["-f", "--flag"] + "alias_name": {"-f", "--flag"} ``` - 2. Dictionary with 'flags' and optional 'default': + 2. Dictionary with `"flags"` and `"default"` value: ```python "alias_name": { - "flags": ["-f", "--flag"], - "default": "some_value" # Optional + "flags": {"-f", "--flag"}, + "default": "some_value", } ``` - 3. Positional argument collection (string value): + 3. Positional argument collection using the literals `"before"` or `"after"`: ```python "alias_name": "before" # Collects non-flagged args before first flag "alias_name": "after" # Collects non-flagged args after last flag @@ -217,58 +228,56 @@ def get_args( Example `find_args`: ```python find_args={ - "text": "before", # Positional args before flags - "arg1": { # With default - "flags": ["-a1", "--arg1"], - "default": "default_val" + "text": "before", # Positional args before flagged args + "arg1": {"-a1", "--arg1"}, # Just flags + "arg2": {"-a2", "--arg2"}, # Just flags + "arg3": { # With default value + "flags": {"-a3", "--arg3"}, + "default": "default_val", }, - "arg2": ("-a2", "--arg2"), # Without default (original format) - "arg3": ["-a3"], # Without default (list format) - "arg4": { # Flag with default True - "flags": ["-f"], - "default": True - } } ``` If the script is called via the command line:\n - `python script.py Hello World -a1 "value1" --arg2 -f`\n + `python script.py Hello World -a1 "value1" --arg2`\n ...it would return an `Args` object where: - `args.text.exists` is `True`, `args.text.values` is `["Hello", "World"]` - - `args.arg1.exists` is `True`, `args.arg1.value` is `"value1"` + - `args.arg1.exists` is `True`, `args.arg1.value` is `"value1"` (flag present with value) - `args.arg2.exists` is `True`, `args.arg2.value` is `None` (flag present without value) - - `args.arg3.exists` is `False`, `args.arg3.value` is `None` (not present, no default) - - `args.arg4.exists` is `True`, `args.arg4.value` is `None` (flag present, no value provided) - - If an arg defined in `find_args` is *not* present in the command line: - - `exists` will be `False` - - `value` will be the specified `default` value, or `None` if no default was specified. - - `values` will be `[]` for positional "before"/"after" arguments.\n - ---------------------------------------------------------------- + - `args.arg3.exists` is `False`, `args.arg3.value` is `"default_val"` (not present, has default value)\n + ----------------------------------------------------------------------------------------------------------- + If an arg, defined with flags in `find_args`, is NOT present in the command line: + * `exists` will be `False` + * `value` will be the specified `default` value, or `None` if no default was specified + * `values` will be `[]` for positional `"before"`/`"after"` arguments\n + ----------------------------------------------------------------------------------------------------------- For positional arguments: - - `"before"`: Collects all non-flagged arguments that appear before the first flag - - `"after"`: Collects all non-flagged arguments that appear after the last flag's value - ---------------------------------------------------------------- - Normally if `allow_spaces` is false, it will take a space as - the end of an args value. If it is true, it will take spaces as - part of the value up until the next arg-flag is found. + - `"before"` collects all non-flagged arguments that appear before the first flag + - `"after"` collects all non-flagged arguments that appear after the last flag's value + ----------------------------------------------------------------------------------------------------------- + Normally if `allow_spaces` is false, it will take a space as the end of an args value. If it is true, + it will take spaces as part of the value up until the next arg-flag is found. (Multiple spaces will become one space in the value.)""" + positional_configs, arg_lookup, results = {}, {}, {} + before_count, after_count = 0, 0 args = _sys.argv[1:] args_len = len(args) - arg_lookup = {} - results = {} - positional_configs = {} - before_count = 0 - after_count = 0 - # PARSE "find_args" CONFIGURATION + if not isinstance(find_args, Mapping): + raise TypeError(f"The 'find_args' parameter must be a mapping (e.g. dict), got {type(find_args)}") + if not isinstance(allow_spaces, bool): + raise TypeError(f"The 'allow_spaces' parameter must be a boolean, got {type(allow_spaces)}") + + # PARSE 'find_args' CONFIGURATION for alias, config in find_args.items(): - flags = None - default_value = None + flags, default_value = None, None + if not alias.isidentifier(): + raise TypeError(f"Argument alias '{alias}' is invalid: It must be a valid Python variable name.") if isinstance(config, str): # HANDLE POSITIONAL ARGUMENT COLLECTION - if config not in ("before", "after"): + if config not in {"before", "after"}: raise ValueError( - f"Invalid positional argument type '{config}' for alias '{alias}'. Must be 'before' or 'after'." + f"Invalid positional argument type '{config}' for alias '{alias}'. Must be either 'before' or 'after'." ) if config == "before": before_count += 1 @@ -280,23 +289,27 @@ def get_args( raise ValueError("Only one alias can have the value 'after' for positional argument collection.") positional_configs[alias] = config results[alias] = {"exists": False, "values": []} - elif isinstance(config, (list, tuple)): + elif isinstance(config, set): flags = config results[alias] = {"exists": False, "value": default_value} elif isinstance(config, dict): if "flags" not in config: raise ValueError(f"Invalid configuration for alias '{alias}'. Dictionary must contain a 'flags' key.") - if "default" not in config: + elif "default" not in config: raise ValueError( - f"Invalid configuration for alias '{alias}'. Dictionary must contain a 'default' key. Use a simple list/tuple if no default value is needed." + f"Invalid configuration for alias '{alias}'. Dictionary must contain a 'default' key.\n" + "Use a simple set of strings if no default value is needed and only flags are to be specified." ) + if not isinstance(config["flags"], set): + raise ValueError(f"Invalid 'flags' for alias '{alias}'. Must be a set of strings.") + if not isinstance(config["default"], str): + raise ValueError(f"Invalid 'default' value for alias '{alias}'. Must be a string.") flags, default_value = config["flags"], config["default"] - if not isinstance(flags, (list, tuple)): - raise ValueError(f"Invalid 'flags' for alias '{alias}'. Must be a list or tuple.") results[alias] = {"exists": False, "value": default_value} else: raise TypeError( - f"Invalid configuration type for alias '{alias}'. Must be a list, tuple, dict or literal 'before' / 'after'." + f"Invalid configuration type for alias '{alias}'.\n" + "Must be a set, dict, literal 'before' or literal 'after'." ) # BUILD FLAG LOOKUP FOR NON-POSITIONAL ARGUMENTS @@ -400,8 +413,24 @@ def pause_exit( exit_code: int = 0, reset_ansi: bool = False, ) -> None: - """Will print the `prompt` and then pause the program if `pause` is - true and after the pause, exit the program if `exit` is set true.""" + """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""" + if not isinstance(pause, bool): + raise TypeError(f"The 'pause' parameter must be a boolean, got {type(pause)}") + if not isinstance(exit, bool): + raise TypeError(f"The 'exit' parameter must be a boolean, got {type(exit)}") + if not isinstance(exit_code, int): + raise TypeError(f"The 'exit_code' parameter must be an integer, got {type(exit_code)}") + elif exit_code < 0: + raise ValueError("The 'exit_code' parameter must be a non-negative integer.") + if not isinstance(reset_ansi, bool): + raise TypeError(f"The 'reset_ansi' parameter must be a boolean, got {type(reset_ansi)}") + FormatCodes.print(prompt, end="", flush=True) if reset_ansi: FormatCodes.print("[_]", end="") @@ -447,13 +476,39 @@ def log( ------------------------------------------------------------------------------------------- The log message can be formatted with special formatting codes. For more detailed information about formatting codes, see `format_codes` module documentation.""" - has_title_bg = title_bg_color is not None and Color.is_valid(title_bg_color) + if not isinstance(title, (str, type(None))): + raise TypeError(f"The 'title' parameter must be a string or None, got {type(title)}") + if not isinstance(format_linebreaks, bool): + raise TypeError(f"The 'format_linebreaks' parameter must be a boolean, got {type(format_linebreaks)}") + if not isinstance(start, str): + raise TypeError(f"The 'start' parameter must be a string, got {type(start)}") + # THE 'end' PARAM IS CHECKED IN 'FormatCodes.print()' + has_title_bg = False + if title_bg_color is not None: + if (Color.is_valid_rgba(title_bg_color) or Color.is_valid_hexa(title_bg_color)): + title_bg_color, has_title_bg = Color.to_hexa(cast(Rgba | Hexa, title_bg_color)), True + else: + raise ValueError(f"The 'title_bg_color' parameter must be a valid Rgba or Hexa color, got {title_bg_color!r}") + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.print()' + if not isinstance(tab_size, int): + raise TypeError(f"The 'tab_size' parameter must be an integer, got {type(tab_size)}") + elif tab_size < 0: + raise ValueError("The 'tab_size' parameter must be a non-negative integer.") + if not isinstance(title_px, int): + raise TypeError(f"The 'title_px' parameter must be an integer, got {type(title_px)}") + elif title_px < 0: + raise ValueError("The 'title_px' parameter must be a non-negative integer.") + if not isinstance(title_mx, int): + raise TypeError(f"The 'title_mx' parameter must be an integer, got {type(title_mx)}") + elif title_mx < 0: + raise ValueError("The 'title_mx' parameter must be a non-negative integer.") + title = "" if title is None else title.strip().upper() - title_fg = Color.text_color_for_on_bg( - Color.to_hexa(title_bg_color) # type: ignore[assignment] - ) if has_title_bg else "_color" + title_fg = Color.text_color_for_on_bg(cast(hexa, title_bg_color)) if has_title_bg else "_color" + 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 = ( @@ -465,9 +520,10 @@ def log( prompt = f"\n{mx}{' ' * title_len}{mx}{tab}".join( Console.__add_back_removed_parts(list(prompt_lst), cast(tuple[tuple[int, str], ...], removals)) ) + if title == "": FormatCodes.print( - f'{start} {f"[{default_color}]" if default_color else ""}{prompt}[_]', + f"{start} {f'[{default_color}]' if default_color else ''}{prompt}[_]", default_color=default_color, end=end, ) @@ -509,6 +565,7 @@ def find_string_part(pos: int) -> int: parts = [result[i][:adjusted_pos], removal, result[i][adjusted_pos:]] result[i] = "".join(parts) offset_adjusts[i] += len(removal) + return result @staticmethod @@ -527,8 +584,19 @@ def debug( """A preset for `log()`: `DEBUG` log message with the options to pause at the message and exit the program after the message was printed. If `active` is false, no debug message will be printed.""" + if not isinstance(active, bool): + raise TypeError(f"The 'active' parameter must be a boolean, got {type(active)}") + if active: - Console.log("DEBUG", prompt, format_linebreaks, start, end, COLOR.YELLOW, default_color) + Console.log( + title="DEBUG", + prompt=prompt, + format_linebreaks=format_linebreaks, + start=start, + end=end, + title_bg_color=COLOR.YELLOW, + default_color=default_color, + ) Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi) @staticmethod @@ -545,7 +613,15 @@ def info( ) -> None: """A preset for `log()`: `INFO` log message with the options to pause at the message and exit the program after the message was printed.""" - Console.log("INFO", prompt, format_linebreaks, start, end, COLOR.BLUE, default_color) + Console.log( + title="INFO", + prompt=prompt, + format_linebreaks=format_linebreaks, + start=start, + end=end, + title_bg_color=COLOR.BLUE, + default_color=default_color, + ) Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi) @staticmethod @@ -562,7 +638,15 @@ def done( ) -> None: """A preset for `log()`: `DONE` log message with the options to pause at the message and exit the program after the message was printed.""" - Console.log("DONE", prompt, format_linebreaks, start, end, COLOR.TEAL, default_color) + Console.log( + title="DONE", + prompt=prompt, + format_linebreaks=format_linebreaks, + start=start, + end=end, + title_bg_color=COLOR.TEAL, + default_color=default_color, + ) Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi) @staticmethod @@ -579,7 +663,15 @@ def warn( ) -> None: """A preset for `log()`: `WARN` log message with the options to pause at the message and exit the program after the message was printed.""" - Console.log("WARN", prompt, format_linebreaks, start, end, COLOR.ORANGE, default_color) + Console.log( + title="WARN", + prompt=prompt, + format_linebreaks=format_linebreaks, + start=start, + end=end, + title_bg_color=COLOR.ORANGE, + default_color=default_color, + ) Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi) @staticmethod @@ -596,7 +688,15 @@ def fail( ) -> None: """A preset for `log()`: `FAIL` log message with the options to pause at the message and exit the program after the message was printed.""" - Console.log("FAIL", prompt, format_linebreaks, start, end, COLOR.RED, default_color) + Console.log( + title="FAIL", + prompt=prompt, + format_linebreaks=format_linebreaks, + start=start, + end=end, + title_bg_color=COLOR.RED, + default_color=default_color, + ) Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi) @staticmethod @@ -613,7 +713,15 @@ def exit( ) -> None: """A preset for `log()`: `EXIT` log message with the options to pause at the message and exit the program after the message was printed.""" - Console.log("EXIT", prompt, format_linebreaks, start, end, COLOR.MAGENTA, default_color) + Console.log( + title="EXIT", + prompt=prompt, + format_linebreaks=format_linebreaks, + start=start, + end=end, + title_bg_color=COLOR.MAGENTA, + default_color=default_color, + ) Console.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi) @staticmethod @@ -640,17 +748,38 @@ 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.""" - lines, unfmt_lines, max_line_len = Console.__prepare_log_box(values, default_color) - pad_w_full = (Console.w - (max_line_len + (2 * w_padding))) if w_full else 0 - if box_bg_color is not None and Color.is_valid(box_bg_color): + if not isinstance(start, str): + raise TypeError(f"The 'start' parameter must be a string, got {type(start)}") + # THE 'end' PARAM IS CHECKED IN 'FormatCodes.print()' + if not (isinstance(box_bg_color, str) or Color.is_valid_rgba(box_bg_color) or Color.is_valid_hexa(box_bg_color)): + raise TypeError(f"The 'box_bg_color' parameter must be a string, Rgba, or Hexa color, got {type(box_bg_color)}") + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.print()' + if not isinstance(w_padding, int): + raise TypeError(f"The 'w_padding' parameter must be an integer, got {type(w_padding)}") + elif w_padding < 0: + raise ValueError("The 'w_padding' parameter must be a non-negative integer.") + if not isinstance(w_full, bool): + raise TypeError(f"The 'w_full' parameter must be a boolean, got {type(w_full)}") + if not isinstance(indent, int): + raise TypeError(f"The 'indent' parameter must be an integer, got {type(indent)}") + elif indent < 0: + raise ValueError("The 'indent' parameter must be a non-negative integer.") + + if Color.is_valid(box_bg_color): box_bg_color = Color.to_hexa(box_bg_color) + + lines, unfmt_lines, max_line_len = Console.__prepare_log_box(values, default_color) + spaces_l = " " * indent + pady = " " * (Console.w if w_full else max_line_len + (2 * w_padding)) + pad_w_full = (Console.w - (max_line_len + (2 * w_padding))) if w_full else 0 + lines = [ f"{spaces_l}[bg:{box_bg_color}]{' ' * w_padding}" + _FC_COMPILED["formatting"].sub(lambda m: f"{m.group(0)}[bg:{box_bg_color}]", line) + (" " * ((w_padding + max_line_len - len(unfmt)) + pad_w_full)) + "[*]" for line, unfmt in zip(lines, unfmt_lines) ] - pady = " " * (Console.w if w_full else max_line_len + (2 * w_padding)) + FormatCodes.print( f"{start}{spaces_l}[bg:{box_bg_color}]{pady}[*]\n" + "\n".join(lines) + f"\n{spaces_l}[bg:{box_bg_color}]{pady}[_]", @@ -707,27 +836,71 @@ def log_box_bordered( 9. left horizontal rule connector 10. horizontal rule 11. right horizontal rule connector""" + if not isinstance(start, str): + raise TypeError(f"The 'start' parameter must be a string, got {type(start)}") + # THE 'end' PARAM IS CHECKED IN 'FormatCodes.print()' + if not isinstance(border_type, str): + raise TypeError(f"The 'border_type' parameter must be a string, got {type(border_type)}") + elif border_type not in {"standard", "rounded", "strong", "double"} and _border_chars is None: + raise ValueError( + f"The 'border_type' parameter must be one of 'standard', 'rounded', 'strong', or 'double', got '{border_type!r}'" + ) + if not (isinstance(border_style, str) or Color.is_valid_rgba(border_style) or Color.is_valid_hexa(border_style)): + raise TypeError(f"The 'border_style' parameter must be a string, Rgba, or Hexa color, got {type(border_style)}") + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.print()' + if not isinstance(w_padding, int): + raise TypeError(f"The 'w_padding' parameter must be an integer, got {type(w_padding)}") + elif w_padding < 0: + raise ValueError("The 'w_padding' parameter must be a non-negative integer.") + if not isinstance(w_full, bool): + raise TypeError(f"The 'w_full' parameter must be a boolean, got {type(w_full)}") + if not isinstance(indent, int): + raise TypeError(f"The 'indent' parameter must be an integer, got {type(indent)}") + elif indent < 0: + raise ValueError("The 'indent' parameter must be a non-negative integer.") + if _border_chars is not None: + if not isinstance(_border_chars, tuple): + raise TypeError(f"The '_border_chars' parameter must be a tuple, got {type(_border_chars)}") + elif len(_border_chars) != 11: + raise ValueError(f"The '_border_chars' parameter must contain exactly 11 characters, got {len(_border_chars)}") + for i, char in enumerate(_border_chars): + if not isinstance(char, str): + raise TypeError( + f"All elements of '_border_chars' must be strings, but element {i} is of type {type(char)}" + ) + elif len(char) != 1: + raise ValueError( + f"All elements of '_border_chars' must be single characters, but element {i} is '{char}' with length {len(char)}" + ) + + if border_style is not None and Color.is_valid(border_style): + border_style = Color.to_hexa(border_style) + borders = { - "standard": ('┌', '─', '┐', '│', '┘', '─', '└', '│', '├', '─', '┤'), - "rounded": ('╭', '─', '╮', '│', '╯', '─', '╰', '│', '├', '─', '┤'), - "strong": ('┏', '━', '┓', '┃', '┛', '━', '┗', '┃', '┣', '━', '┫'), - "double": ('╔', '═', '╗', '║', '╝', '═', '╚', '║', '╠', '═', '╣'), + "standard": ("┌", "─", "┐", "│", "┘", "─", "└", "│", "├", "─", "┤"), + "rounded": ("╭", "─", "╮", "│", "╯", "─", "╰", "│", "├", "─", "┤"), + "strong": ("┏", "━", "┓", "┃", "┛", "━", "┗", "┃", "┣", "━", "┫"), + "double": ("╔", "═", "╗", "║", "╝", "═", "╚", "║", "╠", "═", "╣"), } border_chars = borders.get(border_type, borders["standard"]) if _border_chars is None else _border_chars + lines, unfmt_lines, max_line_len = Console.__prepare_log_box(values, default_color, has_rules=True) - pad_w_full = (Console.w - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0 - if border_style is not None and Color.is_valid(border_style): - border_style = Color.to_hexa(border_style) + spaces_l = " " * indent + pad_w_full = (Console.w - (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] * (Console.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] * (Console.w - (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] * (Console.w - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[10]}[_]" + lines = [ h_rule if _COMPILED["hr"].match(line) else f"{spaces_l}{border_l}{' ' * w_padding}{line}[_]" + " " * ((w_padding + max_line_len - len(unfmt)) + pad_w_full) + border_r for line, unfmt in zip(lines, unfmt_lines) ] + FormatCodes.print( f"{start}{border_t}[_]\n" + "\n".join(lines) + f"\n{border_b}[_]", default_color=default_color, @@ -748,8 +921,8 @@ def __prepare_log_box( val_str, result_parts, current_pos = str(val), [], 0 for match in _COMPILED["hr"].finditer(val_str): start, end = match.span() - should_split_before = start > 0 and val_str[start - 1] != '\n' - should_split_after = end < len(val_str) and val_str[end] != '\n' + should_split_before = start > 0 and val_str[start - 1] != "\n" + should_split_after = end < len(val_str) and val_str[end] != "\n" if should_split_before: if start > current_pos: @@ -782,27 +955,35 @@ def __prepare_log_box( @staticmethod def confirm( prompt: object = "Do you want to continue?", - start="", - end="", + start: str = "", + end: str = "", default_color: Optional[Rgba | Hexa] = None, default_is_yes: bool = True, ) -> 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 - --------------------------------------------------------------------------------------- + ------------------------------------------------------------------------------------ The prompt can be formatted with special formatting codes. For more detailed information about formatting codes, see the `format_codes` module documentation.""" + if not isinstance(start, str): + raise TypeError(f"The 'start' parameter must be a string, got {type(start)}") + # THE 'end' PARAM IS CHECKED IN 'FormatCodes.print()' + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.print()' + if not isinstance(default_is_yes, bool): + raise TypeError(f"The 'default_is_yes' parameter must be a boolean, got {type(default_is_yes)}") + confirmed = input( FormatCodes.to_ansi( - f'{start}{str(prompt)} [_|dim](({"Y" if default_is_yes else "y"}/{"n" if default_is_yes else "N"}): )', + 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")) + ).strip().lower() in ({"", "y", "yes"} if default_is_yes else {"y", "yes"}) + if end: FormatCodes.print(end, end="") return confirmed @@ -810,12 +991,12 @@ def confirm( @staticmethod def multiline_input( prompt: object = "", - start="", - end="\n", + start: str = "", + end: str = "\n", default_color: Optional[Rgba | Hexa] = None, - show_keybindings=True, - input_prefix=" ⮡ ", - reset_ansi=True, + show_keybindings: bool = True, + input_prefix: str = " ⮡ ", + reset_ansi: bool = True, ) -> str: """An input where users can write (and paste) text over multiple lines.\n --------------------------------------------------------------------------------------- @@ -829,6 +1010,17 @@ def multiline_input( --------------------------------------------------------------------------------------- The input prompt can be formatted with special formatting codes. For more detailed information about formatting codes, see the `format_codes` module documentation.""" + if not isinstance(start, str): + raise TypeError(f"The 'start' parameter must be a string, got {type(start)}") + # THE 'end' PARAM IS CHECKED IN 'FormatCodes.print()' + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.print()' + if not isinstance(show_keybindings, bool): + raise TypeError(f"The 'show_keybindings' parameter must be a boolean, got {type(show_keybindings)}") + if not isinstance(input_prefix, str): + raise TypeError(f"The 'input_prefix' parameter must be a string, got {type(input_prefix)}") + if not isinstance(reset_ansi, bool): + raise TypeError(f"The 'reset_ansi' parameter must be a boolean, got {type(reset_ansi)}") + kb = KeyBindings() @kb.add("c-d", eager=True) # CTRL+D @@ -840,6 +1032,7 @@ def _(event): FormatCodes.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) + return input_string T = TypeVar("T") @@ -847,8 +1040,8 @@ def _(event): @staticmethod def input( prompt: object = "", - start="", - end="", + start: str = "", + end: str = "", default_color: Optional[Rgba | Hexa] = None, placeholder: Optional[str] = None, mask_char: Optional[str] = None, @@ -880,10 +1073,43 @@ def input( ------------------------------------------------------------------------------------ The input prompt can be formatted with special formatting codes. For more detailed information about formatting codes, see the `format_codes` module documentation.""" - result_text = "" - tried_pasting = False - filtered_chars = set() + if not isinstance(start, str): + raise TypeError(f"The 'start' parameter must be a string, got {type(start)}") + # THE 'end' PARAM IS CHECKED IN 'FormatCodes.print()' + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.print()' + if placeholder is not None and not isinstance(placeholder, str): + raise TypeError(f"The 'placeholder' parameter must be a string or None, got {type(placeholder)}") + if mask_char is not None: + if not isinstance(mask_char, str): + raise TypeError(f"The 'mask_char' parameter must be a string or None, got {type(mask_char)}") + elif 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: + if not isinstance(min_len, int): + raise TypeError(f"The 'min_len' parameter must be an integer or None, got {type(min_len)}") + elif min_len < 0: + raise ValueError("The 'min_len' parameter must be a non-negative integer.") + if max_len is not None: + if not isinstance(max_len, int): + raise TypeError(f"The 'max_len' parameter must be an integer or None, got {type(max_len)}") + elif max_len < 0: + raise ValueError("The 'max_len' parameter must be a non-negative integer.") + if not (allowed_chars == CHARS.ALL or isinstance(allowed_chars, str)): + raise TypeError(f"The 'allowed_chars' parameter must be a string, got {type(allowed_chars)}") + if not isinstance(allow_paste, bool): + raise TypeError(f"The 'allow_paste' parameter must be a boolean, got {type(allow_paste)}") + if validator is not None and not callable(validator): + raise TypeError(f"The 'validator' parameter must be a callable function or None, got {type(validator)}") + if default_val is not None and not isinstance(default_val, output_type): + raise TypeError( + f"The 'default_val' parameter must be of type {output_type.__name__} or None, got {type(default_val)}" + ) + if not isinstance(output_type, type): + raise TypeError(f"The 'output_type' parameter must be a type, got {type(output_type)}") + + filtered_chars, result_text = set(), "" has_default = default_val is not None + tried_pasting = False class InputValidator(Validator): @@ -891,7 +1117,7 @@ def validate(self, document) -> None: text_to_validate = result_text if mask_char else document.text if min_len and len(text_to_validate) < min_len: raise ValidationError(message="", cursor_position=len(document.text)) - if validator and validator(text_to_validate) not in ("", None): + if validator and validator(text_to_validate) not in {"", None}: raise ValidationError(message="", cursor_position=len(document.text)) def bottom_toolbar() -> _pt.formatted_text.ANSI: @@ -905,7 +1131,7 @@ def bottom_toolbar() -> _pt.formatted_text.ANSI: toolbar_msgs = [] if max_len and len(text_to_check) > max_len: toolbar_msgs.append("[b|#FFF|bg:red]( Text too long! )") - if validator and text_to_check and (validation_error_msg := validator(text_to_check)) not in ("", None): + if validator and text_to_check and (validation_error_msg := validator(text_to_check)) not in {"", None}: toolbar_msgs.append(f"[b|#000|bg:br:red] {validation_error_msg} [_bg]") if filtered_chars: plural = "" if len(char_list := "".join(sorted(filtered_chars))) == 1 else "s" @@ -1012,7 +1238,7 @@ def _(event: KeyPressEvent) -> None: def _(event: KeyPressEvent) -> None: insert_text_event(event) - custom_style = Style.from_dict({'bottom-toolbar': 'noreverse'}) + custom_style = Style.from_dict({"bottom-toolbar": "noreverse"}) session = _pt.PromptSession( message=_pt.formatted_text.ANSI(FormatCodes.to_ansi(str(prompt), default_color=default_color)), validator=InputValidator(), @@ -1027,7 +1253,7 @@ def _(event: KeyPressEvent) -> None: session.prompt() FormatCodes.print(end, end="") - if result_text in ("", None): + if result_text in {"", None}: if has_default: return default_val result_text = "" @@ -1118,12 +1344,19 @@ def set_width(self, min_width: Optional[int] = None, max_width: Optional[int] = - `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("Minimum width must be at least 1.") + if not isinstance(min_width, int): + raise TypeError(f"The 'min_width' parameter must be an integer or None, got {type(min_width)}") + elif min_width < 1: + raise ValueError(f"The 'min_width' parameter must be a positive integer, got {min_width!r}") + self.min_width = max(1, min_width) + if max_width is not None: - if max_width < 1: - raise ValueError("Maximum width must be at least 1.") + if not isinstance(max_width, int): + raise TypeError(f"The 'max_width' parameter must be an integer or None, got {type(max_width)}") + elif max_width < 1: + raise ValueError(f"The 'max_width' parameter must be a positive integer, got {max_width!r}") + self.max_width = max(self.min_width, max_width) def set_bar_format(self, bar_format: Optional[str] = None, limited_bar_format: Optional[str] = None) -> None: @@ -1139,13 +1372,18 @@ def set_bar_format(self, bar_format: Optional[str] = None, limited_bar_format: O -------------------------------------------------------------------------------------------------- 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 not isinstance(bar_format, (str, type(None))): + raise TypeError(f"The 'bar_format' parameter must be a string or None, got {type(bar_format)}") + if not isinstance(limited_bar_format, (str, type(None))): + raise TypeError(f"The 'limited_bar_format' parameter must be a string or None, got {type(limited_bar_format)}") + if bar_format is not None: if not _COMPILED["bar"].search(bar_format): - raise ValueError("'bar_format' must contain the '{bar}' or '{b}' placeholder.") + raise ValueError("The 'bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.") self.bar_format = bar_format if limited_bar_format is not None: if not _COMPILED["bar"].search(limited_bar_format): - raise ValueError("'limited_bar_format' must contain the '{bar}' or '{b}' placeholder.") + raise ValueError("The 'limited_bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.") self.limited_bar_format = limited_bar_format def set_chars(self, chars: tuple[str, ...]) -> None: @@ -1156,9 +1394,10 @@ 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("'chars' must contain at least two characters (full and empty).") - if not all(len(c) == 1 for c in chars if isinstance(c, str)): - raise ValueError("All 'chars' items must be single-character strings.") + raise ValueError("The 'chars' parameter must contain at least two characters (full and empty).") + elif not all(isinstance(c, str) and len(c) == 1 for c in chars): + raise ValueError("All elements of 'chars' must be single-character strings.") + self.chars = chars def show_progress(self, current: int, total: int, label: Optional[str] = None) -> None: @@ -1167,8 +1406,16 @@ def show_progress(self, current: int, total: int, label: Optional[str] = None) - - `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""" - if total <= 0: - raise ValueError("Total must be greater than 0.") + if not isinstance(current, int): + raise TypeError(f"The 'current' parameter must be an integer, got {type(current)}") + elif current < 0: + raise ValueError("The 'current' parameter must be a non-negative integer.") + if not isinstance(total, int): + raise TypeError(f"The 'total' parameter must be an integer, got {type(total)}") + elif total <= 0: + raise ValueError("The 'total' parameter must be a positive integer.") + if label is not None and not isinstance(label, str): + raise TypeError(f"The 'label' parameter must be a string or None, got {type(label)}") try: if not self.active: @@ -1213,8 +1460,14 @@ def progress_context(self, total: int, label: Optional[str] = None) -> Generator # Do some work... update_progress(i, f"Finalizing ({i})") # Update both ```""" - current_progress = 0 - current_label = label + if not isinstance(total, int): + raise TypeError(f"The 'total' parameter must be an integer, got {type(total)}") + elif total <= 0: + raise ValueError("The 'total' parameter must be a positive integer.") + if label is not None and not isinstance(label, str): + raise TypeError(f"The 'label' parameter must be a string or None, got {type(label)}") + + current_label, current_progress = label, 0 try: @@ -1224,7 +1477,7 @@ def update_progress(*args, **kwargs) -> None: # TYPE HINTS DEFINED IN '_Progres current = label = None if len(args) > 2: - raise TypeError(f"update_progress() takes at most 2 positional arguments ({len(args)} given)") + raise TypeError(f"update_progress() takes at most 2 positional arguments, got {len(args)}") elif len(args) >= 1: current = args[0] if len(args) >= 2: @@ -1232,18 +1485,18 @@ def update_progress(*args, **kwargs) -> None: # TYPE HINTS DEFINED IN '_Progres if "current" in kwargs: if current is not None: - raise TypeError("update_progress() got multiple values for argument 'current'") + raise TypeError("update_progress() got multiple values for argument 'current'.") current = kwargs["current"] if "label" in kwargs: if label is not None: - raise TypeError("update_progress() got multiple values for argument 'label'") + raise TypeError("update_progress() got multiple values for argument 'label'.") label = kwargs["label"] if unexpected := set(kwargs.keys()) - {"current", "label"}: - raise TypeError(f"update_progress() got unexpected keyword argument(s): {', '.join(unexpected)}") + raise TypeError(f"update_progress() got unexpected keyword arguments: {', '.join(unexpected)}") if current is None and label is None: - raise TypeError("At least one of 'current' or 'label' must be provided") + raise TypeError("Either the keyword argument 'current' or 'label' must be provided.") if current is not None: current_progress = current diff --git a/src/xulbux/data.py b/src/xulbux/data.py index c8e01fb..3783914 100644 --- a/src/xulbux/data.py +++ b/src/xulbux/data.py @@ -1,37 +1,52 @@ +""" +This module provides the `Data` class, which offers +methods to work with nested data structures. +""" + +from .base.types import DataStructure, IndexIterable from .base.consts import COLOR + from .format_codes import FormatCodes from .string import String +from .regex import Regex -from typing import TypeAlias, Optional, Union, Any +from typing import Optional, Any import base64 as _base64 import math as _math import re as _re -DataStructure: TypeAlias = Union[list, tuple, set, frozenset, dict] -IndexIterable: TypeAlias = Union[list, tuple, set, frozenset] - - class Data: + """This class includes methods to work with nested data structures (dictionaries and lists).""" @staticmethod def serialize_bytes(data: bytes | bytearray) -> dict[str, str]: - """Converts bytes or bytearray to a JSON-compatible format (dictionary) with explicit keys.""" - if isinstance(data, (bytes, bytearray)): - key = "bytearray" if isinstance(data, bytearray) else "bytes" - try: - return {key: data.decode("utf-8"), "encoding": "utf-8"} - except UnicodeDecodeError: - pass - return {key: _base64.b64encode(data).decode("utf-8"), "encoding": "base64"} - raise TypeError(f"Unsupported data type '{type(data)}'") + """Converts bytes or bytearray to a JSON-compatible format (dictionary) with explicit keys.\n + ---------------------------------------------------------------------------------------------- + - `data` -⠀the bytes or bytearray to serialize""" + if not isinstance(data, (bytes, bytearray)): + raise TypeError(f"The 'data' parameter must be a bytes or bytearray object, got {type(data)}") + + key = "bytearray" if isinstance(data, bytearray) else "bytes" + + try: + return {key: data.decode("utf-8"), "encoding": "utf-8"} + except UnicodeDecodeError: + pass + + return {key: _base64.b64encode(data).decode("utf-8"), "encoding": "base64"} @staticmethod def deserialize_bytes(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 + -------------------------------------------------------------------------------------------------------- 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`.""" + if not isinstance(obj, dict): + raise TypeError(f"The 'obj' parameter must be a dictionary, got {type(obj)}") + for key in ("bytes", "bytearray"): if key in obj and "encoding" in obj: if obj["encoding"] == "utf-8": @@ -40,72 +55,109 @@ def deserialize_bytes(obj: dict[str, str]) -> bytes | bytearray: data = _base64.b64decode(obj[key].encode("utf-8")) else: raise ValueError(f"Unknown encoding method '{obj['encoding']}'") + return bytearray(data) if key == "bytearray" else data - raise ValueError(f"Invalid serialized data: {obj}") + + raise ValueError(f"Invalid serialized data:\n {obj}") @staticmethod def chars_count(data: DataStructure) -> int: - """The sum of all the characters amount including the keys in dictionaries.""" + """The sum of all the characters amount including the keys in dictionaries.\n + ------------------------------------------------------------------------------ + - `data` -⠀the data structure to count the characters from""" + if not isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + chars_count = 0 + if isinstance(data, dict): for k, v in data.items(): chars_count += len(str(k)) + (Data.chars_count(v) if isinstance(v, DataStructure) else len(str(v))) + elif isinstance(data, IndexIterable): for item in data: chars_count += Data.chars_count(item) if isinstance(item, DataStructure) else len(str(item)) + return chars_count @staticmethod def strip(data: DataStructure) -> DataStructure: - """Removes leading and trailing whitespaces from the data structure's items.""" + """Removes leading and trailing whitespaces from the data structure's items.\n + ------------------------------------------------------------------------------- + - `data` -⠀the data structure to strip the items from""" + if not isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + if isinstance(data, dict): return {k.strip(): Data.strip(v) if isinstance(v, DataStructure) else v.strip() for k, v in data.items()} + if isinstance(data, IndexIterable): return type(data)(Data.strip(item) if isinstance(item, DataStructure) else item.strip() for item in data) - return data + + raise TypeError(f"Unsupported data structure type: {type(data)}") @staticmethod def remove_empty_items(data: DataStructure, spaces_are_empty: bool = False) -> DataStructure: - """Removes empty items from the data structure. - If `spaces_are_empty` is true, it will count items with only spaces as empty.""" + """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""" + if not isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + if isinstance(data, dict): return { k: (v if not isinstance(v, DataStructure) else Data.remove_empty_items(v, spaces_are_empty)) for k, v in data.items() if not String.is_empty(v, spaces_are_empty) } + if isinstance(data, IndexIterable): return type(data)( item for item in - ((item if not isinstance(item, DataStructure) else Data.remove_empty_items(item, spaces_are_empty)) - for item in data if not String.is_empty(item, spaces_are_empty)) + ( + (item if not isinstance(item, DataStructure) else Data.remove_empty_items(item, spaces_are_empty)) \ + for item in data if not (isinstance(item, (str, type(None))) and String.is_empty(item, spaces_are_empty)) + ) if item not in ([], (), {}, set(), frozenset()) ) - return data + + raise TypeError(f"Unsupported data structure type: {type(data)}") @staticmethod def remove_duplicates(data: DataStructure) -> DataStructure: - """Removes all duplicates from the data structure.""" + """Removes all duplicates from the data structure.\n + ----------------------------------------------------------- + - `data` -⠀the data structure to remove duplicates from""" + if not isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + if isinstance(data, dict): return {k: Data.remove_duplicates(v) if isinstance(v, DataStructure) else v for k, v in data.items()} + if isinstance(data, (list, tuple)): result = [] for item in data: processed_item = Data.remove_duplicates(item) if isinstance(item, DataStructure) else item is_duplicate = False + for existing_item in result: if processed_item == existing_item: is_duplicate = True break + if not is_duplicate: result.append(processed_item) + return type(data)(result) + if isinstance(data, (set, frozenset)): processed_elements = set() for item in data: processed_item = Data.remove_duplicates(item) if isinstance(item, DataStructure) else item processed_elements.add(processed_item) return type(data)(processed_elements) - return data + + raise TypeError(f"Unsupported data structure type: {type(data)}") @staticmethod def remove_comments( @@ -115,12 +167,12 @@ def remove_comments( comment_sep: str = "", ) -> DataStructure: """Remove comments from a list, tuple or dictionary.\n - ---------------------------------------------------------------------------------------------------------------------- - - The `data` parameter is your list, tuple or dictionary, where the comments should get removed from. - - The `comment_start` parameter is the string that marks the start of a comment inside `data`. (default: `>>`) - - The `comment_end` parameter is the string that marks the end of a comment inside `data`. (default: `<<`) - - The `comment_sep` parameter is a 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\n + --------------------------------------------------------------------------------------------------------------- Examples: ```python data = { @@ -128,49 +180,64 @@ def remove_comments( ">> COMMENT IN THE BEGINNING OF THE STRING << value1", "value2 >> COMMENT IN THE END OF THE STRING", "val>> COMMENT IN THE MIDDLE OF THE STRING <> FULL VALUE IS A COMMENT value4" + ">> FULL VALUE IS A COMMENT value4", ], ">> FULL KEY + ALL ITS VALUES ARE A COMMENT key2": [ "value", "value", - "value" + "value", ], - "key3": ">> ALL THE KEYS VALUES ARE COMMENTS value" + "key3": ">> ALL THE KEYS VALUES ARE COMMENTS value", } processed_data = Data.remove_comments( data, comment_start=">>", comment_end="<<", - comment_sep="__" + comment_sep="__", ) ```\n - ---------------------------------------------------------------------------------------------------------------------- + --------------------------------------------------------------------------------------------------------------- For this example, `processed_data` will be: ```python { "key1": [ "value1", "value2", - "val__ue3" + "val__ue3", ], - "key3": None + "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. + * `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 comment_end: - pattern = _re.compile( - rf"^((?:(?!{_re.escape(comment_start)}).)*){_re.escape(comment_start)}(?:(?:(?!{_re.escape(comment_end)}).)*)(?:{_re.escape(comment_end)})?(.*?)$" + if not isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + if not isinstance(comment_start, str): + raise TypeError(f"The 'comment_start' parameter must be a string, got {type(comment_start)}") + elif len(comment_start) == 0: + raise ValueError("The 'comment_start' parameter must not be an empty string.") + if not isinstance(comment_end, str): + raise TypeError(f"The 'comment_end' parameter must be a string, got {type(comment_end)}") + if not isinstance(comment_sep, str): + raise TypeError(f"The 'comment_sep' parameter must be a string, got {type(comment_sep)}") + + pattern = _re.compile(Regex._clean( \ + rf"""^( + (?:(?!{_re.escape(comment_start)}).)* ) + {_re.escape(comment_start)} + (?:(?:(?!{_re.escape(comment_end)}).)*) + (?:{_re.escape(comment_end)})? + (.*?)$""" + )) if len(comment_end) > 0 else None def process_string(s: str) -> Optional[str]: - if comment_end: - match = pattern.match(s) # type: ignore[unbound] - if match: + if pattern: + if (match := pattern.match(s)): start, end = match.group(1).strip(), match.group(2).strip() return f"{start}{comment_sep if start and end else ''}{end}" or None return s.strip() or None @@ -203,18 +270,35 @@ 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 - -------------------------------------------------------------------------------------------- - Ignores the specified (found) key/s or item/s from `ignore_paths`. Comments are not ignored - when comparing. `comment_start` and `comment_end` are only used to correctly recognize the - keys in the `ignore_paths`.\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 - the function `Data.get_path_id()`. See its documentation for more details.""" + the method `Data.get_path_id()`. See its documentation for more details.""" + if not isinstance(data1, DataStructure): + raise TypeError(f"The 'data1' parameter must be a data structure, got {type(data1)}") + if not isinstance(data2, DataStructure): + raise TypeError(f"The 'data2' parameter must be a data structure, got {type(data2)}") + if not isinstance(ignore_paths, (str, list)): + raise TypeError(f"The 'ignore_paths' parameter must be a string or list of strings, got {type(ignore_paths)}") + if not isinstance(path_sep, str): + raise TypeError(f"The 'path_sep' parameter must be a string, got {type(path_sep)}") + elif len(path_sep) == 0: + raise ValueError("The 'path_sep' parameter must not be an empty string.") + # THE 'comment_start' PARAM IS CHECKED IN 'Data.remove_comments()' + # THE 'comment_end' PARAM IS CHECKED IN 'Data.remove_comments()' def process_ignore_paths(ignore_paths: str | list[str], ) -> list[list[str]]: if isinstance(ignore_paths, str): ignore_paths = [ignore_paths] - return [path.split(path_sep) for path in ignore_paths if path] + return [str(path).split(path_sep) for path in ignore_paths if path] def compare( d1: DataStructure, @@ -244,6 +328,7 @@ def compare( processed_data1 = Data.remove_comments(data1, comment_start, comment_end) processed_data2 = Data.remove_comments(data2, comment_start, comment_end) processed_ignore_paths = process_ignore_paths(ignore_paths) + return compare(processed_data1, processed_data2, processed_ignore_paths) @staticmethod @@ -256,9 +341,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 - ------------------------------------------------------------------------------------------------- - The `data` parameter is the list, tuple, or dictionary, which the id should be generated for.\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 + -------------------------------------------------------------------------------------------------- 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 @@ -271,27 +362,31 @@ 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`.\n - ------------------------------------------------------------------------------------------------- - The comments marked with `comment_start` and `comment_end` will be removed, before trying to get - the path id.\n - ------------------------------------------------------------------------------------------------- - The `path_sep` param is the separator between the keys/indexes in the path (default is `->` just - like in the example above).\n - ------------------------------------------------------------------------------------------------- - If `ignore_not_found` is `True`, the function will return `None` if the value is not found - instead of raising an error.""" + index of the value, so `healthy->fruit->0`.""" + if not isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + if not isinstance(value_paths, (str, list)): + raise TypeError(f"The 'value_paths' parameter must be a string or list of strings, got {type(value_paths)}") + if not isinstance(path_sep, str): + raise TypeError(f"The 'path_sep' parameter must be a string, got {type(path_sep)}") + elif len(path_sep) == 0: + raise ValueError("The 'path_sep' parameter must not be an empty string.") + # THE 'comment_start' PARAM IS CHECKED IN 'Data.remove_comments()' + # THE 'comment_end' PARAM IS CHECKED IN 'Data.remove_comments()' + if not isinstance(ignore_not_found, bool): + raise TypeError(f"The 'ignore_not_found' parameter must be a boolean, got {type(ignore_not_found)}") def process_path(path: str, data_obj: DataStructure) -> Optional[str]: keys = path.split(path_sep) - path_ids = [] - max_id_length = 0 + path_ids, max_id_length = [], 0 + for key in keys: if isinstance(data_obj, dict): if key.isdigit(): if ignore_not_found: return None raise TypeError(f"Key '{key}' is invalid for a dict type.") + try: idx = list(data_obj.keys()).index(key) data_obj = data_obj[key] @@ -299,6 +394,7 @@ def process_path(path: str, data_obj: DataStructure) -> Optional[str]: if ignore_not_found: return None raise KeyError(f"Key '{key}' not found in dict.") + elif isinstance(data_obj, IndexIterable): try: idx = int(key) @@ -311,10 +407,13 @@ def process_path(path: str, data_obj: DataStructure) -> Optional[str]: if ignore_not_found: return None raise ValueError(f"Value '{key}' not found in '{type(data_obj).__name__}'") + else: break + path_ids.append(str(idx)) max_id_length = max(max_id_length, len(str(idx))) + if not path_ids: return None return f"{max_id_length}>{''.join(id.zfill(max_id_length) for id in path_ids)}" @@ -322,18 +421,24 @@ def process_path(path: str, data_obj: DataStructure) -> Optional[str]: data = Data.remove_comments(data, comment_start, comment_end) if isinstance(value_paths, str): return process_path(value_paths, data) + results = [process_path(path, data) for path in value_paths] return results if len(results) > 1 else results[0] if results else None @staticmethod def get_value_by_path_id(data: DataStructure, path_id: str, get_key: bool = False) -> Any: - """Retrieves the value from `data` using the provided `path_id`.\n - ------------------------------------------------------------------------------------------------- - Input your `data` along with a `path_id` that was created before using `Data.get_path_id()`. - If `get_key` is true and the final item is in a dict, it returns the key instead of the value.\n - ------------------------------------------------------------------------------------------------- - The function will return the value (or key) from the path ID location, as long as the structure - of `data` hasn't changed since creating the path ID to that 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""" + if not isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + if not isinstance(path_id, str): + raise TypeError(f"The 'path_id' parameter must be a string, got {type(path_id)}") + if not isinstance(get_key, bool): + raise TypeError(f"The 'get_key' parameter must be a boolean, got {type(get_key)}") def get_nested(data: DataStructure, path: list[int], get_key: bool) -> Any: parent = None @@ -344,30 +449,38 @@ def get_nested(data: DataStructure, path: list[int], get_key: bool) -> Any: return keys[idx] parent = data data = data[keys[idx]] + elif isinstance(data, IndexIterable): if i == len(path) - 1 and get_key: if parent is None or not isinstance(parent, dict): - raise ValueError("Cannot get key from a non-dict parent") + raise ValueError(f"Cannot get key from a non-dict parent at path '{path[:i+1]}'") return next(key for key, value in parent.items() if value is data) parent = data data = list(data)[idx] # CONVERT TO LIST FOR INDEXING + else: raise TypeError(f"Unsupported type '{type(data)}' at path '{path[:i+1]}'") + return data return get_nested(data, Data.__sep_path_id(path_id), get_key) @staticmethod def set_value_by_path_id(data: DataStructure, update_values: dict[str, Any]) -> DataStructure: - """Updates the value/s from `update_values` in the `data`.\n - -------------------------------------------------------------------------------- - Input a list, tuple or dict as `data`, along with `update_values`, which is a - dictionary where keys are path IDs and values are the new values to insert: - { "1>012": "new value", "1>31": ["new value 1", "new value 2"], ... } - The path IDs should have been created using `Data.get_path_id()`.\n - -------------------------------------------------------------------------------- - The value from path ID will be changed to the new value, as long as the - structure of `data` hasn't changed since creating the path ID to that value.""" + """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 isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + if not isinstance(update_values, dict): + raise TypeError(f"The 'update_values' parameter must be a dictionary, got {type(update_values)}") def update_nested(data: DataStructure, path: list[int], value: Any) -> DataStructure: if len(path) == 1: @@ -390,10 +503,12 @@ def update_nested(data: DataStructure, path: list[int], value: Any) -> DataStruc valid_entries = [(path_id, new_val) for path_id, new_val in update_values.items()] if not valid_entries: - raise ValueError(f"No valid 'update_values' found in dictionary: {update_values}") + raise ValueError(f"No valid 'update_values' found in dictionary:\n{update_values!r}") + for path_id, new_val in valid_entries: path = Data.__sep_path_id(path_id) data = update_nested(data, path, new_val) + return data @staticmethod @@ -407,21 +522,50 @@ def to_str( _syntax_highlighting: dict[str, str] | bool = False, ) -> str: """Get nicely formatted data structure-strings.\n - ------------------------------------------------------------------------------ - The indentation spaces-amount can be set with with `indent`. + ------------------------------------------------------------------------------------------------- + - `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) + - `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\n + ------------------------------------------------------------------------------------------------- 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 - ------------------------------------------------------------------------------ - If `as_json` is set to `True`, the output will be in valid JSON format.""" + - `2` keeps everything collapsed (all on one line)""" + if not isinstance(data, DataStructure): + raise TypeError(f"The 'data' parameter must be a data structure, got {type(data)}") + if not isinstance(indent, int): + raise TypeError(f"The 'indent' parameter must be an integer, got {type(indent)}") + elif indent < 0: + raise ValueError("The 'indent' parameter must be a non-negative integer.") + if not isinstance(compactness, int): + raise TypeError(f"The 'compactness' parameter must be an integer, got {type(compactness)}") + elif compactness not in {0, 1, 2}: + raise ValueError("The 'compactness' parameter must be 0, 1, or 2.") + if not isinstance(max_width, int): + raise TypeError(f"The 'max_width' parameter must be an integer, got {type(max_width)}") + elif max_width <= 0: + raise ValueError("The 'max_width' parameter must be a positive integer.") + if not isinstance(sep, str): + raise TypeError(f"The 'sep' parameter must be a string, got {type(sep)}") + if not isinstance(as_json, bool): + raise TypeError(f"The 'as_json' parameter must be a boolean, got {type(as_json)}") + if not isinstance(_syntax_highlighting, (dict, bool, type(None))): + raise TypeError( + f"The 'syntax_highlighting' parameter must be a dict, bool, or None, got {type(_syntax_highlighting)}" + ) + _syntax_hl = {} - if do_syntax_hl := _syntax_highlighting not in (None, False): + + if do_syntax_hl := _syntax_highlighting not in {None, False}: 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)}") + _syntax_hl = { "str": (f"[{COLOR.BLUE}]", "[_c]"), "number": (f"[{COLOR.MAGENTA}]", "[_c]"), @@ -430,10 +574,12 @@ def to_str( "punctuation": (f"[{COLOR.DARK_GRAY}]", "[_c]"), } _syntax_hl.update({ - k: (f"[{v}]", "[_]") if k in _syntax_hl and v not in ("", None) else ("", "") + k: (f"[{v}]", "[_]") if k in _syntax_hl and v not in {"", None} else ("", "") for k, v in _syntax_highlighting.items() }) + sep = f"{_syntax_hl['punctuation'][0]}{sep}{_syntax_hl['punctuation'][1]}" + punct_map = {"(": ("/(", "("), **{char: char for char in "'\":)[]{}"}} punct = { k: ((f"{_syntax_hl['punctuation'][0]}{v[0]}{_syntax_hl['punctuation'][1]}" if do_syntax_hl else v[1]) @@ -490,53 +636,44 @@ def should_expand(seq: IndexIterable) -> bool: return True if compactness == 2: return False + complex_types = (list, tuple, dict, set, frozenset) + ((bytes, bytearray) if as_json else ()) complex_items = sum(1 for item in seq if isinstance(item, complex_types)) + return ( complex_items > 1 or (complex_items == 1 and len(seq) > 1) or Data.chars_count(seq) + (len(seq) * len(sep)) > max_width ) def format_dict(d: dict, current_indent: int) -> str: - if not d or compactness == 2: - return ( - punct["{"] - + sep.join(f"{format_value(k)}{punct[':']} {format_value(v, current_indent)}" - for k, v in d.items()) + punct["}"] - ) - if not should_expand(list(d.values())): - return ( - punct["{"] - + sep.join(f"{format_value(k)}{punct[':']} {format_value(v, current_indent)}" - for k, v in d.items()) + punct["}"] - ) + if compactness == 2 or not d or not should_expand(list(d.values())): + return punct["{"] \ + + sep.join( + f"{format_value(k)}{punct[':']} {format_value(v, current_indent)}" + for k, v in d.items() + ) \ + + punct["}"] + items = [] for k, val in d.items(): formatted_value = format_value(val, current_indent) items.append(f"{' ' * (current_indent + indent)}{format_value(k)}{punct[':']} {formatted_value}") + return punct["{"] + "\n" + f"{sep}\n".join(items) + f"\n{' ' * current_indent}" + punct["}"] def format_sequence(seq, current_indent: int) -> str: if as_json: seq = list(seq) - if not seq or compactness == 2: - return ( - punct["["] + sep.join(format_value(item, current_indent) - for item in seq) + punct["]"] if isinstance(seq, list) else punct["("] - + sep.join(format_value(item, current_indent) for item in seq) + punct[")"] - ) - if not should_expand(seq): - return ( - punct["["] + sep.join(format_value(item, current_indent) - for item in seq) + punct["]"] if isinstance(seq, list) else punct["("] - + sep.join(format_value(item, current_indent) for item in seq) + punct[")"] - ) + + brackets = (punct["["], punct["]"]) if isinstance(seq, list) else (punct["("], punct[")"]) + + if compactness == 2 or not seq or not should_expand(seq): + return f"{brackets[0]}{sep.join(format_value(item, current_indent) for item in seq)}{brackets[1]}" + items = [format_value(item, current_indent) for item in seq] formatted_items = f"{sep}\n".join(f'{" " * (current_indent + indent)}{item}' for item in items) - if isinstance(seq, list): - return f"{punct['[']}\n{formatted_items}\n{' ' * current_indent}{punct[']']}" - else: - return f"{punct['(']}\n{formatted_items}\n{' ' * current_indent}{punct[')']}" + + return f"{brackets[0]}\n{formatted_items}\n{' ' * current_indent}{brackets[1]}" return _re.sub(r"\s+(?=\n)", "", format_dict(data, 0) if isinstance(data, dict) else format_sequence(data, 0)) @@ -552,20 +689,26 @@ def print( syntax_highlighting: dict[str, str] | bool = {}, ) -> None: """Print nicely formatted data structures.\n - ------------------------------------------------------------------------------ - The indentation spaces-amount can be set with with `indent`. + --------------------------------------------------------------------------------------------------------------- + - `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 + --------------------------------------------------------------------------------------------------------------- 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 - ------------------------------------------------------------------------------ - If `as_json` is set to `True`, the output will be in valid JSON format.\n - ------------------------------------------------------------------------------ - 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 `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 keys with their default values are: - `str: COLOR.BLUE` - `number: COLOR.MAGENTA` @@ -573,18 +716,30 @@ def print( - `type: "i|" + COLOR.LIGHT_BLUE` - `punctuation: COLOR.DARK_GRAY`\n For no syntax highlighting, set `syntax_highlighting` to `False` or `None`.\n - ------------------------------------------------------------------------------ - For more detailed information about formatting codes, see `format_codes` - module documentation.""" + --------------------------------------------------------------------------------------------------------------- + For more detailed information about formatting codes, see `format_codes` module documentation.""" FormatCodes.print( - Data.to_str(data, indent, compactness, max_width, sep, as_json, syntax_highlighting), + Data.to_str( + data=data, + indent=indent, + compactness=compactness, + max_width=max_width, + sep=sep, + as_json=as_json, + _syntax_highlighting=syntax_highlighting, + ), end=end, ) @staticmethod def __sep_path_id(path_id: str) -> list[int]: - if path_id.count(">") != 1: - raise ValueError(f"Invalid path ID '{path_id}'") - id_part_len = int(path_id.split(">")[0]) - path_ids_str = path_id.split(">")[1] - return [int(path_ids_str[i:i + id_part_len]) for i in range(0, len(path_ids_str), id_part_len)] + if len(split_id := path_id.split(">")) == 2: + id_part_len, path_id_parts = split_id + + if (id_part_len.isdigit() and path_id_parts.isdigit()): + id_part_len = int(id_part_len) + + if id_part_len > 0 and (len(path_id_parts) % id_part_len == 0): + return [int(path_id_parts[i:i + id_part_len]) for i in range(0, len(path_id_parts), id_part_len)] + + raise ValueError(f"Path ID '{path_id}' is an invalid format.") diff --git a/src/xulbux/env_path.py b/src/xulbux/env_path.py index 83b74cf..6f7206e 100644 --- a/src/xulbux/env_path.py +++ b/src/xulbux/env_path.py @@ -1,3 +1,8 @@ +""" +This module provides the `EnvPath` class, which includes +methods to work with the PATH environment variable. +""" + from .path import Path from typing import Optional @@ -6,89 +11,127 @@ class EnvPath: + """This class includes methods to work with the PATH environment variable.""" @staticmethod def paths(as_list: bool = False) -> str | list: - """Get the PATH environment variable.""" + """Get the PATH environment variable.\n + ------------------------------------------------------------------------------ + - `as_list` -⠀if true, returns the paths as a list; otherwise, as a string""" + if not isinstance(as_list, bool): + raise TypeError(f"The 'as_list' parameter must be a boolean, got {type(as_list)}") + paths = _os.environ.get("PATH", "") return paths.split(_os.pathsep) if as_list else paths @staticmethod def has_path(path: Optional[str] = None, cwd: bool = False, base_dir: bool = False) -> bool: - """Check if a path is present in the PATH environment variable.""" - if cwd: - path = _os.getcwd() - elif base_dir: - path = Path.script_dir - elif path is None: - raise ValueError("A path must be provided or either 'cwd' or 'base_dir' must be True.") - paths = EnvPath.paths(as_list=True) - return _os.path.normpath(path) in [_os.path.normpath(p) for p in paths] + """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""" + if not isinstance(path, (str, type(None))): + raise TypeError(f"The 'path' parameter must be a string or None, got {type(path)}") + if not isinstance(cwd, bool): + raise TypeError(f"The 'cwd' parameter must be a boolean, got {type(cwd)}") + if not isinstance(base_dir, bool): + raise TypeError(f"The 'base_dir' parameter must be a boolean, got {type(base_dir)}") + + return _os.path.normpath(EnvPath.__get(path, cwd, base_dir)) \ + in {_os.path.normpath(p) for p in EnvPath.paths(as_list=True)} @staticmethod def add_path(path: Optional[str] = None, cwd: bool = False, base_dir: bool = False) -> None: - """Add a path to the PATH environment variable.""" - path = EnvPath.__get(path, cwd, base_dir) - if not EnvPath.has_path(path): - EnvPath.__persistent(path, add=True) + """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""" + if not isinstance(path, (str, type(None))): + raise TypeError(f"The 'path' parameter must be a string or None, got {type(path)}") + if not isinstance(cwd, bool): + raise TypeError(f"The 'cwd' parameter must be a boolean, got {type(cwd)}") + if not isinstance(base_dir, bool): + raise TypeError(f"The 'base_dir' parameter must be a boolean, got {type(base_dir)}") + + if not EnvPath.has_path(path := EnvPath.__get(path, cwd, base_dir)): + EnvPath.__persistent(path) @staticmethod def remove_path(path: Optional[str] = None, cwd: bool = False, base_dir: bool = False) -> None: - """Remove a path from the PATH environment variable.""" - path = EnvPath.__get(path, cwd, base_dir) - if EnvPath.has_path(path): + """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""" + if not isinstance(path, (str, type(None))): + raise TypeError(f"The 'path' parameter must be a string or None, got {type(path)}") + if not isinstance(cwd, bool): + raise TypeError(f"The 'cwd' parameter must be a boolean, got {type(cwd)}") + if not isinstance(base_dir, bool): + raise TypeError(f"The 'base_dir' parameter must be a boolean, got {type(base_dir)}") + + if EnvPath.has_path(path := EnvPath.__get(path, cwd, base_dir)): EnvPath.__persistent(path, remove=True) @staticmethod def __get(path: Optional[str] = None, cwd: bool = False, base_dir: bool = False) -> str: - """Get and/or normalize the paths.\n + """Get and/or normalize the given path, CWD or base directory.\n ------------------------------------------------------------------------------------ Raise an error if no path is provided and neither `cwd` or `base_dir` is `True`.""" if cwd: - path = _os.getcwd() + if base_dir: + raise ValueError("Both 'cwd' and 'base_dir' cannot be True at the same time.") + path = Path.cwd elif base_dir: path = Path.script_dir - elif path is None: - raise ValueError("A path must be provided or either 'cwd' or 'base_dir' must be True.") + + if path is None: + raise ValueError("No path provided. Please provide a 'path' or set either 'cwd' or 'base_dir' to True.") + return _os.path.normpath(path) @staticmethod - def __persistent(path: str, add: bool = False, remove: bool = False) -> None: + def __persistent(path: str, remove: bool = False) -> None: """Add or remove a path from PATH persistently across sessions as well as the current session.""" - if add == remove: - raise ValueError("Either add or remove must be True, but not both.") + current_paths = list(EnvPath.paths(as_list=True)) path = _os.path.normpath(path) + if remove: current_paths = [p for p in current_paths if _os.path.normpath(p) != _os.path.normpath(path)] - elif add: + else: current_paths.append(path) + _os.environ["PATH"] = new_path = _os.pathsep.join(sorted(set(filter(bool, current_paths)))) - if _sys.platform == "win32": # Windows + + if _sys.platform == "win32": # WINDOWS try: import winreg as _winreg - key = _winreg.OpenKey( - _winreg.HKEY_CURRENT_USER, - "Environment", - 0, - _winreg.KEY_ALL_ACCESS, - ) + 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 ImportError: - print("Warning: Unable to make persistent changes on Windows.") - else: # UNIX-like (Linux/macOS) + except Exception as e: + raise RuntimeError(f"Failed to update PATH in registry:\n " + str(e).replace("\n", " \n")) + + else: # UNIX-LIKE (LINUX/macOS) shell_rc_file = _os.path.expanduser( - "~/.bashrc" if _os.path.exists(_os.path.expanduser("~/.bashrc")) else "~/.zshrc" + "~/.bashrc" if _os.path.exists(_os.path.expanduser("~/.bashrc")) \ + else "~/.zshrc" ) + with open(shell_rc_file, "r+") as f: content = f.read() f.seek(0) + if remove: - new_content = [line for line in content.splitlines() if not line.endswith(f':{path}"')] + new_content = [l for l in content.splitlines() if not l.endswith(f':{path}"')] f.write("\n".join(new_content)) else: f.write(f'{content.rstrip()}\n# Added by XulbuX\nexport PATH="{new_path}"\n') + f.truncate() + _os.system(f"source {shell_rc_file}") diff --git a/src/xulbux/file.py b/src/xulbux/file.py index 145c7b7..f684a6d 100644 --- a/src/xulbux/file.py +++ b/src/xulbux/file.py @@ -1,59 +1,87 @@ +""" +This module provides the `File` class, which includes +methods to work with files and file paths. +""" + +from .base.exceptions import SameContentFileExistsError from .string import String import os as _os -class SameContentFileExistsError(FileExistsError): - ... - - class File: + """This class includes methods to work with files and file paths.""" @staticmethod def rename_extension( - file: str, + file_path: str, new_extension: str, full_extension: bool = False, camel_case_filename: bool = False, ) -> str: """Rename the extension of a file.\n - -------------------------------------------------------------------------- - If `full_extension` is true, everything after the first dot in the - filename will be treated as the extension to replace (e.g. `.tar.gz`). - Otherwise, only the part after the last dot is replaced (e.g. `.gz`).\n - If the `camel_case_filename` parameter is true, the filename will be made - CamelCase in addition to changing the files extension.""" - normalized_file = _os.path.normpath(file) + ---------------------------------------------------------------------------- + - `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""" + if not isinstance(file_path, str): + raise TypeError(f"The 'file_path' parameter must be a string, got {type(file_path)}") + if not isinstance(new_extension, str): + raise TypeError(f"The 'new_extension' parameter must be a string, got {type(new_extension)}") + if not isinstance(full_extension, bool): + raise TypeError(f"The 'full_extension' parameter must be a boolean, got {type(full_extension)}") + if not isinstance(camel_case_filename, bool): + raise TypeError(f"The 'camel_case_filename' parameter must be a boolean, got {type(camel_case_filename)}") + + normalized_file = _os.path.normpath(file_path) directory, filename_with_ext = _os.path.split(normalized_file) + if full_extension: try: - first_dot_index = filename_with_ext.index('.') + first_dot_index = filename_with_ext.index(".") filename = filename_with_ext[:first_dot_index] except ValueError: filename = filename_with_ext else: filename, _ = _os.path.splitext(filename_with_ext) + if camel_case_filename: filename = String.to_camel_case(filename) - if new_extension and not new_extension.startswith('.'): - new_extension = '.' + new_extension + if new_extension and not new_extension.startswith("."): + new_extension = "." + new_extension + return _os.path.join(directory, f"{filename}{new_extension}") @staticmethod - def create(file: str, content: str = "", force: bool = False) -> str: + def create(file_path: str, content: str = "", force: bool = False) -> str: """Create a file with ot without content.\n - ---------------------------------------------------------------------- - The function will throw a `FileExistsError` if a file with the same - name already exists and a `SameContentFileExistsError` if a file with - the same name and content already exists. - To always overwrite the file, set the `force` parameter to `True`.""" - if _os.path.exists(file) and not force: - with open(file, "r", encoding="utf-8") as existing_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)\n + ------------------------------------------------------------------ + 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 not isinstance(file_path, str): + raise TypeError(f"The 'file_path' parameter must be a string, got {type(file_path)}") + if not isinstance(content, str): + raise TypeError(f"The 'content' parameter must be a string, got {type(content)}") + if not isinstance(force, bool): + raise TypeError(f"The 'force' parameter must be a boolean, got {type(force)}") + + if _os.path.exists(file_path) and not force: + with open(file_path, "r", encoding="utf-8") as existing_file: existing_content = existing_file.read() if existing_content == content: raise SameContentFileExistsError("Already created this file. (nothing changed)") raise FileExistsError("File already exists.") - with open(file, "w", encoding="utf-8") as f: + + with open(file_path, "w", encoding="utf-8") as f: f.write(content) - full_path = _os.path.abspath(file) - return full_path + + return _os.path.abspath(file_path) diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index ef9ceb5..3d734c0 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -1,13 +1,12 @@ """ -Methods to transform formatting codes to ANSI and use them for pretty console output: -- `FormatCodes.print()` (print a special format-codes containing string) -- `FormatCodes.input()` (input with a special format-codes containing prompt) -- `FormatCodes.to_ansi()` (transform all special format-codes into ANSI codes in a string)\n +This module provides the `FormatCodes` class, which offers methods to print and work with strings that +contain special formatting codes, which are then converted to ANSI codes for pretty console 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: -```regex +``` 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. ``` @@ -30,19 +29,19 @@ #### Auto Resetting Formatting Codes Certain formatting can automatically be reset, behind a certain amount of text, just like shown in the following example: -```regex +``` 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: -```regex +``` [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: -```regex +``` [cyan]This is cyan text, [u]/(which is underlined now.) And now it is still underlined and cyan. ``` @@ -155,10 +154,12 @@ All of these lighten/darken formatting codes are treated as invalid if no `default_color` is set. """ +from .base.types import Pattern, Match, Rgba, Hexa from .base.consts import ANSI + from .string import String -from .regex import Regex, Match, Pattern -from .color import Color, rgba, Rgba, Hexa +from .regex import Regex +from .color import Color, rgba from typing import Optional, Literal, cast import ctypes as _ctypes @@ -212,6 +213,8 @@ 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.""" @staticmethod def print( @@ -233,8 +236,18 @@ def print( -------------------------------------------------------------------------------------------------- For exact information about how to use special formatting codes, see the `format_codes` module documentation.""" + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.to_ansi()' + # THE 'brightness_steps' PARAM IS CHECKED IN 'FormatCodes.to_ansi()' + if not isinstance(sep, str): + raise TypeError(f"The 'sep' parameter must be a string, got {type(sep)}") + if not isinstance(end, str): + raise TypeError(f"The 'end' parameter must be a string, got {type(end)}") + if not isinstance(flush, bool): + raise TypeError(f"The 'flush' parameter must be a boolean, got {type(flush)}") + FormatCodes.__config_console() _sys.stdout.write(FormatCodes.to_ansi(sep.join(map(str, values)) + end, default_color, brightness_steps)) + if flush: _sys.stdout.flush() @@ -255,8 +268,14 @@ def input( -------------------------------------------------------------------------------------------------- For exact information about how to use special formatting codes, see the `format_codes` module documentation.""" + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.to_ansi()' + # THE 'brightness_steps' PARAM IS CHECKED IN 'FormatCodes.to_ansi()' + if not isinstance(reset_ansi, bool): + raise TypeError(f"The 'reset_ansi' parameter must be a boolean, got {type(reset_ansi)}") + FormatCodes.__config_console() user_input = input(FormatCodes.to_ansi(str(prompt), default_color, brightness_steps)) + if reset_ansi: _sys.stdout.write(f"{ANSI.CHAR}[0m") return user_input @@ -275,17 +294,29 @@ def to_ansi( - `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\n + - `_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, see the `format_codes` module documentation.""" if not isinstance(string, str): - string = str(string) + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.__validate_default_color()' + if not isinstance(brightness_steps, int): + raise TypeError(f"The 'brightness_steps' parameter must be an integer, got {type(brightness_steps)}") + elif not (0 < brightness_steps <= 100): + raise ValueError("The 'brightness_steps' parameter must be between 1 and 100.") + if not isinstance(_default_start, bool): + raise TypeError(f"The '_default_start' parameter must be a boolean, got {type(_default_start)}") + if not isinstance(_validate_default, bool): + raise TypeError(f"The '_validate_default' parameter must be a boolean, got {type(_validate_default)}") + if _validate_default: use_default, default_color = FormatCodes.__validate_default_color(default_color) else: use_default = default_color is not None default_color = cast(Optional[rgba], default_color) + if use_default: string = _COMPILED["*"].sub(r"[\1_|default\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|default|…]` else: @@ -298,8 +329,10 @@ def replace_keys(match: Match) -> str: _formats = formats = match.group(1) auto_reset_escaped = match.group(2) auto_reset_txt = match.group(3) + if formats_escaped := bool(_COMPILED["escape_char_cond"].match(match.group(0))): _formats = formats = _COMPILED["escape_char"].sub(r"\1", formats) # REMOVE / OR \\ + if auto_reset_txt and auto_reset_txt.count("[") > 0 and auto_reset_txt.count("]") > 0: auto_reset_txt = FormatCodes.to_ansi( auto_reset_txt, @@ -308,8 +341,10 @@ def replace_keys(match: Match) -> str: _default_start=False, _validate_default=False, ) + if not formats: return match.group(0) + if formats.count("[") > 0 and formats.count("]") > 0: formats = FormatCodes.to_ansi( formats, @@ -318,17 +353,21 @@ def replace_keys(match: Match) -> str: _default_start=False, _validate_default=False, ) + format_keys = FormatCodes.__formats_to_keys(formats) ansi_formats = [ r if (r := FormatCodes.__get_replacement(k, default_color, brightness_steps)) != k else f"[{k}]" for k in format_keys ] + if auto_reset_txt and not auto_reset_escaped: reset_keys = [] default_color_resets = ("_bg", "default") if use_default else ("_bg", "_c") + for k in format_keys: k_lower = k.lower() k_set = set(k_lower.split(":")) + if _PREFIX["BG"] & k_set and len(k_set) <= 3: if k_set & _PREFIX["BR"]: for i in range(len(k)): @@ -340,20 +379,25 @@ def replace_keys(match: Match) -> str: if is_valid_color(k[i:]): reset_keys.append("_bg") break + elif is_valid_color(k) or any( k_lower.startswith(pref_colon := f"{prefix}:") and is_valid_color(k[len(pref_colon):]) for prefix in _PREFIX["BR"]): reset_keys.append(default_color_resets[1]) + else: reset_keys.append(f"_{k}") + ansi_resets = [ r for k in reset_keys if (r := FormatCodes.__get_replacement(k, default_color, brightness_steps) ).startswith(f"{ANSI.CHAR}{ANSI.START}") ] + else: ansi_resets = [] - if not (len(ansi_formats) == 1 and ansi_formats[0].count(f"{ANSI.CHAR}{ANSI.START}") >= 1) and not all( - f.startswith(f"{ANSI.CHAR}{ANSI.START}") for f in ansi_formats): # FORMATTING WAS INVALID + + if not (len(ansi_formats) == 1 and ansi_formats[0].count(f"{ANSI.CHAR}{ANSI.START}") >= 1) and \ + not all(f.startswith(f"{ANSI.CHAR}{ANSI.START}") for f in ansi_formats): # FORMATTING WAS INVALID return match.group(0) elif formats_escaped: # FORMATTING WAS VALID BUT ESCAPED return f"[{_formats}]({auto_reset_txt})" if auto_reset_txt else f"[{_formats}]" @@ -370,7 +414,11 @@ def replace_keys(match: Match) -> str: + string) if default_color is not None else string @staticmethod - def escape(string: str, default_color: Optional[Rgba | Hexa] = None, _escape_char: Literal["/", "\\"] = "/") -> str: + def escape( + string: str, + default_color: Optional[Rgba | Hexa] = None, + _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 ----------------------------------------------------------------------------------------- @@ -381,9 +429,12 @@ def escape(string: str, default_color: Optional[Rgba | Hexa] = None, _escape_cha For exact information about how to use special formatting codes, see the `format_codes` module documentation.""" if not isinstance(string, str): - string = str(string) - if not _escape_char in {"/", "\\"}: - raise ValueError("'_escape_char' must be either '/' or '\\'") + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.__validate_default_color()' + if not isinstance(_escape_char, str): + raise TypeError(f"The '_escape_char' parameter must be a string, got {type(_escape_char)}") + elif _escape_char not in {"/", "\\"}: + raise ValueError("The '_escape_char' parameter must be either '/' or '\\'.") use_default, default_color = FormatCodes.__validate_default_color(default_color) @@ -426,6 +477,9 @@ def escape_ansi(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""" + if not isinstance(ansi_string, str): + raise TypeError(f"The 'ansi_string' parameter must be a string, got {type(ansi_string)}") + return ansi_string.replace(ANSI.CHAR, ANSI.ESCAPED_CHAR) @staticmethod @@ -442,6 +496,14 @@ def 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 formatting code and the removed formatting code - `_ignore_linebreaks` -⠀whether to ignore line breaks for the removal positions""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + # THE 'default_color' PARAM IS CHECKED IN 'FormatCodes.to_ansi()' + if not isinstance(get_removals, bool): + raise TypeError(f"The 'get_removals' parameter must be a boolean, got {type(get_removals)}") + if not isinstance(_ignore_linebreaks, bool): + raise TypeError(f"The '_ignore_linebreaks' parameter must be a boolean, got {type(_ignore_linebreaks)}") + return FormatCodes.remove_ansi( FormatCodes.to_ansi(string, default_color=default_color), get_removals=get_removals, @@ -460,6 +522,13 @@ def remove_ansi( - `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 not isinstance(ansi_string, str): + raise TypeError(f"The 'ansi_string' parameter must be a string, got {type(ansi_string)}") + if not isinstance(get_removals, bool): + raise TypeError(f"The 'get_removals' parameter must be a boolean, got {type(get_removals)}") + if not isinstance(_ignore_linebreaks, bool): + raise TypeError(f"The '_ignore_linebreaks' parameter must be a boolean, got {type(_ignore_linebreaks)}") + if get_removals: removals = [] @@ -472,9 +541,13 @@ def replacement(match: Match) -> str: clean_string = _COMPILED["ansi_seq"].sub( replacement, - ansi_string.replace("\n", "") if _ignore_linebreaks else ansi_string + ansi_string.replace("\n", "") if _ignore_linebreaks else ansi_string # REMOVE LINEBREAKS FOR POSITIONS ) - return _COMPILED["ansi_seq"].sub("", ansi_string) if _ignore_linebreaks else clean_string, tuple(removals) + if _ignore_linebreaks: + clean_string = _COMPILED["ansi_seq"].sub("", ansi_string) # BUT KEEP LINEBREAKS IN RETURNED CLEAN STRING + + return clean_string, tuple(removals) + else: return _COMPILED["ansi_seq"].sub("", ansi_string) @@ -509,7 +582,7 @@ def __validate_default_color(default_color: Optional[Rgba | Hexa]) -> tuple[bool return True, cast(rgba, default_color) elif Color.is_valid_hexa(default_color, False): return True, Color.to_rgba(default_color) - return False, None + raise TypeError("The 'default_color' parameter must be either a valid RGBA or HEXA color, or None.") @staticmethod def __get_default_ansi( diff --git a/src/xulbux/json.py b/src/xulbux/json.py index becbfd1..9c7354a 100644 --- a/src/xulbux/json.py +++ b/src/xulbux/json.py @@ -1,3 +1,8 @@ +""" +This module provides the `Json` class, which offers methods to read, create and update JSON files, +with support for comments inside the JSON data. +""" + from .data import Data from .file import File from .path import Path @@ -7,6 +12,8 @@ class Json: + """This class provides methods to read, create and update JSON files, + with support for comments inside the JSON data.""" @staticmethod def read( @@ -16,27 +23,40 @@ def read( return_original: bool = False, ) -> dict | tuple[dict, dict]: """Read JSON files, ignoring comments.\n - ------------------------------------------------------------------ - If only `comment_start` is found at the beginning of an item, - the whole item is counted as a comment and therefore ignored. - If `comment_start` and `comment_end` are found inside an item, - the the section from `comment_start` to `comment_end` is ignored. - If `return_original` is true, the original JSON is returned - additionally. (returns: `[processed_json, original_json]`)""" + ------------------------------------------------------------------------------------ + - `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, + see the `Data.remove_comments()` method documentation.""" + if not isinstance(json_file, str): + raise TypeError(f"The 'json_file' parameter must be a string, got {type(json_file)}") + # THE 'comment_start' PARAM IS CHECKED IN 'Data.remove_comments()' + # THE 'comment_end' PARAM IS CHECKED IN 'Data.remove_comments()' + if not isinstance(return_original, bool): + raise TypeError(f"The 'return_original' parameter must be a boolean, got {type(return_original)}") + if not json_file.endswith(".json"): json_file += ".json" - file_path = Path.extend_or_make(json_file, prefer_script_dir=True) - if file_path is None: + if (file_path := Path.extend_or_make(json_file, prefer_script_dir=True)) is None: raise FileNotFoundError(f"Could not find JSON file: {json_file}") + with open(file_path, "r") as f: content = f.read() + try: data = _json.loads(content) except _json.JSONDecodeError as e: raise ValueError(f"Error parsing JSON in '{file_path}': {str(e)}") - processed_data = dict(Data.remove_comments(data, comment_start, comment_end)) - if not processed_data: + + if not (processed_data := dict(Data.remove_comments(data, comment_start, comment_end))): raise ValueError(f"The JSON file '{file_path}' is empty or contains only comments.") + return (processed_data, data) if return_original else processed_data @staticmethod @@ -48,22 +68,35 @@ def create( force: bool = False, ) -> str: """Create a nicely formatted JSON file from a dictionary.\n - ---------------------------------------------------------------------- - The `indent` is the amount of spaces to use for indentation.\n - The `compactness` can be `0`, `1` or `2` and indicates how compact - the data should be formatted (see `Data.to_str()`).\n - The function will throw a `FileExistsError` if a file with the same - name already exists and a `SameContentFileExistsError` if a file with - the same name and content already exists. - To always overwrite the file, set the `force` parameter to `True`.""" + --------------------------------------------------------------------------- + - `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.to_str()` 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 + with the same name and same content already exists.""" + if not isinstance(json_file, str): + raise TypeError(f"The 'json_file' parameter must be a string, got {type(json_file)}") + # THE 'data' PARAM IS CHECKED IN 'Data.to_str()' + # THE 'indent' PARAM IS CHECKED IN 'Data.to_str()' + # THE 'compactness' PARAM IS CHECKED IN 'Data.to_str()' + if not isinstance(force, bool): + raise TypeError(f"The 'force' parameter must be a boolean, got {type(force)}") + if not json_file.endswith(".json"): json_file += ".json" - file_path = Path.extend_or_make(json_file, prefer_script_dir=True) + File.create( - file=file_path, - content=Data.to_str(data, indent, compactness, as_json=True), + file_path=(file_path := Path.extend_or_make(json_file, prefer_script_dir=True)), + content=Data.to_str(data=data, indent=indent, compactness=compactness, as_json=True), force=force, ) + return file_path @staticmethod @@ -74,15 +107,26 @@ def update( comment_end: str = "<<", path_sep: str = "->", ) -> None: - """Update single/multiple values inside JSON files, without needing to know the rest of the data.\n - ---------------------------------------------------------------------------------------------------- - The `update_values` parameter is a dictionary, where the keys are the paths to the data to update, - and the values are the new values to set.\n - Example: For this JSON data: + """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 + ----------------------------------------------------------------------------------- + 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 + to the data to update, and the values are the new values to set.\n + For example for this JSON data: ```python { "healthy": { - "fruit": ["apples", "bananas", "oranges"], + "fruits": ["apples", "bananas", "oranges"], "vegetables": ["carrots", "broccoli", "celery"] } } @@ -90,25 +134,34 @@ def update( ... the `update_values` dictionary could look like this: ```python { - # CHANGE VALUE "apples" TO "strawberries" - "healthy->fruit->0": "strawberries", - # CHANGE VALUE UNDER KEY "vegetables" TO [1, 2, 3] + # CHANGE FIRST LIST-VALUE UNDER 'fruits' TO "strawberries" + "healthy->fruits->0": "strawberries", + # CHANGE VALUE OF KEY 'vegetables' TO [1, 2, 3] "healthy->vegetables": [1, 2, 3] } ``` - In this example, if you want to change the value of `"apples"`, you can use `healthy->fruit->apples` - as the value-path. 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->fruit->0`.\n - ⇾ If the given value-path doesn't exist, it will be created.\n - ----------------------------------------------------------------------------------------------------- - If only `comment_start` is found at the beginning of an item, the whole item is counted as a comment - and therefore completely ignored. If `comment_start` and `comment_end` are found inside an item, the - section from `comment_start` to `comment_end` is counted as a comment and ignored.""" - processed_data, data = Json.read(json_file, comment_start, comment_end, return_original=True) + 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"`, + 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.""" + # THE 'json_file' PARAM IS CHECKED IN 'Json.read()' + if not isinstance(update_values, dict): + raise TypeError(f"The 'update_values' parameter must be a dictionary, got {type(update_values)}") + # THE 'comment_start' PARAM IS CHECKED IN 'Json.read()' + # THE 'comment_end' PARAM IS CHECKED IN 'Json.read()' + # THE 'path_sep' PARAM IS CHECKED IN 'Data.get_path_id()' + + processed_data, data = Json.read( + json_file=json_file, + comment_start=comment_start, + comment_end=comment_end, + return_original=True, + ) def create_nested_path(data_obj: dict, path_keys: list[str], value: Any) -> dict: - current = data_obj - last_idx = len(path_keys) - 1 + last_idx, current = len(path_keys) - 1, data_obj + for i, key in enumerate(path_keys): if i == last_idx: if isinstance(current, dict): @@ -119,7 +172,8 @@ def create_nested_path(data_obj: dict, path_keys: list[str], value: Any) -> dict current.append(None) current[idx] = value else: - raise TypeError(f"Cannot set key '{key}' on {type(current).__name__}") + raise TypeError(f"Cannot set key '{key}' on {type(current)}") + else: next_key = path_keys[i + 1] if isinstance(current, dict): @@ -134,26 +188,21 @@ def create_nested_path(data_obj: dict, path_keys: list[str], value: Any) -> dict current[idx] = [] if next_key.isdigit() else {} current = current[idx] else: - raise TypeError(f"Cannot navigate through '{type(current).__name__}'") + raise TypeError(f"Cannot navigate through {type(current)}") + return data_obj update = {} - for value_path, new_value in update_values.items(): + for val_path, new_val in update_values.items(): try: - path_id = Data.get_path_id( - data=processed_data, - value_paths=value_path, - path_sep=path_sep, - ) - if path_id is not None: - update[path_id] = new_value + if (path_id := Data.get_path_id(data=processed_data, value_paths=val_path, path_sep=path_sep)) is not None: + update[path_id] = new_val else: - keys = value_path.split(path_sep) - keys = value_path.split(path_sep) - data = create_nested_path(data, keys, new_value) + data = create_nested_path(data, val_path.split(path_sep), new_val) except Exception: - keys = value_path.split(path_sep) - data = create_nested_path(data, keys, new_value) - if "update" in locals() and update: + data = create_nested_path(data, val_path.split(path_sep), new_val) + + if update and "update" in locals(): data = Data.set_value_by_path_id(data, update) + Json.create(json_file=json_file, data=dict(data), force=True) diff --git a/src/xulbux/path.py b/src/xulbux/path.py index 759cbf4..8526595 100644 --- a/src/xulbux/path.py +++ b/src/xulbux/path.py @@ -1,3 +1,9 @@ +""" +This module provides the `Path` class, which offers methods to work with file and directory paths. +""" + +from .base.exceptions import PathNotFoundError + from typing import Optional import tempfile as _tempfile import difflib as _difflib @@ -6,10 +12,6 @@ import os as _os -class PathNotFoundError(FileNotFoundError): - ... - - class _Cwd: def __get__(self, obj, owner=None): @@ -33,6 +35,7 @@ def __get__(self, obj, owner=None): class Path: + """This class provides methods to work with file and directory paths.""" cwd: str = _Cwd() # type: ignore[assignment] """The path to the current working directory.""" @@ -47,14 +50,20 @@ def extend( use_closest_match: bool = False, ) -> Optional[str]: """Tries to resolve and extend a relative path to an absolute path.\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 raises a `PathNotFoundError` if `raise_error` is true.\n - -------------------------------------------------------------------------------- - If `use_closest_match` is true, it is possible to have typos in the `search_in` - path/s and it will still find the file if it is under one of those paths.""" - if rel_path in (None, ""): + ------------------------------------------------------------------------------------------- + - `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) + - `raise_error` -⠀if true, raises a `PathNotFoundError` if + the path couldn't be found (otherwise it returns `None`) + - `use_closest_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 returns `None` or + raises a `PathNotFoundError` if `raise_error` is true.""" + if rel_path in {"", None}: if raise_error: raise PathNotFoundError("Path is empty.") return None @@ -126,15 +135,22 @@ def extend_or_make( ) -> str: """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 - ----------------------------------------------------------------------------------------- - 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 that points to where the `rel_path` would be in the CWD.\n - ----------------------------------------------------------------------------------------- - If `use_closest_match` is true, it is possible to have typos in the `search_in` path/s - and it will still find the file if it is under one of those paths.""" + ------------------------------------------------------------------------------------------- + - `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) + - `use_closest_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 + that points to where the `rel_path` would be in the CWD.""" try: return str(Path.extend(rel_path, search_in, raise_error=True, use_closest_match=use_closest_match)) except PathNotFoundError: @@ -146,8 +162,9 @@ def extend_or_make( def remove(path: str, only_content: bool = False) -> None: """Removes the directory or the directory's content at the specified path.\n ----------------------------------------------------------------------------- - Normally it removes the directory and its content, but if `only_content` is - true, the directory is kept and only its contents are removed.""" + - `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 _os.path.exists(path): return None if not only_content: diff --git a/src/xulbux/regex.py b/src/xulbux/regex.py index 44dbb5b..eee77cc 100644 --- a/src/xulbux/regex.py +++ b/src/xulbux/regex.py @@ -1,13 +1,15 @@ -from typing import TypeAlias, Optional +""" +This module provides the `Regex` class, which offers methods +to dynamically generate complex regex patterns for common use cases. +""" + +from typing import Optional import regex as _rx import re as _re -Pattern: TypeAlias = _re.Pattern[str] | _rx.Pattern[str] -Match: TypeAlias = _re.Match[str] | _rx.Match[str] - - class Regex: + """This class provides methods to dynamically generate complex regex patterns for common use cases.""" @staticmethod def quotes() -> str: @@ -18,7 +20,7 @@ def quotes() -> str: - `string` everything inside the found quote pair\n --------------------------------------------------------------------------------- Attention: Requires non-standard library `regex`, not standard library `re`!""" - return r'(?P[\'"])(?P(?:\\.|(?!\g).)*?)\g' + return r"""(?P["'])(?P(?:\\.|(?!\g).)*?)\g""" @staticmethod def brackets( @@ -29,43 +31,91 @@ def brackets( ignore_in_strings: bool = True, ) -> str: """Matches everything inside pairs of brackets, including other nested brackets.\n - ----------------------------------------------------------------------------------- - If `is_group` is true, you will be able to reference the matched content as a - group (e.g. `match.group(…)` or `r'\\…'`). - If `strip_spaces` is true, it will ignore spaces around the content inside the - brackets. - If `ignore_in_strings` is true and a bracket is inside a string (e.g. `'...'` - or `"..."`), it will not be counted as the matching closing bracket.\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 ignore spaces around the content inside the brackets + - `ignore_in_strings` -⠀whether to ignore closing brackets that are inside + strings/quotes (e.g. `'…)…'` or `"…)…"`)\n + --------------------------------------------------------------------------------------- Attention: Requires non-standard library `regex`, not standard library `re`!""" - g, b1, b2, s1, s2 = ( - "" if is_group else "?:", - _rx.escape(bracket1) if len(bracket1) == 1 else bracket1, - _rx.escape(bracket2) if len(bracket2) == 1 else bracket2, - r"\s*" if strip_spaces else "", - "" if strip_spaces else r"\s*", - ) + if not isinstance(bracket1, str): + raise TypeError(f"The 'bracket1' parameter must be a string, got {type(bracket1)}") + if not isinstance(bracket2, str): + raise TypeError(f"The 'bracket2' parameter must be a string, got {type(bracket2)}") + if not isinstance(is_group, bool): + raise TypeError(f"The 'is_group' parameter must be a boolean, got {type(is_group)}") + if not isinstance(strip_spaces, bool): + raise TypeError(f"The 'strip_spaces' parameter must be a boolean, got {type(strip_spaces)}") + if not isinstance(ignore_in_strings, bool): + raise TypeError(f"The 'ignore_in_strings' parameter must be a boolean, got {type(ignore_in_strings)}") + + g = "" 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 "" + s2 = "" if strip_spaces else r"\s*" + if ignore_in_strings: - return rf'{b1}{s1}({g}{s2}(?:[^{b1}{b2}"\']|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|{b1}(?:[^{b1}{b2}"\']|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|(?R))*{b2})*{s2}){s1}{b2}' + return Regex._clean( + rf"""{b1}{s1}({g}{s2}(?: + [^{b1}{b2}"'] + |"(?:\\.|[^"\\])*" + |'(?:\\.|[^'\\])*' + |{b1}(?: + [^{b1}{b2}"'] + |"(?:\\.|[^"\\])*" + |'(?:\\.|[^'\\])*' + |(?R) + )*{b2} + )*{s2}){s1}{b2}""" + ) else: - return rf"{b1}{s1}({g}{s2}(?:[^{b1}{b2}]|{b1}(?:[^{b1}{b2}]|(?R))*{b2})*{s2}){s1}{b2}" + return Regex._clean( + rf"""{b1}{s1}({g}{s2}(?: + [^{b1}{b2}] + |{b1}(?: + [^{b1}{b2}] + |(?R) + )*{b2} + )*{s2}){s1}{b2}""" + ) @staticmethod def outside_strings(pattern: str = r".*") -> str: """Matches the `pattern` only when it is not found inside a string (`'...'` or `"..."`).""" - return rf'(? str: - """Matches everything except `disallowed_pattern`, unless the `disallowed_pattern` - is found inside a string (`'...'` or `"..."`).\n - ------------------------------------------------------------------------------------ - The `ignore_pattern` is just always ignored. For example if `disallowed_pattern` is - `>` and `ignore_pattern` is `->`, the `->`-arrows will be allowed, even though they - have `>` in them. - If `is_group` is true, you will be able to reference the matched content as a group - (e.g. `match.group(…)` or `r'\\…'`).""" - return rf'({"" if is_group else "?:"}(?:(?!{ignore_pattern}).)*(?:(?!{Regex.outside_strings(disallowed_pattern)}).)*)' + """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""" + if not isinstance(disallowed_pattern, str): + raise TypeError(f"The 'disallowed_pattern' parameter must be a string, got {type(disallowed_pattern)}") + if not isinstance(ignore_pattern, str): + raise TypeError(f"The 'ignore_pattern' parameter must be a string, got {type(ignore_pattern)}") + if not isinstance(is_group, bool): + raise TypeError(f"The 'is_group' parameter must be a boolean, got {type(is_group)}") + + g = "" if is_group else "?:" + + return Regex._clean( + rf"""({g} + (?:(?!{ignore_pattern}).)* + (?:(?!{Regex.outside_strings(disallowed_pattern)}).)* + )""" + ) @staticmethod def func_call(func_name: Optional[str] = None) -> str: @@ -75,14 +125,22 @@ def func_call(func_name: Optional[str] = None) -> str: If no `func_name` is given, it will match any function call.\n --------------------------------------------------------------------------------- Attention: Requires non-standard library `regex`, not standard library `re`!""" - return ( - r"(?<=\b)(" + (r"[\w_]+" if func_name is None else func_name) + r")\s*" + Regex.brackets("(", ")", is_group=True) - ) + if func_name is None: + func_name = r"[\w_]+" + elif not isinstance(func_name, str): + raise TypeError(f"The 'func_name' parameter must be a string or None, got {type(func_name)}") + + return rf"""(?<=\b)({func_name})\s*{Regex.brackets("(", ")", is_group=True)}""" @staticmethod - def rgba_str(fix_sep: str = ",", allow_alpha: bool = True) -> str: + def rgba_str(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 + ---------------------------------------------------------------------------------- The RGBA color can be in the formats (for `fix_sep = ','`): - `rgba(r, g, b)` - `rgba(r, g, b, a)` (if `allow_alpha=True`) @@ -94,28 +152,39 @@ def rgba_str(fix_sep: str = ",", allow_alpha: bool = True) -> str: - `r` 0-255 (int: red) - `g` 0-255 (int: green) - `b` 0-255 (int: blue) - - `a` 0.0-1.0 (float: opacity)\n - ---------------------------------------------------------------------------- - If the `fix_sep` is set to nothing, any char that is not a letter or number - can be used to separate the RGBA values, including just a space.""" - if fix_sep in (None, ""): + - `a` 0.0-1.0 (float: opacity)""" + if fix_sep in {"", None}: fix_sep = r"[^0-9A-Z]" - else: + elif isinstance(fix_sep, str): fix_sep = _re.escape(fix_sep) + else: + raise TypeError(f"The 'fix_sep' parameter must be a string or None, got {type(fix_sep)}") + + if not isinstance(allow_alpha, bool): + raise TypeError(f"The 'allow_alpha' parameter must be a boolean, got {type(allow_alpha)}") + rgb_part = rf"""((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}}))) (?:\s*{fix_sep}\s*)((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}}))) (?:\s*{fix_sep}\s*)((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))""" - return ( - rf"""(?ix) - (?:rgb|rgba)?\s*(?:\(?\s*{rgb_part} + + return Regex._clean(rf"""(?ix)(?:rgb|rgba)?\s*(?: + \(?\s*{rgb_part} (?:(?:\s*{fix_sep}\s*)((?:0*(?:0?\.[0-9]+|1\.0+|[0-9]+\.[0-9]+|[0-9]+))))? - \s*\)?)""" if allow_alpha else rf"(?ix)(?:rgb|rgba)?\s*(?:\(?\s*{rgb_part}\s*\)?)" - ) + \s*\)? + )""" if allow_alpha else \ + rf"""(?ix)(?:rgb|rgba)?\s*(?: + \(?\s*{rgb_part}\s*\)? + )""") @staticmethod def hsla_str(fix_sep: 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 + ---------------------------------------------------------------------------------- The HSLA color can be in the formats (for `fix_sep = ','`): - `hsla(h, s, l)` - `hsla(h, s, l, a)` (if `allow_alpha=True`) @@ -127,28 +196,36 @@ def hsla_str(fix_sep: str = ",", allow_alpha: bool = True) -> str: - `h` 0-360 (int: hue) - `s` 0-100 (int: saturation) - `l` 0-100 (int: lightness) - - `a` 0.0-1.0 (float: opacity)\n - ---------------------------------------------------------------------------- - If the `fix_sep` is set to nothing, any char that is not a letter or number - can be used to separate the HSLA values, including just a space.""" - if fix_sep in (None, ""): + - `a` 0.0-1.0 (float: opacity)""" + if fix_sep in {"", None}: fix_sep = r"[^0-9A-Z]" - else: + elif isinstance(fix_sep, str): fix_sep = _re.escape(fix_sep) + else: + raise TypeError(f"The 'fix_sep' parameter must be a string or None, got {type(fix_sep)}") + + if not isinstance(allow_alpha, bool): + raise TypeError(f"The 'allow_alpha' parameter must be a boolean, got {type(allow_alpha)}") + hsl_part = rf"""((?:0*(?:360|3[0-5][0-9]|[12][0-9][0-9]|[1-9]?[0-9])))(?:\s*°)? (?:\s*{fix_sep}\s*)((?:0*(?:100|[1-9][0-9]|[0-9])))(?:\s*%)? (?:\s*{fix_sep}\s*)((?:0*(?:100|[1-9][0-9]|[0-9])))(?:\s*%)?""" - return ( - rf"""(?ix) - (?:hsl|hsla)?\s*(?:\(?\s*{hsl_part} + + return Regex._clean(rf"""(?ix)(?:hsl|hsla)?\s*(?: + \(?\s*{hsl_part} (?:(?:\s*{fix_sep}\s*)((?:0*(?:0?\.[0-9]+|1\.0+|[0-9]+\.[0-9]+|[0-9]+))))? - \s*\)?)""" if allow_alpha else rf"(?ix)(?:hsl|hsla)?\s*(?:\(?\s*{hsl_part}\s*\)?)" - ) + \s*\)? + )""" if allow_alpha else \ + rf"""(?ix)(?:hsl|hsla)?\s*(?: + \(?\s*{hsl_part}\s*\)? + )""") @staticmethod def hexa_str(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 + ---------------------------------------------------------------------- The HEXA color can be in the formats (prefix `#`, `0x` or no prefix): - `RGB` - `RGBA` (if `allow_alpha=True`) @@ -156,7 +233,13 @@ def hexa_str(allow_alpha: bool = True) -> str: - `RRGGBBAA` (if `allow_alpha=True`)\n #### Valid ranges: 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 not isinstance(allow_alpha, bool): + raise TypeError(f"The 'allow_alpha' parameter must be a boolean, got {type(allow_alpha)}") + + 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})" - ) + + @staticmethod + def _clean(pattern: str) -> str: + """Internal method, to make a multiline-string regex pattern into a single-line pattern.""" + return "".join(l.strip() for l in pattern.splitlines()).strip() diff --git a/src/xulbux/string.py b/src/xulbux/string.py index a5e5290..f152a9f 100644 --- a/src/xulbux/string.py +++ b/src/xulbux/string.py @@ -1,17 +1,27 @@ -from typing import Any +""" +This module provides the `String` class, which offers +various utility methods for string manipulation and conversion. +""" + +from typing import Optional, Literal, Any import json as _json import ast as _ast import re as _re class String: + """This class provides various utility methods for string manipulation and conversion.""" @staticmethod def to_type(string: str) -> Any: - """Will convert a string to the found type, including complex nested structures.""" - string = string.strip() + """Will convert a string to the found type, including complex nested structures.\n + ----------------------------------------------------------------------------------- + - `string` -⠀the string to convert""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + try: - return _ast.literal_eval(string) + return _ast.literal_eval(string := string.strip()) except (ValueError, SyntaxError): try: return _json.loads(string) @@ -20,89 +30,185 @@ def to_type(string: str) -> Any: @staticmethod def normalize_spaces(string: str, tab_spaces: int = 4) -> str: - """Replaces all special space characters with normal spaces. - Also replaces tab characters with `tab_spaces` spaces.""" - return ( # YAPF: disable - string.replace("\t", " " * tab_spaces).replace("\u2000", " ").replace("\u2001", " ").replace("\u2002", " ") - .replace("\u2003", " ").replace("\u2004", " ").replace("\u2005", " ").replace("\u2006", " ") + """Replaces all special space characters with normal spaces.\n + --------------------------------------------------------------- + - `tab_spaces` -⠀number of spaces to replace tab chars with""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(tab_spaces, int): + raise ValueError(f"The 'tab_spaces' parameter must be an integer, got {type(tab_spaces)}") + elif 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", " ") - ) # YAPF: enable @staticmethod - def escape(string: str, str_quotes: str = '"') -> str: - """Escapes Python's special characters (e.g. `\n`, `\t`, ...) and quotes inside the string.\n - ---------------------------------------------------------------------------------------------- - `str_quotes` can be either `"` or `'` and should match the quotes, the string will be put - inside of. So if your string will be `"string"`, you should pass `"` to the parameter - `str_quotes`. That way, if the string includes the same quotes, they will be escaped.""" - string = ( # YAPF: disable - string.replace("\\", r"\\").replace("\n", r"\n").replace("\r", r"\r").replace("\t", r"\t") + def escape(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.""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(str_quotes, (str, type(None))): + raise ValueError(f"The 'str_quotes' parameter must be a string or None, got {type(str_quotes)}") + + string = string.replace("\\", r"\\").replace("\n", r"\n").replace("\r", r"\r").replace("\t", r"\t") \ .replace("\b", r"\b").replace("\f", r"\f").replace("\a", r"\a") - ) # YAPF: enable + if str_quotes == '"': - string = string.replace(r"\\'", "'").replace(r'"', r"\"") + return string.replace("\\'", "'").replace('"', r"\"") elif str_quotes == "'": - string = string.replace(r'\\"', '"').replace(r"'", r"\'") - return string + return string.replace('\\"', '"').replace("'", r"\'") + else: + return string @staticmethod - def is_empty(string: str, spaces_are_empty: bool = False): - """Returns `True` if the string is empty and `False` otherwise.\n - ------------------------------------------------------------------------------------------- - If `spaces_are_empty` is true, it will also return `True` if the string is only spaces.""" - return (string in (None, "")) or (spaces_are_empty and isinstance(string, str) and not string.strip()) + def is_empty(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""" + if not isinstance(string, (str, type(None))): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(spaces_are_empty, bool): + raise TypeError(f"The 'spaces_are_empty' parameter must be a boolean, got {type(spaces_are_empty)}") + + return bool( + (string in {"", None}) or \ + (spaces_are_empty and isinstance(string, str) and not string.strip()) + ) @staticmethod def single_char_repeats(string: str, char: str) -> int | bool: """- If the string consists of only the same `char`, it returns the number of times it is present. - - If the string doesn't consist of only the same character, it returns `False`.""" - if len(string) == len(char) * string.count(char): + - If the string doesn't consist of only the same character, it returns `False`.\n + --------------------------------------------------------------------------------------------------- + - `string` -⠀the string to check + - `char` -⠀the character to check for repetition""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(char, str): + raise ValueError(f"The 'char' parameter must be a string, got {type(char)}") + elif len(char) != 1: + raise ValueError(f"The 'char' parameter must be a single character, got {char!r}") + + if len(string) == (len(char) * string.count(char)): return string.count(char) else: return False @staticmethod def decompose(case_string: str, seps: str = "-_", lower_all: bool = True) -> list[str]: - """Will decompose the string (any type of casing, also mixed) into parts.""" - return [(part.lower() if lower_all else part) - for part in _re.split(rf"(?<=[a-z])(?=[A-Z])|[{_re.escape(seps)}]", case_string)] + """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""" + if not isinstance(case_string, str): + raise TypeError(f"The 'case_string' parameter must be a string, got {type(case_string)}") + if not isinstance(seps, str): + raise TypeError(f"The 'seps' parameter must be a string, got {type(seps)}") + if not isinstance(lower_all, bool): + raise TypeError(f"The 'lower_all' parameter must be a boolean, got {type(lower_all)}") + + return [ + (part.lower() if lower_all else part) \ + for part in _re.split(rf"(?<=[a-z])(?=[A-Z])|[{_re.escape(seps)}]", case_string) + ] @staticmethod def to_camel_case(string: str, upper: bool = True) -> str: - """Will convert the string of any type of casing to `UpperCamelCase` or `lowerCamelCase` if `upper` is false.""" + """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""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(upper, bool): + raise TypeError(f"The 'upper' parameter must be a boolean, got {type(upper)}") + parts = String.decompose(string) - return ("" if upper else parts[0].lower()) + "".join(part.capitalize() for part in (parts if upper else parts[1:])) + + return ( + ("" if upper else parts[0].lower()) + \ + "".join(part.capitalize() for part in (parts if upper else parts[1:])) + ) @staticmethod def to_delimited_case(string: str, delimiter: str = "_", screaming: bool = False) -> str: - """Will convert the string of any type of casing to casing delimited by `delimiter`.""" - return delimiter.join(part.upper() if screaming else part for part in String.decompose(string)) + """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""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(delimiter, str): + raise TypeError(f"The 'delimiter' parameter must be a string, got {type(delimiter)}") + if not isinstance(screaming, bool): + raise TypeError(f"The 'screaming' parameter must be a boolean, got {type(screaming)}") + + return delimiter.join( + part.upper() if screaming else part \ + for part in String.decompose(string) + ) @staticmethod def get_lines(string: str, remove_empty_lines: bool = False) -> list[str]: - """Will split the string into lines.""" + """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""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(remove_empty_lines, bool): + raise TypeError(f"The 'remove_empty_lines' parameter must be a boolean, got {type(remove_empty_lines)}") + if not remove_empty_lines: return string.splitlines() - lines = string.splitlines() - if not lines: + elif not (lines := string.splitlines()): return [] - non_empty_lines = [line for line in lines if line.strip()] - if not non_empty_lines: + elif not (non_empty_lines := [line for line in lines if line.strip()]): return [] - return non_empty_lines + else: + return non_empty_lines @staticmethod def remove_consecutive_empty_lines(string: str, max_consecutive: int = 0) -> str: """Will remove consecutive empty lines from the string.\n - -------------------------------------------------------------------------------------------- - - If `max_consecutive` is `0`, it will remove all consecutive empty lines. - - If `max_consecutive` is 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 not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(max_consecutive, int): + raise ValueError(f"The 'max_consecutive' parameter must be an integer, got {type(max_consecutive)}") + elif max_consecutive < 0: + raise ValueError(f"The 'max_consecutive' parameter must be non-negative, got {max_consecutive!r}") + return _re.sub(r"(\n\s*){2,}", r"\1" * (max_consecutive + 1), string) @staticmethod def split_count(string: str, count: int) -> list[str]: - """Will split the string every `count` characters.""" - if count <= 0: - raise ValueError("Count must be greater than 0.") + """Will split the string every `count` characters.\n + ----------------------------------------------------- + - `string` -⠀the string to split + - `count` -⠀the number of characters per part""" + if not isinstance(string, str): + raise TypeError(f"The 'string' parameter must be a string, got {type(string)}") + if not isinstance(count, int): + raise ValueError(f"The 'count' parameter must be an integer, got {type(count)}") + elif count <= 0: + raise ValueError(f"The 'count' parameter must be a positive integer, got {count!r}") + return [string[i:i + count] for i in range(0, len(string), count)] diff --git a/src/xulbux/system.py b/src/xulbux/system.py index da0f2a7..3d852ec 100644 --- a/src/xulbux/system.py +++ b/src/xulbux/system.py @@ -1,3 +1,10 @@ +""" +This module provides the `System` class, which offers +methods to interact with the underlying operating system. +""" + +from .base.types import MissingLibsMsgs + from .format_codes import FormatCodes from .console import Console @@ -24,6 +31,7 @@ def __get__(self, obj, owner=None): class System: + """This class provides methods to interact with the underlying operating system.""" is_elevated: bool = _IsElevated() # type: ignore[assignment] """Is `True` if the current process has elevated privileges and `False` otherwise.""" @@ -36,47 +44,62 @@ def restart(prompt: object = "", wait: int = 0, continue_program: bool = False, - `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""" - system = _platform.system().lower() - if system == "windows": + if not isinstance(wait, int): + raise TypeError(f"The 'wait' parameter must be an integer, got {type(wait)}") + elif wait < 0: + raise ValueError(f"The 'wait' parameter must be non-negative, got {wait!r}") + if not isinstance(continue_program, bool): + raise TypeError(f"The 'continue_program' parameter must be a boolean, got {type(continue_program)}") + if not isinstance(force, bool): + raise TypeError(f"The 'force' parameter must be a boolean, got {type(force)}") + + if (system := _platform.system().lower()) == "windows": if not force: output = _subprocess.check_output("tasklist", shell=True).decode() processes = [line.split()[0] for line in output.splitlines()[3:] if line.strip()] if len(processes) > 2: # EXCLUDING THE PYTHON PROCESS AND CONSOLE raise RuntimeError("Processes are still running. Use the parameter `force=True` to restart anyway.") + if prompt: _os.system(f'shutdown /r /t {wait} /c "{prompt}"') else: _os.system("shutdown /r /t 0") + if continue_program: print(f"Restarting in {wait} seconds...") _time.sleep(wait) - elif system in ("linux", "darwin"): + + elif system in {"linux", "darwin"}: if not force: output = _subprocess.check_output(["ps", "-A"]).decode() processes = output.splitlines()[1:] # EXCLUDE HEADER if len(processes) > 2: # EXCLUDING THE PYTHON PROCESS AND PS raise RuntimeError("Processes are still running. Use the parameter `force=True` to restart anyway.") + if prompt: _subprocess.Popen(["notify-send", "System Restart", str(prompt)]) _time.sleep(wait) + try: _subprocess.run(["sudo", "shutdown", "-r", "now"]) except _subprocess.CalledProcessError: raise PermissionError("Failed to restart: insufficient privileges. Ensure sudo permissions are granted.") + if continue_program: print(f"Restarting in {wait} seconds...") _time.sleep(wait) + else: - raise NotImplementedError(f"Restart not implemented for `{system}`") + raise NotImplementedError(f"Restart not implemented for '{system}' systems.") @staticmethod def check_libs( lib_names: list[str], install_missing: bool = False, - missing_libs_msgs: tuple[str, str] = ( - "The following required libraries are missing:", - "Do you want to install them now?", - ), + missing_libs_msgs: MissingLibsMsgs = { + "found_missing": "The following required libraries are missing:", + "should_install": "Do you want to install them now?", + }, confirm_install: bool = True, ) -> Optional[list[str]]: """Checks if the given list of libraries are installed and optionally installs missing libraries.\n @@ -89,23 +112,39 @@ def check_libs( ------------------------------------------------------------------------------------------------------------ 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.""" + if not isinstance(lib_names, list): + raise TypeError(f"The 'lib_names' parameter must be a list, got {type(lib_names)}") + elif not all(isinstance(lib, str) for lib in lib_names): + raise TypeError("All items in the 'lib_names' list must be strings.") + if not isinstance(install_missing, bool): + raise TypeError(f"The 'install_missing' parameter must be a boolean, got {type(install_missing)}") + if not isinstance(missing_libs_msgs, dict): + raise TypeError(f"The 'missing_libs_msgs' parameter must be a dict, got {type(missing_libs_msgs)}") + elif not all(key in missing_libs_msgs for key in {"found_missing", "should_install"}): + raise ValueError("The 'missing_libs_msgs' dict must contain the keys 'found_missing' and 'should_install'.") + if not isinstance(confirm_install, bool): + raise TypeError(f"The 'confirm_install' parameter must be a boolean, got {type(confirm_install)}") + missing = [] for lib in lib_names: try: __import__(lib) except ImportError: missing.append(lib) + if not missing: return None elif not install_missing: return missing + if confirm_install: - FormatCodes.print(f"[b]({missing_libs_msgs[0]})") + FormatCodes.print(f"[b]({missing_libs_msgs['found_missing']})") for lib in missing: FormatCodes.print(f" [dim](•) [i]{lib}[_i]") print() - if not Console.confirm(missing_libs_msgs[1], end="\n"): + if not Console.confirm(missing_libs_msgs["should_install"], end="\n"): return missing + try: for lib in missing: try: @@ -113,9 +152,12 @@ def check_libs( missing.remove(lib) except _subprocess.CalledProcessError: pass + if len(missing) == 0: return None - return missing + else: + return missing + except _subprocess.CalledProcessError: return missing @@ -123,31 +165,41 @@ def check_libs( def elevate(win_title: Optional[str] = None, args: list = []) -> bool: """Attempts to start a new process with elevated privileges.\n --------------------------------------------------------------------------------- - The param `win_title` is window the title of the elevated process. - The param `args` is the arguments to be passed to the elevated process.\n - After the elevated process started, the original process will exit. + - `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 - will have to continue in a new window after elevation.\n + 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 a `PermissionError` if the user denied the elevation or the elevation failed.""" + if not isinstance(win_title, (str, type(None))): + raise TypeError(f"The 'win_title' parameter must be a string or None, got {type(win_title)}") + if not isinstance(args, list): + raise TypeError(f"The 'args' parameter must be a list, got {type(args)}") + if System.is_elevated: return True + 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)}"' else: args_str = f'-c "exec(open(\\"{_sys.argv[0]}\\").read())" {" ".join(args)}' + result = _ctypes.windll.shell32.ShellExecuteW(None, "runas", _sys.executable, args_str, None, 1) if result <= 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:] + ([] if args is None else args)) + proc = _subprocess.Popen(cmd) proc.wait() if proc.returncode != 0: diff --git a/tests/test_code.py b/tests/test_code.py index 66eb39d..12b704d 100644 --- a/tests/test_code.py +++ b/tests/test_code.py @@ -37,22 +37,25 @@ def test_get_func_calls(): sample = "foo()\nbar(1, 2)\nbaz('test')" result = Code.get_func_calls(sample) assert len(result) == 3 - assert ('foo', '') in result - assert ('bar', '1, 2') in result - assert ('baz', "'test'") in result + assert ("foo", "") in result + assert ("bar", "1, 2") in result + assert ("baz", "'test'") in result + sample = "outer(inner1(), inner2(param))" result = Code.get_func_calls(sample) assert len(result) >= 3 function_names = [match[0] for match in result] - assert 'outer' in function_names - assert 'inner1' in function_names - assert 'inner2' in function_names + assert "outer" in function_names + assert "inner1" in function_names + assert "inner2" in function_names + assert not Code.get_func_calls("no function calls here") + sample = "obj.method()\nobj.other_method(123)" result = Code.get_func_calls(sample) assert len(result) == 2 - assert ('method', '') in result - assert ('other_method', '123') in result + assert ("method", "") in result + assert ("other_method", "123") in result def test_is_js(): @@ -74,5 +77,5 @@ def test_is_js(): js_sample = "const func = () => { return 42; }" assert Code.is_js(js_sample) is True js_sample = "customFunc()" - assert Code.is_js(js_sample, funcs=["customFunc"]) is True + assert Code.is_js(js_sample, funcs={"customFunc"}) is True assert Code.is_js(js_sample) is False diff --git a/tests/test_color.py b/tests/test_color.py index a9e3509..a4e1436 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -3,17 +3,17 @@ def test_rgba_to_hex_int_and_back(): blue = Color.rgba_to_hex_int(0, 0, 255) - black = Color.rgba_to_hex_int(0, 0, 0, 1) - _blue = Color.rgba_to_hex_int(0, 0, 255, preserve_original=True) - _black = Color.rgba_to_hex_int(0, 0, 0, 1, preserve_original=True) + black = Color.rgba_to_hex_int(0, 0, 0, 1.0) + preserved_blue = Color.rgba_to_hex_int(0, 0, 255, preserve_original=True) + preserved_black = Color.rgba_to_hex_int(0, 0, 0, 1.0, preserve_original=True) assert blue == 0x0100FF assert black == 0x010000FF - assert _blue == 0x0000FF - assert _black == 0x000000FF + assert preserved_blue == 0x0000FF + assert preserved_black == 0x000000FF assert Color.hex_int_to_rgba(blue).values() == (0, 0, 255, None) assert Color.hex_int_to_rgba(black).values() == (0, 0, 0, 1.0) - assert Color.hex_int_to_rgba(_blue).values() == (0, 0, 255, None) - assert Color.hex_int_to_rgba(_black).values() == (0, 0, 255, None) + assert Color.hex_int_to_rgba(preserved_blue).values() == (0, 0, 255, None) + assert Color.hex_int_to_rgba(preserved_black).values() == (0, 0, 255, None) assert Color.hex_int_to_rgba(blue, preserve_original=True).values() == (1, 0, 255, None) assert Color.hex_int_to_rgba(black, preserve_original=True).values() == (1, 0, 0, 1.0) @@ -158,14 +158,12 @@ def test_adjust_lightness(): def test_adjust_saturation(): - color = hsla(0, 50, 50) - saturated = Color.adjust_saturation(color, 0.5) # type: ignore[assignment] + color = rgba(128, 80, 80) + saturated = Color.adjust_saturation(color, 0.25) # type: ignore[assignment] assert isinstance(saturated, rgba) - assert saturated.to_hsla().s > color.s - color = hsla(0, 100, 50) - desaturated = Color.adjust_saturation(color, -0.5) # type: ignore[assignment] - assert isinstance(desaturated, rgba) - assert desaturated.to_hsla().s < color.s + assert saturated.to_hsla().s > color.to_hsla().s + assert saturated == rgba(155, 54, 54) + desaturated = Color.adjust_saturation(hexa("#FF0000"), -1.0) assert isinstance(desaturated, hexa) assert desaturated.is_grayscale() is True diff --git a/tests/test_console.py b/tests/test_console.py index d08dd5f..ead76db 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -1,4 +1,4 @@ -from xulbux.console import Console, Args, ArgResult, ProgressBar +from xulbux.console import ProgressBar, Console, ArgResult, Args from xulbux import console from unittest.mock import MagicMock, patch, call @@ -70,90 +70,145 @@ def test_console_size(mock_terminal_size): # CASES WITHOUT SPACES (allow_spaces=False) "argv, find_args, expected_args_dict", [ # NO ARGS PROVIDED - (["script.py"], {"file": ["-f"], "debug": ["-d"] - }, {"file": {"exists": False, "value": None}, "debug": {"exists": False, "value": None}}), + ( + ["script.py"], + {"file": {"-f"}, "debug": {"-d"}}, + {"file": {"exists": False, "value": None}, "debug": {"exists": False, "value": None}}, + ), # SIMPLE FLAG - (["script.py", "-d"], {"file": ["-f"], "debug": ["-d"] - }, {"file": {"exists": False, "value": None}, "debug": {"exists": True, "value": None}}), + ( + ["script.py", "-d"], + {"file": {"-f"}, "debug": {"-d"}}, + {"file": {"exists": False, "value": None}, "debug": {"exists": True, "value": None}}, + ), # FLAG WITH VALUE - (["script.py", "-f", "test.txt"], {"file": ["-f"], "debug": ["-d"]}, - {"file": {"exists": True, "value": "test.txt"}, "debug": {"exists": False, "value": None}}), + ( + ["script.py", "-f", "test.txt"], + {"file": {"-f"}, "debug": {"-d"}}, + {"file": {"exists": True, "value": "test.txt"}, "debug": {"exists": False, "value": None}}, + ), # LONG FLAGS WITH VALUE AND FLAG - (["script.py", "--file", "path/to/file", "--debug"], {"file": ["-f", "--file"], "debug": ["-d", "--debug"]}, - {"file": {"exists": True, "value": "path/to/file"}, "debug": {"exists": True, "value": None}}), - # VALUE WITH SPACE (IGNORED DUE TO allow_spaces=False) - (["script.py", "-f", "file with spaces"], {"file": ["-f"]}, {"file": {"exists": True, "value": "file with spaces"}}), + ( + ["script.py", "--file", "path/to/file", "--debug"], + {"file": {"-f", "--file"}, "debug": {"-d", "--debug"}}, + {"file": {"exists": True, "value": "path/to/file"}, "debug": {"exists": True, "value": None}}, + ), + # VALUE WITH SPACES (ONLY FIRST PART DUE TO allow_spaces=False) + ( + ["script.py", "-t", "text", "with", "spaces"], + {"text": {"-t"}}, + {"text": {"exists": True, "value": "text"}}, + ), # UNKNOWN ARG - (["script.py", "-x"], {"file": ["-f"]}, {"file": {"exists": False, "value": None}}), + ( + ["script.py", "-x"], + {"file": {"-f"}}, + {"file": {"exists": False, "value": None}}, + ), # TWO FLAGS - (["script.py", "-f", "-d"], {"file": ["-f"], "debug": ["-d"] - }, {"file": {"exists": True, "value": None}, "debug": {"exists": True, "value": None}}), + ( + ["script.py", "-f", "-d"], + {"file": {"-f"}, "debug": {"-d"}}, + {"file": {"exists": True, "value": None}, "debug": {"exists": True, "value": None}}, + ), # CASE SENSITIVE FLAGS - (["script.py", "-i", "input.txt", "-I", "ignore"], {"input": ["-i"], "ignore": ["-I"], "help": ["-h"]}, { - "input": {"exists": True, "value": "input.txt"}, "ignore": {"exists": True, "value": "ignore"}, "help": - {"exists": False, "value": None} - }), - # NUMERIC VALUE (REMAINS AS STRING) - (["script.py", "-n", "123"], {"num": ["-n"]}, {"num": {"exists": True, "value": "123"}}), - # BOOLEAN VALUE (REMAINS AS STRING) - (["script.py", "-b", "true"], {"bool_arg": ["-b"]}, {"bool_arg": {"exists": True, "value": "true"}}), - # BOOLEAN VALUE (REMAINS AS STRING) - (["script.py", "-b", "False"], {"bool_arg": ["-b"]}, {"bool_arg": {"exists": True, "value": "False"}}), - - # --- CASES WITH DEFAULTS (dict FORMAT, allow_spaces=False) --- - # DEFAULT USED (string) - (["script.py"], {"output": {"flags": ["-o"], "default": "out.txt"}, "verbose": ["-v"] - }, {"output": {"exists": False, "value": "out.txt"}, "verbose": {"exists": False, "value": None}}), - # VALUE OVERRIDES DEFAULT (string) - (["script.py", "-o", "my_file.log"], {"output": {"flags": ["-o"], "default": "out.txt"}, "verbose": ["-v"]}, - {"output": {"exists": True, "value": "my_file.log"}, "verbose": {"exists": False, "value": None}}), + ( + ["script.py", "-i", "input.txt", "-I", "ignore"], + {"input": {"-i"}, "ignore": {"-I"}, "help": {"-h"}}, + { + "input": {"exists": True, "value": "input.txt"}, + "ignore": {"exists": True, "value": "ignore"}, + "help": {"exists": False, "value": None}, + }, + ), + + # --- CASES WITH DEFAULT VALUES --- + # DEFAULT USED + ( + ["script.py"], + {"output": {"flags": {"-o"}, "default": "out.txt"}, "verbose": {"-v"}}, + {"output": {"exists": False, "value": "out.txt"}, "verbose": {"exists": False, "value": None}}, + ), + # VALUE OVERRIDES DEFAULT + ( + ["script.py", "-o", "my_file.log"], + {"output": {"flags": {"-o"}, "default": "out.txt"}, "verbose": {"-v"}}, + {"output": {"exists": True, "value": "my_file.log"}, "verbose": {"exists": False, "value": None}}, + ), # FLAG PRESENCE OVERRIDES DEFAULT (string -> None) - (["script.py", "-o"], {"output": {"flags": ["-o"], "default": "out.txt"}, "verbose": ["-v"] - }, {"output": {"exists": True, "value": None}, "verbose": {"exists": False, "value": None}}), + ( + ["script.py", "-o"], + {"output": {"flags": {"-o"}, "default": "out.txt"}, "verbose": {"-v"}}, + {"output": {"exists": True, "value": None}, "verbose": {"exists": False, "value": None}}, + ), # FLAG PRESENCE OVERRIDES DEFAULT (False -> None) - (["script.py", "-v"], { - "output": {"flags": ["-o"], "default": "out.txt"}, "verbose": {"flags": ["-v"], "default": False} - }, {"output": {"exists": False, "value": "out.txt"}, "verbose": {"exists": True, "value": None}}), - # DEFAULT USED (int) - (["script.py"], {"mode": {"flags": ["-m"], "default": 1}}, {"mode": {"exists": False, "value": 1}}), - # VALUE OVERRIDES DEFAULT (int BECOMES STRING) - (["script.py", "-m", "2"], {"mode": {"flags": ["-m"], "default": 1}}, {"mode": {"exists": True, "value": "2"}}), - # FLAG PRESENCE OVERRIDES DEFAULT (int -> None) - (["script.py", "-m"], {"mode": {"flags": ["-m"], "default": 1}}, {"mode": {"exists": True, "value": None}}), + ( + ["script.py", "-v"], + {"output": {"flags": {"-o"}, "default": "out.txt"}, "verbose": {"flags": {"-v"}, "default": "False"}}, + {"output": {"exists": False, "value": "out.txt"}, "verbose": {"exists": True, "value": None}}, + ), # --- MIXED list/tuple AND dict FORMATS (allow_spaces=False) --- # DICT VALUE PROVIDED, LIST NOT PROVIDED - (["script.py", "--config", "dev.cfg"], {"config": {"flags": ["-c", "--config"], "default": "prod.cfg"}, "log": ["-l"]}, - {"config": {"exists": True, "value": "dev.cfg"}, "log": {"exists": False, "value": None}}), + ( + ["script.py", "--config", "dev.cfg"], + {"config": {"flags": {"-c", "--config"}, "default": "prod.cfg"}, "log": {"-l"}}, + {"config": {"exists": True, "value": "dev.cfg"}, "log": {"exists": False, "value": None}}, + ), # LIST FLAG PROVIDED, DICT NOT PROVIDED (USES DEFAULT) - (["script.py", "-l"], {"config": {"flags": ["-c", "--config"], "default": "prod.cfg"}, "log": ["-l"] - }, {"config": {"exists": False, "value": "prod.cfg"}, "log": {"exists": True, "value": None}}), + ( + ["script.py", "-l"], + {"config": {"flags": {"-c", "--config"}, "default": "prod.cfg"}, "log": {"-l"}}, + {"config": {"exists": False, "value": "prod.cfg"}, "log": {"exists": True, "value": None}}, + ), # --- 'before' / 'after' SPECIAL CASES --- # 'before' SPECIAL CASE - (["script.py", "arg1", "arg2", "-f", "file.txt"], {"before": "before", "file": ["-f"]}, - {"before": {"exists": True, "values": ["arg1", "arg2"]}, "file": {"exists": True, "value": "file.txt"}}), - (["script.py", "-f", "file.txt"], {"before": "before", "file": ["-f"]}, - {"before": {"exists": False, "values": []}, "file": {"exists": True, "value": "file.txt"}}), + ( + ["script.py", "arg1", "arg2.1 arg2.2", "-f", "file.txt"], + {"before": "before", "file": {"-f"}}, + {"before": {"exists": True, "values": ["arg1", "arg2.1 arg2.2"]}, "file": {"exists": True, "value": "file.txt"}}, + ), + ( + ["script.py", "-f", "file.txt"], + {"before": "before", "file": {"-f"}}, + {"before": {"exists": False, "values": []}, "file": {"exists": True, "value": "file.txt"}}, + ), # 'after' SPECIAL CASE - (["script.py", "-f", "file.txt", "arg1", "arg2"], {"after": "after", "file": ["-f"]}, - {"after": {"exists": True, "values": ["arg1", "arg2"]}, "file": {"exists": True, "value": "file.txt"}}), - (["script.py", "-f", "file.txt"], {"after": "after", "file": ["-f"]}, - {"after": {"exists": False, "values": []}, "file": {"exists": True, "value": "file.txt"}}), + ( + ["script.py", "-f", "file.txt", "arg1", "arg2.1 arg2.2"], + {"after": "after", "file": {"-f"}}, + {"after": {"exists": True, "values": ["arg1", "arg2.1 arg2.2"]}, "file": {"exists": True, "value": "file.txt"}}, + ), + ( + ["script.py", "-f", "file.txt"], + {"after": "after", "file": {"-f"}}, + {"after": {"exists": False, "values": []}, "file": {"exists": True, "value": "file.txt"}}, + ), # --- CUSTOM PREFIX TESTS --- # COLON AND SLASH PREFIXES - (["script.py", ":config", "dev.json", "/output", "result.txt"], {"config": [":config"], "output": ["/output"]}, - {"config": {"exists": True, "value": "dev.json"}, "output": {"exists": True, "value": "result.txt"}}), + ( + ["script.py", ":config", "dev.json", "/output", "result.txt"], + {"config": {":config"}, "output": {"/output"}}, + {"config": {"exists": True, "value": "dev.json"}, "output": {"exists": True, "value": "result.txt"}}, + ), # WORD FLAGS WITHOUT PREFIXES - (["script.py", "verbose", "help", "123"], {"verbose": ["verbose"], "help": ["help"], "number": ["-n"]}, { - "verbose": {"exists": True, "value": None}, "help": {"exists": True, "value": "123"}, "number": - {"exists": False, "value": None} - }), + ( + ["script.py", "verbose", "help", "123"], + {"verbose": {"verbose"}, "help": {"help"}, "number": {"-n"}}, + { + "verbose": {"exists": True, "value": None}, + "help": {"exists": True, "value": "123"}, + "number": {"exists": False, "value": None}, + }, + ), # MIXED CUSTOM PREFIXES WITH DEFAULTS - (["script.py", "@user", "admin"], { - "user": {"flags": ["@user"], "default": "guest"}, "mode": {"flags": ["++mode"], "default": "normal"} - }, {"user": {"exists": True, "value": "admin"}, "mode": {"exists": False, "value": "normal"}}), + ( + ["script.py", "@user", "admin"], + {"user": {"flags": {"@user"}, "default": "guest"}, "mode": {"flags": {"++mode"}, "default": "normal"}}, + {"user": {"exists": True, "value": "admin"}, "mode": {"exists": False, "value": "normal"}}, + ), ] ) def test_get_args_no_spaces(monkeypatch, argv, find_args, expected_args_dict): @@ -165,7 +220,7 @@ def test_get_args_no_spaces(monkeypatch, argv, find_args, expected_args_dict): assert (key in args_result) is True assert isinstance(args_result[key], ArgResult) assert args_result[key].exists == expected["exists"] # type: ignore[access] - # Check if this is a positional arg (has 'values') or regular arg (has 'value') + # CHECK IF THIS IS A POSITIONAL ARG (HAS 'values') OR REGULAR ARG (HAS 'value') if "values" in expected: assert args_result[key].values == expected["values"] # type: ignore[access] else: @@ -180,48 +235,103 @@ def test_get_args_no_spaces(monkeypatch, argv, find_args, expected_args_dict): # CASES WITH SPACES (allow_spaces=True) "argv, find_args, expected_args_dict", [ # SIMPLE VALUE WITH SPACES - (["script.py", "-f", "file with spaces", "-d"], {"file": ["-f"], "debug": ["-d"]}, - {"file": {"exists": True, "value": "file with spaces"}, "debug": {"exists": True, "value": None}}), + ( + ["script.py", "-f", "file with spaces", "-d"], + {"file": {"-f"}, "debug": {"-d"}}, + {"file": {"exists": True, "value": "file with spaces"}, "debug": {"exists": True, "value": None}}, + ), # LONG VALUE WITH SPACES - (["script.py", "--message", "Hello", "world", "how", "are", "you" - ], {"message": ["--message"]}, {"message": {"exists": True, "value": "Hello world how are you"}}), + ( + ["script.py", "--message", "Hello", "world", "how", "are", "you"], + {"message": {"--message"}}, + {"message": {"exists": True, "value": "Hello world how are you"}}, + ), # VALUE WITH SPACES FOLLOWED BY ANOTHER FLAG - (["script.py", "-m", "this is", "a message", "--flag"], {"message": ["-m"], "flag": ["--flag"]}, - {"message": {"exists": True, "value": "this is a message"}, "flag": {"exists": True, "value": None}}), + ( + ["script.py", "-m", "this is", "a message", "--flag"], + {"message": {"-m"}, "flag": {"--flag"}}, + {"message": {"exists": True, "value": "this is a message"}, "flag": {"exists": True, "value": None}}, + ), # VALUE WITH SPACES AT THE END - (["script.py", "-m", "end", "of", "args"], {"message": ["-m"]}, {"message": {"exists": True, "value": "end of args"}}), + ( + ["script.py", "-m", "end", "of", "args"], + {"message": {"-m"}}, + {"message": {"exists": True, "value": "end of args"}}, + ), # CASE SENSITIVE FLAGS WITH SPACES - (["script.py", "-t", "this is", "a test", "-T", "UPPERCASE"], {"text": ["-t"], "title": ["-T"]}, - {"text": {"exists": True, "value": "this is a test"}, "title": {"exists": True, "value": "UPPERCASE"}}), + ( + ["script.py", "-t", "this is", "a test", "-T", "UPPERCASE"], + {"text": {"-t"}, "title": {"-T"}}, + {"text": {"exists": True, "value": "this is a test"}, "title": {"exists": True, "value": "UPPERCASE"}}, + ), # --- CASES WITH DEFAULTS (dict FORMAT, allow_spaces=True) --- # VALUE WITH SPACE OVERRIDES DEFAULT - (["script.py", "--msg", "Default message"], {"msg": {"flags": ["--msg"], "default": "No message"}, "other": ["-o"]}, - {"msg": {"exists": True, "value": "Default message"}, "other": {"exists": False, "value": None}}), + ( + ["script.py", "--msg", "Default message"], + {"msg": {"flags": {"--msg"}, "default": "No message"}, "other": {"-o"}}, + {"msg": {"exists": True, "value": "Default message"}, "other": {"exists": False, "value": None}}, + ), # DEFAULT USED WHEN OTHER FLAG PRESENT - (["script.py", "-o"], {"msg": {"flags": ["--msg"], "default": "No message"}, "other": ["-o"] - }, {"msg": {"exists": False, "value": "No message"}, "other": {"exists": True, "value": None}}), + ( + ["script.py", "-o"], + {"msg": {"flags": {"--msg"}, "default": "No message"}, "other": {"-o"}}, + {"msg": {"exists": False, "value": "No message"}, "other": {"exists": True, "value": None}}, + ), # FLAG PRESENCE OVERRIDES DEFAULT (str -> True) # FLAG WITH NO VALUE SHOULD HAVE None AS VALUE - (["script.py", "--msg"], {"msg": {"flags": ["--msg"], "default": "No message"}, "other": ["-o"] - }, {"msg": {"exists": True, "value": None}, "other": {"exists": False, "value": None}}), + ( + ["script.py", "--msg"], + {"msg": {"flags": {"--msg"}, "default": "No message"}, "other": {"-o"}}, + {"msg": {"exists": True, "value": None}, "other": {"exists": False, "value": None}}, + ), # --- MIXED FORMATS WITH SPACES (allow_spaces=True) --- # LIST VALUE WITH SPACES, dict VALUE PROVIDED - (["script.py", "-f", "input file name", "--mode", "test"], { - "file": ["-f"], "mode": {"flags": ["--mode"], "default": "prod"} - }, {"file": {"exists": True, "value": "input file name"}, "mode": {"exists": True, "value": "test"}}), + ( + ["script.py", "-f", "input file name", "--mode", "test"], + {"file": {"-f"}, "mode": {"flags": {"--mode"}, "default": "prod"}}, + {"file": {"exists": True, "value": "input file name"}, "mode": {"exists": True, "value": "test"}}, + ), # LIST VALUE WITH SPACES, dict NOT PROVIDED (USES DEFAULT) - (["script.py", "-f", "another file"], {"file": ["-f"], "mode": {"flags": ["--mode"], "default": "prod"}}, - {"file": {"exists": True, "value": "another file"}, "mode": {"exists": False, "value": "prod"}}), + ( + ["script.py", "-f", "another file"], + {"file": {"-f"}, "mode": {"flags": {"--mode"}, "default": "prod"}}, + {"file": {"exists": True, "value": "another file"}, "mode": {"exists": False, "value": "prod"}}, + ), + + # --- 'before' / 'after' SPECIAL CASES (ARE NOT AFFECTED BY allow_spaces) --- + # 'before' SPECIAL CASE + ( + ["script.py", "arg1", "arg2.1 arg2.2", "-f", "file.txt"], + {"before": "before", "file": {"-f"}}, + {"before": {"exists": True, "values": ["arg1", "arg2.1 arg2.2"]}, "file": {"exists": True, "value": "file.txt"}}, + ), + # 'after' SPECIAL CASE + ( + ["script.py", "-f", "file.txt", "arg1", "arg2.1 arg2.2"], + {"after": "after", "file": {"-f"}}, + {"after": {"exists": False, "values": []}, "file": {"exists": True, "value": "file.txt arg1 arg2.1 arg2.2"}}, + ), + ( + ["script.py", "arg1", "arg2.1 arg2.2"], + {"after": "after", "file": {"-f"}}, + {"after": {"exists": True, "values": ["arg1", "arg2.1 arg2.2"]}, "file": {"exists": False, "value": None}}, + ), # --- CUSTOM PREFIX TESTS WITH SPACES --- # QUESTION MARK AND DOUBLE PLUS PREFIXES WITH MULTIWORD VALUES - (["script.py", "?help", "show", "detailed", "info", "++mode", "test"], {"help": ["?help"], "mode": ["++mode"]}, - {"help": {"exists": True, "value": "show detailed info"}, "mode": {"exists": True, "value": "test"}}), + ( + ["script.py", "?help", "show", "detailed", "info", "++mode", "test"], + {"help": {"?help"}, "mode": {"++mode"}}, + {"help": {"exists": True, "value": "show detailed info"}, "mode": {"exists": True, "value": "test"}}, + ), # AT SYMBOL PREFIX WITH SPACES - (["script.py", "@message", "Hello", "World", "How", "are", "you" - ], {"message": ["@message"]}, {"message": {"exists": True, "value": "Hello World How are you"}}), + ( + ["script.py", "@message", "Hello", "World", "How", "are", "you"], + {"message": {"@message"}}, + {"message": {"exists": True, "value": "Hello World How are you"}}, + ), ] ) def test_get_args_with_spaces(monkeypatch, argv, find_args, expected_args_dict): @@ -235,13 +345,13 @@ def test_get_args_flag_without_value(monkeypatch): """Test that flags without values have None as their value, not True.""" # TEST SINGLE FLAG WITHOUT VALUE AT END OF ARGS monkeypatch.setattr(sys, "argv", ["script.py", "--verbose"]) - args_result = Console.get_args({"verbose": ["--verbose"]}) + args_result = Console.get_args({"verbose": {"--verbose"}}) assert args_result.verbose.exists is True assert args_result.verbose.value is None # TEST FLAG WITHOUT VALUE FOLLOWED BY ANOTHER FLAG monkeypatch.setattr(sys, "argv", ["script.py", "--verbose", "--debug"]) - args_result = Console.get_args({"verbose": ["--verbose"], "debug": ["--debug"]}) + args_result = Console.get_args({"verbose": {"--verbose"}, "debug": {"--debug"}}) assert args_result.verbose.exists is True assert args_result.verbose.value is None assert args_result.debug.exists is True @@ -249,7 +359,7 @@ def test_get_args_flag_without_value(monkeypatch): # TEST FLAG WITH DEFAULT VALUE BUT NO PROVIDED VALUE monkeypatch.setattr(sys, "argv", ["script.py", "--mode"]) - args_result = Console.get_args({"mode": {"flags": ["--mode"], "default": "production"}}) + args_result = Console.get_args({"mode": {"flags": {"--mode"}, "default": "production"}}) assert args_result.mode.exists is True assert args_result.mode.value is None @@ -263,35 +373,35 @@ def test_get_args_invalid_alias(): def test_get_args_invalid_config(): - with pytest.raises(TypeError, match="Invalid configuration type for alias 'bad_config'. " - "Must be a list, tuple, dict or literal 'before' / 'after'."): + with pytest.raises(TypeError, match="Invalid configuration type for alias 'bad_config'.\n" + "Must be a set, dict, literal 'before' or literal 'after'."): Console.get_args({"bad_config": 123}) # type: ignore[assignment] with pytest.raises(ValueError, match="Invalid configuration for alias 'missing_flags'. Dictionary must contain a 'flags' key."): Console.get_args({"missing_flags": {"default": "value"}}) # type: ignore[assignment] - with pytest.raises(ValueError, match="Invalid configuration for alias 'bad_flags'. " - "Dictionary must contain a 'default' key. Use a simple list/tuple if no default value is needed."): + with pytest.raises(ValueError, + match="Invalid configuration for alias 'bad_flags'. Dictionary must contain a 'default' key.\n" + "Use a simple set of strings if no default value is needed and only flags are to be specified."): Console.get_args({"bad_flags": {"flags": ["--flag"]}}) # type: ignore[assignment] - with pytest.raises(ValueError, match="Invalid 'flags' for alias 'bad_flags'. Must be a list or tuple."): + with pytest.raises(ValueError, match="Invalid 'flags' for alias 'bad_flags'. Must be a set of strings."): Console.get_args({"bad_flags": {"flags": "not-a-list", "default": "value"}}) # type: ignore[assignment] def test_get_args_duplicate_flag(): with pytest.raises(ValueError, match="Duplicate flag '-f' found. It's assigned to both 'file1' and 'file2'."): - Console.get_args({"file1": ["-f", "--file1"], "file2": {"flags": ["-f", "--file2"], "default": ...}}) + Console.get_args({"file1": {"-f", "--file1"}, "file2": {"flags": {"-f", "--file2"}, "default": "..."}}) with pytest.raises(ValueError, match="Duplicate flag '--long' found. It's assigned to both 'arg1' and 'arg2'."): - Console.get_args({"arg1": {"flags": ["--long"], "default": ...}, "arg2": ("-a", "--long")}) + Console.get_args({"arg1": {"flags": {"--long"}, "default": "..."}, "arg2": {"-a", "--long"}}) def test_get_args_dash_values_not_treated_as_flags(monkeypatch): """Test that values starting with dashes are not treated as flags unless explicitly defined""" monkeypatch.setattr(sys, "argv", ["script.py", "-v", "-42", "--input", "-3.14"]) - find_args = {"verbose": ["-v"], "input": ["--input"]} - result = Console.get_args(find_args) + result = Console.get_args({"verbose": {"-v"}, "input": {"--input"}}) assert result.verbose.exists is True assert result.verbose.value == "-42" @@ -302,8 +412,7 @@ def test_get_args_dash_values_not_treated_as_flags(monkeypatch): def test_get_args_dash_strings_as_values(monkeypatch): """Test that dash-prefixed strings are treated as values when not defined as flags""" monkeypatch.setattr(sys, "argv", ["script.py", "-f", "--not-a-flag", "-t", "-another-value"]) - find_args = {"file": ["-f"], "text": ["-t"]} - result = Console.get_args(find_args) + result = Console.get_args({"file": {"-f"}, "text": {"-t"}}) assert result.file.exists is True assert result.file.value == "--not-a-flag" @@ -314,8 +423,7 @@ def test_get_args_dash_strings_as_values(monkeypatch): def test_get_args_positional_with_dashes_before(monkeypatch): """Test that positional 'before' arguments include dash-prefixed values""" monkeypatch.setattr(sys, "argv", ["script.py", "-123", "--some-file", "normal", "-v"]) - find_args = {"before_args": "before", "verbose": ["-v"]} - result = Console.get_args(find_args) + result = Console.get_args({"before_args": "before", "verbose": {"-v"}}) assert result.before_args.exists is True assert result.before_args.values == ["-123", "--some-file", "normal"] @@ -326,8 +434,7 @@ def test_get_args_positional_with_dashes_before(monkeypatch): def test_get_args_positional_with_dashes_after(monkeypatch): """Test that positional 'after' arguments include dash-prefixed values""" monkeypatch.setattr(sys, "argv", ["script.py", "-v", "value", "-123", "--output-file", "-negative"]) - find_args = {"verbose": ["-v"], "after_args": "after"} - result = Console.get_args(find_args) + result = Console.get_args({"verbose": {"-v"}, "after_args": "after"}) assert result.verbose.exists is True assert result.verbose.value == "value" @@ -338,8 +445,7 @@ def test_get_args_positional_with_dashes_after(monkeypatch): def test_get_args_multiword_with_dashes(monkeypatch): """Test multiword values with dashes when allow_spaces=True""" monkeypatch.setattr(sys, "argv", ["script.py", "-m", "start", "-middle", "--end", "-f", "other"]) - find_args = {"message": ["-m"], "file": ["-f"]} - result = Console.get_args(find_args, allow_spaces=True) + result = Console.get_args({"message": {"-m"}, "file": {"-f"}}, allow_spaces=True) assert result.message.exists is True assert result.message.value == "start -middle --end" @@ -355,8 +461,13 @@ def test_get_args_mixed_dash_scenarios(monkeypatch): "after1", "-also-not-flag" ] ) - find_args = {"before": "before", "verbose": ["-v"], "debug": ["-d"], "file": ["--file"], "after": "after"} - result = Console.get_args(find_args) + result = Console.get_args({ + "before": "before", + "verbose": {"-v"}, + "debug": {"-d"}, + "file": {"--file"}, + "after": "after", + }) assert result.before.exists is True assert result.before.values == ["before1", "-not-flag", "before2"] @@ -621,12 +732,12 @@ def test_input_creates_prompt_session(mock_prompt_session, mock_formatcodes_prin assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'message' in call_kwargs - assert 'validator' in call_kwargs - assert 'validate_while_typing' in call_kwargs - assert 'key_bindings' in call_kwargs - assert 'bottom_toolbar' in call_kwargs - assert 'style' in call_kwargs + assert "message" in call_kwargs + assert "validator" in call_kwargs + assert "validate_while_typing" in call_kwargs + assert "key_bindings" in call_kwargs + assert "bottom_toolbar" in call_kwargs + assert "style" in call_kwargs mock_session.prompt.assert_called_once() @@ -638,8 +749,8 @@ def test_input_with_placeholder(mock_prompt_session, mock_formatcodes_print): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'placeholder' in call_kwargs - assert call_kwargs['placeholder'] != "" + assert "placeholder" in call_kwargs + assert call_kwargs["placeholder"] != "" def test_input_without_placeholder(mock_prompt_session, mock_formatcodes_print): @@ -650,8 +761,8 @@ def test_input_without_placeholder(mock_prompt_session, mock_formatcodes_print): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'placeholder' in call_kwargs - assert call_kwargs['placeholder'] == "" + assert "placeholder" in call_kwargs + assert call_kwargs["placeholder"] == "" def test_input_with_validator_function(mock_prompt_session, mock_formatcodes_print): @@ -667,9 +778,9 @@ def email_validator(text): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'validator' in call_kwargs - validator_instance = call_kwargs['validator'] - assert hasattr(validator_instance, 'validate') + assert "validator" in call_kwargs + validator_instance = call_kwargs["validator"] + assert hasattr(validator_instance, "validate") def test_input_with_length_constraints(mock_prompt_session, mock_formatcodes_print): @@ -680,9 +791,9 @@ def test_input_with_length_constraints(mock_prompt_session, mock_formatcodes_pri assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'validator' in call_kwargs - validator_instance = call_kwargs['validator'] - assert hasattr(validator_instance, 'validate') + assert "validator" in call_kwargs + validator_instance = call_kwargs["validator"] + assert hasattr(validator_instance, "validate") def test_input_with_allowed_chars(mock_prompt_session, mock_formatcodes_print): @@ -693,8 +804,8 @@ def test_input_with_allowed_chars(mock_prompt_session, mock_formatcodes_print): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'key_bindings' in call_kwargs - assert call_kwargs['key_bindings'] is not None + assert "key_bindings" in call_kwargs + assert call_kwargs["key_bindings"] is not None def test_input_disable_paste(mock_prompt_session, mock_formatcodes_print): @@ -705,8 +816,8 @@ def test_input_disable_paste(mock_prompt_session, mock_formatcodes_print): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'key_bindings' in call_kwargs - assert call_kwargs['key_bindings'] is not None + assert "key_bindings" in call_kwargs + assert call_kwargs["key_bindings"] is not None def test_input_with_start_end_formatting(mock_prompt_session, mock_formatcodes_print): @@ -727,8 +838,8 @@ def test_input_message_formatting(mock_prompt_session, mock_formatcodes_print): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'message' in call_kwargs - assert call_kwargs['message'] is not None + assert "message" in call_kwargs + assert call_kwargs["message"] is not None def test_input_bottom_toolbar_function(mock_prompt_session, mock_formatcodes_print): @@ -739,8 +850,8 @@ def test_input_bottom_toolbar_function(mock_prompt_session, mock_formatcodes_pri assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'bottom_toolbar' in call_kwargs - toolbar_func = call_kwargs['bottom_toolbar'] + assert "bottom_toolbar" in call_kwargs + toolbar_func = call_kwargs["bottom_toolbar"] assert callable(toolbar_func) try: @@ -758,8 +869,8 @@ def test_input_style_configuration(mock_prompt_session, mock_formatcodes_print): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'style' in call_kwargs - assert call_kwargs['style'] is not None + assert "style" in call_kwargs + assert call_kwargs["style"] is not None def test_input_validate_while_typing_enabled(mock_prompt_session, mock_formatcodes_print): @@ -770,8 +881,8 @@ def test_input_validate_while_typing_enabled(mock_prompt_session, mock_formatcod assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'validate_while_typing' in call_kwargs - assert call_kwargs['validate_while_typing'] is True + assert "validate_while_typing" in call_kwargs + assert call_kwargs["validate_while_typing"] is True def test_input_validator_class_creation(mock_prompt_session, mock_formatcodes_print): @@ -782,10 +893,10 @@ def test_input_validator_class_creation(mock_prompt_session, mock_formatcodes_pr assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'validator' in call_kwargs - validator_instance = call_kwargs['validator'] - assert hasattr(validator_instance, 'validate') - assert callable(getattr(validator_instance, 'validate', None)) + assert "validator" in call_kwargs + validator_instance = call_kwargs["validator"] + assert hasattr(validator_instance, "validate") + assert callable(getattr(validator_instance, "validate", None)) def test_input_key_bindings_setup(mock_prompt_session, mock_formatcodes_print): @@ -796,10 +907,10 @@ def test_input_key_bindings_setup(mock_prompt_session, mock_formatcodes_print): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'key_bindings' in call_kwargs - kb = call_kwargs['key_bindings'] + assert "key_bindings" in call_kwargs + kb = call_kwargs["key_bindings"] assert kb is not None - assert hasattr(kb, 'bindings') + assert hasattr(kb, "bindings") def test_input_mask_char_single_character(mock_prompt_session, mock_formatcodes_print): @@ -837,10 +948,10 @@ def test_input_custom_style_object(mock_prompt_session, mock_formatcodes_print): assert mock_session_class.called call_kwargs = mock_session_class.call_args[1] - assert 'style' in call_kwargs - style = call_kwargs['style'] + assert "style" in call_kwargs + style = call_kwargs["style"] assert style is not None - assert hasattr(style, 'style_rules') or hasattr(style, '_style') + assert hasattr(style, "style_rules") or hasattr(style, "_style") ################################################## PROGRESSBAR TESTS ################################################## @@ -905,16 +1016,16 @@ def test_progressbar_set_chars_invalid(): def test_progressbar_show_progress_invalid_total(): pb = ProgressBar() - with pytest.raises(ValueError, match="Total must be greater than 0"): + with pytest.raises(ValueError, match="The 'total' parameter must be a positive integer."): pb.show_progress(10, 0) - with pytest.raises(ValueError, match="Total must be greater than 0"): + with pytest.raises(ValueError, match="The 'total' parameter must be a positive integer."): pb.show_progress(10, -5) -@patch('sys.stdout', new_callable=io.StringIO) +@patch("sys.stdout", new_callable=io.StringIO) def test_progressbar_show_progress(mock_stdout): pb = ProgressBar() - with patch.object(pb, '_original_stdout', mock_stdout): + with patch.object(pb, "_original_stdout", mock_stdout): pb._original_stdout = mock_stdout pb.active = True pb._draw_progress_bar(50, 100, "Loading") @@ -933,7 +1044,7 @@ def test_progressbar_hide_progress(): def test_progressbar_progress_context(): pb = ProgressBar() - with patch.object(pb, 'show_progress') as mock_show, patch.object(pb, 'hide_progress') as mock_hide: + with patch.object(pb, "show_progress") as mock_show, patch.object(pb, "hide_progress") as mock_hide: with pb.progress_context(100, "Testing") as update_progress: update_progress(25) update_progress(50) @@ -945,8 +1056,8 @@ def test_progressbar_progress_context(): def test_progressbar_progress_context_exception(): pb = ProgressBar() - with (patch.object(pb, 'show_progress') as _, patch.object(pb, 'hide_progress') as - mock_hide, patch.object(pb, '_emergency_cleanup') as mock_cleanup): + with (patch.object(pb, "show_progress") as _, patch.object(pb, "hide_progress") as + mock_hide, patch.object(pb, "_emergency_cleanup") as mock_cleanup): with pytest.raises(ValueError): with pb.progress_context(100, "Testing") as update_progress: update_progress(25) diff --git a/tests/test_data.py b/tests/test_data.py index 6afbab5..4a7aa4a 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -11,7 +11,8 @@ ">> FULL VALUE IS A COMMENT value4", ], ">> FULL KEY + ALL ITS VALUES ARE A COMMENT key2": ["value", "value", "value"], - "key3": ">> ALL THE KEYS VALUES ARE COMMENTS value", + "key3": + ">> ALL THE KEYS VALUES ARE COMMENTS value", } d1_equal = { @@ -32,7 +33,7 @@ def test_serialize_bytes(): utf8_bytes = b"Hello" utf8_bytearray = bytearray(b"World") - non_utf8_bytes = b'\x80abc' + non_utf8_bytes = b"\x80abc" assert Data.serialize_bytes(utf8_bytes) == {"bytes": "Hello", "encoding": "utf-8"} assert Data.serialize_bytes(utf8_bytearray) == {"bytearray": "World", "encoding": "utf-8"} @@ -49,7 +50,7 @@ def test_deserialize_bytes(): utf8_serialized_bytes = {"bytes": "Hello", "encoding": "utf-8"} utf8_serialized_bytearray = {"bytearray": "World", "encoding": "utf-8"} import base64 - non_utf8_bytes = b'\x80abc' + non_utf8_bytes = b"\x80abc" base64_encoded = base64.b64encode(non_utf8_bytes).decode("utf-8") base64_serialized_bytes = {"bytes": base64_encoded, "encoding": "base64"} diff --git a/tests/test_file.py b/tests/test_file.py index 00de1f8..f3f33ff 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -1,4 +1,5 @@ -from xulbux.file import SameContentFileExistsError, File +from xulbux.base.exceptions import SameContentFileExistsError +from xulbux.file import File import pytest import os diff --git a/tests/test_format_codes.py b/tests/test_format_codes.py index 0418889..9c9fda4 100644 --- a/tests/test_format_codes.py +++ b/tests/test_format_codes.py @@ -84,10 +84,12 @@ def test_remove_ansi(): def test_remove_ansi_with_removals(): - ansi_string = f"{bold}Hello {orange}World!{reset}" - clean_string = "Hello World!" + 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) + removals = ((0, bold), (5, orange), (11, reset)) + assert FormatCodes.remove_ansi(ansi_string, get_removals=True, _ignore_linebreaks=True) == (clean_string, removals) def test_remove_formatting(): @@ -101,3 +103,11 @@ def test_remove_formatting_with_removals(): 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) + 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) + removals = ((0, default), (0, bold), (5, reset_bold), (5, orange), (11, default)) + assert FormatCodes.remove( + format_string, default_color="#FFF", get_removals=True, _ignore_linebreaks=True + ) == (clean_string, removals) diff --git a/tests/test_json.py b/tests/test_json.py index da9d618..d238236 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1,4 +1,4 @@ -from xulbux.file import SameContentFileExistsError +from xulbux.base.exceptions import SameContentFileExistsError from xulbux.json import Json import pytest @@ -30,13 +30,13 @@ def create_test_json_string(tmp_path, filename, content): "object": {">>": "whole key & value is a comment"}, ">>": "whole key & value is a comment", } -COMMENT_DATA_STR = '''{ +COMMENT_DATA_STR = """{ "key1": "value with no comments", "key2": "value >>inline comment<<", "list": [1, ">>item is a comment", 2, "item >>inline comment<<"], "object": {">>": "whole key & value is a comment"}, ">>": "whole key & value is a comment" -}''' +}""" COMMENT_DATA_PROCESSED = { "key1": "value with no comments", "key2": "value", @@ -44,27 +44,27 @@ def create_test_json_string(tmp_path, filename, content): "object": {}, } -COMMENT_DATA_START = '''{ +COMMENT_DATA_START = """{ "config": { "version >>ADJUSTED AUTOMATICALLY<<": 1.0, "features": ["a", "b"], ">>": "Features ^ must be adjusted manually" }, "user": "Test User >>DON'T TOUCH<<" -}''' +}""" COMMENT_UPDATE_VALUES = { "config->version": 2.0, "config->features->0": "c", "user": "Cool Test User", } -COMMENT_DATA_END = '''{ +COMMENT_DATA_END = """{ "config": { "version >>ADJUSTED AUTOMATICALLY<<": 2.0, "features": ["c", "b"], ">>": "Features ↑ must be adjusted manually" }, "user": "Cool Test User >>DON'T TOUCH<<" -}''' +}""" UPDATE_DATA_START = { "config": {"version": 1.0, "features": ["a", "b"]}, diff --git a/tests/test_path.py b/tests/test_path.py index a0ffbc0..5abe26e 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -1,4 +1,5 @@ -from xulbux.path import PathNotFoundError, Path +from xulbux.base.exceptions import PathNotFoundError +from xulbux.path import Path import tempfile import pytest diff --git a/tests/test_regex.py b/tests/test_regex.py index 6885527..c3eab2b 100644 --- a/tests/test_regex.py +++ b/tests/test_regex.py @@ -62,7 +62,7 @@ def test_regex_quotes_escaped_quotes(): def test_regex_quotes_nested_quotes(): """Test quotes pattern with nested quotes of different types""" - text = '''He said "She said 'Hello' to me" yesterday''' + text = """He said "She said 'Hello' to me" yesterday""" pattern = Regex.quotes() matches = rx.findall(pattern, text) assert matches == [('"', "She said 'Hello' to me")] @@ -174,7 +174,7 @@ def test_regex_brackets_ignore_in_strings(): text2 = 'outer("inner(test)")' matches2 = rx.findall(pattern, text2) assert len(matches2) == 1 - assert 'inner(test)' in matches2[0] + assert "inner(test)" in matches2[0] def test_regex_outside_strings_pattern(): @@ -205,7 +205,7 @@ def test_regex_outside_strings_with_special_chars(): def test_regex_outside_strings_complex_pattern(): """Test outside_strings with complex pattern""" pattern = Regex.outside_strings(r"[a-z]+") - text = 'word1 "word2" word3 \'word4\' word5' + text = "word1 \"word2\" word3 'word4' word5" matches = re.findall(pattern, text) assert len(matches) >= 3 assert any("word" in match for match in matches) diff --git a/tests/test_string.py b/tests/test_string.py index d4536d8..48d28be 100644 --- a/tests/test_string.py +++ b/tests/test_string.py @@ -16,7 +16,7 @@ def test_to_type(): assert String.to_type('{"c": [3, 4], "d": null}') == {"c": [3, 4], "d": None} assert String.to_type("(1, 'two', 3.0)") == (1, "two", 3.0) assert String.to_type("just a string") == "just a string" - assert String.to_type(" {'key': [1, 'val']} ") == {'key': [1, 'val']} + assert String.to_type(" {'key': [1, 'val']} ") == {"key": [1, "val"]} assert String.to_type("invalid { structure") == "invalid { structure" @@ -30,28 +30,34 @@ def test_normalize_spaces(): def test_escape(): assert String.escape("Line 1\nLine 2\tTabbed") == r"Line 1\nLine 2\tTabbed" - assert String.escape('Path: C:\\Users\\Name') == r"Path: C:\\Users\\Name" - assert String.escape('String with "double quotes"') == r'String with \"double quotes\"' - assert String.escape("String with 'single quotes'") == r"String with 'single quotes'" - assert String.escape('String with "double quotes"', str_quotes="'") == r'String with "double quotes"' - assert String.escape("String with 'single quotes'", str_quotes="'") == r"String with \'single quotes\'" + assert String.escape("Path: C:\\Users\\Name") == r"Path: C:\\Users\\Name" + # DEFAULT: NO ESCAPING QUOTES + assert String.escape('String with "double quotes"') == 'String with "double quotes"' + assert String.escape("String with 'single quotes'") == "String with 'single quotes'" assert String.escape( "Mix: \n \"quotes\" and 'single' \t tabs \\ backslash" + ) == r"""Mix: \n "quotes" and 'single' \t tabs \\ backslash""" + # ESCAPE DOUBLE QUOTES + assert String.escape('String with "double quotes"', str_quotes='"') == r'String with \"double quotes\"' + assert String.escape("String with 'single quotes'", str_quotes='"') == r"String with 'single quotes'" + assert String.escape( + "Mix: \n \"quotes\" and 'single' \t tabs \\ backslash", str_quotes='"' ) == r"Mix: \n \"quotes\" and 'single' \t tabs \\ backslash" + # ESCAPE SINGLE QUOTES + assert String.escape('String with "double quotes"', str_quotes="'") == r'String with "double quotes"' + assert String.escape("String with 'single quotes'", str_quotes="'") == r"String with \'single quotes\'" assert String.escape( "Mix: \n \"quotes\" and 'single' \t tabs \\ backslash", str_quotes="'" ) == r'Mix: \n "quotes" and \'single\' \t tabs \\ backslash' def test_is_empty(): - assert String.is_empty(None) is True # type: ignore[assignment] + assert String.is_empty(None) is True assert String.is_empty("") is True assert String.is_empty(" ") is False assert String.is_empty(" ", spaces_are_empty=True) is True assert String.is_empty("Not Empty") is False assert String.is_empty(" Not Empty ", spaces_are_empty=True) is False - assert String.is_empty(123) is False # type: ignore[assignment] - assert String.is_empty([]) is False # type: ignore[assignment] def test_single_char_repeats(): diff --git a/tests/test_system.py b/tests/test_system.py index 21d64e3..c2fc25b 100644 --- a/tests/test_system.py +++ b/tests/test_system.py @@ -7,10 +7,10 @@ def test_system_class_exists(): """Test that System class exists and has expected methods""" - assert hasattr(System, 'is_elevated') - assert hasattr(System, 'restart') - assert hasattr(System, 'check_libs') - assert hasattr(System, 'elevate') + assert hasattr(System, "is_elevated") + assert hasattr(System, "restart") + assert hasattr(System, "check_libs") + assert hasattr(System, "elevate") def test_system_is_elevated_property(): @@ -32,8 +32,8 @@ def test_check_libs_nonexistent_module(): assert "nonexistent_module_12345" in result -@patch('xulbux.system._subprocess.check_call') -@patch('builtins.input', return_value='n') # Decline installation +@patch("xulbux.system._subprocess.check_call") +@patch("builtins.input", return_value="n") # Decline installation def test_check_libs_decline_install(mock_input, mock_subprocess): """Test check_libs when user declines installation""" result = System.check_libs(["nonexistent_module_12345"], install_missing=True) @@ -42,9 +42,9 @@ def test_check_libs_decline_install(mock_input, mock_subprocess): mock_subprocess.assert_not_called() -@patch('xulbux.system._platform.system') -@patch('xulbux.system._subprocess.check_output') -@patch('xulbux.system._os.system') +@patch("xulbux.system._platform.system") +@patch("xulbux.system._subprocess.check_output") +@patch("xulbux.system._os.system") def test_restart_windows_simple(mock_os_system, mock_subprocess, mock_platform): """Test simple restart on Windows""" mock_platform.return_value.lower.return_value = "windows" @@ -53,8 +53,8 @@ def test_restart_windows_simple(mock_os_system, mock_subprocess, mock_platform): mock_os_system.assert_called_once_with("shutdown /r /t 0") -@patch('xulbux.system._platform.system') -@patch('xulbux.system._subprocess.check_output') +@patch("xulbux.system._platform.system") +@patch("xulbux.system._subprocess.check_output") def test_restart_too_many_processes(mock_subprocess, mock_platform): """Test restart fails when too many processes running""" mock_platform.return_value.lower.return_value = "windows" @@ -63,27 +63,27 @@ def test_restart_too_many_processes(mock_subprocess, mock_platform): System.restart() -@patch('xulbux.system._platform.system') -@patch('xulbux.system._subprocess.check_output') +@patch("xulbux.system._platform.system") +@patch("xulbux.system._subprocess.check_output") def test_restart_unsupported_system(mock_subprocess, mock_platform): """Test restart on unsupported system""" mock_platform.return_value.lower.return_value = "unknown" mock_subprocess.return_value = b"some output" - with pytest.raises(NotImplementedError, match="Restart not implemented for `unknown`"): + with pytest.raises(NotImplementedError, match="Restart not implemented for 'unknown' systems."): System.restart() -@pytest.mark.skipif(os.name != 'nt', reason="Windows-specific test") +@pytest.mark.skipif(os.name != "nt", reason="Windows-specific test") def test_elevate_windows_already_elevated(): """Test elevate on Windows when already elevated""" - with patch.object(System, 'is_elevated', True): + with patch.object(System, "is_elevated", True): result = System.elevate() assert result is True -@pytest.mark.skipif(os.name == 'nt', reason="POSIX-specific test") +@pytest.mark.skipif(os.name == "nt", reason="POSIX-specific test") def test_elevate_posix_already_elevated(): """Test elevate on POSIX when already elevated""" - with patch.object(System, 'is_elevated', True): + with patch.object(System, "is_elevated", True): result = System.elevate() assert result is True