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})"