diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
index 6e6fee4..3595d47 100644
--- a/.github/workflows/python-package.yml
+++ b/.github/workflows/python-package.yml
@@ -17,35 +17,35 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.10", "3.11", "3.12", "3.13"]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- - uses: actions/checkout@v4
-
- - name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v3
- with:
- python-version: ${{ matrix.python-version }}
-
- - name: Cache pip packages
- uses: actions/cache@v3
- with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/pyproject.toml') }}
- restore-keys: |
- ${{ runner.os }}-pip-
-
- - name: Install project and dependencies
- run: |
- python -m pip install --upgrade pip
- pip install -e .[dev]
- pip install flake8 flake8-pyproject pytest
-
- - name: Lint with flake8
- run: |
- python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- python -m flake8 . --exit-zero --max-complexity=12 --statistics
-
- - name: Test with pytest
- run: |
- python -m pytest --verbose
+ - uses: actions/checkout@v4
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v3
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Cache pip packages
+ uses: actions/cache@v3
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+
+ - name: Install project and dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e .[dev]
+ pip install flake8 flake8-pyproject pytest
+
+ - name: Lint with flake8
+ run: |
+ python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
+ python -m flake8 . --exit-zero --max-complexity=12 --statistics
+
+ - name: Test with pytest
+ run: |
+ python -m pytest --verbose
diff --git a/.gitignore b/.gitignore
index 4e8289e..1a9a421 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ cython_debug/
__pypackages__/
# TESTING
+.venv/
.pytest_cache/
# MISCELLANEOUS
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c78df1e..261ab41 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,7 @@
+
+## 16.12.2025 `v1.9.2`
+* Added a new class `LazyRegex` to the `regex` module, which is used to define regex patterns that are only compiled when they are used for the first time.
+* Removed unnecessary character escaping in the precompiled regex patterns in the `console` module.
+* Removed all the runtime type-checks that can also be checked using static type-checking tools, since you're supposed to use type checkers in modern python anyway, and to improve performance.
+* Renamed the internal class method `FormatCodes.__config_console()` to `FormatCodes._config_console()` to make it callable, but still indicate that it's internal.
+* Fixed a small bug where `Console.log_box_…()` would crash, when calling it without providing any `*values` (*content for inside the box*).
+
+**BREAKING CHANGES:**
+* The arguments when calling `Console.get_args()` are no longer specified in a single dictionary, but now each argument is passed as a separate keyword argument.
+ You can still use a dictionary just fine by simply unpacking it with `**`, like this:
+ ```python
+ Console.get_args(**{"arg": {"-a", "--arg"}})
+ ```
+* Replaced the internal `_COMPILED` regex pattern dictionaries with `LazyRegex` objects so it won't compile all regex patterns on library import, but only when they are used for the first time, which improves the library's import time.
+* Renamed the internal `_COMPILED` regex pattern dictionaries to `_PATTERNS` for better clarity.
+* Removed the import of the `ProgressBar` class from the `__init__.py` file, since it's not an important main class that should be imported directly.
+* Renamed the constant `CLR` to `CLI_COLORS` and the constant `HELP` to `CLI_HELP` in the `cli.help` module.
+* Changed the default value of the `strip_spaces` param in `Regex.brackets()` from `True` to `False`, since this is more intuitive behavior.
+
+
## 26.11.2025 `v1.9.1`
@@ -810,7 +832,7 @@ from XulbuX import rgb, hsl, hexa
Optional[bool]:
try:
if (latest := get_latest_version()) in {"", None}:
return None
- latest_v_parts = tuple(int(part) for part in (latest or "").lower().lstrip("v").split('.'))
- installed_v_parts = tuple(int(part) for part in __version__.lower().lstrip("v").split('.'))
+ latest_v_parts = tuple(int(part) for part in (latest or "").lower().lstrip("v").split("."))
+ installed_v_parts = tuple(int(part) for part in __version__.lower().lstrip("v").split("."))
return latest_v_parts <= installed_v_parts
except Exception:
return None
@@ -30,7 +30,8 @@ def is_latest_version() -> Optional[bool]:
URL = "https://pypi.org/pypi/xulbux/json"
IS_LATEST_VERSION = is_latest_version()
-CLR = {
+
+CLI_COLORS = {
"border": "dim|br:black",
"class": "br:cyan",
"const": "br:blue",
@@ -43,33 +44,34 @@ def is_latest_version() -> Optional[bool]:
"punctuator": "br:black",
"text": "white",
}
-HELP = FormatCodes.to_ansi(
+CLI_HELP = FormatCodes.to_ansi(
rf""" [_|b|#7075FF] __ __
[b|#7075FF] _ __ __ __/ / / /_ __ ___ __
[b|#7075FF] | |/ // / / / / / __ \/ / / | |/ /
[b|#7075FF] > , /_/ / /_/ /_/ / /_/ /> , <
- [b|#7075FF]/_/|_|\____/\__/\____/\____//_/|_| [*|#000|BG:#8085FF] v[b]{__version__} [*|dim|{CLR['notice']}]({'' if IS_LATEST_VERSION else ' (newer available)'})[*]
+ [b|#7075FF]/_/|_|\____/\__/\____/\____//_/|_| [*|#000|BG:#8085FF] v[b]{__version__} [*|dim|{CLI_COLORS["notice"]}]({"" if IS_LATEST_VERSION else " (newer available)"})[*]
[i|#9095FF]A TON OF COOL FUNCTIONS, YOU NEED![*]
- [b|{CLR['heading']}](Usage:)[*]
- [{CLR['border']}](╭────────────────────────────────────────────────────╮)[*]
- [{CLR['border']}](│) [i|{CLR['punctuator']}](# LIBRARY CONSTANTS)[*] [{CLR['border']}](│)[*]
- [{CLR['border']}](│) [{CLR['import']}]from [{CLR['lib']}]xulbux[{CLR['punctuator']}].[{CLR['lib']}]base[{CLR['punctuator']}].[{CLR['lib']}]consts [{CLR['import']}]import [{CLR['const']}]COLOR[{CLR['punctuator']}], [{CLR['const']}]CHARS[{CLR['punctuator']}], [{CLR['const']}]ANSI[*] [{CLR['border']}](│)[*]
- [{CLR['border']}](│) [i|{CLR['punctuator']}](# Main Classes)[*] [{CLR['border']}](│)[*]
- [{CLR['border']}](│) [{CLR['import']}]from [{CLR['lib']}]xulbux [{CLR['import']}]import [{CLR['class']}]Code[{CLR['punctuator']}], [{CLR['class']}]Color[{CLR['punctuator']}], [{CLR['class']}]Console[{CLR['punctuator']}], ...[*] [{CLR['border']}](│)[*]
- [{CLR['border']}](│) [i|{CLR['punctuator']}](# module specific imports)[*] [{CLR['border']}](│)[*]
- [{CLR['border']}](│) [{CLR['import']}]from [{CLR['lib']}]xulbux[{CLR['punctuator']}].[{CLR['lib']}]color [{CLR['import']}]import [{CLR['func']}]rgba[{CLR['punctuator']}], [{CLR['func']}]hsla[{CLR['punctuator']}], [{CLR['func']}]hexa[*] [{CLR['border']}](│)
- [{CLR['border']}](╰────────────────────────────────────────────────────╯)[*]
- [b|{CLR['heading']}](Documentation:)[*]
- [{CLR['border']}](╭────────────────────────────────────────────────────╮)[*]
- [{CLR['border']}](│) [{CLR['text']}]For more information see the GitHub page. [{CLR['border']}](│)[*]
- [{CLR['border']}](│) [{CLR['link']}](https://github.com/XulbuX/PythonLibraryXulbuX/wiki) [{CLR['border']}](│)[*]
- [{CLR['border']}](╰────────────────────────────────────────────────────╯)[*]
+ [b|{CLI_COLORS["heading"]}](Usage:)[*]
+ [{CLI_COLORS["border"]}](╭────────────────────────────────────────────────────╮)[*]
+ [{CLI_COLORS["border"]}](│) [i|{CLI_COLORS["punctuator"]}](# LIBRARY CONSTANTS)[*] [{CLI_COLORS["border"]}](│)[*]
+ [{CLI_COLORS["border"]}](│) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]base[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]consts [{CLI_COLORS["import"]}]import [{CLI_COLORS["const"]}]COLOR[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["const"]}]CHARS[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["const"]}]ANSI[*] [{CLI_COLORS["border"]}](│)[*]
+ [{CLI_COLORS["border"]}](│) [i|{CLI_COLORS["punctuator"]}](# Main Classes)[*] [{CLI_COLORS["border"]}](│)[*]
+ [{CLI_COLORS["border"]}](│) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux [{CLI_COLORS["import"]}]import [{CLI_COLORS["class"]}]Code[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["class"]}]Color[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["class"]}]Console[{CLI_COLORS["punctuator"]}], ...[*] [{CLI_COLORS["border"]}](│)[*]
+ [{CLI_COLORS["border"]}](│) [i|{CLI_COLORS["punctuator"]}](# module specific imports)[*] [{CLI_COLORS["border"]}](│)[*]
+ [{CLI_COLORS["border"]}](│) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]color [{CLI_COLORS["import"]}]import [{CLI_COLORS["func"]}]rgba[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["func"]}]hsla[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["func"]}]hexa[*] [{CLI_COLORS["border"]}](│)
+ [{CLI_COLORS["border"]}](╰────────────────────────────────────────────────────╯)[*]
+ [b|{CLI_COLORS["heading"]}](Documentation:)[*]
+ [{CLI_COLORS["border"]}](╭────────────────────────────────────────────────────╮)[*]
+ [{CLI_COLORS["border"]}](│) [{CLI_COLORS["text"]}]For more information see the GitHub page. [{CLI_COLORS["border"]}](│)[*]
+ [{CLI_COLORS["border"]}](│) [{CLI_COLORS["link"]}](https://github.com/XulbuX/PythonLibraryXulbuX/wiki) [{CLI_COLORS["border"]}](│)[*]
+ [{CLI_COLORS["border"]}](╰────────────────────────────────────────────────────╯)[*]
[_]"""
)
def show_help() -> None:
- print(HELP)
+ FormatCodes._config_console()
+ print(CLI_HELP)
Console.pause_exit(pause=True, prompt=" [dim](Press any key to exit...)\n\n")
diff --git a/src/xulbux/code.py b/src/xulbux/code.py
index 12752a9..0ebf2b2 100644
--- a/src/xulbux/code.py
+++ b/src/xulbux/code.py
@@ -18,11 +18,7 @@ def add_indent(code: str, indent: int) -> str:
--------------------------------------------------------------------------
- `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:
+ if 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())
@@ -32,9 +28,6 @@ def get_tab_spaces(code: str) -> int:
"""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
@@ -45,14 +38,8 @@ def change_tab_size(code: str, new_tab_size: int, remove_empty_lines: bool = Fal
- `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:
+ if 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)
@@ -73,9 +60,6 @@ def get_func_calls(code: str) -> list:
"""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 := _rx.findall(r"(?i)" + Regex.func_call(), code)):
@@ -90,12 +74,8 @@ def is_js(code: str, funcs: set[str] = {"__", "$t", "$lang"}) -> bool:
-------------------------------------------------------------
- `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:
+ if 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):
@@ -125,22 +105,22 @@ def is_js(code: str, funcs: set[str] = {"__", "$t", "$lang"}) -> bool:
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
- (r"\$[\w_$]+\s*\(", 2), # jQuery function calls
- (r"\bfunction\s*[\w_$]*\s*\(", 2), # Function declarations
- (r"[\w_$]+\s*=\s*function\s*\(", 2), # Function assignments
- (r"\b[\w_$]+\s*=>\s*[\{\(]", 2), # Arrow functions
- (r"\(function\s*\(\)\s*\{", 2), # IIFE pattern
- (funcs_pattern, 2), # Custom predefined functions
- (r"\b(true|false|null|undefined)\b", 1), # JS literals
- (r"===|!==|\+\+|--|\|\||&&", 1.5), # JS-specific operators
- (r"\bnew\s+[\w_$]+\s*\(", 1.5), # Object instantiation with new
- (r"\b(document|window|console|Math|Array|Object|String|Number)\.", 2), # JS objects
- (r"\basync\s+function|\bawait\b", 2), # Async/await
- (r"\b(if|for|while|switch)\s*\([^)]*\)\s*\{", 1), # Control structures with braces
- (r"\btry\s*\{[^}]*\}\s*catch\s*\(", 1.5), # Try-catch
- (r";[\s\n]*$", 0.5), # Semicolon line endings
+ js_indicators = [(r"\b(var|let|const)\s+[\w_$]+", 2), # JS VARIABLE DECLARATIONS
+ (r"\$[\w_$]+\s*=", 2), # jQuery-STYLE VARIABLES
+ (r"\$[\w_$]+\s*\(", 2), # jQuery FUNCTION CALLS
+ (r"\bfunction\s*[\w_$]*\s*\(", 2), # FUNCTION DECLARATIONS
+ (r"[\w_$]+\s*=\s*function\s*\(", 2), # FUNCTION ASSIGNMENTS
+ (r"\b[\w_$]+\s*=>\s*[\{\(]", 2), # ARROW FUNCTIONS
+ (r"\(function\s*\(\)\s*\{", 2), # IIFE PATTERN
+ (funcs_pattern, 2), # CUSTOM PREDEFINED FUNCTIONS
+ (r"\b(true|false|null|undefined)\b", 1), # JS LITERALS
+ (r"===|!==|\+\+|--|\|\||&&", 1.5), # JS-SPECIFIC OPERATORS
+ (r"\bnew\s+[\w_$]+\s*\(", 1.5), # OBJECT INSTANTIATION WITH NEW
+ (r"\b(document|window|console|Math|Array|Object|String|Number)\.", 2), # JS OBJECTS
+ (r"\basync\s+function|\bawait\b", 2), # ASYNC/AWAIT
+ (r"\b(if|for|while|switch)\s*\([^)]*\)\s*\{", 1), # CONTROL STRUCTURES WITH BRACES
+ (r"\btry\s*\{[^}]*\}\s*catch\s*\(", 1.5), # TRY-CATCH
+ (r";[\s\n]*$", 0.5), # SEMICOLON LINE ENDINGS
]
line_endings = [line.strip() for line in code.splitlines() if line.strip()]
diff --git a/src/xulbux/color.py b/src/xulbux/color.py
index 345d0ea..5abf19d 100644
--- a/src/xulbux/color.py
+++ b/src/xulbux/color.py
@@ -55,17 +55,12 @@ def __init__(self, r: int, g: int, b: int, a: Optional[float] = None, _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 an rgba() color object.")
- if not all(isinstance(x, int) and (0 <= x <= 255) for x in (r, g, b)):
+ if not all((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] inclusive, 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] inclusive, got {a!r}")
+ if a is not None and not (0.0 <= a <= 1.0):
+ raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}")
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))
@@ -317,17 +312,12 @@ def __init__(self, h: int, s: int, l: int, a: Optional[float] = None, _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 object.")
- if not (isinstance(h, int) and (0 <= h <= 360)):
- raise ValueError(f"The 'h' parameter must be an integer in range [0, 360] inclusive, 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] inclusive, 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] inclusive, got {a!r}")
+ if not (0 <= h <= 360):
+ raise ValueError(f"The 'h' parameter must be in range [0, 360] inclusive, got {h!r}")
+ if not all((0 <= x <= 100) for x in (s, l)):
+ raise ValueError(f"The 's' and 'l' parameters must be in range [0, 100] inclusive, got {s=} {l=}")
+ if a is not None and not (0.0 <= a <= 1.0):
+ raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}")
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))
@@ -860,9 +850,6 @@ def is_valid_hsla(color: AnyHsla, allow_alpha: bool = True) -> bool:
-----------------------------------------------------------------
- `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
@@ -907,11 +894,6 @@ def is_valid_hexa(
- `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
@@ -938,9 +920,6 @@ def is_valid(color: AnyRgba | AnyHsla | AnyHexa, allow_alpha: bool = True) -> bo
-------------------------------------------------------------------
- `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) \
@@ -1022,11 +1001,6 @@ def str_to_rgba(string: str, only_first: bool = False) -> Optional[rgba | list[r
---------------------------------------------------------------------------------------------------------------
- `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:
if not (match := _re.search(Regex.rgba_str(allow_alpha=True), string)):
return None
@@ -1071,12 +1045,10 @@ def rgba_to_hex_int(
This could affect the color a little bit, but will make sure, that it won't be interpreted
as a completely different color, when initializing it as a `hexa()` color or changing it
back to RGBA using `Color.hex_int_to_rgba()`."""
- if not all(isinstance(c, int) and 0 <= c <= 255 for c in (r, g, b)):
+ if not all((0 <= c <= 255) for c in (r, g, b)):
raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}")
- if a is not None and not (isinstance(a, float) and 0 <= a <= 1):
+ if a is not None and not (0.0 <= a <= 1.0):
raise ValueError(f"The 'a' parameter must be a float in [0.0, 1.0] or None, got {a!r}")
- 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)))
@@ -1104,11 +1076,7 @@ def hex_int_to_rgba(hex_int: int, preserve_original: bool = False) -> rgba:
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."""
- if not isinstance(hex_int, int):
- 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:
+ if not (0 <= hex_int <= 0xFFFFFFFF):
raise ValueError(f"Expected HEX integer in range [0x000000, 0xFFFFFFFF] inclusive, got 0x{hex_int:X}")
if len(hex_str := f"{hex_int:X}") <= 6:
@@ -1154,12 +1122,10 @@ def luminance(
* `"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)):
+ if not all(0 <= c <= 255 for c in (r, g, b)):
raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}")
if 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
@@ -1190,9 +1156,7 @@ def _linearize_srgb(c: float) -> float:
"""Helper method to linearize sRGB component following the WCAG standard.\n
----------------------------------------------------------------------------
- `c` -⠀the sRGB component value in range [0.0, 1.0] inclusive"""
- 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:
+ if not (0.0 <= c <= 1.0):
raise ValueError(f"The 'c' parameter must be in range [0.0, 1.0] inclusive, got {c!r}")
if c <= 0.03928:
@@ -1207,9 +1171,6 @@ def text_color_for_on_bg(text_bg_color: Rgba | Hexa) -> rgba | hexa | int:
- `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]
@@ -1230,16 +1191,13 @@ def adjust_lightness(color: Rgba | Hexa, lightness_change: float) -> rgba | hexa
in range `-1.0` (darken by 100%) and `1.0` (lighten by 100%)"""
was_hexa = Color.is_valid_hexa(color)
- 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:
+ if not (-1.0 <= lightness_change <= 1.0):
raise ValueError(
f"The 'lightness_change' parameter must be in range [-1.0, 1.0] inclusive, got {lightness_change!r}"
)
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
@@ -1260,11 +1218,7 @@ def adjust_saturation(color: Rgba | Hexa, saturation_change: float) -> rgba | he
in range `-1.0` (saturate by 100%) and `1.0` (desaturate by 100%)"""
was_hexa = Color.is_valid_hexa(color)
- 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:
+ if not (-1.0 <= saturation_change <= 1.0):
raise ValueError(
f"The 'saturation_change' parameter must be in range [-1.0, 1.0] inclusive, got {saturation_change!r}"
)
diff --git a/src/xulbux/console.py b/src/xulbux/console.py
index 294b3bf..c117d42 100644
--- a/src/xulbux/console.py
+++ b/src/xulbux/console.py
@@ -6,11 +6,12 @@
from .base.types import ArgConfigWithDefault, ArgResultRegular, ArgResultPositional, ProgressUpdater, Rgba, Hexa
from .base.consts import COLOR, CHARS, ANSI
-from .format_codes import _COMPILED as _FC_COMPILED, FormatCodes
+from .format_codes import _PATTERNS as _FC_PATTERNS, FormatCodes
from .string import String
from .color import Color, hexa
+from .regex import LazyRegex
-from typing import Generator, Callable, Optional, Literal, Mapping, Pattern, TypeVar, TextIO, cast
+from typing import Generator, Callable, Optional, Literal, TypeVar, TextIO, cast
from prompt_toolkit.key_binding import KeyPressEvent, KeyBindings
from prompt_toolkit.validation import ValidationError, Validator
from prompt_toolkit.styles import Style
@@ -24,22 +25,21 @@
import time as _time
import sys as _sys
import os as _os
-import re as _re
import io as _io
-_COMPILED: dict[str, Pattern] = { # PRECOMPILE REGULAR EXPRESSIONS
- "hr": _re.compile(r"(?i)\{hr\}"),
- "hr_no_nl": _re.compile(r"(?i)(? 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
+ ---------------------------------------------------------------------------------------------------------
+ - `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:
+ are present, as all tokens following the last flag are consumed as that flag's value.
+ - `**find_args` -⠀kwargs defining the argument aliases and their flags/configuration (explained below)\n
+ ---------------------------------------------------------------------------------------------------------
+ The `**find_args` keyword arguments can have the following structures for each alias:
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 `"default"` value:
```python
- "alias_name": {
+ alias_name={
"flags": {"-f", "--flag"},
"default": "some_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
+ alias_name="before" # COLLECTS NON-FLAGGED ARGS BEFORE FIRST FLAG
+ alias_name="after" # COLLECTS NON-FLAGGED ARGS AFTER LAST FLAG
```
- Example `find_args`:
+ #### Example usage:
```python
- find_args={
- "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",
- },
- }
+ Args(
+ # FOUND TWO POSITIONAL ARGUMENTS BEFORE THE FIRST FLAG
+ text = ArgResult(exists=True, values=["Hello", "World"]),
+ # FOUND ONE OF THE SPECIFIED FLAGS WITH A VALUE
+ arg1 = ArgResult(exists=True, value="value1"),
+ # FOUND ONE OF THE SPECIFIED FLAGS WITHOUT A VALUE
+ arg2 = ArgResult(exists=True, value=None),
+ # DIDN'T FIND ANY OF THE SPECIFIED FLAGS BUT HAS A DEFAULT VALUE
+ arg3 = ArgResult(exists=False, value="default_val"),
+ )
```
If the script is called via the command line:\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"` (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 `"default_val"` (not present, has default value)\n
- -----------------------------------------------------------------------------------------------------------
+ ... it would return an `Args` object:
+ ```python
+ Args(
+ # FOUND TWO ARGUMENTS BEFORE THE FIRST FLAG
+ text = ArgResult(exists=True, values=["Hello", "World"]),
+ # FOUND ONE OF THE SPECIFIED FLAGS WITH A FOLLOWING VALUE
+ arg1 = ArgResult(exists=True, value="value1"),
+ # FOUND ONE OF THE SPECIFIED FLAGS BUT NO FOLLOWING VALUE
+ arg2 = ArgResult(exists=True, value=None),
+ # DIDN'T FIND ANY OF THE SPECIFIED FLAGS AND HAS NO DEFAULT VALUE
+ arg3 = ArgResult(exists=False, value="default_val"),
+ )
+ ```
+ ---------------------------------------------------------------------------------------------------------
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.
+ ---------------------------------------------------------------------------------------------------------
+ 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)
-
- 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)}")
+ args_len = len(args := _sys.argv[1:])
# PARSE 'find_args' CONFIGURATION
for alias, config in find_args.items():
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"}:
- raise ValueError(
- f"Invalid positional argument type '{config}' for alias '{alias}'. Must be either 'before' or 'after'."
- )
if config == "before":
before_count += 1
if before_count > 1:
@@ -290,24 +271,18 @@ def get_args(
after_count += 1
if after_count > 1:
raise ValueError("Only one alias can have the value 'after' for positional argument collection.")
+ else:
+ raise ValueError(
+ f"Invalid positional argument type '{config}' for alias '{alias}'.\n"
+ "Must be either 'before' or 'after'."
+ )
positional_configs[alias] = config
results[alias] = {"exists": False, "values": []}
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.")
- elif "default" not in config:
- raise ValueError(
- 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"]
+ flags, default_value = config.get("flags"), config.get("default")
results[alias] = {"exists": False, "value": default_value}
else:
raise TypeError(
@@ -423,17 +398,6 @@ def pause_exit(
- `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="")
@@ -479,31 +443,14 @@ def log(
-------------------------------------------------------------------------------------------
The log message can be formatted with special formatting codes. For more detailed
information about formatting codes, see `format_codes` module documentation."""
- if 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:
+ if title_bg_color is not None and (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
+ if 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:
+ if 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:
+ if title_mx < 0:
raise ValueError("The 'title_mx' parameter must be a non-negative integer.")
title = "" if title is None else title.strip().upper()
@@ -587,9 +534,6 @@ 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(
title="DEBUG",
@@ -751,21 +695,9 @@ def log_box_filled(
-------------------------------------------------------------------------------------
The box content can be formatted with special formatting codes. For more detailed
information about formatting codes, see `format_codes` module documentation."""
- if 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:
+ if 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:
+ if indent < 0:
raise ValueError("The 'indent' parameter must be a non-negative integer.")
if Color.is_valid(box_bg_color):
@@ -777,15 +709,19 @@ def log_box_filled(
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 = [
+ 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)
- ]
+ + _FC_PATTERNS.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)]
FormatCodes.print(
- f"{start}{spaces_l}[bg:{box_bg_color}]{pady}[*]\n" + "\n".join(lines)
- + f"\n{spaces_l}[bg:{box_bg_color}]{pady}[_]",
+ ( \
+ f"{start}{spaces_l}[bg:{box_bg_color}]{pady}[*]\n"
+ + "\n".join(lines)
+ + ("\n" if lines else "")
+ + f"{spaces_l}[bg:{box_bg_color}]{pady}[_]"
+ ),
default_color=default_color or "#000",
sep="\n",
end=end,
@@ -839,42 +775,15 @@ 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:
+ if 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:
+ if 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:
+ if 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 not all(len(char) == 1 for char in _border_chars):
+ raise ValueError("The '_border_chars' parameter must only contain single-character strings.")
if border_style is not None and Color.is_valid(border_style):
border_style = Color.to_hexa(border_style)
@@ -899,13 +808,19 @@ def log_box_bordered(
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)
- ]
+ lines = [( \
+ h_rule if _PATTERNS.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}[_]",
+ ( \
+ f"{start}{border_t}[_]\n"
+ + "\n".join(lines)
+ + ("\n" if lines else "")
+ + f"{border_b}[_]"
+ ),
default_color=default_color,
sep="\n",
end=end,
@@ -922,7 +837,7 @@ def __prepare_log_box(
lines = []
for val in values:
val_str, result_parts, current_pos = str(val), [], 0
- for match in _COMPILED["hr"].finditer(val_str):
+ for match in _PATTERNS.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"
@@ -952,7 +867,7 @@ def __prepare_log_box(
lines = [line for val in values for line in str(val).splitlines()]
unfmt_lines = [FormatCodes.remove(line, default_color) for line in lines]
- max_line_len = max(len(line) for line in unfmt_lines)
+ max_line_len = max(len(line) for line in unfmt_lines) if unfmt_lines else 0
return lines, cast(list[tuple[str, tuple[tuple[int, str], ...]]], unfmt_lines), max_line_len
@staticmethod
@@ -973,13 +888,6 @@ def confirm(
------------------------------------------------------------------------------------
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'}): )",
@@ -1013,17 +921,6 @@ 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
@@ -1076,39 +973,12 @@ 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."""
- 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 is 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)}")
+ if mask_char is not None and len(mask_char) != 1:
+ raise ValueError(f"The 'mask_char' parameter must be a single character, got {mask_char!r}")
+ if min_len is not None and min_len < 0:
+ raise ValueError("The 'min_len' parameter must be a non-negative integer.")
+ if max_len is not None and max_len < 0:
+ raise ValueError("The 'max_len' parameter must be a non-negative integer.")
filtered_chars, result_text = set(), ""
has_default = default_val is not None
@@ -1277,13 +1147,14 @@ class ProgressBar:
-------------------------------------------------------------------------------------------------
- `min_width` -⠀the min width of the progress bar in chars
- `max_width` -⠀the max width of the progress bar in chars
- - `bar_format` -⠀the format string used to render the progress bar, containing placeholders:
+ - `bar_format` -⠀the format strings used to render the progress bar, containing placeholders:
* `{label}` `{l}`
* `{bar}` `{b}`
* `{current}` `{c}`
* `{total}` `{t}`
* `{percentage}` `{percent}` `{p}`
- `limited_bar_format` -⠀a simplified format string used when the console width is too small
+ for the normal `bar_format`
- `chars` -⠀a tuple of characters ordered from full to empty progress
The first character represents completely filled sections, intermediate
characters create smooth transitions, and the last character represents
@@ -1333,17 +1204,13 @@ 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 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:
+ if 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 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:
+ if 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)
@@ -1356,43 +1223,30 @@ def set_bar_format(
) -> None:
"""Set the format string used to render the progress bar.\n
--------------------------------------------------------------------------------------------------
- - `bar_format` -⠀the format string used to render the progress bar, containing placeholders:
+ - `bar_format` -⠀the format strings used to render the progress bar, containing placeholders:
* `{label}` `{l}`
* `{bar}` `{b}`
* `{current}` `{c}`
* `{total}` `{t}`
* `{percentage}` `{percent}` `{p}`
- - `limited_bar_format` -⠀a simplified format string used when the console width is too small
+ - `limited_bar_format` -⠀a simplified format strings used when the console width is too small
- `sep` -⠀the separator string used to join multiple format strings
--------------------------------------------------------------------------------------------------
The bar format (also limited) can additionally be formatted with special formatting codes. For
more detailed information about formatting codes, see the `format_codes` module documentation."""
if bar_format is not None:
- if not isinstance(bar_format, (list, tuple)):
- raise TypeError(f"The 'bar_format' parameter must be a list or tuple of strings, got {type(bar_format)}")
- elif not all(isinstance(s, str) for s in bar_format):
- raise ValueError("All elements of the 'bar_format' parameter must be strings.")
- elif not any(_COMPILED["bar"].search(s) for s in bar_format):
+ if not any(_PATTERNS.bar.search(s) for s in bar_format):
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 isinstance(limited_bar_format, (list, tuple)):
- raise TypeError(
- f"The 'limited_bar_format' parameter must be a list or tuple of strings, got {type(limited_bar_format)}"
- )
- elif not all(isinstance(s, str) for s in limited_bar_format):
- raise ValueError("All elements of the 'limited_bar_format' parameter must be strings.")
- elif not any(_COMPILED["bar"].search(s) for s in limited_bar_format):
+ if not any(_PATTERNS.bar.search(s) for s in limited_bar_format):
raise ValueError("The 'limited_bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.")
self.limited_bar_format = limited_bar_format
if sep is not None:
- if not isinstance(sep, str):
- raise TypeError(f"The 'sep' parameter must be a string or None, got {type(sep)}")
-
self.sep = sep
def set_chars(self, chars: tuple[str, ...]) -> None:
@@ -1415,26 +1269,19 @@ 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"""
- # VALIDATE TYPES NEEDED FOR THROTTLING LOGIC
- if not isinstance(current, int):
- raise TypeError(f"The 'current' parameter must be an integer, got {type(current)}")
- if not isinstance(total, int):
- raise TypeError(f"The 'total' parameter must be an integer, got {type(total)}")
-
# THROTTLE UPDATES (UNLESS IT'S THE FIRST/FINAL UPDATE)
current_time = _time.time()
- if not (self._last_update_time == 0.0 or current >= total or current < 0) \
- and (current_time - self._last_update_time) < self._min_update_interval:
+ if (
+ not (self._last_update_time == 0.0 or current >= total or current < 0) \
+ and (current_time - self._last_update_time) < self._min_update_interval
+ ):
return
self._last_update_time = current_time
- # REMAINING TYPE VALIDATION
if current < 0:
raise ValueError("The 'current' parameter must be a non-negative integer.")
if 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:
@@ -1464,7 +1311,7 @@ def progress_context(self, total: int, label: Optional[str] = None) -> Generator
- `current` -⠀update the current progress value
- `label` -⠀update the progress label\n
- Example usage:
+ #### Example usage:
```python
with ProgressBar().progress_context(500, "Loading...") as update_progress:
update_progress(0) # Show empty bar at start
@@ -1479,12 +1326,8 @@ def progress_context(self, total: int, label: Optional[str] = None) -> Generator
# Do some work...
update_progress(i, f"Finalizing ({i})") # Update both
```"""
- if not isinstance(total, int):
- raise TypeError(f"The 'total' parameter must be an integer, got {type(total)}")
- elif total <= 0:
+ if 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
@@ -1543,7 +1386,7 @@ def _draw_progress_bar(self, current: int, total: int, label: Optional[str] = No
)
bar = f"{self._create_bar(current, total, max(1, bar_width))}[*]"
- progress_text = _COMPILED["bar"].sub(FormatCodes.to_ansi(bar), formatted)
+ progress_text = _PATTERNS.bar.sub(FormatCodes.to_ansi(bar), formatted)
self._current_progress_str = progress_text
self._last_line_len = len(progress_text)
@@ -1561,17 +1404,17 @@ def _get_formatted_info_and_bar_width(
fmt_parts = []
for s in bar_format:
- fmt_part = _COMPILED["label"].sub(label or "", s)
- fmt_part = _COMPILED["current"].sub(str(current), fmt_part)
- fmt_part = _COMPILED["total"].sub(str(total), fmt_part)
- fmt_part = _COMPILED["percentage"].sub(f"{percentage:.1f}", fmt_part)
+ fmt_part = _PATTERNS.label.sub(label or "", s)
+ fmt_part = _PATTERNS.current.sub(str(current), fmt_part)
+ fmt_part = _PATTERNS.total.sub(str(total), fmt_part)
+ fmt_part = _PATTERNS.percentage.sub(f"{percentage:.1f}", fmt_part)
if fmt_part:
fmt_parts.append(fmt_part)
fmt_str = self.sep.join(fmt_parts)
fmt_str = FormatCodes.to_ansi(fmt_str)
- bar_space = Console.w - len(FormatCodes.remove_ansi(_COMPILED["bar"].sub("", fmt_str)))
+ bar_space = Console.w - len(FormatCodes.remove_ansi(_PATTERNS.bar.sub("", fmt_str)))
bar_width = min(bar_space, self.max_width) if bar_space > 0 else 0
return fmt_str, bar_width
@@ -1688,16 +1531,10 @@ def set_format(self, spinner_format: list[str] | tuple[str, ...], sep: Optional[
* `{label}` `{l}`
* `{animation}` `{a}`
- `sep` -⠀the separator string used to join multiple format strings"""
- if not isinstance(spinner_format, (list, tuple)):
- raise TypeError(f"The 'spinner_format' parameter must be a list or tuple, got {type(spinner_format)}")
- elif not all(isinstance(fmt, str) for fmt in spinner_format):
- raise TypeError("All elements of the 'spinner_format' parameter must be strings.")
- elif not any(_COMPILED["animation"].search(fmt) for fmt in spinner_format):
+ if not any(_PATTERNS.animation.search(fmt) for fmt in spinner_format):
raise ValueError(
"At least one format string in 'spinner_format' must contain the '{animation}' or '{a}' placeholder."
)
- if sep is not None and not isinstance(sep, str):
- raise TypeError(f"The 'sep' parameter must be a string or None, got {type(sep)}")
self.spinner_format = spinner_format
self.sep = sep or self.sep
@@ -1706,12 +1543,8 @@ def set_frames(self, frames: tuple[str, ...]) -> None:
"""Set the frames used for the spinner animation.\n
---------------------------------------------------------------------
- `frames` -⠀a tuple of strings representing the animation frames"""
- if not isinstance(frames, tuple):
- raise TypeError(f"The 'frames' parameter must be a tuple of strings, got {type(frames)}")
- elif len(frames) < 2:
+ if len(frames) < 2:
raise ValueError("The 'frames' parameter must contain at least two frames.")
- elif not all(isinstance(frame, str) for frame in frames):
- raise TypeError("All elements of the 'frames' parameter must be strings.")
self.frames = frames
@@ -1719,10 +1552,8 @@ def set_interval(self, interval: int | float) -> None:
"""Set the time interval between each animation frame.\n
-------------------------------------------------------------------
- `interval` -⠀the time in seconds between each animation frame"""
- if not isinstance(interval, (int, float)):
- raise TypeError(f"The 'interval' parameter must be a number, got {type(interval)}")
- elif interval <= 0:
- raise ValueError("The 'interval' parameter must be positive.")
+ if interval <= 0:
+ raise ValueError("The 'interval' parameter must be a positive number.")
self.interval = interval
@@ -1732,8 +1563,6 @@ def start(self, label: Optional[str] = None) -> None:
- `label` -⠀the label to display alongside the spinner"""
if self.active:
return
- 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)}")
self.label = label or self.label
self._start_intercepting()
@@ -1760,9 +1589,6 @@ def update_label(self, label: Optional[str]) -> None:
"""Update the spinner's label text.\n
--------------------------------------
- `new_label` -⠀the new label text"""
- if not isinstance(label, (str, type(None))):
- raise TypeError(f"The 'label' parameter must be a string or None, got {type(label)}")
-
self.label = label
@contextmanager
@@ -1774,7 +1600,7 @@ def context(self, label: Optional[str] = None) -> Generator[Callable[[str], None
The returned callable accepts a single parameter:
- `new_label` -⠀the new label text\n
- Example usage:
+ #### Example usage:
```python
with Spinner().context("Starting...") as update_label:
time.sleep(2)
@@ -1805,7 +1631,7 @@ def _animation_loop(self) -> None:
frame = FormatCodes.to_ansi(f"{self.frames[self._frame_index % len(self.frames)]}[*]")
formatted = FormatCodes.to_ansi(self.sep.join(
s for s in ( \
- _COMPILED["animation"].sub(frame, _COMPILED["label"].sub(self.label or "", s))
+ _PATTERNS.animation.sub(frame, _PATTERNS.label.sub(self.label or "", s))
for s in self.spinner_format
) if s
))
diff --git a/src/xulbux/data.py b/src/xulbux/data.py
index 3783914..2f21294 100644
--- a/src/xulbux/data.py
+++ b/src/xulbux/data.py
@@ -10,7 +10,7 @@
from .string import String
from .regex import Regex
-from typing import Optional, Any
+from typing import Optional, Literal, Any, cast
import base64 as _base64
import math as _math
import re as _re
@@ -24,13 +24,10 @@ def serialize_bytes(data: bytes | bytearray) -> dict[str, str]:
"""Converts bytes or bytearray to a JSON-compatible format (dictionary) with explicit keys.\n
----------------------------------------------------------------------------------------------
- `data` -⠀the bytes or bytearray to serialize"""
- 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"}
+ return {key: cast(bytes | bytearray, data).decode("utf-8"), "encoding": "utf-8"}
except UnicodeDecodeError:
pass
@@ -44,9 +41,6 @@ def deserialize_bytes(obj: dict[str, str]) -> bytes | bytearray:
--------------------------------------------------------------------------------------------------------
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":
@@ -65,9 +59,6 @@ def chars_count(data: DataStructure) -> int:
"""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):
@@ -85,9 +76,6 @@ def strip(data: DataStructure) -> DataStructure:
"""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()}
@@ -102,9 +90,6 @@ def remove_empty_items(data: DataStructure, spaces_are_empty: bool = False) -> D
---------------------------------------------------------------------------------
- `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))
@@ -128,9 +113,6 @@ def remove_duplicates(data: DataStructure) -> DataStructure:
"""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()}
@@ -173,7 +155,7 @@ def remove_comments(
- `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:
+ #### Examples:
```python
data = {
"key1": [
@@ -214,19 +196,11 @@ def remove_comments(
* `value4` The whole value is removed, since the whole value was a comment.
- For `key2`, the key, including its whole values will be removed.
- For `key3`, since all its values are just comments, the key will still exist, but with a value of `None`."""
- if 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)}")
+ if len(comment_start) == 0:
+ raise ValueError("The 'comment_start' parameter string must not be empty.")
pattern = _re.compile(Regex._clean( \
- rf"""^(
+ rf"""^(
(?:(?!{_re.escape(comment_start)}).)*
)
{_re.escape(comment_start)}
@@ -282,18 +256,8 @@ def is_equal(
------------------------------------------------------------------------------------------------
The paths from `ignore_paths` and the `path_sep` parameter work exactly the same way as for
the method `Data.get_path_id()`. See its documentation for more details."""
- if 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()'
+ if len(path_sep) == 0:
+ raise ValueError("The 'path_sep' parameter string must not be empty.")
def process_ignore_paths(ignore_paths: str | list[str], ) -> list[list[str]]:
if isinstance(ignore_paths, str):
@@ -351,7 +315,7 @@ def get_path_id(
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:
+ #### In this example:
```python
{
"healthy": {
@@ -363,18 +327,8 @@ def get_path_id(
... if you want to change the value of `"apples"` to `"strawberries"`, the value path would be
`healthy->fruit->apples` or if you don't know that the value is `"apples"` you can also use the
index of the value, so `healthy->fruit->0`."""
- if 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)}")
+ if len(path_sep) == 0:
+ raise ValueError("The 'path_sep' parameter string must not be empty.")
def process_path(path: str, data_obj: DataStructure) -> Optional[str]:
keys = path.split(path_sep)
@@ -433,12 +387,6 @@ def get_value_by_path_id(data: DataStructure, path_id: str, get_key: bool = Fals
- `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
@@ -477,10 +425,6 @@ def set_value_by_path_id(data: DataStructure, update_values: dict[str, Any]) ->
{ "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:
@@ -515,7 +459,7 @@ def update_nested(data: DataStructure, path: list[int], value: Any) -> DataStruc
def to_str(
data: DataStructure,
indent: int = 4,
- compactness: int = 1,
+ compactness: Literal[0, 1, 2] = 1,
max_width: int = 127,
sep: str = ", ",
as_json: bool = False,
@@ -535,28 +479,10 @@ def to_str(
- `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)"""
- 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:
+ if 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:
+ if 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 = {}
@@ -640,19 +566,15 @@ def should_expand(seq: IndexIterable) -> bool:
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
- )
+ 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 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["}"]
+ 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():
@@ -681,7 +603,7 @@ def format_sequence(seq, current_indent: int) -> str:
def print(
data: DataStructure,
indent: int = 4,
- compactness: int = 1,
+ compactness: Literal[0, 1, 2] = 1,
max_width: int = 127,
sep: str = ", ",
end: str = "\n",
diff --git a/src/xulbux/env_path.py b/src/xulbux/env_path.py
index 6f7206e..d0b29e7 100644
--- a/src/xulbux/env_path.py
+++ b/src/xulbux/env_path.py
@@ -18,9 +18,6 @@ def paths(as_list: bool = False) -> str | list:
"""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
@@ -31,13 +28,6 @@ def has_path(path: Optional[str] = None, cwd: bool = False, base_dir: bool = Fal
- `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)}
@@ -48,13 +38,6 @@ def add_path(path: Optional[str] = None, cwd: bool = False, base_dir: bool = Fal
- `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)
@@ -65,13 +48,6 @@ def remove_path(path: Optional[str] = None, cwd: bool = False, base_dir: bool =
- `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)
@@ -88,14 +64,13 @@ def __get(path: Optional[str] = None, cwd: bool = False, base_dir: bool = False)
path = Path.script_dir
if path is None:
- raise ValueError("No path provided. Please provide a 'path' or set either 'cwd' or 'base_dir' to True.")
+ raise ValueError("No path provided.\nPlease provide a 'path' or set either 'cwd' or 'base_dir' to True.")
return _os.path.normpath(path)
@staticmethod
def __persistent(path: str, remove: bool = False) -> None:
"""Add or remove a path from PATH persistently across sessions as well as the current session."""
-
current_paths = list(EnvPath.paths(as_list=True))
path = _os.path.normpath(path)
@@ -114,7 +89,7 @@ def __persistent(path: str, remove: bool = False) -> None:
_winreg.SetValueEx(key, "PATH", 0, _winreg.REG_EXPAND_SZ, new_path)
_winreg.CloseKey(key)
except Exception as e:
- raise RuntimeError(f"Failed to update PATH in registry:\n " + str(e).replace("\n", " \n"))
+ raise RuntimeError("Failed to update PATH in registry:\n " + str(e).replace("\n", " \n"))
else: # UNIX-LIKE (LINUX/macOS)
shell_rc_file = _os.path.expanduser(
diff --git a/src/xulbux/file.py b/src/xulbux/file.py
index f684a6d..b47e664 100644
--- a/src/xulbux/file.py
+++ b/src/xulbux/file.py
@@ -27,15 +27,6 @@ def rename_extension(
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)
@@ -67,13 +58,6 @@ def create(file_path: str, content: str = "", force: bool = False) -> str:
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()
diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py
index ea3904c..0342a7f 100644
--- a/src/xulbux/format_codes.py
+++ b/src/xulbux/format_codes.py
@@ -154,11 +154,11 @@
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.types import Match, Rgba, Hexa
from .base.consts import ANSI
from .string import String
-from .regex import Regex
+from .regex import LazyRegex, Regex
from .color import Color, rgba
from typing import Optional, Literal, cast
@@ -166,7 +166,6 @@
import regex as _rx
import sys as _sys
import os as _os
-import re as _re
_CONSOLE_ANSI_CONFIGURED: bool = False
@@ -184,32 +183,27 @@
"BG": rf"(?:{'|'.join(_PREFIX['BG'])})\s*:",
"BR": rf"(?:{'|'.join(_PREFIX['BR'])})\s*:",
}
-_COMPILED: dict[str, Pattern] = { # PRECOMPILE REGULAR EXPRESSIONS
- "*": _re.compile(r"\[\s*([^]_]*?)\s*\*\s*([^]_]*?)\]"),
- "*_inside": _re.compile(r"([^|]*?)\s*\*\s*([^|]*)"),
- "ansi_seq": _re.compile(ANSI.CHAR + r"(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"),
- "formatting": _rx.compile(
- Regex.brackets("[", "]", is_group=True, ignore_in_strings=False)
- + r"(?:([/\\]?)"
- + Regex.brackets("(", ")", is_group=True, strip_spaces=False, ignore_in_strings=False)
- + r")?"
+
+# PRECOMPILE REGULAR EXPRESSIONS
+_PATTERNS = LazyRegex(
+ star_reset=r"\[\s*([^]_]*?)\s*\*\s*([^]_]*?)\]",
+ star_reset_inside=r"([^|]*?)\s*\*\s*([^|]*)",
+ ansi_seq=ANSI.CHAR + r"(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])",
+ formatting=(
+ Regex.brackets("[", "]", is_group=True, ignore_in_strings=False) + r"(?:([/\\]?)"
+ + Regex.brackets("(", ")", is_group=True, strip_spaces=False, ignore_in_strings=False) + r")?"
),
- "escape_char": _re.compile(r"(\s*)(\/|\\)"),
- "escape_char_cond": _re.compile(r"(\s*\[\s*)(\/|\\)(?!\2+)"),
- "bg?_default": _re.compile(r"(?i)((?:" + _PREFIX_RX["BG"] + r")?)\s*default"),
- "bg_default": _re.compile(r"(?i)" + _PREFIX_RX["BG"] + r"\s*default"),
- "modifier": _re.compile(
+ escape_char=r"(\s*)(\/|\\)",
+ escape_char_cond=r"(\s*\[\s*)(\/|\\)(?!\2+)",
+ bg_opt_default=r"(?i)((?:" + _PREFIX_RX["BG"] + r")?)\s*default",
+ bg_default=r"(?i)" + _PREFIX_RX["BG"] + r"\s*default",
+ modifier=(
r"(?i)^((?:BG\s*:)?)\s*("
- + "|".join(
- [f"{_re.escape(m)}+" for m in _DEFAULT_COLOR_MODS["lighten"] + _DEFAULT_COLOR_MODS["darken"]]
- )
- + r")$"
- ),
- "rgb": _re.compile(
- r"(?i)^\s*(" + _PREFIX_RX["BG"] + r")?\s*(?:rgb|rgba)?\s*\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?\s*$"
+ + "|".join([f"{_rx.escape(m)}+" for m in _DEFAULT_COLOR_MODS["lighten"] + _DEFAULT_COLOR_MODS["darken"]]) + r")$"
),
- "hex": _re.compile(r"(?i)^\s*(" + _PREFIX_RX["BG"] + r")?\s*(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})\s*$"),
-}
+ rgb=r"(?i)^\s*(" + _PREFIX_RX["BG"] + r")?\s*(?:rgb|rgba)?\s*\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?\s*$",
+ hex=r"(?i)^\s*(" + _PREFIX_RX["BG"] + r")?\s*(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})\s*$",
+)
class FormatCodes:
@@ -236,16 +230,7 @@ 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()
+ FormatCodes._config_console()
_sys.stdout.write(FormatCodes.to_ansi(sep.join(map(str, values)) + end, default_color, brightness_steps))
if flush:
@@ -268,12 +253,7 @@ 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()
+ FormatCodes._config_console()
user_input = input(FormatCodes.to_ansi(str(prompt), default_color, brightness_steps))
if reset_ansi:
@@ -299,17 +279,8 @@ def to_ansi(
--------------------------------------------------------------------------------------------------
For exact information about how to use special formatting codes,
see the `format_codes` module documentation."""
- 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.__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):
+ if 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)
@@ -318,9 +289,9 @@ def to_ansi(
default_color = cast(Optional[rgba], default_color)
if use_default:
- string = _COMPILED["*"].sub(r"[\1_|default\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|default|…]`
+ string = _PATTERNS.star_reset.sub(r"[\1_|default\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|default|…]`
else:
- string = _COMPILED["*"].sub(r"[\1_\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|…]`
+ string = _PATTERNS.star_reset.sub(r"[\1_\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|…]`
def is_valid_color(color: str) -> bool:
return bool((color in ANSI.COLOR_MAP) or Color.is_valid_rgba(color) or Color.is_valid_hexa(color))
@@ -330,8 +301,8 @@ def replace_keys(match: Match) -> str:
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 formats_escaped := bool(_PATTERNS.escape_char_cond.match(match.group(0))):
+ _formats = formats = _PATTERNS.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(
@@ -396,8 +367,10 @@ def replace_keys(match: Match) -> str:
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}]"
@@ -409,7 +382,7 @@ def replace_keys(match: Match) -> str:
) + ("" if auto_reset_escaped else "".join(ansi_resets))
)
- string = "\n".join(_COMPILED["formatting"].sub(replace_keys, line) for line in string.split("\n"))
+ string = "\n".join(_PATTERNS.formatting.sub(replace_keys, line) for line in string.split("\n"))
return (((FormatCodes.__get_default_ansi(default_color) or "") if _default_start else "")
+ string) if default_color is not None else string
@@ -428,14 +401,6 @@ def escape(
-----------------------------------------------------------------------------------------
For exact information about how to use special formatting codes,
see the `format_codes` module documentation."""
- 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.__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)
def escape_format_code(match: Match) -> str:
@@ -443,15 +408,15 @@ def escape_format_code(match: Match) -> str:
formats, auto_reset_txt = match.group(1), match.group(3)
# CHECK IF ALREADY ESCAPED OR CONTAINS NO FORMATTING
- if not formats or _COMPILED["escape_char_cond"].match(match.group(0)):
+ if not formats or _PATTERNS.escape_char_cond.match(match.group(0)):
return match.group(0)
# TEMPORARILY REPLACE `*` FOR VALIDATION
_formats = formats
if use_default:
- _formats = _COMPILED["*_inside"].sub(r"\1_|default\2", formats)
+ _formats = _PATTERNS.star_reset_inside.sub(r"\1_|default\2", formats)
else:
- _formats = _COMPILED["*_inside"].sub(r"\1_\2", formats)
+ _formats = _PATTERNS.star_reset_inside.sub(r"\1_\2", formats)
if all((FormatCodes.__get_replacement(k, default_color) != k) for k in FormatCodes.__formats_to_keys(_formats)):
# ESCAPE THE FORMATTING CODE
@@ -470,16 +435,13 @@ def escape_format_code(match: Match) -> str:
result += f"({escaped_auto_reset})"
return result
- return "\n".join(_COMPILED["formatting"].sub(escape_format_code, l) for l in string.split("\n"))
+ return "\n".join(_PATTERNS.formatting.sub(escape_format_code, l) for l in string.split("\n"))
@staticmethod
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
@@ -493,17 +455,9 @@ def remove(
--------------------------------------------------------------------------------------------------------
- `string` -⠀the string that contains the formatting codes to remove
- `default_color` -⠀the default text color to use if no other text color was applied
- - `get_removals` -⠀if true, additionally to the cleaned string, a list of tuples will be returned,
+ - `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,
@@ -519,16 +473,9 @@ def remove_ansi(
"""Removes all ANSI codes from the string with optional tracking of removed codes.\n
---------------------------------------------------------------------------------------------------
- `ansi_string` -⠀the string that contains the ANSI codes to remove
- - `get_removals` -⠀if true, additionally to the cleaned string, a list of tuples will be returned,
+ - `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 = []
@@ -539,21 +486,23 @@ def replacement(match: Match) -> str:
removals.append((start_pos, match.group()))
return ""
- clean_string = _COMPILED["ansi_seq"].sub(
+ clean_string = _PATTERNS.ansi_seq.sub(
replacement,
ansi_string.replace("\n", "") if _ignore_linebreaks else ansi_string # REMOVE LINEBREAKS FOR POSITIONS
)
if _ignore_linebreaks:
- clean_string = _COMPILED["ansi_seq"].sub("", ansi_string) # BUT KEEP LINEBREAKS IN RETURNED CLEAN STRING
+ clean_string = _PATTERNS.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)
+ return _PATTERNS.ansi_seq.sub("", ansi_string)
@staticmethod
- def __config_console() -> None:
- """Configure the console to be able to interpret ANSI formatting."""
+ def _config_console() -> None:
+ """Internal method which configure the console to be able to interpret and render ANSI formatting.\n
+ -----------------------------------------------------------------------------------------------------
+ This method will only do something the first time it's called. Subsequent calls will do nothing."""
global _CONSOLE_ANSI_CONFIGURED
if not _CONSOLE_ANSI_CONFIGURED:
_sys.stdout.flush()
@@ -595,11 +544,11 @@ def __get_default_ansi(
if not isinstance(default_color, rgba):
return None
_default_color: tuple[int, int, int] = tuple(default_color)[:3]
- if brightness_steps is None or (format_key and _COMPILED["bg?_default"].search(format_key)):
- return (ANSI.SEQ_BG_COLOR if format_key and _COMPILED["bg_default"].search(format_key) else ANSI.SEQ_COLOR).format(
+ if brightness_steps is None or (format_key and _PATTERNS.bg_opt_default.search(format_key)):
+ return (ANSI.SEQ_BG_COLOR if format_key and _PATTERNS.bg_default.search(format_key) else ANSI.SEQ_COLOR).format(
*_default_color
)
- if format_key is None or not (match := _COMPILED["modifier"].match(format_key)):
+ if format_key is None or not (match := _PATTERNS.modifier.match(format_key)):
return None
is_bg, modifiers = match.groups()
adjust = 0
@@ -633,8 +582,8 @@ def __get_replacement(format_key: str, default_color: Optional[rgba], brightness
v for k, v in ANSI.CODES_MAP.items() if format_key == k or (isinstance(k, tuple) and format_key in k)
), None)
)
- rgb_match = _COMPILED["rgb"].match(format_key)
- hex_match = _COMPILED["hex"].match(format_key)
+ rgb_match = _PATTERNS.rgb.match(format_key)
+ hex_match = _PATTERNS.hex.match(format_key)
try:
if rgb_match:
is_bg = rgb_match.group(1)
diff --git a/src/xulbux/json.py b/src/xulbux/json.py
index 3fcbc2b..329c62b 100644
--- a/src/xulbux/json.py
+++ b/src/xulbux/json.py
@@ -7,7 +7,7 @@
from .file import File
from .path import Path
-from typing import Any
+from typing import Literal, Any
import json as _json
@@ -34,13 +34,6 @@ def read(
------------------------------------------------------------------------------------
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"
if (file_path := Path.extend_or_make(json_file, prefer_script_dir=True)) is None:
@@ -64,7 +57,7 @@ def create(
json_file: str,
data: dict,
indent: int = 2,
- compactness: int = 1,
+ compactness: Literal[0, 1, 2] = 1,
force: bool = False,
) -> str:
"""Create a nicely formatted JSON file from a dictionary.\n
@@ -80,14 +73,6 @@ def create(
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"
@@ -145,13 +130,6 @@ def update(
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,
diff --git a/src/xulbux/path.py b/src/xulbux/path.py
index eaff2c0..58586cf 100644
--- a/src/xulbux/path.py
+++ b/src/xulbux/path.py
@@ -65,21 +65,13 @@ def extend(
raises a `PathNotFoundError` if `raise_error` is true."""
search_dirs: list[str] = []
- if not isinstance(rel_path, str):
- raise TypeError(f"The 'rel_path' parameter must be a string, got {type(rel_path)}")
if search_in is not None:
if isinstance(search_in, str):
search_dirs.extend([search_in])
elif isinstance(search_in, list):
- if not all(isinstance(item, str) for item in search_in):
- raise TypeError("All items in the 'search_in' list must be strings.")
search_dirs.extend(search_in)
else:
raise TypeError(f"The 'search_in' parameter must be a string or a list of strings, got {type(search_in)}")
- if not isinstance(raise_error, bool):
- raise TypeError(f"The 'raise_error' parameter must be a boolean, got {type(raise_error)}")
- if not isinstance(use_closest_match, bool):
- raise TypeError(f"The 'use_closest_match' parameter must be a boolean, got {type(use_closest_match)}")
if rel_path == "":
if raise_error:
@@ -165,12 +157,6 @@ def extend_or_make(
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."""
- # THE 'rel_path' PARAM IS CHECKED IN 'Path.extend()'
- # THE 'search_in' PARAM IS CHECKED IN 'Path.extend()'
- if not isinstance(prefer_script_dir, bool):
- raise TypeError(f"The 'prefer_script_dir' parameter must be a boolean, got {type(prefer_script_dir)}")
- # THE 'use_closest_match' PARAM IS CHECKED IN 'Path.extend()'
-
try:
return str(Path.extend( \
rel_path=rel_path,
@@ -191,11 +177,6 @@ def remove(path: str, only_content: bool = False) -> None:
- `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 isinstance(path, str):
- raise TypeError(f"The 'path' parameter must be a string, got {type(path)}")
- if not isinstance(only_content, bool):
- raise TypeError(f"The 'only_content' parameter must be a boolean, got {type(only_content)}")
-
if not _os.path.exists(path):
return None
diff --git a/src/xulbux/regex.py b/src/xulbux/regex.py
index 1e4a53d..c5ddf7f 100644
--- a/src/xulbux/regex.py
+++ b/src/xulbux/regex.py
@@ -27,7 +27,7 @@ def brackets(
bracket1: str = "(",
bracket2: str = ")",
is_group: bool = False,
- strip_spaces: bool = True,
+ strip_spaces: bool = False,
ignore_in_strings: bool = True,
) -> str:
"""Matches everything inside pairs of brackets, including other nested brackets.\n
@@ -35,22 +35,11 @@ def brackets(
- `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
+ - `strip_spaces` -⠀whether to strip spaces from the bracket content or not
- `ignore_in_strings` -⠀whether to ignore closing brackets that are inside
strings/quotes (e.g. `'…)…'` or `"…)…"`)\n
---------------------------------------------------------------------------------------
Attention: Requires non-standard library `regex`, not standard library `re`!"""
- 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
@@ -58,36 +47,33 @@ def brackets(
s2 = "" if strip_spaces else r"\s*"
if ignore_in_strings:
- return Regex._clean(
+ return Regex._clean( \
rf"""{b1}{s1}({g}{s2}(?:
- [^{b1}{b2}"']
- |"(?:\\.|[^"\\])*"
- |'(?:\\.|[^'\\])*'
- |{b1}(?:
[^{b1}{b2}"']
|"(?:\\.|[^"\\])*"
|'(?:\\.|[^'\\])*'
- |(?R)
- )*{b2}
- )*{s2}){s1}{b2}"""
+ |{b1}(?:
+ [^{b1}{b2}"']
+ |"(?:\\.|[^"\\])*"
+ |'(?:\\.|[^'\\])*'
+ |(?R)
+ )*{b2}
+ )*{s2}){s1}{b2}"""
)
else:
- return Regex._clean(
+ return Regex._clean( \
rf"""{b1}{s1}({g}{s2}(?:
- [^{b1}{b2}]
- |{b1}(?:
[^{b1}{b2}]
- |(?R)
- )*{b2}
- )*{s2}){s1}{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 `"..."`)."""
- if not isinstance(pattern, str):
- raise TypeError(f"The 'pattern' parameter must be a string, got {type(pattern)}")
-
return rf"""(?` 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(
+ return Regex._clean( \
rf"""({g}
- (?:(?!{ignore_pattern}).)*
- (?:(?!{Regex.outside_strings(disallowed_pattern)}).)*
- )"""
+ (?:(?!{ignore_pattern}).)*
+ (?:(?!{Regex.outside_strings(disallowed_pattern)}).)*
+ )"""
)
@staticmethod
@@ -125,10 +104,8 @@ 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`!"""
- if func_name is None:
+ if func_name in {"", 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)}"""
@@ -153,28 +130,26 @@ def rgba_str(fix_sep: Optional[str] = ",", allow_alpha: bool = True) -> str:
- `g` 0-255 (int: green)
- `b` 0-255 (int: blue)
- `a` 0.0-1.0 (float: opacity)"""
- if fix_sep in {"", None}:
- fix_sep = r"[^0-9A-Z]"
- 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)}")
+ fix_sep = _re.escape(fix_sep) if isinstance(fix_sep, str) else r"[^0-9A-Z]"
rgb_part = rf"""((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))
(?:\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 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*\)?
- )""")
+ if allow_alpha:
+ 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*\)?
+ )"""
+ )
+ else:
+ return Regex._clean( \
+ rf"""(?ix)(?:rgb|rgba)?\s*(?:
+ \(?\s*{rgb_part}\s*\)?
+ )"""
+ )
@staticmethod
def hsla_str(fix_sep: str = ",", allow_alpha: bool = True) -> str:
@@ -197,28 +172,26 @@ def hsla_str(fix_sep: str = ",", allow_alpha: bool = True) -> str:
- `s` 0-100 (int: saturation)
- `l` 0-100 (int: lightness)
- `a` 0.0-1.0 (float: opacity)"""
- if fix_sep in {"", None}:
- fix_sep = r"[^0-9A-Z]"
- 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)}")
+ fix_sep = _re.escape(fix_sep) if isinstance(fix_sep, str) else r"[^0-9A-Z]"
hsl_part = rf"""((?:0*(?:360|3[0-5][0-9]|[12][0-9][0-9]|[1-9]?[0-9])))(?:\s*°)?
(?:\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 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*\)?
- )""")
+ if allow_alpha:
+ 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*\)?
+ )"""
+ )
+ else:
+ return Regex._clean( \
+ rf"""(?ix)(?:hsl|hsla)?\s*(?:
+ \(?\s*{hsl_part}\s*\)?
+ )"""
+ )
@staticmethod
def hexa_str(allow_alpha: bool = True) -> str:
@@ -233,13 +206,38 @@ 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)"""
- 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."""
+ """Internal method to make a multiline-string regex pattern into a single-line pattern."""
return "".join(l.strip() for l in pattern.splitlines()).strip()
+
+
+class LazyRegex:
+ """A class that lazily compiles and caches regex patterns on first access.\n
+ --------------------------------------------------------------------------------
+ - `**patterns` -⠀keyword arguments where the key is the name of the pattern and
+ the value is the regex pattern string to compile\n
+ --------------------------------------------------------------------------------
+ #### Example usage:
+ ```python
+ PATTERNS = LazyRegex(
+ email=r"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}",
+ phone=r"\\+?\\d{1,3}[-.\\s]?\\(?\\d{1,4}\\)?[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,9}",
+ )
+
+ email_pattern = PATTERNS.email # Compiles and caches the EMAIL pattern
+ phone_pattern = PATTERNS.phone # Compiles and caches the PHONE pattern
+ ```"""
+
+ def __init__(self, **patterns: str):
+ self._patterns = patterns
+
+ def __getattr__(self, name: str) -> _rx.Pattern:
+ if name in self._patterns:
+ setattr(self, name, compiled := _rx.compile(self._patterns[name]))
+ return compiled
+
+ raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
diff --git a/src/xulbux/string.py b/src/xulbux/string.py
index 58890df..9c49c3a 100644
--- a/src/xulbux/string.py
+++ b/src/xulbux/string.py
@@ -17,9 +17,6 @@ def to_type(string: str) -> Any:
"""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 := string.strip())
except (ValueError, SyntaxError):
@@ -33,11 +30,7 @@ def normalize_spaces(string: str, tab_spaces: int = 4) -> str:
"""Replaces all special space characters with normal spaces.\n
---------------------------------------------------------------
- `tab_spaces` -⠀number of spaces to replace tab chars with"""
- 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:
+ if tab_spaces < 0:
raise ValueError(f"The 'tab_spaces' parameter must be non-negative, got {tab_spaces!r}")
return string.replace("\t", " " * tab_spaces).replace("\u2000", " ").replace("\u2001", " ").replace("\u2002", " ") \
@@ -53,11 +46,6 @@ def escape(string: str, str_quotes: Optional[Literal["'", '"']] = None) -> str:
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")
@@ -74,11 +62,6 @@ def is_empty(string: Optional[str], spaces_are_empty: bool = False) -> bool:
-----------------------------------------------------------------------------------------------
- `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())
@@ -91,11 +74,7 @@ def single_char_repeats(string: str, char: str) -> int | bool:
---------------------------------------------------------------------------------------------------
- `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:
+ if 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)):
@@ -110,13 +89,6 @@ def decompose(case_string: str, seps: str = "-_", lower_all: bool = True) -> lis
- `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)
@@ -129,11 +101,6 @@ def to_camel_case(string: str, upper: bool = True) -> str:
- `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 (
@@ -148,13 +115,6 @@ def to_delimited_case(string: str, delimiter: str = "_", screaming: bool = False
- `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)
@@ -166,11 +126,6 @@ def get_lines(string: str, remove_empty_lines: bool = False) -> list[str]:
------------------------------------------------------------------------------------
- `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()
elif not (lines := string.splitlines()):
@@ -189,11 +144,7 @@ def remove_consecutive_empty_lines(string: str, max_consecutive: int = 0) -> str
* 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:
+ if 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)
@@ -204,11 +155,7 @@ def split_count(string: str, count: int) -> list[str]:
-----------------------------------------------------
- `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:
+ if 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 6735f05..bc272a1 100644
--- a/src/xulbux/system.py
+++ b/src/xulbux/system.py
@@ -44,21 +44,15 @@ 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"""
- if not isinstance(wait, int):
- raise TypeError(f"The 'wait' parameter must be an integer, got {type(wait)}")
- elif wait < 0:
+ if 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 len(processes) > 2: # EXCLUDING PYTHON AND SHELL PROCESSES
+ raise RuntimeError("Processes are still running.\nTo restart anyway set parameter 'force' to True.")
if prompt:
_os.system(f'shutdown /r /t {wait} /c "{prompt}"')
@@ -73,8 +67,8 @@ def restart(prompt: object = "", wait: int = 0, continue_program: bool = False,
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 len(processes) > 2: # EXCLUDING PYTHON AND SHELL PROCESSES
+ raise RuntimeError("Processes are still running.\nTo restart anyway set parameter 'force' to True.")
if prompt:
_subprocess.Popen(["notify-send", "System Restart", str(prompt)])
@@ -83,7 +77,7 @@ def restart(prompt: object = "", wait: int = 0, continue_program: bool = False,
try:
_subprocess.run(["sudo", "shutdown", "-r", "now"])
except _subprocess.CalledProcessError:
- raise PermissionError("Failed to restart: insufficient privileges. Ensure sudo permissions are granted.")
+ raise PermissionError("Failed to restart: insufficient privileges.\nEnsure sudo permissions are granted.")
if continue_program:
print(f"Restarting in {wait} seconds...")
@@ -112,19 +106,6 @@ 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:
@@ -174,11 +155,6 @@ def elevate(win_title: Optional[str] = None, args: list = []) -> bool:
---------------------------------------------------------------------------------
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
diff --git a/tests/test_code.py b/tests/test_code.py
index 12b704d..efb705e 100644
--- a/tests/test_code.py
+++ b/tests/test_code.py
@@ -1,5 +1,8 @@
from xulbux.code import Code
+#
+################################################## Code TESTS ##################################################
+
def test_add_indent():
sample = "def hello():\n return 'Hello, World!'"
diff --git a/tests/test_color.py b/tests/test_color.py
index 7e01e70..d1028eb 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -1,5 +1,8 @@
from xulbux.color import Color, rgba, hsla, hexa
+#
+################################################## Color TESTS ##################################################
+
def test_rgba_to_hex_int_and_back():
blue = Color.rgba_to_hex_int(0, 0, 255)
diff --git a/tests/test_color_types.py b/tests/test_color_types.py
index 983de38..2889641 100644
--- a/tests/test_color_types.py
+++ b/tests/test_color_types.py
@@ -22,6 +22,9 @@ def assert_hexa_equal(actual: hexa, expected: str):
assert str(actual) == expected
+################################################## rgba TESTS ##################################################
+
+
def test_rgba_return_values():
assert_rgba_equal(rgba(255, 0, 0, 0.5), (255, 0, 0, 0.5))
assert_hsla_equal(rgba(255, 0, 0, 0.5).to_hsla(), (0, 100, 50, 0.5))
@@ -48,6 +51,41 @@ def test_rgba_return_values():
assert_rgba_equal(rgba(255, 0, 0, 0.5).complementary(), (0, 255, 255, 0.5))
+def test_rgba_construction():
+ assert rgba(100, 150, 200).values() == (100, 150, 200, None)
+ assert rgba(100, 150, 200, 0.5).values() == (100, 150, 200, 0.5)
+ assert rgba(0, 0, 0).values() == (0, 0, 0, None)
+ assert rgba(255, 255, 255).values() == (255, 255, 255, None)
+ try:
+ rgba(300, 150, 200)
+ assert False, "Should raise ValueError for invalid RGB values"
+ except ValueError:
+ pass
+ try:
+ rgba(100, 150, 200, 2.0)
+ assert False, "Should raise ValueError for invalid alpha value"
+ except ValueError:
+ pass
+
+
+def test_rgba_dunder_methods():
+ assert len(rgba(100, 150, 200)) == 3
+ assert len(rgba(100, 150, 200, 0.5)) == 4
+ color = rgba(100, 150, 200, 0.5)
+ assert color[0] == 100
+ assert color[1] == 150
+ assert color[2] == 200
+ assert color[3] == 0.5
+ assert rgba(100, 150, 200) == rgba(100, 150, 200)
+ assert rgba(100, 150, 200) != rgba(200, 100, 150)
+ assert str(rgba(100, 150, 200)) == "(100, 150, 200)"
+ assert str(rgba(100, 150, 200, 0.5)) == "(100, 150, 200, 0.5)"
+ assert repr(rgba(100, 150, 200)).startswith("rgba(")
+
+
+################################################## hsla TESTS ##################################################
+
+
def test_hsla_return_values():
assert_hsla_equal(hsla(0, 100, 50, 0.5), (0, 100, 50, 0.5))
assert_rgba_equal(hsla(0, 100, 50, 0.5).to_rgba(), (255, 0, 0, 0.5))
@@ -74,6 +112,41 @@ def test_hsla_return_values():
assert_hsla_equal(hsla(0, 100, 50, 0.5).complementary(), (180, 100, 50, 0.5))
+def test_hsla_construction():
+ assert hsla(210, 50, 60).values() == (210, 50, 60, None)
+ assert hsla(210, 50, 60, 0.5).values() == (210, 50, 60, 0.5)
+ assert hsla(0, 0, 0).values() == (0, 0, 0, None)
+ assert hsla(360, 100, 100).values() == (360, 100, 100, None)
+ try:
+ hsla(361, 50, 60)
+ assert False, "Should raise ValueError for invalid hue value"
+ except ValueError:
+ pass
+ try:
+ hsla(210, 101, 60)
+ assert False, "Should raise ValueError for invalid saturation value"
+ except ValueError:
+ pass
+
+
+def test_hsla_dunder_methods():
+ assert len(hsla(210, 50, 60)) == 3
+ assert len(hsla(210, 50, 60, 0.5)) == 4
+ color = hsla(210, 50, 60, 0.5)
+ assert color[0] == 210
+ assert color[1] == 50
+ assert color[2] == 60
+ assert color[3] == 0.5
+ assert hsla(210, 50, 60) == hsla(210, 50, 60)
+ assert hsla(210, 50, 60) != hsla(210, 60, 50)
+ assert str(hsla(210, 50, 60)) == "(210°, 50%, 60%)"
+ assert str(hsla(210, 50, 60, 0.5)) == "(210°, 50%, 60%, 0.5)"
+ assert repr(hsla(210, 50, 60)).startswith("hsla(")
+
+
+################################################## hexa TESTS ##################################################
+
+
def test_hexa_return_values():
assert_hexa_equal(hexa("#F008"), "#FF000088")
assert_rgba_equal(hexa("#FF00007F").to_rgba(), (255, 0, 0, 0.5))
@@ -100,40 +173,6 @@ def test_hexa_return_values():
assert_hexa_equal(hexa("#FF00007F").complementary(), "#00FFFF7F")
-def test_rgba_construction():
- assert rgba(100, 150, 200).values() == (100, 150, 200, None)
- assert rgba(100, 150, 200, 0.5).values() == (100, 150, 200, 0.5)
- assert rgba(0, 0, 0).values() == (0, 0, 0, None)
- assert rgba(255, 255, 255).values() == (255, 255, 255, None)
- try:
- rgba(300, 150, 200)
- assert False, "Should raise ValueError for invalid RGB values"
- except ValueError:
- pass
- try:
- rgba(100, 150, 200, 2.0)
- assert False, "Should raise ValueError for invalid alpha value"
- except ValueError:
- pass
-
-
-def test_hsla_construction():
- assert hsla(210, 50, 60).values() == (210, 50, 60, None)
- assert hsla(210, 50, 60, 0.5).values() == (210, 50, 60, 0.5)
- assert hsla(0, 0, 0).values() == (0, 0, 0, None)
- assert hsla(360, 100, 100).values() == (360, 100, 100, None)
- try:
- hsla(361, 50, 60)
- assert False, "Should raise ValueError for invalid hue value"
- except ValueError:
- pass
- try:
- hsla(210, 101, 60)
- assert False, "Should raise ValueError for invalid saturation value"
- except ValueError:
- pass
-
-
def test_hexa_construction():
assert hexa("#F00").values() == (255, 0, 0, None)
assert hexa("#F008").values(True) == (255, 0, 0, 0.53)
@@ -153,36 +192,6 @@ def test_hexa_construction():
pass
-def test_rgba_dunder_methods():
- assert len(rgba(100, 150, 200)) == 3
- assert len(rgba(100, 150, 200, 0.5)) == 4
- color = rgba(100, 150, 200, 0.5)
- assert color[0] == 100
- assert color[1] == 150
- assert color[2] == 200
- assert color[3] == 0.5
- assert rgba(100, 150, 200) == rgba(100, 150, 200)
- assert rgba(100, 150, 200) != rgba(200, 100, 150)
- assert str(rgba(100, 150, 200)) == "(100, 150, 200)"
- assert str(rgba(100, 150, 200, 0.5)) == "(100, 150, 200, 0.5)"
- assert repr(rgba(100, 150, 200)).startswith("rgba(")
-
-
-def test_hsla_dunder_methods():
- assert len(hsla(210, 50, 60)) == 3
- assert len(hsla(210, 50, 60, 0.5)) == 4
- color = hsla(210, 50, 60, 0.5)
- assert color[0] == 210
- assert color[1] == 50
- assert color[2] == 60
- assert color[3] == 0.5
- assert hsla(210, 50, 60) == hsla(210, 50, 60)
- assert hsla(210, 50, 60) != hsla(210, 60, 50)
- assert str(hsla(210, 50, 60)) == "(210°, 50%, 60%)"
- assert str(hsla(210, 50, 60, 0.5)) == "(210°, 50%, 60%, 0.5)"
- assert repr(hsla(210, 50, 60)).startswith("hsla(")
-
-
def test_hexa_dunder_methods():
assert len(hexa("#F00")) == 3
assert len(hexa("#F008")) == 4
diff --git a/tests/test_console.py b/tests/test_console.py
index 0c1d5e4..96ffaa3 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -1,11 +1,12 @@
-from xulbux.console import ProgressBar, Console, ArgResult, Args
+from xulbux.console import Spinner, ProgressBar
+from xulbux.console import ArgResult, Args
+from xulbux.console import Console
from xulbux import console
from unittest.mock import MagicMock, patch, call
from collections import namedtuple
import builtins
import pytest
-import time
import sys
import io
@@ -38,7 +39,7 @@ def mock_prompt_toolkit(monkeypatch):
return mock
-################################################## CONSOLE TESTS ##################################################
+################################################## Console TESTS ##################################################
def test_console_user():
@@ -214,7 +215,7 @@ def test_console_size(mock_terminal_size):
)
def test_get_args_no_spaces(monkeypatch, argv, find_args, expected_args_dict):
monkeypatch.setattr(sys, "argv", argv)
- args_result = Console.get_args(find_args, allow_spaces=False)
+ args_result = Console.get_args(allow_spaces=False, **find_args)
assert isinstance(args_result, Args)
assert args_result.dict() == expected_args_dict
for key, expected in expected_args_dict.items():
@@ -337,7 +338,7 @@ def test_get_args_no_spaces(monkeypatch, argv, find_args, expected_args_dict):
)
def test_get_args_with_spaces(monkeypatch, argv, find_args, expected_args_dict):
monkeypatch.setattr(sys, "argv", argv)
- args_result = Console.get_args(find_args, allow_spaces=True)
+ args_result = Console.get_args(allow_spaces=True, **find_args)
assert isinstance(args_result, Args)
assert args_result.dict() == expected_args_dict
@@ -346,13 +347,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
@@ -360,49 +361,23 @@ 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
-def test_get_args_invalid_alias():
- with pytest.raises(TypeError, match="Argument alias 'invalid-alias' is invalid."):
- Args(**{"invalid-alias": {"exists": False, "value": None}})
-
- with pytest.raises(TypeError, match="Argument alias '123start' is invalid."):
- Args(**{"123start": {"exists": False, "value": None}})
-
-
-def test_get_args_invalid_config():
- 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.\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 set of strings."):
- Console.get_args({"bad_flags": {"flags": "not-a-set", "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"])
- result = Console.get_args({"verbose": {"-v"}, "input": {"--input"}})
+ result = Console.get_args(verbose={"-v"}, input={"--input"})
assert result.verbose.exists is True
assert result.verbose.value == "-42"
@@ -413,7 +388,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"])
- result = Console.get_args({"file": {"-f"}, "text": {"-t"}})
+ result = Console.get_args(file={"-f"}, text={"-t"})
assert result.file.exists is True
assert result.file.value == "--not-a-flag"
@@ -424,7 +399,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"])
- result = Console.get_args({"before_args": "before", "verbose": {"-v"}})
+ 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"]
@@ -435,7 +410,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"])
- result = Console.get_args({"verbose": {"-v"}, "after_args": "after"})
+ result = Console.get_args(verbose={"-v"}, after_args="after")
assert result.verbose.exists is True
assert result.verbose.value == "value"
@@ -446,7 +421,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"])
- result = Console.get_args({"message": {"-m"}, "file": {"-f"}}, allow_spaces=True)
+ result = Console.get_args(allow_spaces=True, message={"-m"}, file={"-f"})
assert result.message.exists is True
assert result.message.value == "start -middle --end"
@@ -462,13 +437,13 @@ def test_get_args_mixed_dash_scenarios(monkeypatch):
"after1", "-also-not-flag"
]
)
- result = Console.get_args({
- "before": "before",
- "verbose": {"-v"},
- "debug": {"-d"},
- "file": {"--file"},
- "after": "after",
- })
+ 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"]
@@ -955,7 +930,7 @@ def test_input_custom_style_object(mock_prompt_session, mock_formatcodes_print):
assert hasattr(style, "style_rules") or hasattr(style, "_style")
-################################################## PROGRESSBAR TESTS ##################################################
+################################################## ProgressBar TESTS ##################################################
def test_progressbar_init():
@@ -1144,3 +1119,128 @@ def test_progressbar_redraw_progress_bar():
pb._current_progress_str = "\x1b[2K\rLoading |████████████| 50%"
pb._redraw_display()
mock_stdout.flush.assert_called_once()
+
+
+################################################## Spinner TESTS ##################################################
+
+
+def test_spinner_init_defaults():
+ spinner = Spinner()
+ assert spinner.label is None
+ assert spinner.interval == 0.2
+ assert spinner.active is False
+ assert spinner.sep == " "
+ assert len(spinner.frames) > 0
+
+
+def test_spinner_init_custom():
+ spinner = Spinner(label="Loading", interval=0.5, sep="-")
+ assert spinner.label == "Loading"
+ assert spinner.interval == 0.5
+ assert spinner.sep == "-"
+
+
+def test_spinner_set_format_valid():
+ spinner = Spinner()
+ spinner.set_format(["{l}", "{a}"])
+ assert spinner.spinner_format == ["{l}", "{a}"]
+
+
+def test_spinner_set_format_invalid():
+ spinner = Spinner()
+ with pytest.raises(ValueError):
+ spinner.set_format(["{l}"]) # MISSING {a}
+
+
+def test_spinner_set_frames_valid():
+ spinner = Spinner()
+ spinner.set_frames(("a", "b"))
+ assert spinner.frames == ("a", "b")
+
+
+def test_spinner_set_frames_invalid():
+ spinner = Spinner()
+ with pytest.raises(ValueError):
+ spinner.set_frames(("a", )) # LESS THAN 2 FRAMES
+
+
+def test_spinner_set_interval_valid():
+ spinner = Spinner()
+ spinner.set_interval(1.0)
+ assert spinner.interval == 1.0
+
+
+def test_spinner_set_interval_invalid():
+ spinner = Spinner()
+ with pytest.raises(ValueError):
+ spinner.set_interval(0)
+ with pytest.raises(ValueError):
+ spinner.set_interval(-1)
+
+
+@patch("xulbux.console._threading.Thread")
+@patch("xulbux.console._threading.Event")
+@patch("sys.stdout", new_callable=MagicMock)
+def test_spinner_start(mock_stdout, mock_event, mock_thread):
+ spinner = Spinner()
+ spinner.start("Test")
+
+ assert spinner.active is True
+ assert spinner.label == "Test"
+ mock_event.assert_called_once()
+ mock_thread.assert_called_once()
+
+ # TEST CALLING START AGAIN DOESN'T DO ANYTHING
+ spinner.start("Test2")
+ assert mock_event.call_count == 1
+
+
+@patch("xulbux.console._threading.Thread")
+@patch("xulbux.console._threading.Event")
+def test_spinner_stop(mock_event, mock_thread):
+ spinner = Spinner()
+ # MANUALLY SET ACTIVE TO SIMULATE RUNNING
+ spinner.active = True
+ mock_stop_event = MagicMock()
+ spinner._stop_event = mock_stop_event
+ mock_animation_thread = MagicMock()
+ spinner._animation_thread = mock_animation_thread
+
+ spinner.stop()
+
+ assert spinner.active is False
+ mock_stop_event.set.assert_called_once()
+ mock_animation_thread.join.assert_called_once()
+
+
+def test_spinner_update_label():
+ spinner = Spinner()
+ spinner.update_label("New Label")
+ assert spinner.label == "New Label"
+
+
+def test_spinner_context_manager():
+ spinner = Spinner()
+ with patch.object(spinner, "start") as mock_start, patch.object(spinner, "stop") as mock_stop:
+
+ with spinner.context("Test") as update:
+ mock_start.assert_called_with("Test")
+ update("New Label")
+ assert spinner.label == "New Label"
+
+ mock_stop.assert_called_once()
+
+
+def test_spinner_context_manager_exception():
+ spinner = Spinner()
+ with ( \
+ patch.object(spinner, "start"),
+ patch.object(spinner, "stop") as mock_stop,
+ patch.object(spinner, "_emergency_cleanup") as mock_cleanup
+ ):
+ with pytest.raises(ValueError):
+ with spinner.context("Test"):
+ raise ValueError("Oops")
+
+ mock_cleanup.assert_called_once()
+ mock_stop.assert_called_once()
diff --git a/tests/test_data.py b/tests/test_data.py
index 4a7aa4a..eacbf98 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -29,6 +29,9 @@
d1_path_id = {"healthy": {"fruit": ["apples", "bananas", "oranges"], "vegetables": ["carrots", "broccoli", "celery"]}}
d2_path_id = {"school": {"material": ["pencil", "paper", "rubber"], "subjects": ["math", "science", "history"]}}
+#
+################################################## Data TESTS ##################################################
+
def test_serialize_bytes():
utf8_bytes = b"Hello"
@@ -42,9 +45,6 @@ def test_serialize_bytes():
import base64
assert base64.b64decode(serialized_non_utf8["bytes"]).decode("latin-1") == non_utf8_bytes.decode("latin-1")
- with pytest.raises(TypeError):
- Data.serialize_bytes("not bytes") # type: ignore[assignment]
-
def test_deserialize_bytes():
utf8_serialized_bytes = {"bytes": "Hello", "encoding": "utf-8"}
diff --git a/tests/test_env_path.py b/tests/test_env_path.py
index 3d57c45..497f3a1 100644
--- a/tests/test_env_path.py
+++ b/tests/test_env_path.py
@@ -1,5 +1,8 @@
from xulbux.env_path import EnvPath
+#
+################################################## EnvPath TESTS ##################################################
+
def test_get_paths():
paths = EnvPath.paths()
diff --git a/tests/test_file.py b/tests/test_file.py
index f3f33ff..6760996 100644
--- a/tests/test_file.py
+++ b/tests/test_file.py
@@ -4,6 +4,9 @@
import pytest
import os
+#
+################################################## File TESTS ##################################################
+
@pytest.mark.parametrize(
"input_file, new_extension, camel_case, full_extension, expected_output", [
diff --git a/tests/test_format_codes.py b/tests/test_format_codes.py
index 9c9fda4..fe5de06 100644
--- a/tests/test_format_codes.py
+++ b/tests/test_format_codes.py
@@ -20,6 +20,9 @@
reset_invert = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_inverse', '_invert', '_in')]}{ANSI.END}"
reset_underline = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_underline', '_u')]}{ANSI.END}"
+#
+################################################## FormatCodes TESTS ##################################################
+
def test_to_ansi():
assert (
diff --git a/tests/test_json.py b/tests/test_json.py
index d238236..8ae5e49 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -80,6 +80,9 @@ def create_test_json_string(tmp_path, filename, content):
"user": {"name": "Test User", "admin": True},
}
+#
+################################################## Json TESTS ##################################################
+
def test_read_simple(tmp_path):
file_path = create_test_json(tmp_path, "simple.json", SIMPLE_DATA)
diff --git a/tests/test_path.py b/tests/test_path.py
index a8de249..d31dd0d 100644
--- a/tests/test_path.py
+++ b/tests/test_path.py
@@ -45,6 +45,9 @@ def setup_test_environment(tmp_path, monkeypatch):
}
+################################################## Path TESTS ##################################################
+
+
def test_path_properties(setup_test_environment):
assert Path.cwd == str(setup_test_environment["cwd"])
assert Path.script_dir == str(setup_test_environment["script_dir"])
@@ -62,8 +65,6 @@ def test_extend(setup_test_environment):
assert Path.extend("") is None
with pytest.raises(PathNotFoundError, match="Path is empty."):
Path.extend("", raise_error=True)
- with pytest.raises(TypeError, match="parameter must be a string"):
- Path.extend(None, raise_error=True) # type: ignore[assignment]
# FOUND IN STANDARD LOCATIONS
assert Path.extend("file_in_cwd.txt") == str(env["cwd"] / "file_in_cwd.txt")
diff --git a/tests/test_regex.py b/tests/test_regex.py
index c3eab2b..633e708 100644
--- a/tests/test_regex.py
+++ b/tests/test_regex.py
@@ -1,8 +1,12 @@
-from xulbux.regex import Regex
+from xulbux.regex import LazyRegex, Regex
import regex as rx
+import pytest
import re
+#
+################################################## Regex TESTS ##################################################
+
def test_regex_quotes_pattern():
"""Test quotes method returns correct pattern"""
@@ -460,3 +464,31 @@ def test_regex_brackets_deeply_nested():
assert len(matches) >= 1
assert "deepest" in matches[0]
assert "Level2" in matches[0]
+
+
+################################################## LazyRegex TESTS ##################################################
+
+
+def test_lazy_regex_init():
+ patterns = LazyRegex(test=r"\d+")
+ assert patterns._patterns == {"test": r"\d+"}
+
+
+def test_lazy_regex_getattr_valid():
+ patterns = LazyRegex(test=r"\d+")
+ regex = patterns.test
+ assert regex.pattern == r"\d+"
+ assert "test" in patterns.__dict__ # CHECK CACHING
+
+
+def test_lazy_regex_getattr_invalid():
+ patterns = LazyRegex(test=r"\d+")
+ with pytest.raises(AttributeError):
+ _ = patterns.invalid
+
+
+def test_lazy_regex_caching():
+ patterns = LazyRegex(test=r"\d+")
+ regex1 = patterns.test
+ regex2 = patterns.test
+ assert regex1 is regex2
diff --git a/tests/test_string.py b/tests/test_string.py
index 48d28be..9eb3650 100644
--- a/tests/test_string.py
+++ b/tests/test_string.py
@@ -2,6 +2,9 @@
import pytest
+#
+################################################## String TESTS ##################################################
+
def test_to_type():
assert String.to_type("123") == 123
diff --git a/tests/test_system.py b/tests/test_system.py
index c2fc25b..1558de5 100644
--- a/tests/test_system.py
+++ b/tests/test_system.py
@@ -4,6 +4,9 @@
import pytest
import os
+#
+################################################## System TESTS ##################################################
+
def test_system_class_exists():
"""Test that System class exists and has expected methods"""
diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py
new file mode 100644
index 0000000..5d0e71d
--- /dev/null
+++ b/tests/test_version_consistency.py
@@ -0,0 +1,71 @@
+from typing import Optional
+from pathlib import Path
+import subprocess
+import pytest
+import os
+import re
+
+# DEFINE PATHS RELATIVE TO THIS TEST FILE tests/test_version.py
+ROOT_DIR = Path(__file__).parent.parent
+PYPROJECT_PATH = ROOT_DIR / "pyproject.toml"
+INIT_PATH = ROOT_DIR / "src" / "xulbux" / "__init__.py"
+
+
+def get_current_branch() -> Optional[str]:
+ # CHECK GITHUB ACTIONS ENVIRONMENT VARIABLES FIRST
+ # GITHUB_HEAD_REF IS SET FOR PULL REQUESTS (SOURCE BRANCH)
+ if branch := os.environ.get("GITHUB_HEAD_REF"):
+ return branch
+ # GITHUB_REF_NAME IS SET FOR PUSHES (BRANCH NAME)
+ if branch := os.environ.get("GITHUB_REF_NAME"):
+ return branch
+
+ # FALLBACK TO GIT COMMAND FOR LOCAL DEV
+ try:
+ result = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True, check=True)
+ return result.stdout.strip() or None
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return None
+
+
+def get_file_version(file_path: Path, pattern: str) -> Optional[str]:
+ if not file_path.exists():
+ return None
+
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+ match = re.search(pattern, content, re.MULTILINE)
+ if match:
+ return match.group(1)
+
+ return None
+
+
+################################################## VERSION CONSISTENCY TEST ##################################################
+
+
+def test_version_consistency():
+ """Verifies that the version numbers in `pyproject.toml` and `__init__.py`
+ match the version specified in the current release branch name (`dev/1.X.Y`)."""
+ # SKIP IF WE CAN'T DETERMINE THE BRANCH (DETACHED HEAD OR NOT A GIT REPO)
+ if not (branch_name := get_current_branch()):
+ pytest.skip("Could not determine git branch name")
+
+ # SKIP IF BRANCH NAME DOESN'T MATCH RELEASE PATTERN dev/1.X.Y
+ if not (branch_match := re.match(r"^dev/(1\.[0-9]+\.[0-9]+)$", branch_name)):
+ pytest.skip(f"Current branch '{branch_name}' is not a release branch (dev/1.X.Y)")
+
+ expected_version = branch_match.group(1)
+
+ # EXTRACT VERSIONS
+ pyproject_version = get_file_version(PYPROJECT_PATH, r'^version\s*=\s*"([^"]+)"')
+ init_version = get_file_version(INIT_PATH, r'^__version__\s*=\s*"([^"]+)"')
+
+ assert pyproject_version is not None, f"Could not find var 'version' in {PYPROJECT_PATH}"
+ assert init_version is not None, f"Could not find var '__version__' in {INIT_PATH}"
+
+ assert pyproject_version == expected_version, \
+ f"Hardcoded lib-version in pyproject.toml ({pyproject_version}) does not match branch version ({expected_version})"
+
+ assert init_version == expected_version, \
+ f"Hardcoded lib-version in src/xulbux/__init__.py ({init_version}) does not match branch version ({expected_version})"