From 23a0329aa48aa18cae3d9ae8af53c70563980046 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Thu, 29 Jan 2026 15:08:09 +0100 Subject: [PATCH 1/3] WIP: add stub files & make all type hints stricter --- .gitignore | 2 +- CHANGELOG.md | 13 +- pyproject.toml | 5 +- setup.py | 57 ++++++- src/xulbux/__init__.py | 2 +- src/xulbux/base/types.py | 49 ++++-- src/xulbux/code.py | 7 +- src/xulbux/color.py | 337 ++++++++++++++++--------------------- src/xulbux/file_sys.py | 6 +- src/xulbux/format_codes.py | 4 +- src/xulbux/py.typed | 0 src/xulbux/regex.py | 2 +- src/xulbux/system.py | 7 +- 13 files changed, 260 insertions(+), 231 deletions(-) create mode 100644 src/xulbux/py.typed diff --git a/.gitignore b/.gitignore index 378fbcf..89c1f6e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ __pycache__/ __pypackages__/ .mypy_cache/ .pytest_cache/ -*.py[cod] +*.py[cdio] *$py.class # BUILD ARTIFACTS diff --git a/CHANGELOG.md b/CHANGELOG.md index c22d009..f4aa6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,21 @@ #
Changelog
+ + +## ... `v1.9.6` + +* The compiled version of the library now includes the type stub files (`.pyi`), so type checkers can properly check types. +* Made all type hints in the whole library way more strict and accurate. +* Removed leftover unnecessary runtime type-checks in several methods throughout the whole library. + + ## 25.01.2026 `v1.9.5` -* Add new class property `Console.encoding`, which returns the encoding used by the console (*e.g.* `utf-8`*,* `cp1252`*, …*). -* Add multiple new class properties to the `System` class: +* Added a new class property `Console.encoding`, which returns the encoding used by the console (*e.g.* `utf-8`*,* `cp1252`*, …*). +* Added multiple new class properties to the `System` class: - `is_linux` Whether the current OS is Linux or not. - `is_mac` Whether the current OS is macOS or not. - `is_unix` Whether the current OS is a Unix-like OS (Linux, macOS, BSD, …) or not. diff --git a/pyproject.toml b/pyproject.toml index 28acabb..a164c7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ build-backend = "setuptools.build_meta" [project] name = "xulbux" -version = "1.9.5" +version = "1.9.6" description = "A Python library to simplify common programming tasks." readme = "README.md" authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] @@ -130,6 +130,9 @@ package-dir = { "" = "src" } [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +xulbux = ["py.typed", "*.pyi", "**/*.pyi"] + [tool.pytest.ini_options] minversion = "7.0" addopts = "-ra -q" diff --git a/setup.py b/setup.py index 5761a66..bd0ce5d 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,7 @@ from setuptools import setup from pathlib import Path +import subprocess +import sys import os @@ -10,14 +12,65 @@ def find_python_files(directory: str) -> list[str]: return python_files -# OPTIONALLY USE MYPYC COMPILATION +def generate_stubs_for_package(): + print("\nGenerating stub files with stubgen...\n") + + try: + skip_stubgen = { + Path("src/xulbux/base/types.py"), # COMPLEX TYPE DEFINITIONS + } + + src_dir = Path("src/xulbux") + generated_count = 0 + skipped_count = 0 + + for py_file in src_dir.rglob("*.py"): + pyi_file = py_file.with_suffix(".pyi") + rel_path = py_file.relative_to(src_dir.parent) + + if py_file in skip_stubgen: + pyi_file.write_text(py_file.read_text(encoding="utf-8"), encoding="utf-8") + print(f" copied {rel_path.with_suffix('.pyi')} (preserving type definitions)") + skipped_count += 1 + continue + + result = subprocess.run( + [sys.executable, "-m", "mypy.stubgen", + str(py_file), + "-o", "src", + "--include-private", + "--export-less"], + capture_output=True, + text=True + ) + + if result.returncode == 0: + print(f" generated {rel_path.with_suffix('.pyi')}") + generated_count += 1 + else: + print(f" failed {rel_path}") + if result.stderr: + print(f" {result.stderr.strip()}") + + print(f"\nStub generation complete! ({generated_count} generated, {skipped_count} copied)\n") + + except Exception as e: + fmt_error = "\n ".join(str(e).splitlines()) + print(f"[WARNING] Could not generate stubs:\n {fmt_error}\n") + + ext_modules = [] + +# OPTIONALLY USE MYPYC COMPILATION if os.environ.get("XULBUX_USE_MYPYC", "1") == "1": try: from mypyc.build import mypycify + print("\nCompiling with mypyc...\n") source_files = find_python_files("src/xulbux") - ext_modules = mypycify(source_files) + ext_modules = mypycify(source_files, opt_level="3") + + generate_stubs_for_package() except (ImportError, Exception) as e: fmt_error = "\n ".join(str(e).splitlines()) diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index 780b1c3..6e7086c 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -1,5 +1,5 @@ __package_name__ = "xulbux" -__version__ = "1.9.5" +__version__ = "1.9.6" __description__ = "A Python library to simplify common programming tasks." __status__ = "Production/Stable" diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py index abd1562..c48e9fa 100644 --- a/src/xulbux/base/types.py +++ b/src/xulbux/base/types.py @@ -27,48 +27,48 @@ # ################################################## TypeAlias ################################################## -PathsList: TypeAlias = Union[list[Path], list[str], list[Path | str]] +PathsList: TypeAlias = Union[list[Path], list[str], list[Union[Path, str]]] """Union of all supported list types for a list of paths.""" -DataStructure: TypeAlias = Union[list, tuple, set, frozenset, dict] +DataStructure: TypeAlias = Union[list[Any], tuple[Any, ...], set[Any], frozenset[Any], dict[Any, Any]] """Union of supported data structures used in the `data` module.""" DataStructureTypes = (list, tuple, set, frozenset, dict) """Tuple of supported data structures used in the `data` module.""" -IndexIterable: TypeAlias = Union[list, tuple, set, frozenset] +IndexIterable: TypeAlias = Union[list[Any], tuple[Any, ...], set[Any], frozenset[Any]] """Union of all iterable types that support indexing operations.""" IndexIterableTypes = (list, tuple, set, frozenset) """Tuple of all iterable types that support indexing operations.""" Rgba: TypeAlias = Union[ tuple[Int_0_255, Int_0_255, Int_0_255], - tuple[Int_0_255, Int_0_255, Int_0_255, Float_0_1], + tuple[Int_0_255, Int_0_255, Int_0_255, Optional[Float_0_1]], list[Int_0_255], - list[Union[Int_0_255, Float_0_1]], - dict[str, Union[int, float]], + list[Union[Int_0_255, Optional[Float_0_1]]], + "RgbaDict", "rgba", str, ] """Matches all supported RGBA color value formats.""" Hsla: TypeAlias = Union[ tuple[Int_0_360, Int_0_100, Int_0_100], - tuple[Int_0_360, Int_0_100, Int_0_100, Float_0_1], + tuple[Int_0_360, Int_0_100, Int_0_100, Optional[Float_0_1]], list[Union[Int_0_360, Int_0_100]], - list[Union[Int_0_360, Int_0_100, Float_0_1]], - dict[str, Union[int, float]], + list[Union[Int_0_360, Int_0_100, Optional[Float_0_1]]], + "HslaDict", "hsla", str, ] """Matches all supported HSLA color value formats.""" Hexa: TypeAlias = Union[str, int, "hexa"] -"""Matches all supported hexadecimal color value formats.""" +"""Matches all supported HEXA color value formats.""" AnyRgba: TypeAlias = Any -"""Generic type alias for RGBA color values in any supported format (type checking disabled).""" +"""Generic type alias for RGBA color values in any format (type checking disabled).""" AnyHsla: TypeAlias = Any -"""Generic type alias for HSLA color values in any supported format (type checking disabled).""" +"""Generic type alias for HSLA color values in any format (type checking disabled).""" AnyHexa: TypeAlias = Any -"""Generic type alias for hexadecimal color values in any supported format (type checking disabled).""" +"""Generic type alias for HEXA color values in any format (type checking disabled).""" ArgParseConfig: TypeAlias = Union[set[str], "ArgConfigWithDefault", Literal["before", "after"]] """Matches the command-line-parsing configuration of a single argument.""" @@ -92,7 +92,6 @@ class ArgConfigWithDefault(TypedDict): flags: set[str] default: str - class ArgData(TypedDict): """Schema for the resulting data of parsing a single command-line argument.""" exists: bool @@ -101,6 +100,28 @@ class ArgData(TypedDict): flag: Optional[str] +class RgbaDict(TypedDict): + """Dictionary schema for RGBA color components.""" + r: Int_0_255 + g: Int_0_255 + b: Int_0_255 + a: Optional[Float_0_1] + +class HslaDict(TypedDict): + """Dictionary schema for HSLA color components.""" + h: Int_0_360 + s: Int_0_100 + l: Int_0_100 + a: Optional[Float_0_1] + +class HexaDict(TypedDict): + """Dictionary schema for HEXA color components.""" + r: str + g: str + b: str + a: Optional[str] + + class MissingLibsMsgs(TypedDict): """Configuration schema for custom messages in `System.check_libs()` when checking library dependencies.""" found_missing: str diff --git a/src/xulbux/code.py b/src/xulbux/code.py index 1971176..431856e 100644 --- a/src/xulbux/code.py +++ b/src/xulbux/code.py @@ -6,6 +6,7 @@ from .regex import Regex from .data import Data +from typing import Any import regex as _rx @@ -48,7 +49,7 @@ def change_tab_size(cls, code: str, new_tab_size: int, remove_empty_lines: bool return "\n".join(code_lines) return code - result = [] + result: list[str] = [] for line in code_lines: indent_level = (len(line) - len(stripped := line.lstrip())) // tab_spaces result.append((" " * (indent_level * new_tab_size)) + stripped) @@ -56,11 +57,11 @@ def change_tab_size(cls, code: str, new_tab_size: int, remove_empty_lines: bool return "\n".join(result) @classmethod - def get_func_calls(cls, code: str) -> list: + def get_func_calls(cls, code: str) -> list[list[Any]]: """Will try to get all function calls and return them as a list.\n ------------------------------------------------------------------- - `code` -⠀the code to analyze""" - nested_func_calls = [] + nested_func_calls: list[list[Any]] = [] for _, func_attrs in (funcs := _rx.findall(r"(?i)" + Regex.func_call(), code)): if (nested_calls := _rx.findall(r"(?i)" + Regex.func_call(), func_attrs)): diff --git a/src/xulbux/color.py b/src/xulbux/color.py index 2a7eb4d..74c252d 100644 --- a/src/xulbux/color.py +++ b/src/xulbux/color.py @@ -6,10 +6,10 @@ includes methods to work with colors in various formats. """ -from .base.types import AnyRgba, AnyHsla, AnyHexa, Rgba, Hsla, Hexa +from .base.types import RgbaDict, HslaDict, HexaDict, AnyRgba, AnyHsla, AnyHexa, Rgba, Hsla, Hexa from .regex import Regex -from typing import Iterator, Optional, Literal, cast +from typing import Iterator, Optional, Literal, overload, cast import re as _re @@ -69,10 +69,18 @@ def __len__(self) -> int: """The number of components in the color (3 or 4).""" return 3 if self.a is None else 4 - def __iter__(self) -> Iterator: + def __iter__(self) -> Iterator[int | Optional[float]]: return iter((self.r, self.g, self.b) + (() if self.a is None else (self.a, ))) - def __getitem__(self, index: int) -> int | float: + @overload + def __getitem__(self, index: Literal[0, 1, 2]) -> int: + ... + + @overload + def __getitem__(self, index: Literal[3]) -> Optional[float]: + ... + + def __getitem__(self, index: int) -> int | Optional[float]: return ((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))[index] def __eq__(self, other: object) -> bool: @@ -91,20 +99,20 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() - def dict(self) -> dict: + def dict(self) -> RgbaDict: """Returns the color components as a dictionary with keys `"r"`, `"g"`, `"b"` and optionally `"a"`.""" - return dict(r=self.r, g=self.g, b=self.b) if self.a is None else dict(r=self.r, g=self.g, b=self.b, a=self.a) + return {"r": self.r, "g": self.g, "b": self.b, "a": self.a} - def values(self) -> tuple: + def values(self) -> tuple[int, int, int, Optional[float]]: """Returns the color components as separate values `r, g, b, a`.""" return self.r, self.g, self.b, self.a - def to_hsla(self) -> "hsla": + def to_hsla(self) -> hsla: """Returns the color as `hsla()` color object.""" h, s, l = self._rgb_to_hsl(self.r, self.g, self.b) return hsla(h, s, l, self.a, _validate=False) - def to_hexa(self) -> "hexa": + def to_hexa(self) -> hexa: """Returns the color as `hexa()` color object.""" return hexa("", self.r, self.g, self.b, self.a) @@ -112,66 +120,51 @@ def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None - def lighten(self, amount: float) -> "rgba": + def lighten(self, amount: float) -> rgba: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.r, self.g, self.b, self.a = self.to_hsla().lighten(amount).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def darken(self, amount: float) -> "rgba": + def darken(self, amount: float) -> rgba: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.r, self.g, self.b, self.a = self.to_hsla().darken(amount).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def saturate(self, amount: float) -> "rgba": + def saturate(self, amount: float) -> rgba: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.r, self.g, self.b, self.a = self.to_hsla().saturate(amount).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def desaturate(self, amount: float) -> "rgba": + def desaturate(self, amount: float) -> rgba: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.r, self.g, self.b, self.a = self.to_hsla().desaturate(amount).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def rotate(self, degrees: int) -> "rgba": + def rotate(self, degrees: int) -> rgba: """Rotates the colors hue by the specified number of degrees.""" - if not isinstance(degrees, int): - raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}") - self.r, self.g, self.b, self.a = self.to_hsla().rotate(degrees).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def invert(self, invert_alpha: bool = False) -> "rgba": + def invert(self, invert_alpha: bool = False) -> rgba: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" - if not isinstance(invert_alpha, bool): - raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}") - self.r, self.g, self.b = 255 - self.r, 255 - self.g, 255 - self.b if invert_alpha and self.a is not None: self.a = 1 - self.a - return rgba(self.r, self.g, self.b, self.a, _validate=False) - def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "rgba": + def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> rgba: """Converts the color to grayscale using the luminance formula.\n --------------------------------------------------------------------------- - `method` -⠀the luminance calculation method to use: @@ -183,7 +176,7 @@ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method)) return rgba(self.r, self.g, self.b, self.a, _validate=False) - def blend(self, other: Rgba, ratio: float = 0.5, additive_alpha: bool = False) -> "rgba": + def blend(self, other: Rgba, ratio: float = 0.5, additive_alpha: bool = False) -> rgba: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - `other` -⠀the other RGBA color to blend with @@ -192,27 +185,20 @@ def blend(self, other: Rgba, ratio: float = 0.5, additive_alpha: bool = False) - * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture) * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture) - `additive_alpha` -⠀whether to blend the alpha channels additively or not""" - if not isinstance(other, rgba): - if Color.is_valid_rgba(other): - other = Color.to_rgba(other) - else: - raise TypeError(f"The 'other' parameter must be a valid RGBA color, got {type(other)}") - if not isinstance(ratio, float): - raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}") - elif not (0.0 <= ratio <= 1.0): + if not (0.0 <= ratio <= 1.0): raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}") - if not isinstance(additive_alpha, bool): - raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}") + + other_rgba = Color.to_rgba(other) ratio *= 2 - self.r = int(max(0, min(255, int(round((self.r * (2 - ratio)) + (other.r * ratio)))))) - self.g = int(max(0, min(255, int(round((self.g * (2 - ratio)) + (other.g * ratio)))))) - self.b = int(max(0, min(255, int(round((self.b * (2 - ratio)) + (other.b * ratio)))))) - none_alpha = self.a is None and (len(other) <= 3 or other[3] is None) + self.r = int(max(0, min(255, int((self.r * (2 - ratio)) + (other_rgba.r * ratio) + 0.5)))) + self.g = int(max(0, min(255, int((self.g * (2 - ratio)) + (other_rgba.g * ratio) + 0.5)))) + self.b = int(max(0, min(255, int((self.b * (2 - ratio)) + (other_rgba.b * ratio) + 0.5)))) + none_alpha = self.a is None and (len(other_rgba) <= 3 or other_rgba[3] is None) if not none_alpha: self_a = 1 if self.a is None else self.a - other_a = (other[3] if other[3] is not None else 1) if len(other) > 3 else 1 + other_a = (other_rgba[3] if other_rgba[3] is not None else 1) if len(other_rgba) > 3 else 1 if additive_alpha: self.a = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio))) @@ -240,21 +226,19 @@ def is_opaque(self) -> bool: """Returns `True` if the color has no transparency.""" return self.a == 1 or self.a is None - def with_alpha(self, alpha: float) -> "rgba": + def with_alpha(self, alpha: float) -> rgba: """Returns a new color with the specified alpha value.""" - if not isinstance(alpha, float): - raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}") - elif not (0.0 <= alpha <= 1.0): + if not (0.0 <= alpha <= 1.0): raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") return rgba(self.r, self.g, self.b, alpha, _validate=False) - def complementary(self) -> "rgba": + def complementary(self) -> rgba: """Returns the complementary color (180 degrees on the color wheel).""" return self.to_hsla().complementary().to_rgba() @staticmethod - def _rgb_to_hsl(r: int, g: int, b: int) -> tuple: + def _rgb_to_hsl(r: int, g: int, b: int) -> tuple[int, int, int]: """Internal method to convert RGB to HSL color space.""" _r, _g, _b = r / 255.0, g / 255.0, b / 255.0 max_c, min_c = max(_r, _g, _b), min(_r, _g, _b) @@ -333,10 +317,18 @@ def __len__(self) -> int: """The number of components in the color (3 or 4).""" return 3 if self.a is None else 4 - def __iter__(self) -> Iterator: + def __iter__(self) -> Iterator[int | Optional[float]]: return iter((self.h, self.s, self.l) + (() if self.a is None else (self.a, ))) - def __getitem__(self, index: int) -> int | float: + @overload + def __getitem__(self, index: Literal[0, 1, 2]) -> int: + ... + + @overload + def __getitem__(self, index: Literal[3]) -> Optional[float]: + ... + + def __getitem__(self, index: int) -> int | Optional[float]: return ((self.h, self.s, self.l) + (() if self.a is None else (self.a, )))[index] def __eq__(self, other: object) -> bool: @@ -355,20 +347,20 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() - def dict(self) -> dict: + def dict(self) -> HslaDict: """Returns the color components as a dictionary with keys `"h"`, `"s"`, `"l"` and optionally `"a"`.""" - return dict(h=self.h, s=self.s, l=self.l) if self.a is None else dict(h=self.h, s=self.s, l=self.l, a=self.a) + return {"h": self.h, "s": self.s, "l": self.l, "a": self.a} - def values(self) -> tuple: + def values(self) -> tuple[int, int, int, Optional[float]]: """Returns the color components as separate values `h, s, l, a`.""" return self.h, self.s, self.l, self.a - def to_rgba(self) -> "rgba": + def to_rgba(self) -> rgba: """Returns the color as `rgba()` color object.""" r, g, b = self._hsl_to_rgb(self.h, self.s, self.l) return rgba(r, g, b, self.a, _validate=False) - def to_hexa(self) -> "hexa": + def to_hexa(self) -> hexa: """Returns the color as `hexa()` color object.""" r, g, b = self._hsl_to_rgb(self.h, self.s, self.l) return hexa("", r, g, b, self.a) @@ -377,59 +369,45 @@ def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None - def lighten(self, amount: float) -> "hsla": + def lighten(self, amount: float) -> hsla: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.l = int(min(100, self.l + (100 - self.l) * amount)) return hsla(self.h, self.s, self.l, self.a, _validate=False) - def darken(self, amount: float) -> "hsla": + def darken(self, amount: float) -> hsla: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.l = int(max(0, self.l * (1 - amount))) return hsla(self.h, self.s, self.l, self.a, _validate=False) - def saturate(self, amount: float) -> "hsla": + def saturate(self, amount: float) -> hsla: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.s = int(min(100, self.s + (100 - self.s) * amount)) return hsla(self.h, self.s, self.l, self.a, _validate=False) - def desaturate(self, amount: float) -> "hsla": + def desaturate(self, amount: float) -> hsla: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.s = int(max(0, self.s * (1 - amount))) return hsla(self.h, self.s, self.l, self.a, _validate=False) - def rotate(self, degrees: int) -> "hsla": + def rotate(self, degrees: int) -> hsla: """Rotates the colors hue by the specified number of degrees.""" - if not isinstance(degrees, int): - raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}") - self.h = (self.h + degrees) % 360 return hsla(self.h, self.s, self.l, self.a, _validate=False) - def invert(self, invert_alpha: bool = False) -> "hsla": + def invert(self, invert_alpha: bool = False) -> hsla: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" - if not isinstance(invert_alpha, bool): - raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}") - self.h = (self.h + 180) % 360 self.l = 100 - self.l if invert_alpha and self.a is not None: @@ -437,7 +415,7 @@ def invert(self, invert_alpha: bool = False) -> "hsla": return hsla(self.h, self.s, self.l, self.a, _validate=False) - def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "hsla": + def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> hsla: """Converts the color to grayscale using the luminance formula.\n --------------------------------------------------------------------------- - `method` -⠀the luminance calculation method to use: @@ -451,7 +429,7 @@ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag self.h, self.s, self.l, _ = rgba(l, l, l, _validate=False).to_hsla().values() return hsla(self.h, self.s, self.l, self.a, _validate=False) - def blend(self, other: Hsla, ratio: float = 0.5, additive_alpha: bool = False) -> "hsla": + def blend(self, other: Hsla, ratio: float = 0.5, additive_alpha: bool = False) -> hsla: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - `other` -⠀the other HSLA color to blend with @@ -462,12 +440,8 @@ def blend(self, other: Hsla, ratio: float = 0.5, additive_alpha: bool = False) - - `additive_alpha` -⠀whether to blend the alpha channels additively or not""" if not Color.is_valid_hsla(other): raise TypeError(f"The 'other' parameter must be a valid HSLA color, got {type(other)}") - if not isinstance(ratio, float): - raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}") - elif not (0.0 <= ratio <= 1.0): + if not (0.0 <= ratio <= 1.0): raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}") - if not isinstance(additive_alpha, bool): - raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}") self.h, self.s, self.l, self.a = self.to_rgba().blend(Color.to_rgba(other), ratio, additive_alpha).to_hsla().values() return hsla(self.h, self.s, self.l, self.a, _validate=False) @@ -488,7 +462,7 @@ def is_opaque(self) -> bool: """Returns `True` if the color has no transparency.""" return self.a == 1 or self.a is None - def with_alpha(self, alpha: float) -> "hsla": + def with_alpha(self, alpha: float) -> hsla: """Returns a new color with the specified alpha value.""" if not isinstance(alpha, float): raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}") @@ -497,12 +471,12 @@ def with_alpha(self, alpha: float) -> "hsla": return hsla(self.h, self.s, self.l, alpha, _validate=False) - def complementary(self) -> "hsla": + def complementary(self) -> hsla: """Returns the complementary color (180 degrees on the color wheel).""" return hsla((self.h + 180) % 360, self.s, self.l, self.a, _validate=False) @classmethod - def _hsl_to_rgb(cls, h: int, s: int, l: int) -> tuple: + def _hsl_to_rgb(cls, h: int, s: int, l: int) -> tuple[int, int, int]: """Internal method to convert HSL to RGB color space.""" _h, _s, _l = h / 360, s / 100, l / 100 @@ -621,20 +595,18 @@ def __init__( else: raise ValueError(f"Invalid HEXA color string '{color}'. Must be in formats RGB, RGBA, RRGGBB or RRGGBBAA.") - elif isinstance(color, int): - self.r, self.g, self.b, self.a = Color.hex_int_to_rgba(color).values() else: - raise TypeError(f"The 'color' parameter must be a string or integer, got {type(color)}") + self.r, self.g, self.b, self.a = Color.hex_int_to_rgba(color).values() def __len__(self) -> int: """The number of components in the color (3 or 4).""" return 3 if self.a is None else 4 - def __iter__(self) -> Iterator: + def __iter__(self) -> Iterator[str]: return iter((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") + (() if self.a is None else (f"{int(self.a * 255):02X}", ))) - def __getitem__(self, index: int) -> str | int: + def __getitem__(self, index: int) -> str: return ((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") \ + (() if self.a is None else (f"{int(self.a * 255):02X}", )))[index] @@ -654,29 +626,16 @@ def __repr__(self) -> str: def __str__(self) -> str: return f"#{self.r:02X}{self.g:02X}{self.b:02X}{'' if self.a is None else f'{int(self.a * 255):02X}'}" - def dict(self) -> dict: + def dict(self) -> HexaDict: """Returns the color components as a dictionary with hex string values for keys `"r"`, `"g"`, `"b"` and optionally `"a"`.""" - return ( - dict(r=f"{self.r:02X}", g=f"{self.g:02X}", b=f"{self.b:02X}") if self.a is None else dict( - r=f"{self.r:02X}", - g=f"{self.g:02X}", - b=f"{self.b:02X}", - a=f"{int(self.a * 255):02X}", - ) - ) + return {"r": f"{self.r:02X}", "g": f"{self.g:02X}", "b": f"{self.b:02X}", "a": None if self.a is None else f"{int(self.a * 255):02X}"} - def values(self, round_alpha: bool = True) -> tuple: + def values(self, round_alpha: bool = True) -> tuple[int, int, int, Optional[float]]: """Returns the color components as separate values `r, g, b, a`.""" - if not isinstance(round_alpha, bool): - raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}") - return self.r, self.g, self.b, None if self.a is None else (round(self.a, 2) if round_alpha else self.a) - def to_rgba(self, round_alpha: bool = True) -> "rgba": + def to_rgba(self, round_alpha: bool = True) -> rgba: """Returns the color as `rgba()` color object.""" - if not isinstance(round_alpha, bool): - raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}") - return rgba( self.r, self.g, @@ -685,77 +644,60 @@ def to_rgba(self, round_alpha: bool = True) -> "rgba": _validate=False, ) - def to_hsla(self, round_alpha: bool = True) -> "hsla": + def to_hsla(self, round_alpha: bool = True) -> hsla: """Returns the color as `hsla()` color object.""" - if not isinstance(round_alpha, bool): - raise TypeError(f"The 'round_alpha' parameter must be a boolean, got {type(round_alpha)}") - return self.to_rgba(round_alpha).to_hsla() def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None - def lighten(self, amount: float) -> "hexa": + def lighten(self, amount: float) -> hexa: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.r, self.g, self.b, self.a = self.to_rgba(False).lighten(amount).values() return hexa("", self.r, self.g, self.b, self.a) - def darken(self, amount: float) -> "hexa": + def darken(self, amount: float) -> hexa: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.r, self.g, self.b, self.a = self.to_rgba(False).darken(amount).values() return hexa("", self.r, self.g, self.b, self.a) - def saturate(self, amount: float) -> "hexa": + def saturate(self, amount: float) -> hexa: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.r, self.g, self.b, self.a = self.to_rgba(False).saturate(amount).values() return hexa("", self.r, self.g, self.b, self.a) - def desaturate(self, amount: float) -> "hexa": + def desaturate(self, amount: float) -> hexa: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" - if not isinstance(amount, float): - raise TypeError(f"The 'amount' parameter must be a float, got {type(amount)}") - elif not (0.0 <= amount <= 1.0): + if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") self.r, self.g, self.b, self.a = self.to_rgba(False).desaturate(amount).values() return hexa("", self.r, self.g, self.b, self.a) - def rotate(self, degrees: int) -> "hexa": + def rotate(self, degrees: int) -> hexa: """Rotates the colors hue by the specified number of degrees.""" - if not isinstance(degrees, int): - raise TypeError(f"The 'degrees' parameter must be an integer, got {type(degrees)}") - self.r, self.g, self.b, self.a = self.to_rgba(False).rotate(degrees).values() return hexa("", self.r, self.g, self.b, self.a) - def invert(self, invert_alpha: bool = False) -> "hexa": + def invert(self, invert_alpha: bool = False) -> hexa: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" - if not isinstance(invert_alpha, bool): - raise TypeError(f"The 'invert_alpha' parameter must be a boolean, got {type(invert_alpha)}") - self.r, self.g, self.b, self.a = self.to_rgba(False).invert().values() if invert_alpha and self.a is not None: self.a = 1 - self.a return hexa("", self.r, self.g, self.b, self.a) - def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> "hexa": + def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> hexa: """Converts the color to grayscale using the luminance formula.\n --------------------------------------------------------------------------- - `method` -⠀the luminance calculation method to use: @@ -767,7 +709,7 @@ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method)) return hexa("", self.r, self.g, self.b, self.a) - def blend(self, other: Hexa, ratio: float = 0.5, additive_alpha: bool = False) -> "hexa": + def blend(self, other: Hexa, ratio: float = 0.5, additive_alpha: bool = False) -> hexa: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - `other` -⠀the other HEXA color to blend with @@ -778,12 +720,8 @@ def blend(self, other: Hexa, ratio: float = 0.5, additive_alpha: bool = False) - - `additive_alpha` -⠀whether to blend the alpha channels additively or not""" if not Color.is_valid_hexa(other): raise TypeError(f"The 'other' parameter must be a valid HEXA color, got {type(other)}") - if not isinstance(ratio, float): - raise TypeError(f"The 'ratio' parameter must be a float, got {type(ratio)}") - elif not (0.0 <= ratio <= 1.0): + if not (0.0 <= ratio <= 1.0): raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}") - if not isinstance(additive_alpha, bool): - raise TypeError(f"The 'additive_alpha' parameter must be a boolean, got {type(additive_alpha)}") self.r, self.g, self.b, self.a = self.to_rgba(False).blend(Color.to_rgba(other), ratio, additive_alpha).values() return hexa("", self.r, self.g, self.b, self.a) @@ -804,7 +742,7 @@ def is_opaque(self) -> bool: """Returns `True` if the color has no transparency (`alpha == 1.0`).""" return self.a == 1 or self.a is None - def with_alpha(self, alpha: float) -> "hexa": + def with_alpha(self, alpha: float) -> hexa: """Returns a new color with the specified alpha value.""" if not isinstance(alpha, float): raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}") @@ -813,7 +751,7 @@ def with_alpha(self, alpha: float) -> "hexa": return hexa("", self.r, self.g, self.b, alpha) - def complementary(self) -> "hexa": + def complementary(self) -> hexa: """Returns the complementary color (180 degrees on the color wheel).""" return self.to_hsla(False).complementary().to_hexa() @@ -827,31 +765,36 @@ def is_valid_rgba(cls, color: AnyRgba, 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, rgba): return True elif isinstance(color, (list, tuple)): - if allow_alpha and cls.has_alpha(color): + if (allow_alpha \ + and len(color) == 4 + and all(isinstance(c, int) for c in color[:3]) + and isinstance(color[3], (float, type(None))) + ): return ( 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255 and (0 <= color[3] <= 1 or color[3] is None) ) - elif len(color) == 3: + elif len(color) == 3 and all(isinstance(c, int) for c in color): return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255 else: return False elif isinstance(color, dict): - if allow_alpha and cls.has_alpha(color): + if (allow_alpha \ + and len(color) == 4 + and all(isinstance(color.get(c), int) for c in ("r", "g", "b")) + and isinstance(color.get("a", "no alpha"), (float, type(None))) + ): return ( 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255 and (0 <= color["a"] <= 1 or color["a"] is None) ) - elif len(color) == 3: + elif len(color) == 3 and all(isinstance(color.get(c), int) for c in ("r", "g", "b")): return 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255 else: return False @@ -874,23 +817,31 @@ def is_valid_hsla(cls, color: AnyHsla, allow_alpha: bool = True) -> bool: return True elif isinstance(color, (list, tuple)): - if allow_alpha and cls.has_alpha(color): + if (allow_alpha \ + and len(color) == 4 + and all(isinstance(c, int) for c in color[:3]) + and isinstance(color[3], (float, type(None))) + ): return ( 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100 and (0 <= color[3] <= 1 or color[3] is None) ) - elif len(color) == 3: + elif len(color) == 3 and all(isinstance(c, int) for c in color): return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100 else: return False elif isinstance(color, dict): - if allow_alpha and cls.has_alpha(color): + if (allow_alpha \ + and len(color) == 4 + and all(isinstance(color.get(c), int) for c in ("h", "s", "l")) + and isinstance(color.get("a", "no alpha"), (float, type(None))) + ): return ( 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100 and (0 <= color["a"] <= 1 or color["a"] is None) ) - elif len(color) == 3: + elif len(color) == 3 and all(isinstance(color.get(c), int) for c in ("h", "s", "l")): return 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100 else: return False @@ -972,9 +923,9 @@ def has_alpha(cls, color: Rgba | Hsla | Hexa) -> bool: if parsed_hsla := cls.str_to_hsla(color, only_first=True): return cast(hsla, parsed_hsla).has_alpha() - elif isinstance(color, (list, tuple)) and len(color) == 4 and color[3] is not None: + elif isinstance(color, (list, tuple)) and len(color) == 4: return True - elif isinstance(color, dict) and len(color) == 4 and color["a"] is not None: + elif isinstance(color, dict) and len(color) == 4: return True return False @@ -987,11 +938,11 @@ def to_rgba(cls, color: Rgba | Hsla | Hexa) -> rgba: if isinstance(color, (hsla, hexa)): return color.to_rgba() elif cls.is_valid_hsla(color): - return cls._parse_hsla(color).to_rgba() + return cls._parse_hsla(cast(Hsla, color)).to_rgba() elif cls.is_valid_hexa(color): return hexa(cast(str | int, color)).to_rgba() elif cls.is_valid_rgba(color): - return cls._parse_rgba(color) + return cls._parse_rgba(cast(Rgba, color)) raise ValueError(f"Could not convert color {color!r} to RGBA.") @classmethod @@ -1002,11 +953,11 @@ def to_hsla(cls, color: Rgba | Hsla | Hexa) -> hsla: if isinstance(color, (rgba, hexa)): return color.to_hsla() elif cls.is_valid_rgba(color): - return cls._parse_rgba(color).to_hsla() + return cls._parse_rgba(cast(Rgba, color)).to_hsla() elif cls.is_valid_hexa(color): return hexa(cast(str | int, color)).to_hsla() elif cls.is_valid_hsla(color): - return cls._parse_hsla(color) + return cls._parse_hsla(cast(Hsla, color)) raise ValueError(f"Could not convert color {color!r} to HSLA.") @classmethod @@ -1017,9 +968,9 @@ def to_hexa(cls, color: Rgba | Hsla | Hexa) -> hexa: if isinstance(color, (rgba, hsla)): return color.to_hexa() elif cls.is_valid_rgba(color): - return cls._parse_rgba(color).to_hexa() + return cls._parse_rgba(cast(Rgba, color)).to_hexa() elif cls.is_valid_hsla(color): - return cls._parse_hsla(color).to_hexa() + return cls._parse_hsla(cast(Hsla, color)).to_hexa() elif cls.is_valid_hexa(color): return color if isinstance(color, hexa) else hexa(cast(str | int, color)) raise ValueError(f"Could not convert color {color!r} to HEXA") @@ -1220,8 +1171,8 @@ def text_color_for_on_bg(cls, 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 = cls.is_valid_hexa(text_bg_color), isinstance(text_bg_color, int) - text_bg_color = cls.to_rgba(text_bg_color) - brightness = 0.2126 * text_bg_color[0] + 0.7152 * text_bg_color[1] + 0.0722 * text_bg_color[2] + text_bg_rgba = cls.to_rgba(text_bg_color) + brightness = 0.2126 * text_bg_rgba[0] + 0.7152 * text_bg_rgba[1] + 0.0722 * text_bg_rgba[2] return ( (0xFFFFFF if was_int else hexa("", 255, 255, 255)) if was_hexa \ @@ -1238,18 +1189,17 @@ def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float) -> rgba | - `color` -⠀the color to adjust (can be in RGBA or HEXA format) - `lightness_change` -⠀the amount to change the lightness by, in range `-1.0` (darken by 100%) and `1.0` (lighten by 100%)""" - was_hexa = cls.is_valid_hexa(color) - 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 = cls.to_hsla(color) + was_hexa = cls.is_valid_hexa(color) + hsla_color = cls.to_hsla(color) h, s, l, a = ( int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \ - hsla_color[3] if cls.has_alpha(hsla_color) else None + hsla_color[3] if hsla_color.has_alpha() else None ) l = int(max(0, min(100, l + lightness_change * 100))) @@ -1265,18 +1215,17 @@ def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float) -> rgba - `color` -⠀the color to adjust (can be in RGBA or HEXA format) - `saturation_change` -⠀the amount to change the saturation by, in range `-1.0` (saturate by 100%) and `1.0` (desaturate by 100%)""" - was_hexa = cls.is_valid_hexa(color) - 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}" ) - hsla_color: hsla = cls.to_hsla(color) + was_hexa = cls.is_valid_hexa(color) + hsla_color = cls.to_hsla(color) h, s, l, a = ( int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \ - hsla_color[3] if cls.has_alpha(hsla_color) else None + hsla_color[3] if hsla_color.has_alpha() else None ) s = int(max(0, min(100, s + saturation_change * 100))) @@ -1286,34 +1235,34 @@ def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float) -> rgba ) @classmethod - def _parse_rgba(cls, color: AnyRgba) -> rgba: + def _parse_rgba(cls, color: Rgba) -> rgba: """Internal method to parse a color to an RGBA object.""" if isinstance(color, rgba): return color elif isinstance(color, (list, tuple)): if len(color) == 4: - return rgba(color[0], color[1], color[2], color[3], _validate=False) + return rgba(int(color[0]), int(color[1]), int(color[2]), float(color[3]), _validate=False) elif len(color) == 3: - return rgba(color[0], color[1], color[2], None, _validate=False) + return rgba(int(color[0]), int(color[1]), int(color[2]), None, _validate=False) elif isinstance(color, dict): - return rgba(color["r"], color["g"], color["b"], color.get("a"), _validate=False) + return rgba(int(color["r"]), int(color["g"]), int(color["b"]), color.get("a"), _validate=False) elif isinstance(color, str): if parsed := cls.str_to_rgba(color, only_first=True): return cast(rgba, parsed) raise ValueError(f"Could not parse RGBA color: {color!r}") @classmethod - def _parse_hsla(cls, color: AnyHsla) -> hsla: + def _parse_hsla(cls, color: Hsla) -> hsla: """Internal method to parse a color to an HSLA object.""" if isinstance(color, hsla): return color elif isinstance(color, (list, tuple)): if len(color) == 4: - return hsla(color[0], color[1], color[2], color[3], _validate=False) + return hsla(int(color[0]), int(color[1]), int(color[2]), float(color[3]), _validate=False) elif len(color) == 3: - return hsla(color[0], color[1], color[2], None, _validate=False) + return hsla(int(color[0]), int(color[1]), int(color[2]), None, _validate=False) elif isinstance(color, dict): - return hsla(color["h"], color["s"], color["l"], color.get("a"), _validate=False) + return hsla(int(color["h"]), int(color["s"]), int(color["l"]), color.get("a"), _validate=False) elif isinstance(color, str): if parsed := cls.str_to_hsla(color, only_first=True): return cast(hsla, parsed) diff --git a/src/xulbux/file_sys.py b/src/xulbux/file_sys.py index f5ef031..1ed0edb 100644 --- a/src/xulbux/file_sys.py +++ b/src/xulbux/file_sys.py @@ -89,12 +89,8 @@ def extend_path( if search_in is not None: if isinstance(search_in, (str, Path)): search_dirs.extend([Path(search_in)]) - elif isinstance(search_in, list): - search_dirs.extend([Path(path) for path in search_in]) else: - raise TypeError( - f"The 'search_in' parameter must be a string, Path, or a list of strings/Paths, got {type(search_in)}" - ) + search_dirs.extend([Path(path) for path in search_in]) return _ExtendPathHelper( cls, diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 62ea6c4..537f7be 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -468,9 +468,7 @@ def _get_default_ansi( _modifiers: tuple[str, str] = (_DEFAULT_COLOR_MODS["lighten"], _DEFAULT_COLOR_MODS["darken"]), ) -> Optional[str]: """Internal method to get the `default_color` and lighter/darker versions of it as ANSI code.""" - if not isinstance(default_color, rgba): - return None - _default_color: tuple[int, int, int] = tuple(default_color)[:3] + _default_color: tuple[int, int, int] = (default_color[0], default_color[1], default_color[2]) if brightness_steps is None or (format_key and _PATTERNS.bg_opt_default.search(format_key)): return (ANSI.SEQ_BG_COLOR if format_key and _PATTERNS.bg_default.search(format_key) else ANSI.SEQ_COLOR).format( *_default_color diff --git a/src/xulbux/py.typed b/src/xulbux/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/xulbux/regex.py b/src/xulbux/regex.py index a9ef866..1daa129 100644 --- a/src/xulbux/regex.py +++ b/src/xulbux/regex.py @@ -239,7 +239,7 @@ class LazyRegex: def __init__(self, **patterns: str): self._patterns = patterns - def __getattr__(self, name: str) -> _rx.Pattern: + def __getattr__(self, name: str) -> _rx.Pattern[str]: if name in self._patterns: setattr(self, name, compiled := _rx.compile(self._patterns[name])) return compiled diff --git a/src/xulbux/system.py b/src/xulbux/system.py index 500dffd..b118942 100644 --- a/src/xulbux/system.py +++ b/src/xulbux/system.py @@ -97,8 +97,7 @@ def architecture(cls) -> str: def cpu_count(cls) -> int: """The number of CPU cores available.""" try: - count = _multiprocessing.cpu_count() - return count if count is not None else 1 + return _multiprocessing.cpu_count() except (NotImplementedError, AttributeError): return 1 @@ -148,7 +147,7 @@ def check_libs( return _SystemCheckLibsHelper(lib_names, install_missing, missing_libs_msgs, confirm_install)() @classmethod - def elevate(cls, win_title: Optional[str] = None, args: Optional[list] = None) -> bool: + def elevate(cls, win_title: Optional[str] = None, args: Optional[list[str]] = None) -> bool: """Attempts to start a new process with elevated privileges.\n --------------------------------------------------------------------------------- - `win_title` -⠀the window title of the elevated process (only on Windows) @@ -285,7 +284,7 @@ def __call__(self) -> Optional[list[str]]: def find_missing_libs(self) -> list[str]: """Find which libraries are missing.""" - missing = [] + missing: list[str] = [] for lib in self.lib_names: try: __import__(lib) From 4fb1c6411845849c898144a5e692b45914b966f9 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Fri, 30 Jan 2026 15:58:25 +0100 Subject: [PATCH 2/3] WIP: make all type hints stricter --- CHANGELOG.md | 1 + src/xulbux/cli/help.py | 2 +- src/xulbux/color.py | 106 +++++++++++++---------- src/xulbux/console.py | 136 ++++++++++++++--------------- src/xulbux/data.py | 171 ++++++++++++++++++++----------------- src/xulbux/format_codes.py | 47 +++++----- src/xulbux/json.py | 40 +++++---- tests/test_console.py | 132 ++++++++++++++-------------- 8 files changed, 340 insertions(+), 295 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4aa6fb..9029e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ * The compiled version of the library now includes the type stub files (`.pyi`), so type checkers can properly check types. * Made all type hints in the whole library way more strict and accurate. * Removed leftover unnecessary runtime type-checks in several methods throughout the whole library. +* Renamed the `Spinner` class from the `console` module to `Throbber`, since that name is closer to what it's actually used for. diff --git a/src/xulbux/cli/help.py b/src/xulbux/cli/help.py index 5828a16..0e80b48 100644 --- a/src/xulbux/cli/help.py +++ b/src/xulbux/cli/help.py @@ -72,6 +72,6 @@ def is_latest_version() -> Optional[bool]: def show_help() -> None: - FormatCodes._config_console() + FormatCodes._config_console() # type: ignore[protected-access] print(CLI_HELP) Console.pause_exit(pause=True, prompt=" [dim](Press any key to exit...)\n\n") diff --git a/src/xulbux/color.py b/src/xulbux/color.py index 74c252d..110439f 100644 --- a/src/xulbux/color.py +++ b/src/xulbux/color.py @@ -9,7 +9,7 @@ from .base.types import RgbaDict, HslaDict, HexaDict, AnyRgba, AnyHsla, AnyHexa, Rgba, Hsla, Hexa from .regex import Regex -from typing import Iterator, Optional, Literal, overload, cast +from typing import Iterator, Optional, Literal, Any, overload, cast import re as _re @@ -197,8 +197,8 @@ def blend(self, other: Rgba, ratio: float = 0.5, additive_alpha: bool = False) - none_alpha = self.a is None and (len(other_rgba) <= 3 or other_rgba[3] is None) if not none_alpha: - self_a = 1 if self.a is None else self.a - other_a = (other_rgba[3] if other_rgba[3] is not None else 1) if len(other_rgba) > 3 else 1 + self_a: float = 1.0 if self.a is None else self.a + other_a: float = cast(float, 1.0 if other_rgba[3] is None else other_rgba[3]) if len(other_rgba) > 3 else 1.0 if additive_alpha: self.a = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio))) @@ -770,32 +770,36 @@ def is_valid_rgba(cls, color: AnyRgba, allow_alpha: bool = True) -> bool: return True elif isinstance(color, (list, tuple)): + array_color = cast(list[Any] | tuple[Any, ...], color) + if (allow_alpha \ - and len(color) == 4 - and all(isinstance(c, int) for c in color[:3]) - and isinstance(color[3], (float, type(None))) + and len(array_color) == 4 + and all(isinstance(val, int) for val in array_color[:3]) + and isinstance(array_color[3], (float, type(None))) ): return ( - 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255 - and (0 <= color[3] <= 1 or color[3] is None) + 0 <= array_color[0] <= 255 and 0 <= array_color[1] <= 255 and 0 <= array_color[2] <= 255 + and (array_color[3] is None or 0 <= array_color[3] <= 1) ) - elif len(color) == 3 and all(isinstance(c, int) for c in color): - return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255 + elif len(array_color) == 3 and all(isinstance(val, int) for val in array_color): + return 0 <= array_color[0] <= 255 and 0 <= array_color[1] <= 255 and 0 <= array_color[2] <= 255 else: return False elif isinstance(color, dict): + dict_color = cast(dict[str, Any], color) + if (allow_alpha \ - and len(color) == 4 - and all(isinstance(color.get(c), int) for c in ("r", "g", "b")) - and isinstance(color.get("a", "no alpha"), (float, type(None))) + and len(dict_color) == 4 + and all(isinstance(dict_color.get(ch), int) for ch in ("r", "g", "b")) + and isinstance(dict_color.get("a", "no alpha"), (float, type(None))) ): return ( - 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255 - and (0 <= color["a"] <= 1 or color["a"] is None) + 0 <= dict_color["r"] <= 255 and 0 <= dict_color["g"] <= 255 and 0 <= dict_color["b"] <= 255 + and (dict_color["a"] is None or 0 <= dict_color["a"] <= 1) ) - elif len(color) == 3 and all(isinstance(color.get(c), int) for c in ("r", "g", "b")): - return 0 <= color["r"] <= 255 and 0 <= color["g"] <= 255 and 0 <= color["b"] <= 255 + elif len(dict_color) == 3 and all(isinstance(dict_color.get(ch), int) for ch in ("r", "g", "b")): + return 0 <= dict_color["r"] <= 255 and 0 <= dict_color["g"] <= 255 and 0 <= dict_color["b"] <= 255 else: return False @@ -817,32 +821,36 @@ def is_valid_hsla(cls, color: AnyHsla, allow_alpha: bool = True) -> bool: return True elif isinstance(color, (list, tuple)): + array_color = cast(list[Any] | tuple[Any, ...], color) + if (allow_alpha \ - and len(color) == 4 - and all(isinstance(c, int) for c in color[:3]) - and isinstance(color[3], (float, type(None))) + and len(array_color) == 4 + and all(isinstance(val, int) for val in array_color[:3]) + and isinstance(array_color[3], (float, type(None))) ): return ( - 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100 - and (0 <= color[3] <= 1 or color[3] is None) + 0 <= array_color[0] <= 360 and 0 <= array_color[1] <= 100 and 0 <= array_color[2] <= 100 + and (array_color[3] is None or 0 <= array_color[3] <= 1) ) - elif len(color) == 3 and all(isinstance(c, int) for c in color): - return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100 + elif len(array_color) == 3 and all(isinstance(val, int) for val in array_color): + return 0 <= array_color[0] <= 360 and 0 <= array_color[1] <= 100 and 0 <= array_color[2] <= 100 else: return False elif isinstance(color, dict): + dict_color = cast(dict[str, Any], color) + if (allow_alpha \ - and len(color) == 4 - and all(isinstance(color.get(c), int) for c in ("h", "s", "l")) - and isinstance(color.get("a", "no alpha"), (float, type(None))) + and len(dict_color) == 4 + and all(isinstance(dict_color.get(ch), int) for ch in ("h", "s", "l")) + and isinstance(dict_color.get("a", "no alpha"), (float, type(None))) ): return ( - 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100 - and (0 <= color["a"] <= 1 or color["a"] is None) + 0 <= dict_color["h"] <= 360 and 0 <= dict_color["s"] <= 100 and 0 <= dict_color["l"] <= 100 + and (dict_color["a"] is None or 0 <= dict_color["a"] <= 1) ) - elif len(color) == 3 and all(isinstance(color.get(c), int) for c in ("h", "s", "l")): - return 0 <= color["h"] <= 360 and 0 <= color["s"] <= 100 and 0 <= color["l"] <= 100 + elif len(dict_color) == 3 and all(isinstance(dict_color.get(ch), int) for ch in ("h", "s", "l")): + return 0 <= dict_color["h"] <= 360 and 0 <= dict_color["s"] <= 100 and 0 <= dict_color["l"] <= 100 else: return False @@ -1239,34 +1247,46 @@ def _parse_rgba(cls, color: Rgba) -> rgba: """Internal method to parse a color to an RGBA object.""" if isinstance(color, rgba): return color + elif isinstance(color, (list, tuple)): - if len(color) == 4: - return rgba(int(color[0]), int(color[1]), int(color[2]), float(color[3]), _validate=False) - elif len(color) == 3: - return rgba(int(color[0]), int(color[1]), int(color[2]), None, _validate=False) + array_color = cast(list[Any] | tuple[Any, ...], color) + if len(array_color) == 4: + return rgba(int(array_color[0]), int(array_color[1]), int(array_color[2]), float(array_color[3]), _validate=False) + elif len(array_color) == 3: + return rgba(int(array_color[0]), int(array_color[1]), int(array_color[2]), None, _validate=False) + raise ValueError(f"Could not parse RGBA color: {color!r}") + elif isinstance(color, dict): - return rgba(int(color["r"]), int(color["g"]), int(color["b"]), color.get("a"), _validate=False) - elif isinstance(color, str): + dict_color = cast(dict[str, Any], color) + return rgba(int(dict_color["r"]), int(dict_color["g"]), int(dict_color["b"]), dict_color.get("a"), _validate=False) + + else: if parsed := cls.str_to_rgba(color, only_first=True): return cast(rgba, parsed) - raise ValueError(f"Could not parse RGBA color: {color!r}") + raise ValueError(f"Could not parse RGBA color: {color!r}") @classmethod def _parse_hsla(cls, color: Hsla) -> hsla: """Internal method to parse a color to an HSLA object.""" if isinstance(color, hsla): return color + elif isinstance(color, (list, tuple)): + array_color = cast(list[Any] | tuple[Any, ...], color) if len(color) == 4: - return hsla(int(color[0]), int(color[1]), int(color[2]), float(color[3]), _validate=False) + return hsla(int(array_color[0]), int(array_color[1]), int(array_color[2]), float(array_color[3]), _validate=False) elif len(color) == 3: - return hsla(int(color[0]), int(color[1]), int(color[2]), None, _validate=False) + return hsla(int(array_color[0]), int(array_color[1]), int(array_color[2]), None, _validate=False) + raise ValueError(f"Could not parse HSLA color: {color!r}") + elif isinstance(color, dict): - return hsla(int(color["h"]), int(color["s"]), int(color["l"]), color.get("a"), _validate=False) - elif isinstance(color, str): + dict_color = cast(dict[str, Any], color) + return hsla(int(dict_color["h"]), int(dict_color["s"]), int(dict_color["l"]), dict_color.get("a"), _validate=False) + + else: if parsed := cls.str_to_hsla(color, only_first=True): return cast(hsla, parsed) - raise ValueError(f"Could not parse HSLA color: {color!r}") + raise ValueError(f"Could not parse HSLA color: {color!r}") @staticmethod def _linearize_srgb(c: float) -> float: diff --git a/src/xulbux/console.py b/src/xulbux/console.py index 44f898c..a7c324d 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -1,5 +1,5 @@ """ -This module provides the `Console`, `ProgressBar`, and `Spinner` classes +This module provides the `Console`, `ProgressBar`, and `Throbber` classes which offer methods for logging and other actions within the console. """ @@ -7,7 +7,7 @@ from .base.decorators import mypyc_attr from .base.consts import COLOR, CHARS, ANSI -from .format_codes import _PATTERNS as _FC_PATTERNS, FormatCodes +from .format_codes import _PATTERNS as _FC_PATTERNS, FormatCodes # type: ignore[private-access] from .string import String from .color import Color, hexa from .regex import LazyRegex @@ -15,6 +15,7 @@ from typing import Generator, Callable, Optional, Literal, TypeVar, TextIO, Any, overload, cast from prompt_toolkit.key_binding import KeyPressEvent, KeyBindings from prompt_toolkit.validation import ValidationError, Validator +from prompt_toolkit.document import Document from prompt_toolkit.styles import Style from prompt_toolkit.keys import Keys from contextlib import contextmanager @@ -115,7 +116,7 @@ def __len__(self): """The number of arguments stored in the `ParsedArgs` object.""" return len(vars(self)) - def __contains__(self, key): + def __contains__(self, key: str) -> bool: """Checks if an argument with the given alias exists in the `ParsedArgs` object.""" return key in vars(self) @@ -126,9 +127,9 @@ def __bool__(self) -> bool: def __getattr__(self, name: str) -> ParsedArgData: raise AttributeError(f"'{type(self).__name__}' object has no attribute {name}") - def __getitem__(self, key): + def __getitem__(self, key: str | int) -> ParsedArgData: if isinstance(key, int): - return list(self.__iter__())[key] + return list(self.values())[key] return getattr(self, key) def __iter__(self) -> Generator[tuple[str, ParsedArgData], None, None]: @@ -387,7 +388,7 @@ def log( information about formatting codes, see `format_codes` module documentation.""" has_title_bg: bool = False 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 + title_bg_color, has_title_bg = Color.to_hexa(title_bg_color), True if tab_size < 0: raise ValueError("The 'tab_size' parameter must be a non-negative integer.") if title_px < 0: @@ -412,11 +413,9 @@ def log( String.split_count(line, cls.w - (title_len + len(tab) + 2 * len(mx))) \ for line in str(clean_prompt).splitlines() ) - for item in ([""] if lst == [] else (lst if isinstance(lst, list) else [lst])) + for item in ([""] if lst == [] else lst) ] - prompt = f"\n{mx}{' ' * title_len}{mx}{tab}".join( - cls._add_back_removed_parts(prompt_lst, cast(tuple[tuple[int, str], ...], removals)) - ) + prompt = f"\n{mx}{' ' * title_len}{mx}{tab}".join(cls._add_back_removed_parts(prompt_lst, removals)) if title == "": FormatCodes.print( @@ -709,7 +708,7 @@ def log_box_bordered( 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): + if Color.is_valid(border_style): border_style = Color.to_hexa(border_style) borders = { @@ -915,7 +914,7 @@ def input( kb.add(Keys.Any)(helper.handle_any) custom_style = Style.from_dict({"bottom-toolbar": "noreverse"}) - session: _pt.PromptSession = _pt.PromptSession( + session: _pt.PromptSession[str] = _pt.PromptSession( message=_pt.formatted_text.ANSI(FormatCodes.to_ansi(str(prompt), default_color=default_color)), validator=_ConsoleInputValidator( get_text=helper.get_text, @@ -954,7 +953,7 @@ def input( def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[int, str], ...]) -> list[str]: """Adds back the removed parts into the split string parts at their original positions.""" cumulative_pos = [0] - for length in (len(s) for s in split_string): + for length in (len(part) for part in split_string): cumulative_pos.append(cumulative_pos[-1] + length) result, offset_adjusts = split_string.copy(), [0] * len(split_string) @@ -995,9 +994,12 @@ def _prepare_log_box( ) -> tuple[list[str], list[str], int]: """Prepares the log box content and returns it along with the max line length.""" if has_rules: - lines = [] + lines: list[str] = [] + for val in values: - val_str, result_parts, current_pos = str(val), [], 0 + result_parts: list[str] = [] + val_str, current_pos = str(val), 0 + for match in _PATTERNS.hr.finditer(val_str): start, end = match.span() should_split_before = start > 0 and val_str[start - 1] != "\n" @@ -1110,25 +1112,19 @@ def _parse_arg_config(self, alias: str, config: ArgParseConfig) -> Optional[set[ return config # SET OF FLAGS WITH SPECIFIED DEFAULT VALUE - elif isinstance(config, dict): - if not config.get("flags"): + else: + if not config["flags"]: raise ValueError( f"No flags provided under alias '{alias}'.\n" "The 'flags'-key set must contain at least one flag to search for." ) self.parsed_args[alias] = ParsedArgData( exists=False, - values=[default] if (default := config.get("default")) is not None else [], + values=[config["default"]], is_pos=False, ) return config["flags"] - else: - raise TypeError( - f"Invalid configuration type under alias '{alias}'.\n" - "Must be a set, dict, literal 'before' or literal 'after'." - ) - def find_flag_positions(self) -> None: """Find positions of first and last flags for positional argument collection.""" i = 0 @@ -1273,7 +1269,7 @@ def __init__(self, box_bg_color: str | Rgba | Hexa) -> None: self.box_bg_color = box_bg_color def __call__(self, m: _rx.Match[str]) -> str: - return f"{cast(str, m.group(0))}[bg:{self.box_bg_color}]" + return f"{m.group(0)}[bg:{self.box_bg_color}]" class _ConsoleInputHelper: @@ -1443,7 +1439,7 @@ def __init__( self.min_len = min_len self.validator = validator - def validate(self, document) -> None: + def validate(self, document: Document) -> None: text_to_validate = self.get_text() if self.mask_char else document.text if self.min_len and len(text_to_validate) < self.min_len: raise ValidationError(message="", cursor_position=len(document.text)) @@ -1546,13 +1542,13 @@ def set_bar_format( The bar format (also limited) can additionally be formatted with special formatting codes. For more detailed information about formatting codes, see the `format_codes` module documentation.""" if bar_format is not None: - if not any(_PATTERNS.bar.search(s) for s in bar_format): + if not any(_PATTERNS.bar.search(part) for part in bar_format): raise ValueError("The 'bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.") self.bar_format = bar_format if limited_bar_format is not None: - if not any(_PATTERNS.bar.search(s) for s in limited_bar_format): + if not any(_PATTERNS.bar.search(part) for part in limited_bar_format): raise ValueError("The 'limited_bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.") self.limited_bar_format = limited_bar_format @@ -1569,7 +1565,7 @@ def set_chars(self, chars: tuple[str, ...]) -> None: empty sections. If None, uses default Unicode block characters.""" if len(chars) < 2: raise ValueError("The 'chars' parameter must contain at least two characters (full and empty).") - elif not all(isinstance(c, str) and len(c) == 1 for c in chars): + elif not all(len(char) == 1 for char in chars): raise ValueError("All elements of 'chars' must be single-character strings.") self.chars = chars @@ -1676,10 +1672,10 @@ def _get_formatted_info_and_bar_width( percentage: float, label: Optional[str] = None, ) -> tuple[str, int]: - fmt_parts = [] + fmt_parts: list[str] = [] - for s in bar_format: - fmt_part = _PATTERNS.label.sub(label or "", s) + for part in bar_format: + fmt_part = _PATTERNS.label.sub(label or "", part) fmt_part = _PATTERNS.current.sub(_ProgressBarCurrentReplacer(current), fmt_part) fmt_part = _PATTERNS.total.sub(_ProgressBarTotalReplacer(total), fmt_part) fmt_part = _PATTERNS.percentage.sub(_ProgressBarPercentageReplacer(percentage), fmt_part) @@ -1696,7 +1692,7 @@ def _get_formatted_info_and_bar_width( def _create_bar(self, current: int, total: int, bar_width: int) -> str: progress = current / total if total > 0 else 0 - bar = [] + bar: list[str] = [] for i in range(bar_width): pos_progress = (i + 1) / bar_width @@ -1825,32 +1821,32 @@ def __call__(self, match: _rx.Match[str]) -> str: return f"{self.percentage:.{match.group(1) if match.group(1) else '1'}f}" -class Spinner: - """A console spinner for indeterminate processes with customizable appearance. +class Throbber: + """A console throbber for indeterminate processes with customizable appearance. This class intercepts stdout to allow printing while the animation is active.\n --------------------------------------------------------------------------------------------- - `label` -⠀the current label text - - `spinner_format` -⠀the format string used to render the spinner, containing placeholders: + - `throbber_format` -⠀the format string used to render the throbber, containing placeholders: * `{label}` `{l}` * `{animation}` `{a}` - `frames` -⠀a tuple of strings representing the animation frames - `interval` -⠀the time in seconds between each animation frame --------------------------------------------------------------------------------------------- - The `spinner_format` can additionally be formatted with special formatting codes. For more + The `throbber_format` can additionally be formatted with special formatting codes. For more detailed information about formatting codes, see the `format_codes` module documentation.""" def __init__( self, label: Optional[str] = None, - spinner_format: list[str] | tuple[str, ...] = ["{l}", "[b]({a}) "], + throbber_format: list[str] | tuple[str, ...] = ["{l}", "[b]({a}) "], sep: str = " ", frames: tuple[str, ...] = ("· ", "·· ", "···", " ··", " ·", " ·", " ··", "···", "·· ", "· "), interval: float = 0.2, ): - self.spinner_format: list[str] | tuple[str, ...] - """The format strings used to render the spinner (joined by `sep`).""" + self.throbber_format: list[str] | tuple[str, ...] + """The format strings used to render the throbber (joined by `sep`).""" self.sep: str - """The separator string used to join multiple spinner-format strings.""" + """The separator string used to join multiple throbber-format strings.""" self.frames: tuple[str, ...] """A tuple of strings representing the animation frames.""" self.interval: float @@ -1858,10 +1854,10 @@ def __init__( self.label: Optional[str] """The current label text.""" self.active: bool = False - """Whether the spinner is currently active (intercepting stdout) or not.""" + """Whether the throbber is currently active (intercepting stdout) or not.""" self.update_label(label) - self.set_format(spinner_format, sep) + self.set_format(throbber_format, sep) self.set_frames(frames) self.set_interval(interval) @@ -1873,23 +1869,23 @@ def __init__( self._stop_event: Optional[_threading.Event] = None self._animation_thread: Optional[_threading.Thread] = None - def set_format(self, spinner_format: list[str] | tuple[str, ...], sep: Optional[str] = None) -> None: - """Set the format string used to render the spinner.\n + def set_format(self, throbber_format: list[str] | tuple[str, ...], sep: Optional[str] = None) -> None: + """Set the format string used to render the throbber.\n --------------------------------------------------------------------------------------------- - - `spinner_format` -⠀the format strings used to render the spinner, containing placeholders: + - `throbber_format` -⠀the format strings used to render the throbber, containing placeholders: * `{label}` `{l}` * `{animation}` `{a}` - `sep` -⠀the separator string used to join multiple format strings""" - if not any(_PATTERNS.animation.search(fmt) for fmt in spinner_format): + if not any(_PATTERNS.animation.search(fmt) for fmt in throbber_format): raise ValueError( - "At least one format string in 'spinner_format' must contain the '{animation}' or '{a}' placeholder." + "At least one format string in 'throbber_format' must contain the '{animation}' or '{a}' placeholder." ) - self.spinner_format = spinner_format + self.throbber_format = throbber_format self.sep = sep or self.sep def set_frames(self, frames: tuple[str, ...]) -> None: - """Set the frames used for the spinner animation.\n + """Set the frames used for the throbber animation.\n --------------------------------------------------------------------- - `frames` -⠀a tuple of strings representing the animation frames""" if len(frames) < 2: @@ -1907,9 +1903,9 @@ def set_interval(self, interval: int | float) -> None: self.interval = interval def start(self, label: Optional[str] = None) -> None: - """Start the spinner animation and intercept stdout.\n + """Start the throbber animation and intercept stdout.\n ---------------------------------------------------------- - - `label` -⠀the label to display alongside the spinner""" + - `label` -⠀the label to display alongside the throbber""" if self.active: return @@ -1920,7 +1916,7 @@ def start(self, label: Optional[str] = None) -> None: self._animation_thread.start() def stop(self) -> None: - """Stop and hide the spinner and restore normal console output.""" + """Stop and hide the throbber and restore normal console output.""" if self.active: if self._stop_event: self._stop_event.set() @@ -1931,11 +1927,11 @@ def stop(self) -> None: self._animation_thread = None self._frame_index = 0 - self._clear_spinner_line() + self._clear_throbber_line() self._stop_intercepting() def update_label(self, label: Optional[str]) -> None: - """Update the spinner's label text.\n + """Update the throbber's label text.\n -------------------------------------- - `new_label` -⠀the new label text""" self.label = label @@ -1944,14 +1940,14 @@ def update_label(self, label: Optional[str]) -> None: def context(self, label: Optional[str] = None) -> Generator[Callable[[str], None], None, None]: """Context manager for automatic cleanup. Returns a function to update the label.\n ---------------------------------------------------------------------------------------------- - - `label` -⠀the label to display alongside the spinner + - `label` -⠀the label to display alongside the throbber ----------------------------------------------------------------------------------------------- The returned callable accepts a single parameter: - `new_label` -⠀the new label text\n #### Example usage: ```python - with Spinner().context("Starting...") as update_label: + with Throbber().context("Starting...") as update_label: time.sleep(2) update_label("Processing...") time.sleep(3) @@ -1979,10 +1975,8 @@ 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 ( \ - _PATTERNS.animation.sub(frame, _PATTERNS.label.sub(self.label or "", s)) - for s in self.spinner_format - ) if s + fmt_part for part in self.throbber_format if \ + (fmt_part := _PATTERNS.animation.sub(frame, _PATTERNS.label.sub(self.label or "", part))) )) self._current_animation_str = formatted @@ -2018,14 +2012,14 @@ def _emergency_cleanup(self) -> None: except Exception: pass - def _clear_spinner_line(self) -> None: + def _clear_throbber_line(self) -> None: if self._last_line_len > 0 and self._original_stdout: self._original_stdout.write(f"{ANSI.CHAR}[2K\r") self._original_stdout.flush() def _flush_buffer(self) -> None: if self._buffer and self._original_stdout: - self._clear_spinner_line() + self._clear_throbber_line() for content in self._buffer: self._original_stdout.write(content) self._original_stdout.flush() @@ -2041,28 +2035,28 @@ def _redraw_display(self) -> None: class _InterceptedOutput: """Custom StringIO that captures output and stores it in the progress bar buffer.""" - def __init__(self, progress_bar: ProgressBar | Spinner): - self.progress_bar = progress_bar + def __init__(self, status_indicator: ProgressBar | Throbber): + self.status_indicator = status_indicator self.string_io = StringIO() def write(self, content: str) -> int: self.string_io.write(content) try: if content and content != "\r": - self.progress_bar._buffer.append(content) + cast(ProgressBar | Throbber, self.status_indicator)._buffer.append(content) # type: ignore[protected-access] return len(content) except Exception: - self.progress_bar._emergency_cleanup() + self.status_indicator._emergency_cleanup() # type: ignore[protected-access] raise def flush(self) -> None: self.string_io.flush() try: - if self.progress_bar.active and self.progress_bar._buffer: - self.progress_bar._flush_buffer() - self.progress_bar._redraw_display() + if self.status_indicator.active and self.status_indicator._buffer: # type: ignore[protected-access] + self.status_indicator._flush_buffer() # type: ignore[protected-access] + self.status_indicator._redraw_display() # type: ignore[protected-access] except Exception: - self.progress_bar._emergency_cleanup() + self.status_indicator._emergency_cleanup() # type: ignore[protected-access] raise def __getattr__(self, name: str) -> Any: diff --git a/src/xulbux/data.py b/src/xulbux/data.py index 06d1c81..683129b 100644 --- a/src/xulbux/data.py +++ b/src/xulbux/data.py @@ -71,13 +71,20 @@ def chars_count(cls, data: DataStructure) -> int: chars_count = 0 if isinstance(data, dict): - for k, v in data.items(): - chars_count += len(str(k)) + (cls.chars_count(v) if isinstance(v, DataStructureTypes) else len(str(v))) - - elif isinstance(data, IndexIterableTypes): + for key, val in data.items(): + chars_count += len(str(key)) + ( + cls.chars_count(cast(DataStructure, val)) \ + if isinstance(val, DataStructureTypes) + else len(str(val)) + ) + else: for item in data: - chars_count += cls.chars_count(item) if isinstance(item, DataStructureTypes) else len(str(item)) - + chars_count += ( + cls.chars_count(cast(DataStructure, item)) \ + if isinstance(item, DataStructureTypes) + else len(str(item)) + ) + return chars_count @classmethod @@ -86,12 +93,18 @@ def strip(cls, data: DataStructure) -> DataStructure: ------------------------------------------------------------------------------- - `data` -⠀the data structure to strip the items from""" if isinstance(data, dict): - return {k.strip(): cls.strip(v) if isinstance(v, DataStructureTypes) else v.strip() for k, v in data.items()} - - if isinstance(data, IndexIterableTypes): - return type(data)(cls.strip(item) if isinstance(item, DataStructureTypes) else item.strip() for item in data) + return {key.strip(): ( + cls.strip(cast(DataStructure, val)) \ + if isinstance(val, DataStructureTypes) + else val.strip() + ) for key, val in data.items()} - raise TypeError(f"Unsupported data structure type: {type(data)}") + else: + return type(data)(( + cls.strip(cast(DataStructure, item)) \ + if isinstance(item, DataStructureTypes) + else item.strip() + ) for item in data) @classmethod def remove_empty_items(cls, data: DataStructure, spaces_are_empty: bool = False) -> DataStructure: @@ -101,34 +114,30 @@ def remove_empty_items(cls, data: DataStructure, spaces_are_empty: bool = False) - `spaces_are_empty` -⠀if true, it will count items with only spaces as empty""" if isinstance(data, dict): return { - k: (v if not isinstance(v, DataStructureTypes) else cls.remove_empty_items(v, spaces_are_empty)) - for k, v in data.items() if not String.is_empty(v, spaces_are_empty) + key: (val if not isinstance(val, DataStructureTypes) else cls.remove_empty_items(cast(DataStructure, val), spaces_are_empty)) + for key, val in data.items() if not String.is_empty(val, spaces_are_empty) } - if isinstance(data, IndexIterableTypes): + else: return type(data)( - item for item in - ( - (item if not isinstance(item, DataStructureTypes) else cls.remove_empty_items(item, spaces_are_empty)) \ + item for item in ( + (item if not isinstance(item, DataStructureTypes) else cls.remove_empty_items(cast(DataStructure, item), spaces_are_empty)) \ for item in data if not (isinstance(item, (str, type(None))) and String.is_empty(item, spaces_are_empty)) - ) - if item not in ([], (), {}, set(), frozenset()) + ) if item not in ([], (), {}, set(), frozenset()) ) - raise TypeError(f"Unsupported data structure type: {type(data)}") - @classmethod def remove_duplicates(cls, data: DataStructure) -> DataStructure: """Removes all duplicates from the data structure.\n ----------------------------------------------------------- - `data` -⠀the data structure to remove duplicates from""" if isinstance(data, dict): - return {k: cls.remove_duplicates(v) if isinstance(v, DataStructureTypes) else v for k, v in data.items()} + return {key: cls.remove_duplicates(cast(DataStructure, val)) if isinstance(val, DataStructureTypes) else val for key, val in data.items()} - if isinstance(data, (list, tuple)): + elif isinstance(data, (list, tuple)): result: list[Any] = [] for item in data: - processed_item = cls.remove_duplicates(item) if isinstance(item, DataStructureTypes) else item + processed_item = cls.remove_duplicates(cast(DataStructure, item)) if isinstance(item, DataStructureTypes) else item is_duplicate: bool = False for existing_item in result: @@ -141,15 +150,13 @@ def remove_duplicates(cls, data: DataStructure) -> DataStructure: return type(data)(result) - if isinstance(data, (set, frozenset)): - processed_elements = set() + else: + processed_elements: set[Any] = set() for item in data: - processed_item = cls.remove_duplicates(item) if isinstance(item, DataStructureTypes) else item + processed_item = cls.remove_duplicates(cast(DataStructure, item)) if isinstance(item, DataStructureTypes) else item processed_elements.add(processed_item) return type(data)(processed_elements) - raise TypeError(f"Unsupported data structure type: {type(data)}") - @classmethod def remove_comments( cls, @@ -309,19 +316,21 @@ def get_value_by_path_id(cls, data: DataStructure, path_id: str, get_key: bool = for i, path_idx in enumerate(path): if isinstance(current_data, dict): - keys = list(current_data.keys()) + dict_data = cast(dict[Any, Any], current_data) + keys: list[str] = list(dict_data.keys()) if i == len(path) - 1 and get_key: return keys[path_idx] - parent = current_data - current_data = current_data[keys[path_idx]] + parent = dict_data + current_data = dict_data[keys[path_idx]] elif isinstance(current_data, IndexIterableTypes): + idx_iterable_data = cast(IndexIterable, current_data) if i == len(path) - 1 and get_key: if parent is None or not isinstance(parent, dict): raise ValueError(f"Cannot get key from a non-dict parent at path '{path[:i + 1]}'") - return next(key for key, value in parent.items() if value is current_data) - parent = current_data - current_data = list(current_data)[path_idx] # CONVERT TO LIST FOR INDEXING + return next(key for key, value in parent.items() if value is idx_iterable_data) + parent = idx_iterable_data + current_data = list(idx_iterable_data)[path_idx] # CONVERT TO LIST FOR INDEXING else: raise TypeError(f"Unsupported type '{type(current_data)}' at path '{path[:i + 1]}'") @@ -474,24 +483,26 @@ def _compare_nested( return False if isinstance(data1, dict) and isinstance(data2, dict): - if set(data1.keys()) != set(data2.keys()): + dict_data1, dict_data2 = cast(dict[Any, Any], data1), cast(dict[Any, Any], data2) + if set(dict_data1.keys()) != set(dict_data2.keys()): return False return all(cls._compare_nested( \ - data1=data1[key], - data2=data2[key], + data1=dict_data1[key], + data2=dict_data2[key], ignore_paths=ignore_paths, current_path=current_path + [key], - ) for key in data1) + ) for key in dict_data1) - elif isinstance(data1, (list, tuple)): - if len(data1) != len(data2): + elif isinstance(data1, (list, tuple)) and isinstance(data2, (list, tuple)): + array_data1, array_data2 = cast(IndexIterable, data1), cast(IndexIterable, data2) + if len(array_data1) != len(array_data2): return False return all(cls._compare_nested( \ data1=item1, data2=item2, ignore_paths=ignore_paths, current_path=current_path + [str(i)], - ) for i, (item1, item2) in enumerate(zip(data1, data2))) + ) for i, (item1, item2) in enumerate(zip(array_data1, array_data2))) elif isinstance(data1, (set, frozenset)): return data1 == data2 @@ -519,23 +530,27 @@ def _set_nested_val(cls, data: DataStructure, id_path: list[int], value: Any) -> if len(id_path) == 1: if isinstance(current_data, dict): - keys, data_dict = list(current_data.keys()), dict(current_data) - data_dict[keys[id_path[0]]] = value - return data_dict + dict_data = cast(dict[Any, Any], current_data) + keys, dict_data = list(dict_data.keys()), dict(dict_data) + dict_data[keys[id_path[0]]] = value + return dict_data elif isinstance(current_data, IndexIterableTypes): - was_t, data_list = type(current_data), list(current_data) - data_list[id_path[0]] = value - return was_t(data_list) + idx_iterable_data = cast(IndexIterable, current_data) + was_t, idx_iterable_data = type(idx_iterable_data), list(idx_iterable_data) + idx_iterable_data[id_path[0]] = value + return was_t(idx_iterable_data) else: if isinstance(current_data, dict): - keys, data_dict = list(current_data.keys()), dict(current_data) - data_dict[keys[id_path[0]]] = cls._set_nested_val(data_dict[keys[id_path[0]]], id_path[1:], value) - return data_dict + dict_data = cast(dict[Any, Any], current_data) + keys, dict_data = list(dict_data.keys()), dict(dict_data) + dict_data[keys[id_path[0]]] = cls._set_nested_val(dict_data[keys[id_path[0]]], id_path[1:], value) + return dict_data elif isinstance(current_data, IndexIterableTypes): - was_t, data_list = type(current_data), list(current_data) - data_list[id_path[0]] = cls._set_nested_val(data_list[id_path[0]], id_path[1:], value) - return was_t(data_list) + idx_iterable_data = cast(IndexIterable, current_data) + was_t, idx_iterable_data = type(idx_iterable_data), list(idx_iterable_data) + idx_iterable_data[id_path[0]] = cls._set_nested_val(idx_iterable_data[id_path[0]], id_path[1:], value) + return was_t(idx_iterable_data) return current_data @@ -549,7 +564,7 @@ def __init__(self, data: DataStructure, comment_start: str, comment_end: str, co self.comment_end = comment_end self.comment_sep = comment_sep - self.pattern = _re.compile(Regex._clean( \ + self.pattern = _re.compile(Regex._clean( # type: ignore[protected-access] rf"""^( (?:(?!{_re.escape(comment_start)}).)* ) @@ -564,16 +579,18 @@ def __call__(self) -> DataStructure: def remove_nested_comments(self, item: Any) -> Any: if isinstance(item, dict): + dict_item = cast(dict[Any, Any], item) return { key: val for key, val in ( \ - (self.remove_nested_comments(k), self.remove_nested_comments(v)) for k, v in item.items() + (self.remove_nested_comments(k), self.remove_nested_comments(v)) for k, v in dict_item.items() ) if key is not None } if isinstance(item, IndexIterableTypes): - processed = (v for v in map(self.remove_nested_comments, item) if v is not None) - return type(item)(processed) + idx_iterable_item = cast(IndexIterable, item) + processed = (val for val in map(self.remove_nested_comments, idx_iterable_item) if val is not None) + return type(idx_iterable_item)(processed) if isinstance(item, str): if self.pattern: @@ -689,8 +706,8 @@ def __init__( raise TypeError(f"Expected 'syntax_highlighting' to be a dict or bool. Got: {type(syntax_highlighting)}") self.syntax_hl.update({ - k: (f"[{v}]", "[_]") if k in self.syntax_hl and v not in {"", None} else ("", "") - for k, v in syntax_highlighting.items() + key: (f"[{val}]", "[_]") if key in self.syntax_hl and val not in {"", None} else ("", "") + for key, val in syntax_highlighting.items() }) sep = f"{self.syntax_hl['punctuation'][0]}{sep}{self.syntax_hl['punctuation'][1]}" @@ -699,10 +716,10 @@ def __init__( punct_map: dict[str, str | tuple[str, str]] = {"(": ("/(", "("), **{c: c for c in "'\":)[]{}"}} self.punct: dict[str, str] = { - k: ((f"{self.syntax_hl['punctuation'][0]}{v[0]}{self.syntax_hl['punctuation'][1]}" if self.do_syntax_hl else v[1]) - if isinstance(v, (list, tuple)) else - (f"{self.syntax_hl['punctuation'][0]}{v}{self.syntax_hl['punctuation'][1]}" if self.do_syntax_hl else v)) - for k, v in punct_map.items() + key: ((f"{self.syntax_hl['punctuation'][0]}{val[0]}{self.syntax_hl['punctuation'][1]}" if self.do_syntax_hl else val[1]) + if isinstance(val, (list, tuple)) else + (f"{self.syntax_hl['punctuation'][0]}{val}{self.syntax_hl['punctuation'][1]}" if self.do_syntax_hl else val)) + for key, val in punct_map.items() } def __call__(self) -> str: @@ -713,19 +730,19 @@ def __call__(self) -> str: def format_value(self, value: Any, current_indent: Optional[int] = None) -> str: if current_indent is not None and isinstance(value, dict): - return self.format_dict(value, current_indent + self.indent) + return self.format_dict(cast(dict[Any, Any], value), current_indent + self.indent) elif current_indent is not None and hasattr(value, "__dict__"): return self.format_dict(value.__dict__, current_indent + self.indent) elif current_indent is not None and isinstance(value, IndexIterableTypes): - return self.format_sequence(value, current_indent + self.indent) + return self.format_sequence(cast(IndexIterable, value), current_indent + self.indent) elif current_indent is not None and isinstance(value, (bytes, bytearray)): obj_dict = self.cls.serialize_bytes(value) return ( self.format_dict(obj_dict, current_indent + self.indent) if self.as_json else ( - f"{self.syntax_hl['type'][0]}{(k := next(iter(obj_dict)))}{self.syntax_hl['type'][1]}" - + self.format_sequence((obj_dict[k], obj_dict["encoding"]), current_indent + self.indent) - if self.do_syntax_hl else (k := next(iter(obj_dict))) - + self.format_sequence((obj_dict[k], obj_dict["encoding"]), current_indent + self.indent) + f"{self.syntax_hl['type'][0]}{(key := next(iter(obj_dict)))}{self.syntax_hl['type'][1]}" + + self.format_sequence((obj_dict[key], obj_dict["encoding"]), current_indent + self.indent) + if self.do_syntax_hl else (key := next(iter(obj_dict))) + + self.format_sequence((obj_dict[key], obj_dict["encoding"]), current_indent + self.indent) ) ) elif isinstance(value, bool): @@ -770,20 +787,20 @@ def should_expand(self, seq: IndexIterable) -> bool: or (complex_items == 1 and len(seq) > 1) \ or self.cls.chars_count(seq) + (len(seq) * len(self.sep)) > self.max_width - def format_dict(self, d: dict, current_indent: int) -> str: - if self.compactness == 2 or not d or not self.should_expand(list(d.values())): + def format_dict(self, data_dict: dict[Any, Any], current_indent: int) -> str: + if self.compactness == 2 or not data_dict or not self.should_expand(list(data_dict.values())): return self.punct["{"] + self.sep.join( - f"{self.format_value(k)}{self.punct[':']} {self.format_value(v, current_indent)}" for k, v in d.items() + f"{self.format_value(key)}{self.punct[':']} {self.format_value(val, current_indent)}" for key, val in data_dict.items() ) + self.punct["}"] - items = [] - for k, val in d.items(): + items: list[str] = [] + for key, val in data_dict.items(): formatted_value = self.format_value(val, current_indent) - items.append(f"{' ' * (current_indent + self.indent)}{self.format_value(k)}{self.punct[':']} {formatted_value}") + items.append(f"{' ' * (current_indent + self.indent)}{self.format_value(key)}{self.punct[':']} {formatted_value}") return self.punct["{"] + "\n" + f"{self.sep}\n".join(items) + f"\n{' ' * current_indent}" + self.punct["}"] - def format_sequence(self, seq, current_indent: int) -> str: + def format_sequence(self, seq: IndexIterable, current_indent: int) -> str: if self.as_json: seq = list(seq) diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 537f7be..5564110 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -407,7 +407,7 @@ def _config_console(cls) -> None: kernel32.SetConsoleMode(h, mode.value | 0x0004) except Exception: pass - _CONSOLE_ANSI_CONFIGURED = True + _CONSOLE_ANSI_CONFIGURED = True # type: ignore[assignment] @staticmethod def _validate_default_color(default_color: Optional[Rgba | Hexa]) -> tuple[bool, Optional[rgba]]: @@ -417,14 +417,14 @@ def _validate_default_color(default_color: Optional[Rgba | Hexa]) -> tuple[bool, if Color.is_valid_hexa(default_color, False): return True, hexa(cast(str | int, default_color)).to_rgba() elif Color.is_valid_rgba(default_color, False): - return True, Color._parse_rgba(default_color) + return True, Color._parse_rgba(cast(Rgba, default_color)) # type: ignore[protected-access] raise TypeError("The 'default_color' parameter must be either a valid RGBA or HEXA color, or None.") @staticmethod def _formats_to_keys(formats: str) -> list[str]: """Internal method to convert a string of multiple format keys to a list of individual, stripped format keys.""" - return [k.strip() for k in formats.split("|") if k.strip()] + return [key.strip() for key in formats.split("|") if key.strip()] @classmethod def _get_replacement(cls, format_key: str, default_color: Optional[rgba], brightness_steps: int = 20) -> str: @@ -438,7 +438,8 @@ def _get_replacement(cls, format_key: str, default_color: Optional[rgba], bright if (isinstance(map_key, tuple) and format_key in map_key) or format_key == map_key: return _ANSI_SEQ_1.format( next(( - v for k, v in ANSI.CODES_MAP.items() if format_key == k or (isinstance(k, tuple) and format_key in k) + val for key, val in ANSI.CODES_MAP.items() \ + if format_key == key or (isinstance(key, tuple) and format_key in key) ), None) ) rgb_match = _PATTERNS.rgb.match(format_key) @@ -534,7 +535,10 @@ def __call__(self, match: _rx.Match[str]) -> str: else: _formats = _PATTERNS.star_reset_inside.sub(r"\1_\2", formats) - if all((self.cls._get_replacement(k, self.default_color) != k) for k in self.cls._formats_to_keys(_formats)): + if all( + self.cls._get_replacement(format_key, self.default_color) != format_key # type: ignore[protected-access] + for format_key in self.cls._formats_to_keys(_formats) # type: ignore[protected-access] + ): # ESCAPE THE FORMATTING CODE escaped = f"[{self.escape_char}{formats}]" if auto_reset_txt: @@ -633,11 +637,12 @@ def process_formats_and_auto_reset(self) -> None: def convert_to_ansi(self) -> None: """Convert format keys to ANSI codes and generate resets if needed.""" - self.format_keys = self.cls._formats_to_keys(self.formats) - self.ansi_formats = [ - r if (r := self.cls._get_replacement(k, self.default_color, self.brightness_steps)) != k else f"[{k}]" - for k in self.format_keys - ] + self.format_keys = self.cls._formats_to_keys(self.formats) # type: ignore[protected-access] + self.ansi_formats = [( + ansi_code \ + if (ansi_code := self.cls._get_replacement(format_key, self.default_color, self.brightness_steps)) != format_key # type: ignore[protected-access] + else f"[{format_key}]" + ) for format_key in self.format_keys] # GENERATE RESET CODES IF AUTO-RESET IS ACTIVE if self.auto_reset_txt and not self.auto_reset_escaped: @@ -650,40 +655,40 @@ def gen_reset_codes(self) -> None: default_color_resets = ("_bg", "default") if self.use_default else ("_bg", "_c") reset_keys: list[str] = [] - for k in self.format_keys: - k_lower = k.lower() + for format_key in self.format_keys: + k_lower = format_key.lower() k_set = set(k_lower.split(":")) # BACKGROUND COLOR FORMAT if _PREFIX["BG"] & k_set and len(k_set) <= 3: if k_set & _PREFIX["BR"]: # BRIGHT BACKGROUND COLOR - RESET BOTH BG AND COLOR - for i in range(len(k)): - if self.is_valid_color(k[i:]): + for i in range(len(format_key)): + if self.is_valid_color(format_key[i:]): reset_keys.extend(default_color_resets) break else: # REGULAR BACKGROUND COLOR - RESET ONLY BG - for i in range(len(k)): - if self.is_valid_color(k[i:]): + for i in range(len(format_key)): + if self.is_valid_color(format_key[i:]): reset_keys.append("_bg") break # TEXT COLOR FORMAT - elif self.is_valid_color(k) or any( - k_lower.startswith(pref_colon := f"{prefix}:") and self.is_valid_color(k[len(pref_colon):]) \ + elif self.is_valid_color(format_key) or any( + k_lower.startswith(pref_colon := f"{prefix}:") and self.is_valid_color(format_key[len(pref_colon):]) \ for prefix in _PREFIX["BR"] ): reset_keys.append(default_color_resets[1]) # TEXT STYLE FORMAT else: - reset_keys.append(f"_{k}") + reset_keys.append(f"_{format_key}") # CONVERT RESET KEYS TO ANSI CODES self.ansi_resets = [ - r for k in reset_keys if ( \ - r := self.cls._get_replacement(k, self.default_color, self.brightness_steps) + ansi_code for reset_key in reset_keys if ( \ + ansi_code := self.cls._get_replacement(reset_key, self.default_color, self.brightness_steps) # type: ignore[protected-access] ).startswith(f"{ANSI.CHAR}{ANSI.START}") ] diff --git a/src/xulbux/json.py b/src/xulbux/json.py index c60bca7..bc2d55b 100644 --- a/src/xulbux/json.py +++ b/src/xulbux/json.py @@ -3,6 +3,7 @@ create and update JSON files, with support for comments inside the JSON data. """ +from .base.types import DataStructure from .file_sys import FileSys from .data import Data from .file import File @@ -23,7 +24,7 @@ def read( comment_start: str = ">>", comment_end: str = "<<", return_original: bool = False, - ) -> dict | tuple[dict, dict]: + ) -> dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]: """Read JSON files, ignoring comments.\n ------------------------------------------------------------------------------------ - `json_file` -⠀the path (relative or absolute) to the JSON file to read @@ -58,7 +59,7 @@ def read( def create( cls, json_file: Path | str, - data: dict, + data: dict[str, Any], indent: int = 2, compactness: Literal[0, 1, 2] = 1, force: bool = False, @@ -141,17 +142,24 @@ 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.""" - processed_data, data = cls.read( - json_file=json_file, - comment_start=comment_start, - comment_end=comment_end, - return_original=True, + processed_data, data = cast( + tuple[dict[str, Any], dict[str, Any]], + cls.read( + json_file=json_file, + comment_start=comment_start, + comment_end=comment_end, + return_original=True, + ), ) update: dict[str, Any] = {} for val_path, new_val in update_values.items(): try: - if (path_id := Data.get_path_id(data=processed_data, value_paths=val_path, path_sep=path_sep)) is not None: + if (path_id := Data.get_path_id( + data=cast(DataStructure, processed_data), + value_paths=val_path, + path_sep=path_sep, + )) is not None: update[cast(str, path_id)] = new_val else: data = cls._create_nested_path(data, val_path.split(path_sep), new_val) @@ -164,7 +172,7 @@ def update( cls.create(json_file=json_file, data=dict(data), force=True) @staticmethod - def _create_nested_path(data_obj: dict, path_keys: list[str], value: Any) -> dict: + def _create_nested_path(data_obj: dict[str, Any], path_keys: list[str], value: Any) -> dict[str, Any]: """Internal method that creates nested dictionaries/lists based on the given path keys and sets the specified value at the end of the path.""" last_idx, current = len(path_keys) - 1, data_obj @@ -175,11 +183,11 @@ def _create_nested_path(data_obj: dict, path_keys: list[str], value: Any) -> dic current[key] = value elif isinstance(current, list) and key.isdigit(): idx = int(key) - while len(current) <= idx: - current.append(None) + while len(cast(list[Any], current)) <= idx: + cast(list[Any], current).append(None) current[idx] = value else: - raise TypeError(f"Cannot set key '{key}' on {type(current)}") + raise TypeError(f"Cannot set key '{key}' on {type(cast(Any, current))}") else: next_key = path_keys[i + 1] @@ -189,12 +197,12 @@ def _create_nested_path(data_obj: dict, path_keys: list[str], value: Any) -> dic current = current[key] elif isinstance(current, list) and key.isdigit(): idx = int(key) - while len(current) <= idx: - current.append(None) + while len(cast(list[Any], current)) <= idx: + cast(list[Any], current).append(None) if current[idx] is None: current[idx] = [] if next_key.isdigit() else {} - current = current[idx] + current = cast(list[Any], current)[idx] else: - raise TypeError(f"Cannot navigate through {type(current)}") + raise TypeError(f"Cannot navigate through {type(cast(Any, current))}") return data_obj diff --git a/tests/test_console.py b/tests/test_console.py index bd9c705..46b3775 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -1,5 +1,5 @@ from xulbux.console import ParsedArgData, ParsedArgs -from xulbux.console import Spinner, ProgressBar +from xulbux.console import Throbber, ProgressBar from xulbux.console import Console from xulbux import console @@ -1029,128 +1029,128 @@ def test_progressbar_redraw_progress_bar(): mock_stdout.flush.assert_called_once() -################################################## Spinner TESTS ################################################## +################################################## Throbber 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_throbber_init_defaults(): + throbber = Throbber() + assert throbber.label is None + assert throbber.interval == 0.2 + assert throbber.active is False + assert throbber.sep == " " + assert len(throbber.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_throbber_init_custom(): + throbber = Throbber(label="Loading", interval=0.5, sep="-") + assert throbber.label == "Loading" + assert throbber.interval == 0.5 + assert throbber.sep == "-" -def test_spinner_set_format_valid(): - spinner = Spinner() - spinner.set_format(["{l}", "{a}"]) - assert spinner.spinner_format == ["{l}", "{a}"] +def test_throbber_set_format_valid(): + throbber = Throbber() + throbber.set_format(["{l}", "{a}"]) + assert throbber.throbber_format == ["{l}", "{a}"] -def test_spinner_set_format_invalid(): - spinner = Spinner() +def test_throbber_set_format_invalid(): + throbber = Throbber() with pytest.raises(ValueError): - spinner.set_format(["{l}"]) # MISSING {a} + throbber.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_throbber_set_frames_valid(): + throbber = Throbber() + throbber.set_frames(("a", "b")) + assert throbber.frames == ("a", "b") -def test_spinner_set_frames_invalid(): - spinner = Spinner() +def test_throbber_set_frames_invalid(): + throbber = Throbber() with pytest.raises(ValueError): - spinner.set_frames(("a", )) # LESS THAN 2 FRAMES + throbber.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_throbber_set_interval_valid(): + throbber = Throbber() + throbber.set_interval(1.0) + assert throbber.interval == 1.0 -def test_spinner_set_interval_invalid(): - spinner = Spinner() +def test_throbber_set_interval_invalid(): + throbber = Throbber() with pytest.raises(ValueError): - spinner.set_interval(0) + throbber.set_interval(0) with pytest.raises(ValueError): - spinner.set_interval(-1) + throbber.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): +def test_throbber_start(mock_stdout, mock_event, mock_thread): mock_thread.return_value.start.return_value = None - spinner = Spinner() - spinner.start("Test") + throbber = Throbber() + throbber.start("Test") - assert spinner.active is True - assert spinner.label == "Test" + assert throbber.active is True + assert throbber.label == "Test" mock_event.assert_called_once() mock_thread.assert_called_once() # TEST CALLING START AGAIN DOESN'T DO ANYTHING - spinner.start("Test2") + throbber.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() +def test_throbber_stop(mock_event, mock_thread): + throbber = Throbber() # MANUALLY SET ACTIVE TO SIMULATE RUNNING - spinner.active = True + throbber.active = True mock_stop_event = MagicMock() mock_stop_event.set.return_value = None - spinner._stop_event = mock_stop_event + throbber._stop_event = mock_stop_event mock_animation_thread = MagicMock() mock_animation_thread.join.return_value = None - spinner._animation_thread = mock_animation_thread + throbber._animation_thread = mock_animation_thread - spinner.stop() + throbber.stop() - assert spinner.active is False + assert throbber.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_throbber_update_label(): + throbber = Throbber() + throbber.update_label("New Label") + assert throbber.label == "New Label" -def test_spinner_context_manager(): - spinner = Spinner() +def test_throbber_context_manager(): + throbber = Throbber() # TEST CONTEXT MANAGER BEHAVIOR BY CHECKING ACTUAL EFFECTS - with spinner.context("Test") as update: - assert spinner.active is True - assert spinner.label == "Test" + with throbber.context("Test") as update: + assert throbber.active is True + assert throbber.label == "Test" update("New Label") - assert spinner.label == "New Label" + assert throbber.label == "New Label" - # AFTER CONTEXT EXITS, SPINNER SHOULD BE STOPPED - assert spinner.active is False + # AFTER CONTEXT EXITS, THROBBER SHOULD BE STOPPED + assert throbber.active is False -def test_spinner_context_manager_exception(): - spinner = Spinner() +def test_throbber_context_manager_exception(): + throbber = Throbber() # TEST THAT CLEANUP HAPPENS EVEN WITH EXCEPTIONS with pytest.raises(ValueError): - with spinner.context("Test"): + with throbber.context("Test"): raise ValueError("Oops") - # AFTER EXCEPTION, SPINNER SHOULD STILL BE CLEANED UP - assert spinner.active is False + # AFTER EXCEPTION, THROBBER SHOULD STILL BE CLEANED UP + assert throbber.active is False From 86e5e6f40d1e183134a880404bbc3bc74cd8ed6c Mon Sep 17 00:00:00 2001 From: XulbuX Date: Sat, 31 Jan 2026 00:08:41 +0100 Subject: [PATCH 3/3] WIP: make all type hints stricter --- CHANGELOG.md | 6 + src/xulbux/base/consts.py | 2 +- src/xulbux/base/types.py | 9 +- src/xulbux/code.py | 10 +- src/xulbux/color.py | 384 ++++++++++++++++++++++++++----------- src/xulbux/console.py | 171 ++++++++++------- src/xulbux/data.py | 222 +++++++++++++-------- src/xulbux/env_path.py | 34 ++-- src/xulbux/file.py | 4 +- src/xulbux/file_sys.py | 37 ++-- src/xulbux/format_codes.py | 125 +++++++++--- src/xulbux/json.py | 73 ++++--- src/xulbux/regex.py | 16 +- src/xulbux/string.py | 26 +-- src/xulbux/system.py | 23 ++- 15 files changed, 758 insertions(+), 384 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9029e44..08de135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,13 @@ * The compiled version of the library now includes the type stub files (`.pyi`), so type checkers can properly check types. * Made all type hints in the whole library way more strict and accurate. * Removed leftover unnecessary runtime type-checks in several methods throughout the whole library. + +**BREAKING CHANGES:** +* All methods that should use positional-only params up to a certain point, now actually enforce that by using the `/` syntax. * Renamed the `Spinner` class from the `console` module to `Throbber`, since that name is closer to what it's actually used for. +* Changed the name of the TypeAlias `DataStructure` to `DataObj` because that name is shorter and more general. +* Changed both names `DataStructureTypes` and `IndexIterableTypes` to `DataObjTT` and `IndexIterableTT` respectively (`TT` *stands for types-tuple*). +* Made the return value of `String.single_char_repeats()` always be *`int`* and not *int* | *bool*. diff --git a/src/xulbux/base/consts.py b/src/xulbux/base/consts.py index 8d5425a..0fd895c 100644 --- a/src/xulbux/base/consts.py +++ b/src/xulbux/base/consts.py @@ -88,7 +88,7 @@ class ANSI: """End of an ANSI escape sequence.""" @classmethod - def seq(cls, placeholders: int = 1) -> FormattableString: + def seq(cls, placeholders: int = 1, /) -> FormattableString: """Generates an ANSI escape sequence with the specified number of placeholders.""" return cls.CHAR + cls.START + cls.SEP.join(["{}" for _ in range(placeholders)]) + cls.END diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py index c48e9fa..7b8b793 100644 --- a/src/xulbux/base/types.py +++ b/src/xulbux/base/types.py @@ -30,14 +30,14 @@ PathsList: TypeAlias = Union[list[Path], list[str], list[Union[Path, str]]] """Union of all supported list types for a list of paths.""" -DataStructure: TypeAlias = Union[list[Any], tuple[Any, ...], set[Any], frozenset[Any], dict[Any, Any]] +DataObj: TypeAlias = Union[list[Any], tuple[Any, ...], set[Any], frozenset[Any], dict[Any, Any]] """Union of supported data structures used in the `data` module.""" -DataStructureTypes = (list, tuple, set, frozenset, dict) +DataObjTT = (list, tuple, set, frozenset, dict) """Tuple of supported data structures used in the `data` module.""" IndexIterable: TypeAlias = Union[list[Any], tuple[Any, ...], set[Any], frozenset[Any]] """Union of all iterable types that support indexing operations.""" -IndexIterableTypes = (list, tuple, set, frozenset) +IndexIterableTT = (list, tuple, set, frozenset) """Tuple of all iterable types that support indexing operations.""" Rgba: TypeAlias = Union[ @@ -92,6 +92,7 @@ class ArgConfigWithDefault(TypedDict): flags: set[str] default: str + class ArgData(TypedDict): """Schema for the resulting data of parsing a single command-line argument.""" exists: bool @@ -107,6 +108,7 @@ class RgbaDict(TypedDict): b: Int_0_255 a: Optional[Float_0_1] + class HslaDict(TypedDict): """Dictionary schema for HSLA color components.""" h: Int_0_360 @@ -114,6 +116,7 @@ class HslaDict(TypedDict): l: Int_0_100 a: Optional[Float_0_1] + class HexaDict(TypedDict): """Dictionary schema for HEXA color components.""" r: str diff --git a/src/xulbux/code.py b/src/xulbux/code.py index 431856e..dc789ed 100644 --- a/src/xulbux/code.py +++ b/src/xulbux/code.py @@ -14,7 +14,7 @@ class Code: """This class includes methods to work with code strings.""" @classmethod - def add_indent(cls, code: str, indent: int) -> str: + def add_indent(cls, code: str, indent: int, /) -> str: """Adds `indent` spaces at the beginning of each line.\n -------------------------------------------------------------------------- - `code` -⠀the code to indent @@ -25,7 +25,7 @@ def add_indent(cls, code: str, indent: int) -> str: return "\n".join(" " * indent + line for line in code.splitlines()) @classmethod - def get_tab_spaces(cls, code: str) -> int: + def get_tab_spaces(cls, code: str, /) -> int: """Will try to get the amount of spaces used for indentation.\n ---------------------------------------------------------------- - `code` -⠀the code to analyze""" @@ -33,7 +33,7 @@ def get_tab_spaces(cls, code: str) -> int: return min(non_zero_indents) if (non_zero_indents := [i for i in indents if i > 0]) else 0 @classmethod - def change_tab_size(cls, code: str, new_tab_size: int, remove_empty_lines: bool = False) -> str: + def change_tab_size(cls, code: str, new_tab_size: int, /, *, remove_empty_lines: bool = False) -> str: """Replaces all tabs with `new_tab_size` spaces.\n -------------------------------------------------------------------------------- - `code` -⠀the code to modify the tab size of @@ -57,7 +57,7 @@ def change_tab_size(cls, code: str, new_tab_size: int, remove_empty_lines: bool return "\n".join(result) @classmethod - def get_func_calls(cls, code: str) -> list[list[Any]]: + def get_func_calls(cls, code: str, /) -> list[list[Any]]: """Will try to get all function calls and return them as a list.\n ------------------------------------------------------------------- - `code` -⠀the code to analyze""" @@ -70,7 +70,7 @@ def get_func_calls(cls, code: str) -> list[list[Any]]: return list(Data.remove_duplicates(funcs + nested_func_calls)) @classmethod - def is_js(cls, code: str, funcs: set[str] = {"__", "$t", "$lang"}) -> bool: + def is_js(cls, code: str, /, *, funcs: set[str] = {"__", "$t", "$lang"}) -> bool: """Will check if the code is very likely to be JavaScript.\n ------------------------------------------------------------- - `code` -⠀the code to analyze diff --git a/src/xulbux/color.py b/src/xulbux/color.py index 110439f..1fc4f0a 100644 --- a/src/xulbux/color.py +++ b/src/xulbux/color.py @@ -41,7 +41,7 @@ class rgba: - `with_alpha(alpha)` to create a new color with different alpha - `complementary()` to get the complementary color""" - def __init__(self, r: int, g: int, b: int, a: Optional[float] = None, _validate: bool = True): + def __init__(self, r: int, g: int, b: int, a: Optional[float] = None, /, *, _validate: bool = True): self.r: int """The red channel in range [0, 255] inclusive.""" self.g: int @@ -73,23 +73,23 @@ def __iter__(self) -> Iterator[int | Optional[float]]: return iter((self.r, self.g, self.b) + (() if self.a is None else (self.a, ))) @overload - def __getitem__(self, index: Literal[0, 1, 2]) -> int: + def __getitem__(self, index: Literal[0, 1, 2], /) -> int: ... @overload - def __getitem__(self, index: Literal[3]) -> Optional[float]: + def __getitem__(self, index: Literal[3], /) -> Optional[float]: ... - def __getitem__(self, index: int) -> int | Optional[float]: + def __getitem__(self, index: int, /) -> int | Optional[float]: return ((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))[index] - def __eq__(self, other: object) -> bool: + def __eq__(self, other: object, /) -> bool: """Check if two `rgba` objects are the same color.""" if not isinstance(other, rgba): return False return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a) - def __ne__(self, other: object) -> bool: + def __ne__(self, other: object, /) -> bool: """Check if two `rgba` objects are different colors.""" return not self.__eq__(other) @@ -114,13 +114,13 @@ def to_hsla(self) -> hsla: def to_hexa(self) -> hexa: """Returns the color as `hexa()` color object.""" - return hexa("", self.r, self.g, self.b, self.a) + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None - def lighten(self, amount: float) -> rgba: + def lighten(self, amount: float, /) -> rgba: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") @@ -128,7 +128,7 @@ def lighten(self, amount: float) -> rgba: self.r, self.g, self.b, self.a = self.to_hsla().lighten(amount).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def darken(self, amount: float) -> rgba: + def darken(self, amount: float, /) -> rgba: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") @@ -136,7 +136,7 @@ def darken(self, amount: float) -> rgba: self.r, self.g, self.b, self.a = self.to_hsla().darken(amount).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def saturate(self, amount: float) -> rgba: + def saturate(self, amount: float, /) -> rgba: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") @@ -144,7 +144,7 @@ def saturate(self, amount: float) -> rgba: self.r, self.g, self.b, self.a = self.to_hsla().saturate(amount).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def desaturate(self, amount: float) -> rgba: + def desaturate(self, amount: float, /) -> rgba: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") @@ -152,19 +152,19 @@ def desaturate(self, amount: float) -> rgba: self.r, self.g, self.b, self.a = self.to_hsla().desaturate(amount).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def rotate(self, degrees: int) -> rgba: + def rotate(self, degrees: int, /) -> rgba: """Rotates the colors hue by the specified number of degrees.""" self.r, self.g, self.b, self.a = self.to_hsla().rotate(degrees).to_rgba().values() return rgba(self.r, self.g, self.b, self.a, _validate=False) - def invert(self, invert_alpha: bool = False) -> rgba: + def invert(self, *, invert_alpha: bool = False) -> rgba: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" self.r, self.g, self.b = 255 - self.r, 255 - self.g, 255 - self.b if invert_alpha and self.a is not None: self.a = 1 - self.a return rgba(self.r, self.g, self.b, self.a, _validate=False) - def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> rgba: + def grayscale(self, *, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> rgba: """Converts the color to grayscale using the luminance formula.\n --------------------------------------------------------------------------- - `method` -⠀the luminance calculation method to use: @@ -176,7 +176,7 @@ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method)) return rgba(self.r, self.g, self.b, self.a, _validate=False) - def blend(self, other: Rgba, ratio: float = 0.5, additive_alpha: bool = False) -> rgba: + def blend(self, other: Rgba, /, ratio: float = 0.5, *, additive_alpha: bool = False) -> rgba: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - `other` -⠀the other RGBA color to blend with @@ -226,7 +226,7 @@ def is_opaque(self) -> bool: """Returns `True` if the color has no transparency.""" return self.a == 1 or self.a is None - def with_alpha(self, alpha: float) -> rgba: + def with_alpha(self, alpha: float, /) -> rgba: """Returns a new color with the specified alpha value.""" if not (0.0 <= alpha <= 1.0): raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") @@ -289,7 +289,7 @@ class hsla: - `with_alpha(alpha)` to create a new color with different alpha - `complementary()` to get the complementary color""" - def __init__(self, h: int, s: int, l: int, a: Optional[float] = None, _validate: bool = True): + def __init__(self, h: int, s: int, l: int, a: Optional[float] = None, /, *, _validate: bool = True): self.h: int """The hue channel in range [0, 360] inclusive.""" self.s: int @@ -321,23 +321,23 @@ def __iter__(self) -> Iterator[int | Optional[float]]: return iter((self.h, self.s, self.l) + (() if self.a is None else (self.a, ))) @overload - def __getitem__(self, index: Literal[0, 1, 2]) -> int: + def __getitem__(self, index: Literal[0, 1, 2], /) -> int: ... @overload - def __getitem__(self, index: Literal[3]) -> Optional[float]: + def __getitem__(self, index: Literal[3], /) -> Optional[float]: ... - def __getitem__(self, index: int) -> int | Optional[float]: + def __getitem__(self, index: int, /) -> int | Optional[float]: return ((self.h, self.s, self.l) + (() if self.a is None else (self.a, )))[index] - def __eq__(self, other: object) -> bool: + def __eq__(self, other: object, /) -> bool: """Check if two `hsla` objects are the same color.""" if not isinstance(other, hsla): return False return (self.h, self.s, self.l, self.a) == (other.h, other.s, other.l, other.a) - def __ne__(self, other: object) -> bool: + def __ne__(self, other: object, /) -> bool: """Check if two `hsla` objects are different colors.""" return not self.__eq__(other) @@ -363,13 +363,13 @@ def to_rgba(self) -> rgba: def to_hexa(self) -> hexa: """Returns the color as `hexa()` color object.""" r, g, b = self._hsl_to_rgb(self.h, self.s, self.l) - return hexa("", r, g, b, self.a) + return hexa(_r=r, _g=g, _b=b, _a=self.a) def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None - def lighten(self, amount: float) -> hsla: + def lighten(self, amount: float, /) -> hsla: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") @@ -377,7 +377,7 @@ def lighten(self, amount: float) -> hsla: self.l = int(min(100, self.l + (100 - self.l) * amount)) return hsla(self.h, self.s, self.l, self.a, _validate=False) - def darken(self, amount: float) -> hsla: + def darken(self, amount: float, /) -> hsla: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") @@ -385,7 +385,7 @@ def darken(self, amount: float) -> hsla: self.l = int(max(0, self.l * (1 - amount))) return hsla(self.h, self.s, self.l, self.a, _validate=False) - def saturate(self, amount: float) -> hsla: + def saturate(self, amount: float, /) -> hsla: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") @@ -393,7 +393,7 @@ def saturate(self, amount: float) -> hsla: self.s = int(min(100, self.s + (100 - self.s) * amount)) return hsla(self.h, self.s, self.l, self.a, _validate=False) - def desaturate(self, amount: float) -> hsla: + def desaturate(self, amount: float, /) -> hsla: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") @@ -401,12 +401,12 @@ def desaturate(self, amount: float) -> hsla: self.s = int(max(0, self.s * (1 - amount))) return hsla(self.h, self.s, self.l, self.a, _validate=False) - def rotate(self, degrees: int) -> hsla: + def rotate(self, degrees: int, /) -> hsla: """Rotates the colors hue by the specified number of degrees.""" self.h = (self.h + degrees) % 360 return hsla(self.h, self.s, self.l, self.a, _validate=False) - def invert(self, invert_alpha: bool = False) -> hsla: + def invert(self, *, invert_alpha: bool = False) -> hsla: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" self.h = (self.h + 180) % 360 self.l = 100 - self.l @@ -415,7 +415,7 @@ def invert(self, invert_alpha: bool = False) -> hsla: return hsla(self.h, self.s, self.l, self.a, _validate=False) - def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> hsla: + def grayscale(self, *, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> hsla: """Converts the color to grayscale using the luminance formula.\n --------------------------------------------------------------------------- - `method` -⠀the luminance calculation method to use: @@ -429,7 +429,7 @@ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag self.h, self.s, self.l, _ = rgba(l, l, l, _validate=False).to_hsla().values() return hsla(self.h, self.s, self.l, self.a, _validate=False) - def blend(self, other: Hsla, ratio: float = 0.5, additive_alpha: bool = False) -> hsla: + def blend(self, other: Hsla, /, ratio: float = 0.5, *, additive_alpha: bool = False) -> hsla: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - `other` -⠀the other HSLA color to blend with @@ -443,7 +443,11 @@ def blend(self, other: Hsla, ratio: float = 0.5, additive_alpha: bool = False) - if not (0.0 <= ratio <= 1.0): raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}") - self.h, self.s, self.l, self.a = self.to_rgba().blend(Color.to_rgba(other), ratio, additive_alpha).to_hsla().values() + self.h, self.s, self.l, self.a = self.to_rgba().blend( + Color.to_rgba(other), + ratio, + additive_alpha=additive_alpha, + ).to_hsla().values() return hsla(self.h, self.s, self.l, self.a, _validate=False) def is_dark(self) -> bool: @@ -462,7 +466,7 @@ def is_opaque(self) -> bool: """Returns `True` if the color has no transparency.""" return self.a == 1 or self.a is None - def with_alpha(self, alpha: float) -> hsla: + def with_alpha(self, alpha: float, /) -> hsla: """Returns a new color with the specified alpha value.""" if not isinstance(alpha, float): raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}") @@ -534,14 +538,38 @@ class hexa: - `with_alpha(alpha)` to create a new color with different alpha - `complementary()` to get the complementary color""" + @overload def __init__( self, color: str | int, + /, + ) -> None: + ... + + @overload + def __init__( + self, + color: None = None, + /, + *, + _r: int, + _g: int, + _b: int, + _a: Optional[float] = None, + ) -> None: + """Internal API: all `_r`, `_g`, `_b` required when color is `None`.""" + ... + + def __init__( + self, + color: Optional[str | int] = None, + /, + *, _r: Optional[int] = None, _g: Optional[int] = None, _b: Optional[int] = None, _a: Optional[float] = None, - ): + ) -> None: self.r: int """The red channel in range [0, 255] inclusive.""" self.g: int @@ -595,7 +623,7 @@ def __init__( else: raise ValueError(f"Invalid HEXA color string '{color}'. Must be in formats RGB, RGBA, RRGGBB or RRGGBBAA.") - else: + elif isinstance(color, int): self.r, self.g, self.b, self.a = Color.hex_int_to_rgba(color).values() def __len__(self) -> int: @@ -606,17 +634,17 @@ def __iter__(self) -> Iterator[str]: return iter((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") + (() if self.a is None else (f"{int(self.a * 255):02X}", ))) - def __getitem__(self, index: int) -> str: + def __getitem__(self, index: int, /) -> str: return ((f"{self.r:02X}", f"{self.g:02X}", f"{self.b:02X}") \ + (() if self.a is None else (f"{int(self.a * 255):02X}", )))[index] - def __eq__(self, other: object) -> bool: + def __eq__(self, other: object, /) -> bool: """Check if two `hexa` objects are the same color.""" if not isinstance(other, hexa): return False return (self.r, self.g, self.b, self.a) == (other.r, other.g, other.b, other.a) - def __ne__(self, other: object) -> bool: + def __ne__(self, other: object, /) -> bool: """Check if two `hexa` objects are different colors.""" return not self.__eq__(other) @@ -628,13 +656,16 @@ def __str__(self) -> str: def dict(self) -> HexaDict: """Returns the color components as a dictionary with hex string values for keys `"r"`, `"g"`, `"b"` and optionally `"a"`.""" - return {"r": f"{self.r:02X}", "g": f"{self.g:02X}", "b": f"{self.b:02X}", "a": None if self.a is None else f"{int(self.a * 255):02X}"} + return { + "r": f"{self.r:02X}", "g": f"{self.g:02X}", "b": f"{self.b:02X}", "a": + None if self.a is None else f"{int(self.a * 255):02X}" + } - def values(self, round_alpha: bool = True) -> tuple[int, int, int, Optional[float]]: + def values(self, *, round_alpha: bool = True) -> tuple[int, int, int, Optional[float]]: """Returns the color components as separate values `r, g, b, a`.""" return self.r, self.g, self.b, None if self.a is None else (round(self.a, 2) if round_alpha else self.a) - def to_rgba(self, round_alpha: bool = True) -> rgba: + def to_rgba(self, *, round_alpha: bool = True) -> rgba: """Returns the color as `rgba()` color object.""" return rgba( self.r, @@ -644,60 +675,60 @@ def to_rgba(self, round_alpha: bool = True) -> rgba: _validate=False, ) - def to_hsla(self, round_alpha: bool = True) -> hsla: + def to_hsla(self, *, round_alpha: bool = True) -> hsla: """Returns the color as `hsla()` color object.""" - return self.to_rgba(round_alpha).to_hsla() + return self.to_rgba(round_alpha=round_alpha).to_hsla() def has_alpha(self) -> bool: """Returns `True` if the color has an alpha channel and `False` otherwise.""" return self.a is not None - def lighten(self, amount: float) -> hexa: + def lighten(self, amount: float, /) -> hexa: """Increases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_rgba(False).lighten(amount).values() - return hexa("", self.r, self.g, self.b, self.a) + self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).lighten(amount).values() + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) - def darken(self, amount: float) -> hexa: + def darken(self, amount: float, /) -> hexa: """Decreases the colors lightness by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_rgba(False).darken(amount).values() - return hexa("", self.r, self.g, self.b, self.a) + self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).darken(amount).values() + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) - def saturate(self, amount: float) -> hexa: + def saturate(self, amount: float, /) -> hexa: """Increases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_rgba(False).saturate(amount).values() - return hexa("", self.r, self.g, self.b, self.a) + self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).saturate(amount).values() + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) - def desaturate(self, amount: float) -> hexa: + def desaturate(self, amount: float, /) -> hexa: """Decreases the colors saturation by the specified amount in range [0.0, 1.0] inclusive.""" if not (0.0 <= amount <= 1.0): raise ValueError(f"The 'amount' parameter must be in range [0.0, 1.0] inclusive, got {amount!r}") - self.r, self.g, self.b, self.a = self.to_rgba(False).desaturate(amount).values() - return hexa("", self.r, self.g, self.b, self.a) + self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).desaturate(amount).values() + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) - def rotate(self, degrees: int) -> hexa: + def rotate(self, degrees: int, /) -> hexa: """Rotates the colors hue by the specified number of degrees.""" - self.r, self.g, self.b, self.a = self.to_rgba(False).rotate(degrees).values() - return hexa("", self.r, self.g, self.b, self.a) + self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).rotate(degrees).values() + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) - def invert(self, invert_alpha: bool = False) -> hexa: + def invert(self, *, invert_alpha: bool = False) -> hexa: """Inverts the color by rotating hue by 180 degrees and inverting lightness.""" - self.r, self.g, self.b, self.a = self.to_rgba(False).invert().values() + self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).invert().values() if invert_alpha and self.a is not None: self.a = 1 - self.a - return hexa("", self.r, self.g, self.b, self.a) + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) - def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> hexa: + def grayscale(self, *, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2") -> hexa: """Converts the color to grayscale using the luminance formula.\n --------------------------------------------------------------------------- - `method` -⠀the luminance calculation method to use: @@ -707,9 +738,9 @@ def grayscale(self, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag * `"bt601"` ITU-R BT.601 standard (older TV standard)""" # THE 'method' PARAM IS CHECKED IN 'Color.luminance()' self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method)) - return hexa("", self.r, self.g, self.b, self.a) + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) - def blend(self, other: Hexa, ratio: float = 0.5, additive_alpha: bool = False) -> hexa: + def blend(self, other: Hexa, /, ratio: float = 0.5, *, additive_alpha: bool = False) -> hexa: """Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n ---------------------------------------------------------------------------------------------------------- - `other` -⠀the other HEXA color to blend with @@ -723,12 +754,16 @@ def blend(self, other: Hexa, ratio: float = 0.5, additive_alpha: bool = False) - if not (0.0 <= ratio <= 1.0): raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}") - self.r, self.g, self.b, self.a = self.to_rgba(False).blend(Color.to_rgba(other), ratio, additive_alpha).values() - return hexa("", self.r, self.g, self.b, self.a) + self.r, self.g, self.b, self.a = self.to_rgba(round_alpha=False).blend( + Color.to_rgba(other), + ratio, + additive_alpha=additive_alpha, + ).values() + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) def is_dark(self) -> bool: """Returns `True` if the color is considered dark (`lightness < 50%`).""" - return self.to_hsla(False).is_dark() + return self.to_hsla(round_alpha=False).is_dark() def is_light(self) -> bool: """Returns `True` if the color is considered light (`lightness >= 50%`).""" @@ -736,31 +771,31 @@ def is_light(self) -> bool: def is_grayscale(self) -> bool: """Returns `True` if the color is grayscale (`saturation == 0`).""" - return self.to_hsla(False).is_grayscale() + return self.to_hsla(round_alpha=False).is_grayscale() def is_opaque(self) -> bool: """Returns `True` if the color has no transparency (`alpha == 1.0`).""" return self.a == 1 or self.a is None - def with_alpha(self, alpha: float) -> hexa: + def with_alpha(self, alpha: float, /) -> hexa: """Returns a new color with the specified alpha value.""" if not isinstance(alpha, float): raise TypeError(f"The 'alpha' parameter must be a float, got {type(alpha)}") elif not (0.0 <= alpha <= 1.0): raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}") - return hexa("", self.r, self.g, self.b, alpha) + return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a) def complementary(self) -> hexa: """Returns the complementary color (180 degrees on the color wheel).""" - return self.to_hsla(False).complementary().to_hexa() + return self.to_hsla(round_alpha=False).complementary().to_hexa() class Color: """This class includes methods to work with colors in different formats.""" @classmethod - def is_valid_rgba(cls, color: AnyRgba, allow_alpha: bool = True) -> bool: + def is_valid_rgba(cls, color: AnyRgba, /, *, allow_alpha: bool = True) -> bool: """Check if the given color is a valid RGBA color.\n ----------------------------------------------------------------- - `color` -⠀the color to check (can be in any supported format) @@ -811,7 +846,7 @@ def is_valid_rgba(cls, color: AnyRgba, allow_alpha: bool = True) -> bool: return False @classmethod - def is_valid_hsla(cls, color: AnyHsla, allow_alpha: bool = True) -> bool: + def is_valid_hsla(cls, color: AnyHsla, /, *, allow_alpha: bool = True) -> bool: """Check if the given color is a valid HSLA color.\n ----------------------------------------------------------------- - `color` -⠀the color to check (can be in any supported format) @@ -861,10 +896,36 @@ def is_valid_hsla(cls, color: AnyHsla, allow_alpha: bool = True) -> bool: pass return False + @overload @classmethod def is_valid_hexa( cls, color: AnyHexa, + /, + *, + allow_alpha: bool = True, + get_prefix: Literal[True], + ) -> tuple[bool, Optional[Literal["#", "0x"]]]: + ... + + @overload + @classmethod + def is_valid_hexa( + cls, + color: AnyHexa, + /, + *, + allow_alpha: bool = True, + get_prefix: Literal[False] = False, + ) -> bool: + ... + + @classmethod + def is_valid_hexa( + cls, + color: AnyHexa, + /, + *, allow_alpha: bool = True, get_prefix: bool = False, ) -> bool | tuple[bool, Optional[Literal["#", "0x"]]]: @@ -895,19 +956,19 @@ def is_valid_hexa( return (False, None) if get_prefix else False @classmethod - def is_valid(cls, color: AnyRgba | AnyHsla | AnyHexa, allow_alpha: bool = True) -> bool: + def is_valid(cls, color: AnyRgba | AnyHsla | AnyHexa, /, *, allow_alpha: bool = True) -> bool: """Check if the given color is a valid RGBA, HSLA or HEXA color.\n ------------------------------------------------------------------- - `color` -⠀the color to check (can be in any supported format) - `allow_alpha` -⠀whether to allow alpha channel in the color""" return bool( - cls.is_valid_rgba(color, allow_alpha) \ - or cls.is_valid_hsla(color, allow_alpha) \ - or cls.is_valid_hexa(color, allow_alpha) + cls.is_valid_rgba(color, allow_alpha=allow_alpha) \ + or cls.is_valid_hsla(color, allow_alpha=allow_alpha) \ + or cls.is_valid_hexa(color, allow_alpha=allow_alpha) ) @classmethod - def has_alpha(cls, color: Rgba | Hsla | Hexa) -> bool: + def has_alpha(cls, color: Rgba | Hsla | Hexa, /) -> bool: """Check if the given color has an alpha channel.\n --------------------------------------------------------------------------- - `color` -⠀the color to check (can be in any supported format)""" @@ -927,9 +988,9 @@ def has_alpha(cls, color: Rgba | Hsla | Hexa) -> bool: elif isinstance(color, str): if parsed_rgba := cls.str_to_rgba(color, only_first=True): - return cast(rgba, parsed_rgba).has_alpha() + return parsed_rgba.has_alpha() if parsed_hsla := cls.str_to_hsla(color, only_first=True): - return cast(hsla, parsed_hsla).has_alpha() + return parsed_hsla.has_alpha() elif isinstance(color, (list, tuple)) and len(color) == 4: return True @@ -939,7 +1000,7 @@ def has_alpha(cls, color: Rgba | Hsla | Hexa) -> bool: return False @classmethod - def to_rgba(cls, color: Rgba | Hsla | Hexa) -> rgba: + def to_rgba(cls, color: Rgba | Hsla | Hexa, /) -> rgba: """Will try to convert any color type to a color of type RGBA.\n --------------------------------------------------------------------- - `color` -⠀the color to convert (can be in any supported format)""" @@ -954,7 +1015,7 @@ def to_rgba(cls, color: Rgba | Hsla | Hexa) -> rgba: raise ValueError(f"Could not convert color {color!r} to RGBA.") @classmethod - def to_hsla(cls, color: Rgba | Hsla | Hexa) -> hsla: + def to_hsla(cls, color: Rgba | Hsla | Hexa, /) -> hsla: """Will try to convert any color type to a color of type HSLA.\n --------------------------------------------------------------------- - `color` -⠀the color to convert (can be in any supported format)""" @@ -969,7 +1030,7 @@ def to_hsla(cls, color: Rgba | Hsla | Hexa) -> hsla: raise ValueError(f"Could not convert color {color!r} to HSLA.") @classmethod - def to_hexa(cls, color: Rgba | Hsla | Hexa) -> hexa: + def to_hexa(cls, color: Rgba | Hsla | Hexa, /) -> hexa: """Will try to convert any color type to a color of type HEXA.\n --------------------------------------------------------------------- - `color` -⠀the color to convert (can be in any supported format)""" @@ -983,8 +1044,18 @@ def to_hexa(cls, color: Rgba | Hsla | Hexa) -> hexa: return color if isinstance(color, hexa) else hexa(cast(str | int, color)) raise ValueError(f"Could not convert color {color!r} to HEXA") + @overload + @classmethod + def str_to_rgba(cls, string: str, /, *, only_first: Literal[True]) -> Optional[rgba]: + ... + + @overload + @classmethod + def str_to_rgba(cls, string: str, /, *, only_first: Literal[False] = False) -> Optional[list[rgba]]: + ... + @classmethod - def str_to_rgba(cls, string: str, only_first: bool = False) -> Optional[rgba | list[rgba]]: + def str_to_rgba(cls, string: str, /, *, only_first: bool = False) -> Optional[rgba | list[rgba]]: """Will try to recognize RGBA colors inside a string and output the found ones as RGBA objects.\n --------------------------------------------------------------------------------------------------------------- - `string` -⠀the string to search for RGBA colors @@ -992,12 +1063,12 @@ def str_to_rgba(cls, string: str, only_first: bool = False) -> Optional[rgba | l if only_first: if not (match := _re.search(Regex.rgba_str(allow_alpha=True), string)): return None - m = match.groups() + matches = match.groups() return rgba( - int(m[0]), - int(m[1]), - int(m[2]), - ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None), + int(matches[0]), + int(matches[1]), + int(matches[2]), + ((int(matches[3]) if "." not in matches[3] else float(matches[3])) if matches[3] else None), _validate=False, ) @@ -1006,16 +1077,26 @@ def str_to_rgba(cls, string: str, only_first: bool = False) -> Optional[rgba | l return None return [ rgba( - int(m[0]), - int(m[1]), - int(m[2]), - ((int(m[3]) if "." not in m[3] else float(m[3])) if m[3] else None), + int(match[0]), + int(match[1]), + int(match[2]), + ((int(match[3]) if "." not in match[3] else float(match[3])) if match[3] else None), _validate=False, - ) for m in matches + ) for match in matches ] + @overload + @classmethod + def str_to_hsla(cls, string: str, /, *, only_first: Literal[True]) -> Optional[hsla]: + ... + + @overload + @classmethod + def str_to_hsla(cls, string: str, /, *, only_first: Literal[False] = False) -> Optional[list[hsla]]: + ... + @classmethod - def str_to_hsla(cls, string: str, only_first: bool = False) -> Optional[hsla | list[hsla]]: + def str_to_hsla(cls, string: str, /, *, only_first: bool = False) -> Optional[hsla | list[hsla]]: """Will try to recognize HSLA colors inside a string and output the found ones as HSLA objects.\n --------------------------------------------------------------------------------------------------------------- - `string` -⠀the string to search for HSLA colors @@ -1052,6 +1133,8 @@ def rgba_to_hex_int( g: int, b: int, a: Optional[float] = None, + /, + *, preserve_original: bool = False, ) -> int: """Convert RGBA channels to a HEXA integer (alpha is optional).\n @@ -1087,7 +1170,7 @@ def rgba_to_hex_int( return hex_int @classmethod - def hex_int_to_rgba(cls, hex_int: int, preserve_original: bool = False) -> rgba: + def hex_int_to_rgba(cls, hex_int: int, /, *, preserve_original: bool = False) -> rgba: """Convert a HEX integer to RGBA channels.\n ------------------------------------------------------------------------------------------- - `hex_int` -⠀the HEX integer to convert @@ -1122,12 +1205,56 @@ def hex_int_to_rgba(cls, hex_int: int, preserve_original: bool = False) -> rgba: else: raise ValueError(f"Could not convert HEX integer 0x{hex_int:X} to RGBA color.") + @overload + @classmethod + def luminance( + cls, + r: int, + g: int, + b: int, + /, + *, + output_type: type[int], + method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2", + ) -> int: + ... + + @overload @classmethod def luminance( cls, r: int, g: int, b: int, + /, + *, + output_type: type[float], + method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2", + ) -> float: + ... + + @overload + @classmethod + def luminance( + cls, + r: int, + g: int, + b: int, + /, + *, + output_type: None = None, + method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2", + ) -> int: + ... + + @classmethod + def luminance( + cls, + r: int, + g: int, + b: int, + /, + *, output_type: Optional[type[int | float]] = None, method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2", ) -> int | float: @@ -1145,8 +1272,6 @@ def luminance( * `"bt601"` ITU-R BT.601 standard (older TV standard)""" if not all(0 <= c <= 255 for c in (r, g, b)): raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}") - 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}") _r, _g, _b = r / 255.0, g / 255.0, b / 255.0 @@ -1172,8 +1297,23 @@ def luminance( else: return round(luminance * 255) + @overload + @classmethod + def text_color_for_on_bg(cls, text_bg_color: rgba, /) -> rgba: + ... + + @overload + @classmethod + def text_color_for_on_bg(cls, text_bg_color: hexa, /) -> hexa: + ... + + @overload @classmethod - def text_color_for_on_bg(cls, text_bg_color: Rgba | Hexa) -> rgba | hexa | int: + def text_color_for_on_bg(cls, text_bg_color: int, /) -> int: + ... + + @classmethod + def text_color_for_on_bg(cls, text_bg_color: Rgba | Hexa, /) -> rgba | hexa | int: """Returns either black or white text color for optimal contrast on the given background color.\n -------------------------------------------------------------------------------------------------- - `text_bg_color` -⠀the background color (can be in RGBA or HEXA format)""" @@ -1183,15 +1323,25 @@ def text_color_for_on_bg(cls, text_bg_color: Rgba | Hexa) -> rgba | hexa | int: brightness = 0.2126 * text_bg_rgba[0] + 0.7152 * text_bg_rgba[1] + 0.0722 * text_bg_rgba[2] return ( - (0xFFFFFF if was_int else hexa("", 255, 255, 255)) if was_hexa \ + (0xFFFFFF if was_int else hexa(_r=255, _g=255, _b=255)) if was_hexa \ else rgba(255, 255, 255, _validate=False) ) if brightness < 128 else ( - (0x000 if was_int else hexa("", 0, 0, 0)) if was_hexa \ + (0x000 if was_int else hexa(_r=0, _g=0, _b=0)) if was_hexa \ else rgba(0, 0, 0, _validate=False) ) + @overload @classmethod - def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float) -> rgba | hexa: + def adjust_lightness(cls, color: rgba, lightness_change: float, /) -> rgba: + ... + + @overload + @classmethod + def adjust_lightness(cls, color: hexa, lightness_change: float, /) -> hexa: + ... + + @classmethod + def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float, /) -> rgba | hexa: """In- or decrease the lightness of the input color.\n ------------------------------------------------------------------ - `color` -⠀the color to adjust (can be in RGBA or HEXA format) @@ -1216,8 +1366,18 @@ def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float) -> rgba | else hsla(h, s, l, a, _validate=False).to_rgba() ) + @overload + @classmethod + def adjust_saturation(cls, color: rgba, saturation_change: float, /) -> rgba: + ... + + @overload @classmethod - def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float) -> rgba | hexa: + def adjust_saturation(cls, color: hexa, saturation_change: float, /) -> hexa: + ... + + @classmethod + def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float, /) -> rgba | hexa: """In- or decrease the saturation of the input color.\n ----------------------------------------------------------------------- - `color` -⠀the color to adjust (can be in RGBA or HEXA format) @@ -1243,7 +1403,7 @@ def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float) -> rgba ) @classmethod - def _parse_rgba(cls, color: Rgba) -> rgba: + def _parse_rgba(cls, color: Rgba, /) -> rgba: """Internal method to parse a color to an RGBA object.""" if isinstance(color, rgba): return color @@ -1251,7 +1411,9 @@ def _parse_rgba(cls, color: Rgba) -> rgba: elif isinstance(color, (list, tuple)): array_color = cast(list[Any] | tuple[Any, ...], color) if len(array_color) == 4: - return rgba(int(array_color[0]), int(array_color[1]), int(array_color[2]), float(array_color[3]), _validate=False) + return rgba( + int(array_color[0]), int(array_color[1]), int(array_color[2]), float(array_color[3]), _validate=False + ) elif len(array_color) == 3: return rgba(int(array_color[0]), int(array_color[1]), int(array_color[2]), None, _validate=False) raise ValueError(f"Could not parse RGBA color: {color!r}") @@ -1262,11 +1424,11 @@ def _parse_rgba(cls, color: Rgba) -> rgba: else: if parsed := cls.str_to_rgba(color, only_first=True): - return cast(rgba, parsed) + return parsed raise ValueError(f"Could not parse RGBA color: {color!r}") @classmethod - def _parse_hsla(cls, color: Hsla) -> hsla: + def _parse_hsla(cls, color: Hsla, /) -> hsla: """Internal method to parse a color to an HSLA object.""" if isinstance(color, hsla): return color @@ -1274,7 +1436,9 @@ def _parse_hsla(cls, color: Hsla) -> hsla: elif isinstance(color, (list, tuple)): array_color = cast(list[Any] | tuple[Any, ...], color) if len(color) == 4: - return hsla(int(array_color[0]), int(array_color[1]), int(array_color[2]), float(array_color[3]), _validate=False) + return hsla( + int(array_color[0]), int(array_color[1]), int(array_color[2]), float(array_color[3]), _validate=False + ) elif len(color) == 3: return hsla(int(array_color[0]), int(array_color[1]), int(array_color[2]), None, _validate=False) raise ValueError(f"Could not parse HSLA color: {color!r}") @@ -1285,11 +1449,11 @@ def _parse_hsla(cls, color: Hsla) -> hsla: else: if parsed := cls.str_to_hsla(color, only_first=True): - return cast(hsla, parsed) + return parsed raise ValueError(f"Could not parse HSLA color: {color!r}") @staticmethod - def _linearize_srgb(c: float) -> float: + def _linearize_srgb(c: float, /) -> float: """Helper method to linearize sRGB component following the WCAG standard.""" if not (0.0 <= c <= 1.0): raise ValueError(f"The 'c' parameter must be in range [0.0, 1.0] inclusive, got {c!r}") diff --git a/src/xulbux/console.py b/src/xulbux/console.py index a7c324d..a2ab50a 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -58,7 +58,7 @@ class ParsedArgData: ------------------------------------------------------------------------------------------------------------ When the `ParsedArgData` instance is accessed as a boolean it will correspond to the `exists` attribute.""" - def __init__(self, exists: bool, values: list[str], is_pos: bool, flag: Optional[str] = None): + def __init__(self, *, exists: bool, values: list[str], is_pos: bool, flag: Optional[str] = None): self.exists: bool = exists """Whether the argument was found or not.""" self.is_pos: bool = is_pos @@ -72,7 +72,7 @@ def __bool__(self) -> bool: """Whether the argument was found or not (i.e. the `exists` attribute).""" return self.exists - def __eq__(self, other: object) -> bool: + def __eq__(self, other: object, /) -> bool: """Check if two `ParsedArgData` objects are equal by comparing their attributes.""" if not isinstance(other, ParsedArgData): return False @@ -83,7 +83,7 @@ def __eq__(self, other: object) -> bool: and self.flag == other.flag ) - def __ne__(self, other: object) -> bool: + def __ne__(self, other: object, /) -> bool: """Check if two `ParsedArgData` objects are not equal by comparing their attributes.""" return not self.__eq__(other) @@ -116,7 +116,7 @@ def __len__(self): """The number of arguments stored in the `ParsedArgs` object.""" return len(vars(self)) - def __contains__(self, key: str) -> bool: + def __contains__(self, key: str, /) -> bool: """Checks if an argument with the given alias exists in the `ParsedArgs` object.""" return key in vars(self) @@ -124,10 +124,10 @@ def __bool__(self) -> bool: """Whether the `ParsedArgs` object contains any arguments.""" return len(self) > 0 - def __getattr__(self, name: str) -> ParsedArgData: + def __getattr__(self, name: str, /) -> ParsedArgData: raise AttributeError(f"'{type(self).__name__}' object has no attribute {name}") - def __getitem__(self, key: str | int) -> ParsedArgData: + def __getitem__(self, key: str | int, /) -> ParsedArgData: if isinstance(key, int): return list(self.values())[key] return getattr(self, key) @@ -136,13 +136,13 @@ def __iter__(self) -> Generator[tuple[str, ParsedArgData], None, None]: for key, val in cast(dict[str, ParsedArgData], vars(self)).items(): yield (key, val) - def __eq__(self, other: object) -> bool: + def __eq__(self, other: object, /) -> bool: """Check if two `ParsedArgs` objects are equal by comparing their stored arguments.""" if not isinstance(other, ParsedArgs): return False return vars(self) == vars(other) - def __ne__(self, other: object) -> bool: + def __ne__(self, other: object, /) -> bool: """Check if two `ParsedArgs` objects are not equal by comparing their stored arguments.""" return not self.__eq__(other) @@ -161,7 +161,7 @@ def dict(self) -> dict[str, ArgData]: """Returns the arguments as a dictionary.""" return {key: val.dict() for key, val in self.__iter__()} - def get(self, key: str, default: Any = None) -> ParsedArgData | Any: + def get(self, key: str, /, default: Any = None) -> ParsedArgData | Any: """Returns the argument result for the given alias, or `default` if not found.""" return getattr(self, key, default) @@ -261,7 +261,7 @@ class Console(metaclass=_ConsoleMeta): """This class provides methods for logging and other actions within the console.""" @classmethod - def get_args(cls, arg_parse_configs: ArgParseConfigs, flag_value_sep: str = "=") -> ParsedArgs: + def get_args(cls, arg_parse_configs: ArgParseConfigs, /, *, flag_value_sep: str = "=") -> ParsedArgs: """Will search for the specified args in the command-line arguments and return the results as a special `ParsedArgs` object.\n ------------------------------------------------------------------------------------------------- @@ -328,6 +328,8 @@ def get_args(cls, arg_parse_configs: ArgParseConfigs, flag_value_sep: str = "=") def pause_exit( cls, prompt: object = "", + /, + *, pause: bool = True, exit: bool = False, exit_code: int = 0, @@ -362,6 +364,8 @@ def log( cls, title: Optional[str] = None, prompt: object = "", + /, + *, format_linebreaks: bool = True, start: str = "", end: str = "\n", @@ -403,10 +407,7 @@ def log( tab = " " * (tab_size - 1 - ((len(mx) + (title_len := len(title) + 2 * len(px))) % tab_size)) if format_linebreaks: - clean_prompt, removals = cast( - tuple[str, tuple[tuple[int, str], ...]], - FormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True), - ) + clean_prompt, removals = *FormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True), prompt_lst: list[str] = [ item for lst in ( @@ -435,6 +436,8 @@ def log( def debug( cls, prompt: object = "Point in program reached.", + /, + *, active: bool = True, format_linebreaks: bool = True, start: str = "", @@ -450,8 +453,8 @@ def debug( If `active` is false, no debug message will be printed.""" if active: cls.log( - title="DEBUG", - prompt=prompt, + "DEBUG", + prompt, format_linebreaks=format_linebreaks, start=start, end=end, @@ -464,6 +467,8 @@ def debug( def info( cls, prompt: object = "Program running.", + /, + *, format_linebreaks: bool = True, start: str = "", end: str = "\n", @@ -476,8 +481,8 @@ def info( """A preset for `log()`: `INFO` log message with the options to pause at the message and exit the program after the message was printed.""" cls.log( - title="INFO", - prompt=prompt, + "INFO", + prompt, format_linebreaks=format_linebreaks, start=start, end=end, @@ -490,6 +495,8 @@ def info( def done( cls, prompt: object = "Program finished.", + /, + *, format_linebreaks: bool = True, start: str = "", end: str = "\n", @@ -502,8 +509,8 @@ def done( """A preset for `log()`: `DONE` log message with the options to pause at the message and exit the program after the message was printed.""" cls.log( - title="DONE", - prompt=prompt, + "DONE", + prompt, format_linebreaks=format_linebreaks, start=start, end=end, @@ -516,6 +523,8 @@ def done( def warn( cls, prompt: object = "Important message.", + /, + *, format_linebreaks: bool = True, start: str = "", end: str = "\n", @@ -528,8 +537,8 @@ def warn( """A preset for `log()`: `WARN` log message with the options to pause at the message and exit the program after the message was printed.""" cls.log( - title="WARN", - prompt=prompt, + "WARN", + prompt, format_linebreaks=format_linebreaks, start=start, end=end, @@ -542,6 +551,8 @@ def warn( def fail( cls, prompt: object = "Program error.", + /, + *, format_linebreaks: bool = True, start: str = "", end: str = "\n", @@ -554,8 +565,8 @@ def fail( """A preset for `log()`: `FAIL` log message with the options to pause at the message and exit the program after the message was printed.""" cls.log( - title="FAIL", - prompt=prompt, + "FAIL", + prompt, format_linebreaks=format_linebreaks, start=start, end=end, @@ -568,6 +579,8 @@ def fail( def exit( cls, prompt: object = "Program ended.", + /, + *, format_linebreaks: bool = True, start: str = "", end: str = "\n", @@ -580,8 +593,8 @@ def exit( """A preset for `log()`: `EXIT` log message with the options to pause at the message and exit the program after the message was printed.""" cls.log( - title="EXIT", - prompt=prompt, + "EXIT", + prompt, format_linebreaks=format_linebreaks, start=start, end=end, @@ -753,6 +766,8 @@ def log_box_bordered( def confirm( cls, prompt: object = "Do you want to continue?", + /, + *, start: str = "", end: str = "", default_color: Optional[Rgba | Hexa] = None, @@ -783,6 +798,8 @@ def confirm( def multiline_input( cls, prompt: object = "", + /, + *, start: str = "", end: str = "\n", default_color: Optional[Rgba | Hexa] = None, @@ -818,6 +835,8 @@ def multiline_input( def input( cls, prompt: object = "", + /, + *, start: str = "", end: str = "", default_color: Optional[Rgba | Hexa] = None, @@ -838,6 +857,8 @@ def input( def input( cls, prompt: object = "", + /, + *, start: str = "", end: str = "", default_color: Optional[Rgba | Hexa] = None, @@ -857,6 +878,8 @@ def input( def input( cls, prompt: object = "", + /, + *, start: str = "", end: str = "", default_color: Optional[Rgba | Hexa] = None, @@ -917,7 +940,7 @@ def input( session: _pt.PromptSession[str] = _pt.PromptSession( message=_pt.formatted_text.ANSI(FormatCodes.to_ansi(str(prompt), default_color=default_color)), validator=_ConsoleInputValidator( - get_text=helper.get_text, + helper.get_text, mask_char=mask_char, min_len=min_len, validator=validator, @@ -950,7 +973,7 @@ def input( raise @classmethod - def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[int, str], ...]) -> list[str]: + def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[int, str], ...], /) -> list[str]: """Adds back the removed parts into the split string parts at their original positions.""" cumulative_pos = [0] for length in (len(part) for part in split_string): @@ -973,7 +996,7 @@ def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[ return result @staticmethod - def _find_string_part(pos: int, cumulative_pos: list[int]) -> int: + def _find_string_part(pos: int, cumulative_pos: list[int], /) -> int: """Finds the index of the string part that contains the given position.""" left, right = 0, len(cumulative_pos) - 1 while left < right: @@ -989,7 +1012,9 @@ def _find_string_part(pos: int, cumulative_pos: list[int]) -> int: @staticmethod def _prepare_log_box( values: list[object] | tuple[object, ...], + /, default_color: Optional[Rgba | Hexa] = None, + *, has_rules: bool = False, ) -> tuple[list[str], list[str], int]: """Prepares the log box content and returns it along with the max line length.""" @@ -1029,19 +1054,19 @@ def _prepare_log_box( else: lines = [line for val in values for line in str(val).splitlines()] - unfmt_lines = [cast(str, FormatCodes.remove(line, default_color)) for line in lines] + unfmt_lines = [FormatCodes.remove(line, default_color) for line in lines] max_line_len = max(len(line) for line in unfmt_lines) if unfmt_lines else 0 return lines, unfmt_lines, max_line_len @staticmethod - def _multiline_input_submit(event: KeyPressEvent) -> None: + def _multiline_input_submit(event: KeyPressEvent, /) -> None: event.app.exit(result=event.app.current_buffer.document.text) class _ConsoleArgsParseHelper: """Internal, callable helper class to parse command-line arguments.""" - def __init__(self, arg_parse_configs: ArgParseConfigs, flag_value_sep: str): + def __init__(self, arg_parse_configs: ArgParseConfigs, /, flag_value_sep: str): self.arg_parse_configs = arg_parse_configs self.flag_value_sep = flag_value_sep @@ -1080,7 +1105,7 @@ def parse_arg_configs(self) -> None: ) self.arg_lookup[flag] = alias - def _parse_arg_config(self, alias: str, config: ArgParseConfig) -> Optional[set[str]]: + def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[set[str]]: """Parse an individual argument configuration.""" # POSITIONAL ARGUMENT CONFIGURATION if isinstance(config, str): @@ -1170,7 +1195,7 @@ def process_positional_args(self) -> None: "Must be either 'before' or 'after'." ) - def _collect_before_arg(self, alias: str) -> None: + def _collect_before_arg(self, alias: str, /) -> None: """Collect positional `"before"` arguments.""" before_args: list[str] = [] end_pos: int = self.first_flag_pos if self.first_flag_pos is not None else self.args_len @@ -1183,7 +1208,7 @@ def _collect_before_arg(self, alias: str) -> None: self.parsed_args[alias].values = before_args self.parsed_args[alias].exists = len(before_args) > 0 - def _collect_after_arg(self, alias: str) -> None: + def _collect_after_arg(self, alias: str, /) -> None: """Collect positional `"after"` arguments.""" after_args: list[str] = [] start_pos: int = (self.last_flag_pos + 1) if self.last_flag_pos is not None else 0 @@ -1212,7 +1237,7 @@ def _collect_after_arg(self, alias: str) -> None: self.parsed_args[alias].values = after_args self.parsed_args[alias].exists = len(after_args) > 0 - def _is_positional_arg(self, arg: str, allow_separator: bool = True) -> bool: + def _is_positional_arg(self, arg: str, /, *, allow_separator: bool = True) -> bool: """Check if an argument is positional (not a flag or separator).""" if self.flag_value_sep in arg and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup: return True @@ -1265,10 +1290,10 @@ def process_flagged_args(self) -> None: class _ConsoleLogBoxBgReplacer: """Internal, callable class to replace matched text with background-colored text for log boxes.""" - def __init__(self, box_bg_color: str | Rgba | Hexa) -> None: + def __init__(self, box_bg_color: str | Rgba | Hexa, /) -> None: self.box_bg_color = box_bg_color - def __call__(self, m: _rx.Match[str]) -> str: + def __call__(self, m: _rx.Match[str], /) -> str: return f"{m.group(0)}[bg:{self.box_bg_color}]" @@ -1330,7 +1355,7 @@ def bottom_toolbar(self) -> _pt.formatted_text.ANSI: except Exception: return _pt.formatted_text.ANSI("") - def process_insert_text(self, text: str) -> tuple[str, set[str]]: + def process_insert_text(self, text: str, /) -> tuple[str, set[str]]: """Processes the inserted text according to the allowed characters and max length.""" removed_chars: set[str] = set() @@ -1356,7 +1381,7 @@ def process_insert_text(self, text: str) -> tuple[str, set[str]]: return processed_text, removed_chars - def insert_text_event(self, event: KeyPressEvent) -> None: + def insert_text_event(self, event: KeyPressEvent, /) -> None: """Handles text insertion events (typing/pasting).""" try: if not (insert_text := event.data): @@ -1377,7 +1402,7 @@ def insert_text_event(self, event: KeyPressEvent) -> None: except Exception: pass - def remove_text_event(self, event: KeyPressEvent, is_backspace: bool = False) -> None: + def remove_text_event(self, event: KeyPressEvent, /, *, is_backspace: bool = False) -> None: """Handles text removal events (backspace/delete).""" try: buffer = event.app.current_buffer @@ -1402,26 +1427,26 @@ def remove_text_event(self, event: KeyPressEvent, is_backspace: bool = False) -> except Exception: pass - def handle_delete(self, event: KeyPressEvent) -> None: + def handle_delete(self, event: KeyPressEvent, /) -> None: self.remove_text_event(event) - def handle_backspace(self, event: KeyPressEvent) -> None: + def handle_backspace(self, event: KeyPressEvent, /) -> None: self.remove_text_event(event, is_backspace=True) @staticmethod - def handle_control_a(event: KeyPressEvent) -> None: + def handle_control_a(event: KeyPressEvent, /) -> None: buffer = event.app.current_buffer buffer.cursor_position = 0 buffer.start_selection() buffer.cursor_position = len(buffer.text) - def handle_paste(self, event: KeyPressEvent) -> None: + def handle_paste(self, event: KeyPressEvent, /) -> None: if self.allow_paste: self.insert_text_event(event) else: self.tried_pasting = True - def handle_any(self, event: KeyPressEvent) -> None: + def handle_any(self, event: KeyPressEvent, /) -> None: self.insert_text_event(event) @@ -1430,6 +1455,8 @@ class _ConsoleInputValidator(Validator): def __init__( self, get_text: Callable[[], str], + /, + *, mask_char: Optional[str], min_len: Optional[int], validator: Optional[Callable[[str], Optional[str]]], @@ -1471,6 +1498,7 @@ class ProgressBar: def __init__( self, + *, min_width: int = 10, max_width: int = 50, bar_format: list[str] | tuple[str, ...] = ["{l}", "▕{b}▏", "[b]({c:,})/{t:,}", "[dim](([i]({p}%)))"], @@ -1494,7 +1522,7 @@ def __init__( """A tuple of characters ordered from full to empty progress.""" self.set_width(min_width, max_width) - self.set_bar_format(bar_format, limited_bar_format, sep) + self.set_bar_format(bar_format, limited_bar_format, sep=sep) self.set_chars(chars) self._buffer: list[str] = [] @@ -1525,6 +1553,7 @@ def set_bar_format( self, bar_format: Optional[list[str] | tuple[str, ...]] = None, limited_bar_format: Optional[list[str] | tuple[str, ...]] = None, + *, sep: Optional[str] = None, ) -> None: """Set the format string used to render the progress bar.\n @@ -1556,7 +1585,7 @@ def set_bar_format( if sep is not None: self.sep = sep - def set_chars(self, chars: tuple[str, ...]) -> None: + def set_chars(self, chars: tuple[str, ...], /) -> None: """Set the characters used to render the progress bar.\n -------------------------------------------------------------------------- - `chars` -⠀a tuple of characters ordered from full to empty progress
@@ -1570,7 +1599,7 @@ def set_chars(self, chars: tuple[str, ...]) -> None: self.chars = chars - def show_progress(self, current: int, total: int, label: Optional[str] = None) -> None: + def show_progress(self, current: int, total: int, /, label: Optional[str] = None) -> None: """Show or update the progress bar.\n ------------------------------------------------------------------------------------------- - `current` -⠀the current progress value (below `0` or greater than `total` hides the bar) @@ -1608,7 +1637,7 @@ def hide_progress(self) -> None: self._stop_intercepting() @contextmanager - def progress_context(self, total: int, label: Optional[str] = None) -> Generator[ProgressUpdater, None, None]: + def progress_context(self, total: int, /, label: Optional[str] = None) -> Generator[ProgressUpdater, None, None]: """Context manager for automatic cleanup. Returns a function to update progress.\n ---------------------------------------------------------------------------------------------------- - `total` -⠀the total value representing 100% progress (must be greater than `0`) @@ -1644,7 +1673,7 @@ def progress_context(self, total: int, label: Optional[str] = None) -> Generator finally: self.hide_progress() - def _draw_progress_bar(self, current: int, total: int, label: Optional[str] = None) -> None: + def _draw_progress_bar(self, current: int, total: int, /, label: Optional[str] = None) -> None: if total <= 0 or not self._original_stdout: return @@ -1670,6 +1699,7 @@ def _get_formatted_info_and_bar_width( current: int, total: int, percentage: float, + /, label: Optional[str] = None, ) -> tuple[str, int]: fmt_parts: list[str] = [] @@ -1690,7 +1720,7 @@ def _get_formatted_info_and_bar_width( return fmt_str, bar_width - def _create_bar(self, current: int, total: int, bar_width: int) -> str: + def _create_bar(self, current: int, total: int, bar_width: int, /) -> str: progress = current / total if total > 0 else 0 bar: list[str] = [] @@ -1755,7 +1785,7 @@ class _ProgressContextHelper: - `type_checking` -⠀whether to check the parameters' types: Is false per default to save performance, but can be set to true for debugging purposes.""" - def __init__(self, progress_bar: ProgressBar, total: int, label: Optional[str]): + def __init__(self, progress_bar: ProgressBar, total: int, label: Optional[str], /): self.progress_bar = progress_bar self.total = total self.current_label = label @@ -1784,16 +1814,16 @@ def __call__(self, *args: Any, **kwargs: Any) -> None: if label is not None: self.current_label = label - self.progress_bar.show_progress(current=self.current_progress, total=self.total, label=self.current_label) + self.progress_bar.show_progress(self.current_progress, self.total, label=self.current_label) class _ProgressBarCurrentReplacer: """Internal, callable class to replace `{current}` placeholder with formatted number.""" - def __init__(self, current: int) -> None: + def __init__(self, current: int, /) -> None: self.current = current - def __call__(self, match: _rx.Match[str]) -> str: + def __call__(self, match: _rx.Match[str], /) -> str: if (sep := match.group(1)): return f"{self.current:,}".replace(",", sep) return str(self.current) @@ -1802,10 +1832,10 @@ def __call__(self, match: _rx.Match[str]) -> str: class _ProgressBarTotalReplacer: """Internal, callable class to replace `{total}` placeholder with formatted number.""" - def __init__(self, total: int) -> None: + def __init__(self, total: int, /) -> None: self.total = total - def __call__(self, match: _rx.Match[str]) -> str: + def __call__(self, match: _rx.Match[str], /) -> str: if (sep := match.group(1)): return f"{self.total:,}".replace(",", sep) return str(self.total) @@ -1814,10 +1844,10 @@ def __call__(self, match: _rx.Match[str]) -> str: class _ProgressBarPercentageReplacer: """Internal, callable class to replace `{percentage}` placeholder with formatted float.""" - def __init__(self, percentage: float) -> None: + def __init__(self, percentage: float, /) -> None: self.percentage = percentage - def __call__(self, match: _rx.Match[str]) -> str: + def __call__(self, match: _rx.Match[str], /) -> str: return f"{self.percentage:.{match.group(1) if match.group(1) else '1'}f}" @@ -1837,6 +1867,7 @@ class Throbber: def __init__( self, + *, label: Optional[str] = None, throbber_format: list[str] | tuple[str, ...] = ["{l}", "[b]({a}) "], sep: str = " ", @@ -1857,7 +1888,7 @@ def __init__( """Whether the throbber is currently active (intercepting stdout) or not.""" self.update_label(label) - self.set_format(throbber_format, sep) + self.set_format(throbber_format, sep=sep) self.set_frames(frames) self.set_interval(interval) @@ -1869,7 +1900,7 @@ def __init__( self._stop_event: Optional[_threading.Event] = None self._animation_thread: Optional[_threading.Thread] = None - def set_format(self, throbber_format: list[str] | tuple[str, ...], sep: Optional[str] = None) -> None: + def set_format(self, throbber_format: list[str] | tuple[str, ...], *, sep: Optional[str] = None) -> None: """Set the format string used to render the throbber.\n --------------------------------------------------------------------------------------------- - `throbber_format` -⠀the format strings used to render the throbber, containing placeholders: @@ -1884,7 +1915,7 @@ def set_format(self, throbber_format: list[str] | tuple[str, ...], sep: Optional self.throbber_format = throbber_format self.sep = sep or self.sep - def set_frames(self, frames: tuple[str, ...]) -> None: + def set_frames(self, frames: tuple[str, ...], /) -> None: """Set the frames used for the throbber animation.\n --------------------------------------------------------------------- - `frames` -⠀a tuple of strings representing the animation frames""" @@ -1893,7 +1924,7 @@ def set_frames(self, frames: tuple[str, ...]) -> None: self.frames = frames - def set_interval(self, interval: int | float) -> None: + def set_interval(self, interval: int | float, /) -> None: """Set the time interval between each animation frame.\n ------------------------------------------------------------------- - `interval` -⠀the time in seconds between each animation frame""" @@ -1902,7 +1933,7 @@ def set_interval(self, interval: int | float) -> None: self.interval = interval - def start(self, label: Optional[str] = None) -> None: + def start(self, label: Optional[str] = None, /) -> None: """Start the throbber animation and intercept stdout.\n ---------------------------------------------------------- - `label` -⠀the label to display alongside the throbber""" @@ -1930,14 +1961,14 @@ def stop(self) -> None: self._clear_throbber_line() self._stop_intercepting() - def update_label(self, label: Optional[str]) -> None: + def update_label(self, label: Optional[str], /) -> None: """Update the throbber's label text.\n -------------------------------------- - `new_label` -⠀the new label text""" self.label = label @contextmanager - def context(self, label: Optional[str] = None) -> Generator[Callable[[str], None], None, None]: + def context(self, label: Optional[str] = None, /) -> Generator[Callable[[str], None], None, None]: """Context manager for automatic cleanup. Returns a function to update the label.\n ---------------------------------------------------------------------------------------------- - `label` -⠀the label to display alongside the throbber @@ -2035,11 +2066,11 @@ def _redraw_display(self) -> None: class _InterceptedOutput: """Custom StringIO that captures output and stores it in the progress bar buffer.""" - def __init__(self, status_indicator: ProgressBar | Throbber): + def __init__(self, status_indicator: ProgressBar | Throbber, /): self.status_indicator = status_indicator self.string_io = StringIO() - def write(self, content: str) -> int: + def write(self, content: str, /) -> int: self.string_io.write(content) try: if content and content != "\r": @@ -2059,5 +2090,5 @@ def flush(self) -> None: self.status_indicator._emergency_cleanup() # type: ignore[protected-access] raise - def __getattr__(self, name: str) -> Any: + def __getattr__(self, name: str, /) -> Any: return getattr(self.string_io, name) diff --git a/src/xulbux/data.py b/src/xulbux/data.py index 683129b..44f8f67 100644 --- a/src/xulbux/data.py +++ b/src/xulbux/data.py @@ -3,18 +3,20 @@ methods to work with nested data structures. """ -from .base.types import DataStructureTypes, IndexIterableTypes, DataStructure, IndexIterable +from .base.types import IndexIterableTT, IndexIterable, DataObjTT, DataObj as DataObjType from .format_codes import FormatCodes from .string import String from .regex import Regex -from typing import Optional, Literal, Final, Any, cast +from typing import Optional, Literal, TypeVar, Final, Any, overload, cast import base64 as _base64 import math as _math import re as _re +DataObj = TypeVar("DataObj", bound=DataObjType) + _DEFAULT_SYNTAX_HL: Final[dict[str, tuple[str, str]]] = { "str": ("[br:blue]", "[_c]"), "number": ("[br:magenta]", "[_c]"), @@ -29,7 +31,7 @@ class Data: """This class includes methods to work with nested data structures (dictionaries and lists).""" @classmethod - def serialize_bytes(cls, data: bytes | bytearray) -> dict[str, str]: + def serialize_bytes(cls, data: bytes | bytearray, /) -> dict[str, str]: """Converts bytes or bytearray to a JSON-compatible format (dictionary) with explicit keys.\n ---------------------------------------------------------------------------------------------- - `data` -⠀the bytes or bytearray to serialize""" @@ -43,7 +45,7 @@ def serialize_bytes(cls, data: bytes | bytearray) -> dict[str, str]: return {key: _base64.b64encode(data).decode("utf-8"), "encoding": "base64"} @classmethod - def deserialize_bytes(cls, obj: dict[str, str]) -> bytes | bytearray: + def deserialize_bytes(cls, obj: dict[str, str], /) -> bytes | bytearray: """Tries to converts a JSON-compatible bytes/bytearray format (dictionary) back to its original type.\n -------------------------------------------------------------------------------------------------------- - `obj` -⠀the dictionary to deserialize\n @@ -64,7 +66,7 @@ def deserialize_bytes(cls, obj: dict[str, str]) -> bytes | bytearray: raise ValueError(f"Invalid serialized data:\n {obj}") @classmethod - def chars_count(cls, data: DataStructure) -> int: + def chars_count(cls, data: DataObjType, /) -> int: """The sum of all the characters amount including the keys in dictionaries.\n ------------------------------------------------------------------------------ - `data` -⠀the data structure to count the characters from""" @@ -73,71 +75,82 @@ def chars_count(cls, data: DataStructure) -> int: if isinstance(data, dict): for key, val in data.items(): chars_count += len(str(key)) + ( - cls.chars_count(cast(DataStructure, val)) \ - if isinstance(val, DataStructureTypes) + cls.chars_count(cast(DataObjType, val)) \ + if isinstance(val, DataObjTT) else len(str(val)) ) else: for item in data: chars_count += ( - cls.chars_count(cast(DataStructure, item)) \ - if isinstance(item, DataStructureTypes) + cls.chars_count(cast(DataObjType, item)) \ + if isinstance(item, DataObjTT) else len(str(item)) ) - + return chars_count @classmethod - def strip(cls, data: DataStructure) -> DataStructure: + def strip(cls, data: DataObj, /) -> DataObj: """Removes leading and trailing whitespaces from the data structure's items.\n ------------------------------------------------------------------------------- - `data` -⠀the data structure to strip the items from""" if isinstance(data, dict): - return {key.strip(): ( - cls.strip(cast(DataStructure, val)) \ - if isinstance(val, DataStructureTypes) + return type(data)({key.strip(): ( + cls.strip(cast(DataObjType, val)) \ + if isinstance(val, DataObjTT) else val.strip() - ) for key, val in data.items()} + ) for key, val in data.items()}) else: return type(data)(( - cls.strip(cast(DataStructure, item)) \ - if isinstance(item, DataStructureTypes) + cls.strip(cast(DataObjType, item)) \ + if isinstance(item, DataObjTT) else item.strip() ) for item in data) @classmethod - def remove_empty_items(cls, data: DataStructure, spaces_are_empty: bool = False) -> DataStructure: + def remove_empty_items(cls, data: DataObj, /, *, spaces_are_empty: bool = False) -> DataObj: """Removes empty items from the data structure.\n --------------------------------------------------------------------------------- - `data` -⠀the data structure to remove empty items from. - `spaces_are_empty` -⠀if true, it will count items with only spaces as empty""" if isinstance(data, dict): - return { - key: (val if not isinstance(val, DataStructureTypes) else cls.remove_empty_items(cast(DataStructure, val), spaces_are_empty)) + return type(data)({ + key: ( + val if not isinstance(val, DataObjTT) else + cls.remove_empty_items(cast(DataObjType, val), spaces_are_empty=spaces_are_empty) + ) for key, val in data.items() if not String.is_empty(val, spaces_are_empty) - } + }) else: return type(data)( item for item in ( - (item if not isinstance(item, DataStructureTypes) else cls.remove_empty_items(cast(DataStructure, item), spaces_are_empty)) \ - for item in data if not (isinstance(item, (str, type(None))) and String.is_empty(item, spaces_are_empty)) + ( + item \ + if not isinstance(item, DataObjTT) + else cls.remove_empty_items(cast(DataObjType, item), spaces_are_empty=spaces_are_empty) + ) + for item in data + if not (isinstance(item, (str, type(None))) and String.is_empty(item, spaces_are_empty)) ) if item not in ([], (), {}, set(), frozenset()) ) @classmethod - def remove_duplicates(cls, data: DataStructure) -> DataStructure: + def remove_duplicates(cls, data: DataObj, /) -> DataObj: """Removes all duplicates from the data structure.\n ----------------------------------------------------------- - `data` -⠀the data structure to remove duplicates from""" if isinstance(data, dict): - return {key: cls.remove_duplicates(cast(DataStructure, val)) if isinstance(val, DataStructureTypes) else val for key, val in data.items()} + return type(data)({ + key: cls.remove_duplicates(cast(DataObjType, val)) if isinstance(val, DataObjTT) else val + for key, val in data.items() + }) elif isinstance(data, (list, tuple)): result: list[Any] = [] for item in data: - processed_item = cls.remove_duplicates(cast(DataStructure, item)) if isinstance(item, DataStructureTypes) else item + processed_item = cls.remove_duplicates(cast(DataObjType, item)) if isinstance(item, DataObjTT) else item is_duplicate: bool = False for existing_item in result: @@ -153,18 +166,20 @@ def remove_duplicates(cls, data: DataStructure) -> DataStructure: else: processed_elements: set[Any] = set() for item in data: - processed_item = cls.remove_duplicates(cast(DataStructure, item)) if isinstance(item, DataStructureTypes) else item + processed_item = cls.remove_duplicates(cast(DataObjType, item)) if isinstance(item, DataObjTT) else item processed_elements.add(processed_item) return type(data)(processed_elements) @classmethod def remove_comments( cls, - data: DataStructure, + data: DataObj, + /, + *, comment_start: str = ">>", comment_end: str = "<<", comment_sep: str = "", - ) -> DataStructure: + ) -> DataObj: """Remove comments from a list, tuple or dictionary.\n --------------------------------------------------------------------------------------------------------------- - `data` -⠀list, tuple or dictionary, where the comments should get removed from @@ -216,19 +231,23 @@ def remove_comments( if len(comment_start) == 0: raise ValueError("The 'comment_start' parameter string must not be empty.") - return _DataRemoveCommentsHelper( - data=data, - comment_start=comment_start, - comment_end=comment_end, - comment_sep=comment_sep, - )() + return type(data)( + _DataRemoveCommentsHelper( + data, + comment_start=comment_start, + comment_end=comment_end, + comment_sep=comment_sep, + )() + ) @classmethod def is_equal( cls, - data1: DataStructure, - data2: DataStructure, + data1: DataObjType, + data2: DataObjType, + /, ignore_paths: str | list[str] = "", + *, path_sep: str = "->", comment_start: str = ">>", comment_end: str = "<<", @@ -254,16 +273,48 @@ def is_equal( ignore_paths = [ignore_paths] return cls._compare_nested( - data1=cls.remove_comments(data1, comment_start, comment_end), - data2=cls.remove_comments(data2, comment_start, comment_end), + cls.remove_comments(data1, comment_start=comment_start, comment_end=comment_end), + cls.remove_comments(data2, comment_start=comment_start, comment_end=comment_end), ignore_paths=[str(path).split(path_sep) for path in ignore_paths if path], ) + @overload + @classmethod + def get_path_id( + cls, + data: DataObjType, + value_paths: str, + /, + *, + path_sep: str = "->", + comment_start: str = ">>", + comment_end: str = "<<", + ignore_not_found: bool = False, + ) -> Optional[str]: + ... + + @overload @classmethod def get_path_id( cls, - data: DataStructure, + data: DataObjType, + value_paths: list[str], + /, + *, + path_sep: str = "->", + comment_start: str = ">>", + comment_end: str = "<<", + ignore_not_found: bool = False, + ) -> list[Optional[str]]: + ... + + @classmethod + def get_path_id( + cls, + data: DataObjType, value_paths: str | list[str], + /, + *, path_sep: str = "->", comment_start: str = ">>", comment_end: str = "<<", @@ -295,22 +346,25 @@ def get_path_id( if len(path_sep) == 0: raise ValueError("The 'path_sep' parameter string must not be empty.") - data = cls.remove_comments(data, comment_start, comment_end) + data = cls.remove_comments(data, comment_start=comment_start, comment_end=comment_end) if isinstance(value_paths, str): - return _DataGetPathIdHelper(value_paths, path_sep, data, ignore_not_found)() + return _DataGetPathIdHelper(value_paths, path_sep=path_sep, data_obj=data, ignore_not_found=ignore_not_found)() - results = [_DataGetPathIdHelper(path, path_sep, data, ignore_not_found)() for path in value_paths] + results = [ + _DataGetPathIdHelper(path, path_sep=path_sep, data_obj=data, ignore_not_found=ignore_not_found)() + for path in value_paths + ] return results if len(results) > 1 else results[0] if results else None @classmethod - def get_value_by_path_id(cls, data: DataStructure, path_id: str, get_key: bool = False) -> Any: + def get_value_by_path_id(cls, data: DataObjType, path_id: str, /, *, get_key: bool = False) -> Any: """Retrieves the value from `data` using the provided `path_id`, as long as the data structure hasn't changed since creating the path ID.\n -------------------------------------------------------------------------------------------------- - `data` -⠀the list, tuple, or dictionary to retrieve the value from - `path_id` -⠀the path ID to the value to retrieve, created before using `Data.get_path_id()` - `get_key` -⠀if true and the final item is in a dict, it returns the key instead of the value""" - parent: Optional[DataStructure] = None + parent: Optional[DataObjType] = None path = cls._sep_path_id(path_id) current_data: Any = data @@ -323,7 +377,7 @@ def get_value_by_path_id(cls, data: DataStructure, path_id: str, get_key: bool = parent = dict_data current_data = dict_data[keys[path_idx]] - elif isinstance(current_data, IndexIterableTypes): + elif isinstance(current_data, IndexIterableTT): idx_iterable_data = cast(IndexIterable, current_data) if i == len(path) - 1 and get_key: if parent is None or not isinstance(parent, dict): @@ -338,7 +392,7 @@ def get_value_by_path_id(cls, data: DataStructure, path_id: str, get_key: bool = return current_data @classmethod - def set_value_by_path_id(cls, data: DataStructure, update_values: dict[str, Any]) -> DataStructure: + def set_value_by_path_id(cls, data: DataObj, update_values: dict[str, Any], /) -> DataObj: """Updates the value/s from `update_values` in the `data`, as long as the data structure hasn't changed since creating the path ID to that value.\n ----------------------------------------------------------------------------------------- @@ -353,14 +407,16 @@ def set_value_by_path_id(cls, data: DataStructure, update_values: dict[str, Any] raise ValueError(f"No valid 'update_values' found in dictionary:\n{update_values!r}") for path_id, new_val in valid_update_values: - data = cls._set_nested_val(data, id_path=cls._sep_path_id(path_id), value=new_val) + data = cls._set_nested_val(data, cls._sep_path_id(path_id), new_val) return data @classmethod def render( cls, - data: DataStructure, + data: DataObjType, + /, + *, indent: int = 4, compactness: Literal[0, 1, 2] = 1, max_width: int = 127, @@ -404,7 +460,7 @@ def render( return _DataRenderHelper( cls, - data=data, + data, indent=indent, compactness=compactness, max_width=max_width, @@ -416,7 +472,9 @@ def render( @classmethod def print( cls, - data: DataStructure, + data: DataObjType, + /, + *, indent: int = 4, compactness: Literal[0, 1, 2] = 1, max_width: int = 127, @@ -457,7 +515,7 @@ def print( For more detailed information about formatting codes, see the `format_codes` module documentation.""" FormatCodes.print( cls.render( - data=data, + data, indent=indent, compactness=compactness, max_width=max_width, @@ -473,6 +531,7 @@ def _compare_nested( cls, data1: Any, data2: Any, + /, ignore_paths: list[list[str]], current_path: list[str] = [], ) -> bool: @@ -487,8 +546,8 @@ def _compare_nested( if set(dict_data1.keys()) != set(dict_data2.keys()): return False return all(cls._compare_nested( \ - data1=dict_data1[key], - data2=dict_data2[key], + dict_data1[key], + dict_data2[key], ignore_paths=ignore_paths, current_path=current_path + [key], ) for key in dict_data1) @@ -498,8 +557,8 @@ def _compare_nested( if len(array_data1) != len(array_data2): return False return all(cls._compare_nested( \ - data1=item1, - data2=item2, + item1, + item2, ignore_paths=ignore_paths, current_path=current_path + [str(i)], ) for i, (item1, item2) in enumerate(zip(array_data1, array_data2))) @@ -510,7 +569,7 @@ def _compare_nested( return data1 == data2 @staticmethod - def _sep_path_id(path_id: str) -> list[int]: + def _sep_path_id(path_id: str, /) -> list[int]: """Internal method to separate a path-ID string into its ID parts as a list of integers.""" if len(split_id := path_id.split(">")) == 2: id_part_len, path_id_parts = split_id @@ -524,7 +583,7 @@ def _sep_path_id(path_id: str) -> list[int]: raise ValueError(f"Path ID '{path_id}' is an invalid format.") @classmethod - def _set_nested_val(cls, data: DataStructure, id_path: list[int], value: Any) -> Any: + def _set_nested_val(cls, data: DataObjType, id_path: list[int], value: Any, /) -> Any: """Internal method to set a value in a nested data structure based on the provided ID path.""" current_data: Any = data @@ -534,7 +593,7 @@ def _set_nested_val(cls, data: DataStructure, id_path: list[int], value: Any) -> keys, dict_data = list(dict_data.keys()), dict(dict_data) dict_data[keys[id_path[0]]] = value return dict_data - elif isinstance(current_data, IndexIterableTypes): + elif isinstance(current_data, IndexIterableTT): idx_iterable_data = cast(IndexIterable, current_data) was_t, idx_iterable_data = type(idx_iterable_data), list(idx_iterable_data) idx_iterable_data[id_path[0]] = value @@ -546,7 +605,7 @@ def _set_nested_val(cls, data: DataStructure, id_path: list[int], value: Any) -> keys, dict_data = list(dict_data.keys()), dict(dict_data) dict_data[keys[id_path[0]]] = cls._set_nested_val(dict_data[keys[id_path[0]]], id_path[1:], value) return dict_data - elif isinstance(current_data, IndexIterableTypes): + elif isinstance(current_data, IndexIterableTT): idx_iterable_data = cast(IndexIterable, current_data) was_t, idx_iterable_data = type(idx_iterable_data), list(idx_iterable_data) idx_iterable_data[id_path[0]] = cls._set_nested_val(idx_iterable_data[id_path[0]], id_path[1:], value) @@ -558,7 +617,7 @@ def _set_nested_val(cls, data: DataStructure, id_path: list[int], value: Any) -> class _DataRemoveCommentsHelper: """Internal, callable helper class to remove all comments from nested data structures.""" - def __init__(self, data: DataStructure, comment_start: str, comment_end: str, comment_sep: str): + def __init__(self, data: DataObjType, /, *, comment_start: str, comment_end: str, comment_sep: str): self.data = data self.comment_start = comment_start self.comment_end = comment_end @@ -574,10 +633,10 @@ def __init__(self, data: DataStructure, comment_start: str, comment_end: str, co (.*?)$""" )) if len(comment_end) > 0 else None - def __call__(self) -> DataStructure: + def __call__(self) -> DataObjType: return self.remove_nested_comments(self.data) - def remove_nested_comments(self, item: Any) -> Any: + def remove_nested_comments(self, item: Any, /) -> Any: if isinstance(item, dict): dict_item = cast(dict[Any, Any], item) return { @@ -587,7 +646,7 @@ def remove_nested_comments(self, item: Any) -> Any: ) if key is not None } - if isinstance(item, IndexIterableTypes): + if isinstance(item, IndexIterableTT): idx_iterable_item = cast(IndexIterable, item) processed = (val for val in map(self.remove_nested_comments, idx_iterable_item) if val is not None) return type(idx_iterable_item)(processed) @@ -607,7 +666,7 @@ def remove_nested_comments(self, item: Any) -> Any: class _DataGetPathIdHelper: """Internal, callable helper class to process a data path and generate its unique path ID.""" - def __init__(self, path: str, path_sep: str, data_obj: DataStructure, ignore_not_found: bool): + def __init__(self, path: str, /, *, path_sep: str, data_obj: DataObjType, ignore_not_found: bool): self.keys = path.split(path_sep) self.data_obj = data_obj self.ignore_not_found = ignore_not_found @@ -625,14 +684,14 @@ def __call__(self) -> Optional[str]: return None return f"{self.max_id_length}>{''.join(id.zfill(self.max_id_length) for id in self.path_ids)}" - def process_key(self, key: str) -> bool: + def process_key(self, key: str, /) -> bool: """Process a single key and update `path_ids`. Returns `False` if processing should stop.""" idx: Optional[int] = None if isinstance(self.current_data, dict): if (idx := self.process_dict_key(key)) is None: return False - elif isinstance(self.current_data, IndexIterableTypes): + elif isinstance(self.current_data, IndexIterableTT): if (idx := self.process_iterable_key(key)) is None: return False else: @@ -642,7 +701,7 @@ def process_key(self, key: str) -> bool: self.max_id_length = max(self.max_id_length, len(str(idx))) return True - def process_dict_key(self, key: str) -> Optional[int]: + def process_dict_key(self, key: str, /) -> Optional[int]: """Process a key for dictionary data. Returns the index or `None` if not found.""" if key.isdigit(): if self.ignore_not_found: @@ -658,7 +717,7 @@ def process_dict_key(self, key: str) -> Optional[int]: return None raise KeyError(f"Key '{key}' not found in dict.") - def process_iterable_key(self, key: str) -> Optional[int]: + def process_iterable_key(self, key: str, /) -> Optional[int]: """Process a key for iterable data. Returns the index or `None` if not found.""" try: idx = int(key) @@ -681,7 +740,9 @@ class _DataRenderHelper: def __init__( self, cls: type[Data], - data: DataStructure, + data: DataObjType, + /, + *, indent: int, compactness: Literal[0, 1, 2], max_width: int, @@ -716,9 +777,11 @@ def __init__( punct_map: dict[str, str | tuple[str, str]] = {"(": ("/(", "("), **{c: c for c in "'\":)[]{}"}} self.punct: dict[str, str] = { - key: ((f"{self.syntax_hl['punctuation'][0]}{val[0]}{self.syntax_hl['punctuation'][1]}" if self.do_syntax_hl else val[1]) - if isinstance(val, (list, tuple)) else - (f"{self.syntax_hl['punctuation'][0]}{val}{self.syntax_hl['punctuation'][1]}" if self.do_syntax_hl else val)) + key: (( + f"{self.syntax_hl['punctuation'][0]}{val[0]}{self.syntax_hl['punctuation'][1]}" + if self.do_syntax_hl else val[1] + ) if isinstance(val, (list, tuple)) else + (f"{self.syntax_hl['punctuation'][0]}{val}{self.syntax_hl['punctuation'][1]}" if self.do_syntax_hl else val)) for key, val in punct_map.items() } @@ -728,12 +791,12 @@ def __call__(self) -> str: self.format_dict(self.data, 0) if isinstance(self.data, dict) else self.format_sequence(self.data, 0) ) - def format_value(self, value: Any, current_indent: Optional[int] = None) -> str: + def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> str: if current_indent is not None and isinstance(value, dict): return self.format_dict(cast(dict[Any, Any], value), current_indent + self.indent) elif current_indent is not None and hasattr(value, "__dict__"): return self.format_dict(value.__dict__, current_indent + self.indent) - elif current_indent is not None and isinstance(value, IndexIterableTypes): + elif current_indent is not None and isinstance(value, IndexIterableTT): return self.format_sequence(cast(IndexIterable, value), current_indent + self.indent) elif current_indent is not None and isinstance(value, (bytes, bytearray)): obj_dict = self.cls.serialize_bytes(value) @@ -771,7 +834,7 @@ def format_value(self, value: Any, current_indent: Optional[int] = None) -> str: + self.punct["'"] if self.do_syntax_hl else self.punct["'"] + String.escape(str(value), "'") + self.punct["'"] )) - def should_expand(self, seq: IndexIterable) -> bool: + def should_expand(self, seq: IndexIterable, /) -> bool: if self.compactness == 0: return True if self.compactness == 2: @@ -787,10 +850,11 @@ def should_expand(self, seq: IndexIterable) -> bool: or (complex_items == 1 and len(seq) > 1) \ or self.cls.chars_count(seq) + (len(seq) * len(self.sep)) > self.max_width - def format_dict(self, data_dict: dict[Any, Any], current_indent: int) -> str: + def format_dict(self, data_dict: dict[Any, Any], current_indent: int, /) -> str: if self.compactness == 2 or not data_dict or not self.should_expand(list(data_dict.values())): return self.punct["{"] + self.sep.join( - f"{self.format_value(key)}{self.punct[':']} {self.format_value(val, current_indent)}" for key, val in data_dict.items() + f"{self.format_value(key)}{self.punct[':']} {self.format_value(val, current_indent)}" + for key, val in data_dict.items() ) + self.punct["}"] items: list[str] = [] @@ -800,7 +864,7 @@ def format_dict(self, data_dict: dict[Any, Any], current_indent: int) -> str: return self.punct["{"] + "\n" + f"{self.sep}\n".join(items) + f"\n{' ' * current_indent}" + self.punct["}"] - def format_sequence(self, seq: IndexIterable, current_indent: int) -> str: + def format_sequence(self, seq: IndexIterable, current_indent: int, /) -> str: if self.as_json: seq = list(seq) diff --git a/src/xulbux/env_path.py b/src/xulbux/env_path.py index b70d125..809e55b 100644 --- a/src/xulbux/env_path.py +++ b/src/xulbux/env_path.py @@ -5,7 +5,7 @@ from .file_sys import FileSys -from typing import Optional, cast +from typing import Optional, Literal, overload from pathlib import Path import sys as _sys import os as _os @@ -14,8 +14,18 @@ class EnvPath: """This class includes methods to work with the PATH environment variable.""" + @overload @classmethod - def paths(cls, as_list: bool = False) -> Path | list[Path]: + def paths(cls, *, as_list: Literal[True]) -> list[Path]: + ... + + @overload + @classmethod + def paths(cls, *, as_list: Literal[False] = False) -> Path: + ... + + @classmethod + def paths(cls, *, as_list: bool = False) -> Path | list[Path]: """Get the PATH environment variable.\n ------------------------------------------------------------------------------------------------ - `as_list` -⠀if true, returns the paths as a list of `Path`s; otherwise, as a single `Path`""" @@ -25,39 +35,39 @@ def paths(cls, as_list: bool = False) -> Path | list[Path]: return Path(paths_str) @classmethod - def has_path(cls, path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = False) -> bool: + def has_path(cls, path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: bool = False) -> bool: """Check if a path is present in the PATH environment variable.\n ------------------------------------------------------------------------ - `path` -⠀the path to check for - `cwd` -⠀if true, uses the current working directory as the path - `base_dir` -⠀if true, uses the script's base directory as the path""" - check_path = cls._get(path, cwd, base_dir).resolve() - return check_path in {path.resolve() for path in cast(list[Path], cls.paths(as_list=True))} + check_path = cls._get(path, cwd=cwd, base_dir=base_dir).resolve() + return check_path in {path.resolve() for path in cls.paths(as_list=True)} @classmethod - def add_path(cls, path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = False) -> None: + def add_path(cls, path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: bool = False) -> None: """Add a path to the PATH environment variable.\n ------------------------------------------------------------------------ - `path` -⠀the path to add - `cwd` -⠀if true, uses the current working directory as the path - `base_dir` -⠀if true, uses the script's base directory as the path""" - path_obj = cls._get(path, cwd, base_dir) + path_obj = cls._get(path, cwd=cwd, base_dir=base_dir) if not cls.has_path(path_obj): cls._persistent(path_obj) @classmethod - def remove_path(cls, path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = False) -> None: + def remove_path(cls, path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: bool = False) -> None: """Remove a path from the PATH environment variable.\n ------------------------------------------------------------------------ - `path` -⠀the path to remove - `cwd` -⠀if true, uses the current working directory as the path - `base_dir` -⠀if true, uses the script's base directory as the path""" - path_obj = cls._get(path, cwd, base_dir) + path_obj = cls._get(path, cwd=cwd, base_dir=base_dir) if cls.has_path(path_obj): cls._persistent(path_obj, remove=True) @staticmethod - def _get(path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = False) -> Path: + def _get(path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: bool = False) -> Path: """Internal method to get the normalized `path`, CWD path or script directory path.\n -------------------------------------------------------------------------------------- Raise an error if no path is provided and neither `cwd` or `base_dir` is true.""" @@ -74,10 +84,10 @@ def _get(path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = return Path(path) if isinstance(path, str) else path @classmethod - def _persistent(cls, path: Path, remove: bool = False) -> None: + def _persistent(cls, path: Path, /, *, remove: bool = False) -> None: """Internal method to add or remove a path from the PATH environment variable, persistently, across sessions, as well as the current session.""" - current_paths = cast(list[Path], cls.paths(as_list=True)) + current_paths = cls.paths(as_list=True) path_resolved = path.resolve() if remove: diff --git a/src/xulbux/file.py b/src/xulbux/file.py index 7621b8d..a199fbc 100644 --- a/src/xulbux/file.py +++ b/src/xulbux/file.py @@ -17,6 +17,8 @@ def rename_extension( cls, file_path: Path | str, new_extension: str, + /, + *, full_extension: bool = False, camel_case_filename: bool = False, ) -> Path: @@ -48,7 +50,7 @@ def rename_extension( return path.parent / f"{filename}{new_extension}" @classmethod - def create(cls, file_path: Path | str, content: str = "", force: bool = False) -> Path: + def create(cls, file_path: Path | str, content: str = "", /, *, force: bool = False) -> Path: """Create a file with ot without content.\n ------------------------------------------------------------------ - `file_path` -⠀the path where the file should be created diff --git a/src/xulbux/file_sys.py b/src/xulbux/file_sys.py index 1ed0edb..5caa309 100644 --- a/src/xulbux/file_sys.py +++ b/src/xulbux/file_sys.py @@ -52,7 +52,9 @@ class FileSys(metaclass=_FileSysMeta): def extend_path( cls, rel_path: Path | str, + /, search_in: Optional[Path | str | PathsList] = None, + *, fuzzy_match: bool = False, raise_error: bool = False, ) -> Optional[Path]: @@ -92,19 +94,15 @@ def extend_path( else: search_dirs.extend([Path(path) for path in search_in]) - return _ExtendPathHelper( - cls, - rel_path=path, - search_dirs=search_dirs, - fuzzy_match=fuzzy_match, - raise_error=raise_error, - )() + return _ExtendPathHelper(cls, path, search_dirs=search_dirs, fuzzy_match=fuzzy_match, raise_error=raise_error)() @classmethod def extend_or_make_path( cls, rel_path: Path | str, + /, search_in: Optional[Path | str | list[Path | str]] = None, + *, prefer_script_dir: bool = True, fuzzy_match: bool = False, ) -> Path: @@ -127,12 +125,7 @@ def extend_or_make_path( If `prefer_script_dir` is false, it will instead make a path that points to where the `rel_path` would be in the CWD.""" try: - result = cls.extend_path( - rel_path=rel_path, - search_in=search_in, - raise_error=True, - fuzzy_match=fuzzy_match, - ) + result = cls.extend_path(rel_path, search_in=search_in, raise_error=True, fuzzy_match=fuzzy_match) return result if result is not None else Path() except PathNotFoundError: @@ -141,7 +134,7 @@ def extend_or_make_path( return base_dir / path @classmethod - def remove(cls, path: Path | str, only_content: bool = False) -> None: + def remove(cls, path: Path | str, /, *, only_content: bool = False) -> None: """Removes the directory or the directory's content at the specified path.\n ----------------------------------------------------------------------------- - `path` -⠀the path to the directory or file to remove @@ -175,7 +168,9 @@ def __init__( self, cls: type[FileSys], rel_path: Path, + /, search_dirs: list[Path], + *, fuzzy_match: bool, raise_error: bool, ): @@ -209,7 +204,7 @@ def __call__(self) -> Optional[Path]: return self.search_in_dirs(expanded_path) @staticmethod - def expand_env_vars(path: Path) -> Path: + def expand_env_vars(path: Path, /) -> Path: """Expand all environment variables in the given path.""" if "%" not in (str_path := str(path)): return path @@ -220,24 +215,20 @@ def expand_env_vars(path: Path) -> Path: return Path("".join(parts)) - def search_in_dirs(self, path: Path) -> Optional[Path]: + def search_in_dirs(self, path: Path, /) -> Optional[Path]: """Search for the path in all configured directories.""" for search_dir in self.search_dirs: if (full_path := search_dir / path).exists(): return full_path elif self.fuzzy_match: - if (match := self.find_path( \ - base_dir=search_dir, - target_path=path, - fuzzy_match=self.fuzzy_match, - )) is not None: + if (match := self.find_path(search_dir, path, fuzzy_match=self.fuzzy_match)) is not None: return match if self.raise_error: raise PathNotFoundError(f"Path {self.rel_path!r} not found in specified directories.") return None - def find_path(self, base_dir: Path, target_path: Path, fuzzy_match: bool) -> Optional[Path]: + def find_path(self, base_dir: Path, target_path: Path, /, *, fuzzy_match: bool) -> Optional[Path]: """Find a path by traversing the given parts from the base directory, optionally using closest matches for each part.""" current_path: Path = base_dir @@ -252,7 +243,7 @@ def find_path(self, base_dir: Path, target_path: Path, fuzzy_match: bool) -> Opt return current_path if current_path.exists() and current_path != base_dir else None @staticmethod - def get_closest_match(dir: Path, path_part: str) -> Optional[str]: + def get_closest_match(dir: Path, path_part: str, /) -> Optional[str]: """Internal method to get the closest matching file or folder name in the given directory for the given path part.""" try: diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 5564110..e683ddd 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -161,7 +161,7 @@ from .regex import LazyRegex, Regex from .color import Color, rgba, hexa -from typing import Optional, Literal, Final, cast +from typing import Optional, Literal, Final, overload, cast import ctypes as _ctypes import regex as _rx import sys as _sys @@ -245,8 +245,10 @@ def print( def input( cls, prompt: object = "", + /, default_color: Optional[Rgba | Hexa] = None, brightness_steps: int = 20, + *, reset_ansi: bool = False, ) -> str: """An input, whose `prompt` can be formatted using formatting codes.\n @@ -270,8 +272,10 @@ def input( def to_ansi( cls, string: str, + /, default_color: Optional[Rgba | Hexa] = None, brightness_steps: int = 20, + *, _default_start: bool = True, _validate_default: bool = True, ) -> str: @@ -301,8 +305,14 @@ def to_ansi( string = _PATTERNS.star_reset.sub(r"[\1_\2]", string) # REPLACE `[…|*|…]` WITH `[…|_|…]` string = "\n".join( - _PATTERNS.formatting.sub(_ReplaceKeysHelper(cls, use_default, default_color, brightness_steps), line) - for line in string.split("\n") + _PATTERNS.formatting.sub( + _ReplaceKeysHelper( + cls, + use_default=use_default, + default_color=default_color, + brightness_steps=brightness_steps, + ), line + ) for line in string.split("\n") ) return ( @@ -314,7 +324,9 @@ def to_ansi( def escape( cls, string: str, + /, default_color: Optional[Rgba | Hexa] = None, + *, _escape_char: Literal["/", "\\"] = "/", ) -> str: """Escapes all valid formatting codes in the string, so they are visible when output @@ -329,22 +341,52 @@ def escape( use_default, default_color = cls._validate_default_color(default_color) return "\n".join( - _PATTERNS.formatting.sub(_EscapeFormatCodeHelper(cls, use_default, default_color, _escape_char), line) - for line in string.split("\n") + _PATTERNS.formatting.sub( + _EscapeFormatCodeHelper(cls, use_default=use_default, default_color=default_color, escape_char=_escape_char), + line, + ) for line in string.split("\n") ) @classmethod - def escape_ansi(cls, ansi_string: str) -> str: + def escape_ansi(cls, ansi_string: str, /) -> str: """Escapes all ANSI codes in the string, so they are visible when output to the console.\n ------------------------------------------------------------------------------------------- - `ansi_string` -⠀the string that contains the ANSI codes to escape""" return ansi_string.replace(ANSI.CHAR, ANSI.CHAR_ESCAPED) + @overload @classmethod def remove( cls, string: str, + /, default_color: Optional[Rgba | Hexa] = None, + *, + get_removals: Literal[True], + _ignore_linebreaks: bool = False, + ) -> tuple[str, tuple[tuple[int, str], ...]]: + ... + + @overload + @classmethod + def remove( + cls, + string: str, + /, + default_color: Optional[Rgba | Hexa] = None, + *, + get_removals: Literal[False] = False, + _ignore_linebreaks: bool = False, + ) -> str: + ... + + @classmethod + def remove( + cls, + string: str, + /, + default_color: Optional[Rgba | Hexa] = None, + *, get_removals: bool = False, _ignore_linebreaks: bool = False, ) -> str | tuple[str, tuple[tuple[int, str], ...]]: @@ -361,10 +403,36 @@ def remove( _ignore_linebreaks=_ignore_linebreaks, ) + @overload + @classmethod + def remove_ansi( + cls, + ansi_string: str, + /, + *, + get_removals: Literal[True], + _ignore_linebreaks: bool = False, + ) -> tuple[str, tuple[tuple[int, str], ...]]: + ... + + @overload + @classmethod + def remove_ansi( + cls, + ansi_string: str, + /, + *, + get_removals: Literal[False] = False, + _ignore_linebreaks: bool = False, + ) -> str: + ... + @classmethod def remove_ansi( cls, ansi_string: str, + /, + *, get_removals: bool = False, _ignore_linebreaks: bool = False, ) -> str | tuple[str, tuple[tuple[int, str], ...]]: @@ -410,24 +478,24 @@ def _config_console(cls) -> None: _CONSOLE_ANSI_CONFIGURED = True # type: ignore[assignment] @staticmethod - def _validate_default_color(default_color: Optional[Rgba | Hexa]) -> tuple[bool, Optional[rgba]]: + def _validate_default_color(default_color: Optional[Rgba | Hexa], /) -> tuple[bool, Optional[rgba]]: """Internal method to validate and convert `default_color` to a `rgba` color object.""" if default_color is None: return False, None - if Color.is_valid_hexa(default_color, False): + if Color.is_valid_hexa(default_color, allow_alpha=False): return True, hexa(cast(str | int, default_color)).to_rgba() - elif Color.is_valid_rgba(default_color, False): + elif Color.is_valid_rgba(default_color, allow_alpha=False): return True, Color._parse_rgba(cast(Rgba, default_color)) # type: ignore[protected-access] raise TypeError("The 'default_color' parameter must be either a valid RGBA or HEXA color, or None.") @staticmethod - def _formats_to_keys(formats: str) -> list[str]: + def _formats_to_keys(formats: str, /) -> list[str]: """Internal method to convert a string of multiple format keys to a list of individual, stripped format keys.""" return [key.strip() for key in formats.split("|") if key.strip()] @classmethod - def _get_replacement(cls, format_key: str, default_color: Optional[rgba], brightness_steps: int = 20) -> str: + def _get_replacement(cls, format_key: str, default_color: Optional[rgba], /, brightness_steps: int = 20) -> str: """Internal method that gives you the corresponding ANSI code for the given format key. If `default_color` is not `None`, the text color will be `default_color` if all formats are reset or you can get lighter or darker version of `default_color` (also as BG)""" @@ -464,8 +532,10 @@ def _get_replacement(cls, format_key: str, default_color: Optional[rgba], bright @staticmethod def _get_default_ansi( default_color: rgba, + /, format_key: Optional[str] = None, brightness_steps: Optional[int] = None, + *, _modifiers: tuple[str, str] = (_DEFAULT_COLOR_MODS["lighten"], _DEFAULT_COLOR_MODS["darken"]), ) -> Optional[str]: """Internal method to get the `default_color` and lighter/darker versions of it as ANSI code.""" @@ -487,13 +557,15 @@ def _get_default_ansi( if adjust == 0: return None elif modifiers in _modifiers[0]: - new_rgb = tuple(Color.adjust_lightness(default_color, (brightness_steps / 100) * adjust)) + adjusted_rgb = Color.adjust_lightness(default_color, (brightness_steps / 100) * adjust) + new_rgb = (adjusted_rgb[0], adjusted_rgb[1], adjusted_rgb[2]) elif modifiers in _modifiers[1]: - new_rgb = tuple(Color.adjust_lightness(default_color, -(brightness_steps / 100) * adjust)) + adjusted_rgb = Color.adjust_lightness(default_color, -(brightness_steps / 100) * adjust) + new_rgb = (adjusted_rgb[0], adjusted_rgb[1], adjusted_rgb[2]) return (ANSI.SEQ_BG_COLOR if is_bg else ANSI.SEQ_COLOR).format(*new_rgb[:3]) @staticmethod - def _normalize_key(format_key: str) -> str: + def _normalize_key(format_key: str, /) -> str: """Internal method to normalize the given format key.""" k_parts = format_key.replace(" ", "").lower().split(":") prefix_str = "".join( @@ -512,6 +584,7 @@ class _EscapeFormatCodeHelper: def __init__( self, cls: type[FormatCodes], + *, use_default: bool, default_color: Optional[rgba], escape_char: Literal["/", "\\"], @@ -521,7 +594,7 @@ def __init__( self.default_color = default_color self.escape_char: Literal["/", "\\"] = escape_char - def __call__(self, match: _rx.Match[str]) -> str: + def __call__(self, match: _rx.Match[str], /) -> str: formats, auto_reset_txt = match.group(1), match.group(3) # CHECK IF ALREADY ESCAPED OR CONTAINS NO FORMATTING @@ -535,15 +608,14 @@ def __call__(self, match: _rx.Match[str]) -> str: else: _formats = _PATTERNS.star_reset_inside.sub(r"\1_\2", formats) - if all( - self.cls._get_replacement(format_key, self.default_color) != format_key # type: ignore[protected-access] - for format_key in self.cls._formats_to_keys(_formats) # type: ignore[protected-access] - ): + if all(self.cls._get_replacement(format_key, self.default_color) != format_key # type: ignore[protected-access] + for format_key in self.cls._formats_to_keys(_formats) # type: ignore[protected-access] + ): # ESCAPE THE FORMATTING CODE escaped = f"[{self.escape_char}{formats}]" if auto_reset_txt: # RECURSIVELY ESCAPE FORMATTING IN AUTO-RESET TEXT - escaped_auto_reset = self.cls.escape(auto_reset_txt, self.default_color, self.escape_char) + escaped_auto_reset = self.cls.escape(auto_reset_txt, self.default_color, _escape_char=self.escape_char) escaped += f"({escaped_auto_reset})" return escaped else: @@ -551,7 +623,7 @@ def __call__(self, match: _rx.Match[str]) -> str: result = f"[{formats}]" if auto_reset_txt: # STILL RECURSIVELY PROCESS AUTO-RESET TEXT - escaped_auto_reset = self.cls.escape(auto_reset_txt, self.default_color, self.escape_char) + escaped_auto_reset = self.cls.escape(auto_reset_txt, self.default_color, _escape_char=self.escape_char) result += f"({escaped_auto_reset})" return result @@ -559,10 +631,10 @@ def __call__(self, match: _rx.Match[str]) -> str: class _RemAnsiSeqHelper: """Internal, callable helper class to remove ANSI sequences and track their removal positions.""" - def __init__(self, removals: list[tuple[int, str]]): + def __init__(self, removals: list[tuple[int, str]], /): self.removals = removals - def __call__(self, match: _rx.Match[str]) -> str: + def __call__(self, match: _rx.Match[str], /) -> str: start_pos = match.start() - sum(len(removed) for _, removed in self.removals) if self.removals and self.removals[-1][0] == start_pos: start_pos = self.removals[-1][0] @@ -576,6 +648,7 @@ class _ReplaceKeysHelper: def __init__( self, cls: type[FormatCodes], + *, use_default: bool, default_color: Optional[rgba], brightness_steps: int, @@ -595,7 +668,7 @@ def __init__( self.ansi_formats: list[str] = [] self.ansi_resets: list[str] = [] - def __call__(self, match: _rx.Match[str]) -> str: + def __call__(self, match: _rx.Match[str], /) -> str: self.original_formats = self.formats = match.group(1) self.auto_reset_escaped = bool(match.group(2)) self.auto_reset_txt = match.group(3) @@ -692,7 +765,7 @@ def gen_reset_codes(self) -> None: ).startswith(f"{ANSI.CHAR}{ANSI.START}") ] - def build_output(self, match: _rx.Match[str]) -> str: + def build_output(self, match: _rx.Match[str], /) -> str: """Build the final output string based on processed formats and resets.""" # CHECK IF ALL FORMATS WERE VALID has_single_valid_ansi = len(self.ansi_formats) == 1 and self.ansi_formats[0].count(f"{ANSI.CHAR}{ANSI.START}") >= 1 @@ -720,6 +793,6 @@ def build_output(self, match: _rx.Match[str]) -> str: return output - def is_valid_color(self, color: str) -> bool: + def is_valid_color(self, color: str, /) -> bool: """Check whether the given color string is a valid formatting-key color.""" return bool((color in ANSI.COLOR_MAP) or Color.is_valid_rgba(color) or Color.is_valid_hexa(color)) diff --git a/src/xulbux/json.py b/src/xulbux/json.py index bc2d55b..48c429f 100644 --- a/src/xulbux/json.py +++ b/src/xulbux/json.py @@ -3,12 +3,12 @@ create and update JSON files, with support for comments inside the JSON data. """ -from .base.types import DataStructure +from .base.types import DataObj from .file_sys import FileSys from .data import Data from .file import File -from typing import Literal, Any, cast +from typing import Literal, Any, overload, cast from pathlib import Path import json as _json @@ -17,10 +17,38 @@ class Json: """This class provides methods to read, create and update JSON files, with support for comments inside the JSON data.""" + @overload @classmethod def read( cls, json_file: Path | str, + /, + *, + comment_start: str = ">>", + comment_end: str = "<<", + return_original: Literal[True], + ) -> tuple[dict[str, Any], dict[str, Any]]: + ... + + @overload + @classmethod + def read( + cls, + json_file: Path | str, + /, + *, + comment_start: str = ">>", + comment_end: str = "<<", + return_original: Literal[False] = False, + ) -> dict[str, Any]: + ... + + @classmethod + def read( + cls, + json_file: Path | str, + /, + *, comment_start: str = ">>", comment_end: str = "<<", return_original: bool = False, @@ -50,7 +78,7 @@ def read( fmt_error = "\n ".join(str(e).splitlines()) raise ValueError(f"Error parsing JSON in {file_path!r}:\n {fmt_error}") from e - if not (processed_data := dict(Data.remove_comments(data, comment_start, comment_end))): + if not (processed_data := dict(Data.remove_comments(data, comment_start=comment_start, comment_end=comment_end))): raise ValueError(f"The JSON file {file_path!r} is empty or contains only comments.") return (processed_data, data) if return_original else processed_data @@ -60,6 +88,8 @@ def create( cls, json_file: Path | str, data: dict[str, Any], + /, + *, indent: int = 2, compactness: Literal[0, 1, 2] = 1, force: bool = False, @@ -82,14 +112,8 @@ def create( file_path = FileSys.extend_or_make_path(json_path, prefer_script_dir=True) File.create( - file_path=file_path, - content=Data.render( - data=data, - indent=indent, - compactness=compactness, - as_json=True, - syntax_highlighting=False, - ), + file_path, + Data.render(data, indent=indent, compactness=compactness, as_json=True, syntax_highlighting=False), force=force, ) @@ -100,6 +124,8 @@ def update( cls, json_file: Path | str, update_values: dict[str, Any], + /, + *, comment_start: str = ">>", comment_end: str = "<<", path_sep: str = "->", @@ -142,25 +168,18 @@ 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.""" - processed_data, data = cast( - tuple[dict[str, Any], dict[str, Any]], - cls.read( - json_file=json_file, - comment_start=comment_start, - comment_end=comment_end, - return_original=True, - ), + processed_data, data = cls.read( + json_file, + comment_start=comment_start, + comment_end=comment_end, + return_original=True, ) update: dict[str, Any] = {} for val_path, new_val in update_values.items(): try: - if (path_id := Data.get_path_id( - data=cast(DataStructure, processed_data), - value_paths=val_path, - path_sep=path_sep, - )) is not None: - update[cast(str, path_id)] = new_val + if (path_id := Data.get_path_id(cast(DataObj, processed_data), val_path, path_sep=path_sep)) is not None: + update[path_id] = new_val else: data = cls._create_nested_path(data, val_path.split(path_sep), new_val) except Exception: @@ -169,10 +188,10 @@ def update( if update: data = Data.set_value_by_path_id(data, update) - cls.create(json_file=json_file, data=dict(data), force=True) + cls.create(json_file, data, force=True) @staticmethod - def _create_nested_path(data_obj: dict[str, Any], path_keys: list[str], value: Any) -> dict[str, Any]: + def _create_nested_path(data_obj: dict[str, Any], path_keys: list[str], value: Any, /) -> dict[str, Any]: """Internal method that creates nested dictionaries/lists based on the given path keys and sets the specified value at the end of the path.""" last_idx, current = len(path_keys) - 1, data_obj diff --git a/src/xulbux/regex.py b/src/xulbux/regex.py index 1daa129..3344410 100644 --- a/src/xulbux/regex.py +++ b/src/xulbux/regex.py @@ -29,6 +29,8 @@ def brackets( cls, bracket1: str = "(", bracket2: str = ")", + /, + *, is_group: bool = False, strip_spaces: bool = False, ignore_in_strings: bool = True, @@ -75,12 +77,12 @@ def brackets( ) @classmethod - def outside_strings(cls, pattern: str = r".*") -> str: + def outside_strings(cls, pattern: str = r".*", /) -> str: """Matches the `pattern` only when it is not found inside a string (`'…'` or `"…"`).""" return rf"""(? str: + def all_except(cls, disallowed_pattern: str, /, ignore_pattern: str = "", *, is_group: bool = False) -> str: """Matches everything up to the `disallowed_pattern`, unless the `disallowed_pattern` is found inside a string/quotes (`'…'` or `"…"`).\n ------------------------------------------------------------------------------------- @@ -100,7 +102,7 @@ def all_except(cls, disallowed_pattern: str, ignore_pattern: str = "", is_group: ) @classmethod - def func_call(cls, func_name: Optional[str] = None) -> str: + def func_call(cls, func_name: Optional[str] = None, /) -> str: """Match a function call, and get back two groups: 1. function name 2. the function's arguments\n @@ -113,7 +115,7 @@ def func_call(cls, func_name: Optional[str] = None) -> str: return rf"""(?<=\b)({func_name})\s*{cls.brackets("(", ")", is_group=True)}""" @classmethod - def rgba_str(cls, fix_sep: Optional[str] = ",", allow_alpha: bool = True) -> str: + def rgba_str(cls, fix_sep: Optional[str] = ",", *, allow_alpha: bool = True) -> str: """Matches an RGBA color inside a string.\n ---------------------------------------------------------------------------------- - `fix_sep` -⠀the fixed separator between the RGBA values (e.g. `,`, `;` …)
@@ -155,7 +157,7 @@ def rgba_str(cls, fix_sep: Optional[str] = ",", allow_alpha: bool = True) -> str ) @classmethod - def hsla_str(cls, fix_sep: Optional[str] = ",", allow_alpha: bool = True) -> str: + def hsla_str(cls, fix_sep: Optional[str] = ",", *, allow_alpha: bool = True) -> str: """Matches a HSLA color inside a string.\n ---------------------------------------------------------------------------------- - `fix_sep` -⠀the fixed separator between the HSLA values (e.g. `,`, `;` …)
@@ -197,7 +199,7 @@ def hsla_str(cls, fix_sep: Optional[str] = ",", allow_alpha: bool = True) -> str ) @classmethod - def hexa_str(cls, allow_alpha: bool = True) -> str: + def hexa_str(cls, *, allow_alpha: bool = True) -> str: """Matches a HEXA color inside a string.\n ---------------------------------------------------------------------- - `allow_alpha` -⠀whether to include the alpha channel in the match\n @@ -239,7 +241,7 @@ class LazyRegex: def __init__(self, **patterns: str): self._patterns = patterns - def __getattr__(self, name: str) -> _rx.Pattern[str]: + def __getattr__(self, name: str, /) -> _rx.Pattern[str]: if name in self._patterns: setattr(self, name, compiled := _rx.compile(self._patterns[name])) return compiled diff --git a/src/xulbux/string.py b/src/xulbux/string.py index 35746c2..cdd11ab 100644 --- a/src/xulbux/string.py +++ b/src/xulbux/string.py @@ -13,7 +13,7 @@ class String: """This class provides various utility methods for string manipulation and conversion.""" @classmethod - def to_type(cls, string: str) -> Any: + def to_type(cls, string: str, /) -> Any: """Will convert a string to the found type, including complex nested structures.\n ----------------------------------------------------------------------------------- - `string` -⠀the string to convert""" @@ -26,7 +26,7 @@ def to_type(cls, string: str) -> Any: return string @classmethod - def normalize_spaces(cls, string: str, tab_spaces: int = 4) -> str: + def normalize_spaces(cls, string: str, /, tab_spaces: int = 4) -> str: """Replaces all special space characters with normal spaces.\n --------------------------------------------------------------- - `tab_spaces` -⠀number of spaces to replace tab chars with""" @@ -38,7 +38,7 @@ def normalize_spaces(cls, string: str, tab_spaces: int = 4) -> str: .replace("\u2007", " ").replace("\u2008", " ").replace("\u2009", " ").replace("\u200A", " ") @classmethod - def escape(cls, string: str, str_quotes: Optional[Literal["'", '"']] = None) -> str: + def escape(cls, string: str, /, str_quotes: Optional[Literal["'", '"']] = None) -> str: """Escapes Python's special characters (e.g. `\\n`, `\\t`, …) and quotes inside the string.\n -------------------------------------------------------------------------------------------------------- - `string` -⠀the string to escape @@ -57,7 +57,7 @@ def escape(cls, string: str, str_quotes: Optional[Literal["'", '"']] = None) -> return string @classmethod - def is_empty(cls, string: Optional[str], spaces_are_empty: bool = False) -> bool: + def is_empty(cls, string: Optional[str], /, *, spaces_are_empty: bool = False) -> bool: """Returns `True` if the string is considered empty and `False` otherwise.\n ----------------------------------------------------------------------------------------------- - `string` -⠀the string to check (or `None`, which is considered empty) @@ -68,9 +68,9 @@ def is_empty(cls, string: Optional[str], spaces_are_empty: bool = False) -> bool ) @classmethod - def single_char_repeats(cls, string: str, char: str) -> int | bool: + def single_char_repeats(cls, string: str, char: str, /) -> int: """- If the string consists of only the same `char`, it returns the number of times it is present. - - If the string doesn't consist of only the same character, it returns `False`.\n + - If the string doesn't consist of only the same character, it returns `0`.\n --------------------------------------------------------------------------------------------------- - `string` -⠀the string to check - `char` -⠀the character to check for repetition""" @@ -80,10 +80,10 @@ def single_char_repeats(cls, string: str, char: str) -> int | bool: if len(string) == (len(char) * string.count(char)): return string.count(char) else: - return False + return 0 @classmethod - def decompose(cls, case_string: str, seps: str = "-_", lower_all: bool = True) -> list[str]: + def decompose(cls, case_string: str, /, seps: str = "-_", *, lower_all: bool = True) -> list[str]: """Will decompose the string (any type of casing, also mixed) into parts.\n ---------------------------------------------------------------------------- - `case_string` -⠀the string to decompose @@ -95,7 +95,7 @@ def decompose(cls, case_string: str, seps: str = "-_", lower_all: bool = True) - ] @classmethod - def to_camel_case(cls, string: str, upper: bool = True) -> str: + def to_camel_case(cls, string: str, /, *, upper: bool = True) -> str: """Will convert the string of any type of casing to CamelCase.\n ----------------------------------------------------------------- - `string` -⠀the string to convert @@ -109,7 +109,7 @@ def to_camel_case(cls, string: str, upper: bool = True) -> str: ) @classmethod - def to_delimited_case(cls, string: str, delimiter: str = "_", screaming: bool = False) -> str: + def to_delimited_case(cls, string: str, /, delimiter: str = "_", *, screaming: bool = False) -> str: """Will convert the string of any type of casing to delimited case.\n ----------------------------------------------------------------------- - `string` -⠀the string to convert @@ -121,7 +121,7 @@ def to_delimited_case(cls, string: str, delimiter: str = "_", screaming: bool = ) @classmethod - def get_lines(cls, string: str, remove_empty_lines: bool = False) -> list[str]: + def get_lines(cls, string: str, /, *, remove_empty_lines: bool = False) -> list[str]: """Will split the string into lines.\n ------------------------------------------------------------------------------------ - `string` -⠀the string to split @@ -136,7 +136,7 @@ def get_lines(cls, string: str, remove_empty_lines: bool = False) -> list[str]: return non_empty_lines @classmethod - def remove_consecutive_empty_lines(cls, string: str, max_consecutive: int = 0) -> str: + def remove_consecutive_empty_lines(cls, string: str, /, max_consecutive: int = 0) -> str: """Will remove consecutive empty lines from the string.\n ------------------------------------------------------------------------------------- - `string` -⠀the string to process @@ -150,7 +150,7 @@ def remove_consecutive_empty_lines(cls, string: str, max_consecutive: int = 0) - return _re.sub(r"(\n\s*){2,}", r"\1" * (max_consecutive + 1), string) @classmethod - def split_count(cls, string: str, count: int) -> list[str]: + def split_count(cls, string: str, count: int, /) -> list[str]: """Will split the string every `count` characters.\n ----------------------------------------------------- - `string` -⠀the string to split diff --git a/src/xulbux/system.py b/src/xulbux/system.py index b118942..5d6f361 100644 --- a/src/xulbux/system.py +++ b/src/xulbux/system.py @@ -111,7 +111,7 @@ class System(metaclass=_SystemMeta): """This class provides methods to interact with the underlying operating system.""" @classmethod - def restart(cls, prompt: object = "", wait: int = 0, continue_program: bool = False, force: bool = False) -> None: + def restart(cls, prompt: object = "", /, *, wait: int = 0, continue_program: bool = False, force: bool = False) -> None: """Restarts the system with some advanced options\n -------------------------------------------------------------------------------------------------- - `prompt` -⠀the message to be displayed in the systems restart notification @@ -121,12 +121,14 @@ def restart(cls, prompt: object = "", wait: int = 0, continue_program: bool = Fa if wait < 0: raise ValueError(f"The 'wait' parameter must be non-negative, got {wait!r}") - _SystemRestartHelper(prompt, wait, continue_program, force)() + _SystemRestartHelper(prompt, wait=wait, continue_program=continue_program, force=force)() @classmethod def check_libs( cls, lib_names: list[str], + /, + *, install_missing: bool = False, missing_libs_msgs: MissingLibsMsgs = { "found_missing": "The following required libraries are missing:", @@ -144,7 +146,12 @@ 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.""" - return _SystemCheckLibsHelper(lib_names, install_missing, missing_libs_msgs, confirm_install)() + return _SystemCheckLibsHelper( + lib_names, + install_missing=install_missing, + missing_libs_msgs=missing_libs_msgs, + confirm_install=confirm_install, + )() @classmethod def elevate(cls, win_title: Optional[str] = None, args: Optional[list[str]] = None) -> bool: @@ -192,7 +199,7 @@ def elevate(cls, win_title: Optional[str] = None, args: Optional[list[str]] = No class _SystemRestartHelper: """Internal, callable helper class to handle system restart with platform-specific logic.""" - def __init__(self, prompt: object, wait: int, continue_program: bool, force: bool): + def __init__(self, prompt: object, /, *, wait: int, continue_program: bool, force: bool): self.prompt = prompt self.wait = wait self.continue_program = continue_program @@ -206,7 +213,7 @@ def __call__(self) -> None: else: raise NotImplementedError(f"Restart not implemented for '{system}' systems.") - def check_running_processes(self, command: str | list[str], skip_lines: int = 0) -> None: + def check_running_processes(self, command: str | list[str], /, skip_lines: int = 0) -> None: """Check if processes are running and raise error if force is False.""" if self.force: return @@ -260,6 +267,8 @@ class _SystemCheckLibsHelper: def __init__( self, lib_names: list[str], + /, + *, install_missing: bool, missing_libs_msgs: MissingLibsMsgs, confirm_install: bool, @@ -292,7 +301,7 @@ def find_missing_libs(self) -> list[str]: missing.append(lib) return missing - def confirm_installation(self, missing: list[str]) -> bool: + def confirm_installation(self, missing: list[str], /) -> bool: """Ask user for confirmation before installing libraries.""" FormatCodes.print(f"[b]({self.missing_libs_msgs['found_missing']})") for lib in missing: @@ -300,7 +309,7 @@ def confirm_installation(self, missing: list[str]) -> bool: print() return Console.confirm(self.missing_libs_msgs["should_install"], end="\n") - def install_libs(self, missing: list[str]) -> Optional[list[str]]: + def install_libs(self, missing: list[str], /) -> Optional[list[str]]: """Install missing libraries using pip.""" for lib in missing[:]: try: