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://pypi.org/project/xulbux) [](https://clickpy.clickhouse.com/dashboard/xulbux) [](https://github.com/XulbuX/PythonLibraryXulbuX/blob/main/LICENSE) [](https://github.com/XulbuX/PythonLibraryXulbuX/commits) [](https://github.com/XulbuX/PythonLibraryXulbuX/issues) [
-](https://github.com/XulbuX/PythonLibraryXulbuX/stargazers)
+[](https://pypi.org/project/xulbux) [](https://clickpy.clickhouse.com/dashboard/xulbux) [](https://github.com/XulbuX/PythonLibraryXulbuX/blob/main/LICENSE) [](https://github.com/XulbuX/PythonLibraryXulbuX/commits) [](https://github.com/XulbuX/PythonLibraryXulbuX/issues) [](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