diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml
index b4e7a91..b0e979a 100644
--- a/.github/workflows/test-and-lint.yml
+++ b/.github/workflows/test-and-lint.yml
@@ -44,8 +44,8 @@ jobs:
- name: Lint with flake8
run: |
- python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
- python -m flake8 . --exit-zero --max-complexity=12 --statistics
+ python -m flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=build
+ python -m flake8 . --exit-zero --max-complexity=12 --statistics --exclude=build
- name: Type check with pyright
run: |
diff --git a/.style.yapf b/.style.yapf
index 66e86d7..53c00ed 100644
--- a/.style.yapf
+++ b/.style.yapf
@@ -1,19 +1,19 @@
[style]
+ALLOW_SPLIT_BEFORE_DICT_VALUE = true
BASED_ON_STYLE = pep8
+BLANK_LINE_BEFORE_NESTED_CLASS_OR_DEF = 1
+BLANK_LINES_BETWEEN_TOP_LEVEL_IMPORTS_AND_VARIABLES = 2
+COALESCE_BRACKETS = true
COLUMN_LIMIT = 127
-ALLOW_SPLIT_BEFORE_DICT_VALUE = true
-SPLIT_BEFORE_FIRST_ARGUMENT = true
-SPLIT_BEFORE_LOGICAL_OPERATOR = true
-SPLIT_BEFORE_BITWISE_OPERATOR = true
-SPLIT_BEFORE_ARITHMETIC_OPERATOR = true
-SPLIT_BEFORE_DOT = true
-SPLIT_COMPLEX_COMPREHENSION = false
DEDENT_CLOSING_BRACKETS = true
-INDENT_CLOSING_BRACKETS = false
-COALESCE_BRACKETS = true
-EACH_DICT_ENTRY_ON_SEPARATE_LINE = false
-BLANK_LINES_BETWEEN_TOP_LEVEL_IMPORTS_AND_VARIABLES = 2
DISABLE_SPLIT_LIST_WITH_COMMENT = true
+EACH_DICT_ENTRY_ON_SEPARATE_LINE = false
+INDENT_CLOSING_BRACKETS = false
SPACES_AROUND_SUBSCRIPT_COLON = false
SPACES_BEFORE_COMMENT = 2
-BLANK_LINE_BEFORE_NESTED_CLASS_OR_DEF = 1
+SPLIT_BEFORE_ARITHMETIC_OPERATOR = true
+SPLIT_BEFORE_BITWISE_OPERATOR = true
+SPLIT_BEFORE_DOT = true
+SPLIT_BEFORE_FIRST_ARGUMENT = true
+SPLIT_BEFORE_LOGICAL_OPERATOR = true
+SPLIT_COMPLEX_COMPREHENSION = false
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a3c609..b22a787 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,107 +1,150 @@
-
-
- scroll to bottom ๐ ซ
-
+๏ปฟ
#
Changelog
+
+
+## โฆโ`v1.9.8`
+
+* Unified all error messages throughout the whole library, to always pass the given value if the error is caused by that value being invalid.
+* Added a new param `allow_space_value` to `Console.get_args()` and made `flag_value_sep` optional, which allows you to specify whether flags should be able to receive their values with a space in between (*e.g.* `--flag value` instead of just `--flag=value`).
+* Added a new `skip` param to `Console.get_args()`, which skips the first N command-line arguments before parsing; useful when the leading argv entries are a command/subcommand and not relevant to the caller.
+* Added three new read-only attributes to `ParsedArgs`:
+ - `is_empty` is true if no argument was found **and** none have any values (*not even defaults*).
+ - `any_exist` is true if at least one argument was explicitly found.
+ - `all_exist` is true if every argument was explicitly found.
+ - `unknown_flags` is a list of all the unknown flags that were found in the command-line arguments (*args that look like flags but are not defined in the config*).
+* Added `ParsedArgs.RESERVED_ALIASES` โ a `frozenset` of names that cannot be used as argument aliases. Passing a reserved name now raises a clear `ValueError`.
+* Reformat all docstrings of the whole library.
+* Improved the performance of `Console.log()` and `FormatCodes.to_ansi()` by restructuring the way they process the formatting and output.
+* Improved the performance of `String.normalize_spaces()` by using `str.translate()` instead of multiple `str.replace()` calls.
+* Improved the performance of `Data.remove_duplicates()` for lists and tuples: hashable items now deduplicate in O(n) using `dict.fromkeys()`, with an O(nยฒ) equality-check fallback only for unhashable items (*lists, dicts, sets*).
+* The `Console.log()` method no longer forces the title to be all uppercase, giving the user a bit more freedom in how they want to format their title.
+* Added a new, typed, operator-based formatting API in `format_codes`.
+ The `Format` class (*alias* `F`) exposes every ANSI style/color attribute and uses `|` to combine codes and `()` to apply them to text โ e.g. `(F.BOLD | F.RED)("hi")` and `F.hex("#F67")("hi")`.
+ The new `FormatCodes(*segments, sep="\n")` (*alias* `FC`) class builds the ANSI string on construction and exposes `.ansi`, `.raw`, `.code_positions`, `.print()` and `.input()`.
+ A companion `Term` class provides commonly used cursor- and screen-control sequences (`Term.HIDE_CURSOR`, `Term.up(n)`, `Term.move(row, col)`, `Term.title(text)`, โฆ).
+* Removed the `xulbux-lib fc` CLI command, since the new formatting API doesn't support its old format string syntax.
+
+**BREAKING CHANGES:**
+
+* Renamed `r`, `g`, `b` and `a` to `red`, `green`, `blue` and `alpha` everywhere in the library, to follow the no-single-letter-names convention.
+* Renamed `h`, `s` and `l` to `hue`, `sat` and `light` everywhere in the library, to follow the no-single-letter-names convention.
+* Renamed the `Console.w` and `Console.h` properties to `Console.width` and `Console.height`, to follow the no-single-letter-names convention.
+* Renamed the `ANSI.SEQ_COLOR` lib constant to `ANSI.SEQ_FG_COLOR` to match the naming pattern of all other `ANSI` constants.
+* Removed the `background:` and `bright:` prefixes from the library, so now you can just use the `bg:` and `br:` ones, for consistency.
+* Removed the `format_linebreaks` param from `Console.log()`, as the whole point of the `log()` method is to get a nicely formatted log message.
+* The `Console.get_args()` method no longer treats unknown flags as values but therefore saves them to the new `unknown_flags` property of the returned `ParsedArgs` object.
+* Changed the type of `ParsedArgData.values` and `ArgData.values` from
list[*str*] to
tuple[*str*, ...], since the values of an argument should be immutable after parsing.
+* The original bracket-syntax `FormatCodes` class has been renamed to `deprFormatCodes` and moved into a new `depr_format_codes` module.
+ All internal call sites in the library still use the deprecated implementation; both APIs are exported in parallel.
+ `deprFormatCodes` will be removed in a future release โ migrate to the new operator-based API at your convenience.
+
+
## 26.04.2026โ`v1.9.7`
-* Restructured CLI commands under a single `xulbux-lib` entry point:
- - `xulbux-lib` shows library info.
- - `xulbux-lib fc` (*new*) parses and renders a string's format codes as ANSI console output.
-* Added `.get()` method to `ParsedArgData` for safe index access on parsed argument values.
-* Added missing `__init__.py` files to the `base` and `cli` subpackages.
-* Fixed `ModuleNotFoundError` caused by `mypyc` compiling `__init__.py` files, which broke subpackage imports.
-* Simplified CI workflows to use `pip`'s build isolation instead of manually specifying build dependencies.
-* Fixed a small bug in `ProgressBar`, where it would only overwrite and not actually clear the previous line.
-* Added a new constant `ANSI.COLOR_VARIANTS_MAP`, which contains all possible color variants that can be used in formatting.
-* Made it possible to also pass console default colors to `title_bg_color` in `Console.log()`, instead of only custom RGBA or HEXA colors.
-* Added a new format key `link:โฆ` to `FormatCodes`, which allows you to create hyperlinks in the console output with the syntax `[link:URL](display text)`.
+* Restructured CLI commands under a single `xulbux-lib` entry point:
+ - `xulbux-lib` shows library info.
+ - `xulbux-lib fc` (*new*) parses and renders a string's format codes as ANSI console output.
+* Added `.get()` method to `ParsedArgData` for safe index access on parsed argument values.
+* Added missing `__init__.py` files to the `base` and `cli` subpackages.
+* Fixed `ModuleNotFoundError` caused by `mypyc` compiling `__init__.py` files, which broke subpackage imports.
+* Simplified CI workflows to use `pip`'s build isolation instead of manually specifying build dependencies.
+* Fixed a small bug in `ProgressBar`, where it would only overwrite and not actually clear the previous line.
+* Added a new constant `ANSI.COLOR_VARIANTS_MAP`, which contains all possible color variants that can be used in formatting.
+* Made it possible to also pass console default colors to `title_bg_color` in `Console.log()`, instead of only custom RGBA or HEXA colors.
+* Added a new format key `link:โฆ` to `FormatCodes`, which allows you to create hyperlinks in the console output with the syntax `[link:URL](display text)`.
**BREAKING CHANGES:**
-* The `ANSI.COLOR_MAP` constant is now a set for better lookup performance, as the color order doesn't matter there.
-* All `Console` methods that allow console default colors as input for their color params, now actually validate the given color, raising an error if it's not valid.
-* The default for `box_bg_color` in `Console.log_box_filled()` is now the console foreground color (`None`) instead of `br:green`.
+
+* The `ANSI.COLOR_MAP` constant is now a set for better lookup performance, as the color order doesn't matter there.
+* All `Console` methods that allow console default colors as input for their color params, now actually validate the given color, raising an error if it's not valid.
+* The default for `box_bg_color` in `Console.log_box_filled()` is now the console foreground color (`None`) instead of `br:green`.
## 13.04.2026โ`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.
+* 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 and/or keyword-only params, now actually enforce that by using the `/` and `*` syntax in the method definitions.
-* 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 type-tuple*).
-* Made the return value of `String.single_char_repeats()` always be *`int`* and not
*int* | *bool*.
+
+* All methods that should use positional-only and/or keyword-only params, now actually enforce that by using the `/` and `*` syntax in the method definitions.
+* 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 type-tuple*).
+* Made the return value of `String.single_char_repeats()` always be *`int`* and not
*int* | *bool*.
## 25.01.2026โ`v1.9.5`
-* 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.
- - `hostname` The network hostname of the current machine.
- - `username` The current user's username.
- - `os_name` The name of the operating system (*e.g.* `Windows`*,* `Linux`*, โฆ*).
- - `os_version` The version of the operating system.
- - `architecture` The CPU architecture (*e.g.* `x86_64`*,* `ARM`*, โฆ*).
- - `cpu_count` The number of CPU cores available.
- - `python_version` The Python version string (*e.g.* `3.10.4`).
-* Created a two new TypeAliases:
- - `ArgParseConfig` Matches the command-line-parsing configuration of a single argument.
- - `ArgParseConfigs` Matches the command-line-parsing configurations of multiple arguments, packed in a dictionary.
-* Added a new attribute `flag` to the `ArgData` TypedDict and the `ArgResult` class, which contains the specific flag that was found or `None` for positional args.
+* 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.
+ - `hostname` The network hostname of the current machine.
+ - `username` The current user's username.
+ - `os_name` The name of the operating system (*e.g.* `Windows`*,* `Linux`*, โฆ*).
+ - `os_version` The version of the operating system.
+ - `architecture` The CPU architecture (*e.g.* `x86_64`*,* `ARM`*, โฆ*).
+ - `cpu_count` The number of CPU cores available.
+ - `python_version` The Python version string (*e.g.* `3.10.4`).
+* Created a two new TypeAliases:
+ - `ArgParseConfig` Matches the command-line-parsing configuration of a single argument.
+ - `ArgParseConfigs` Matches the command-line-parsing configurations of multiple arguments, packed in a dictionary.
+* Added a new attribute `flag` to the `ArgData` TypedDict and the `ArgResult` class, which contains the specific flag that was found or `None` for positional args.
**BREAKING CHANGES:**
-* Rewrote `Console.get_args()` for a different parsing functionality:
- - Flagged values are now too saved to lists, so now only the `values` attribute is used for all argument types.
- - The results of parsed command-line arguments are also no longer differentiated between regular flagged arguments and positional `"before"`/`"after"` arguments.
- - The param `allow_spaces` was removed, and therefore a new param `flag_value_sep` was added, which specifies the character/s used to separate flags from their values.
- This means, flags can new **only** receive values when the separator is present (*e.g.* `--flag=value` *or* `--flag = value`).
-* Combined the custom TypedDict classes `ArgResultRegular` and `ArgResultPositional` into a single TypedDict class `ArgData`, which is now used for all parsed command-line arguments.
-* Renamed the classes `Args` and `ArgResult` to `ParsedArgs` and `ParsedArgData`, to better describe their purpose.
-* Renamed the attribute `is_positional` to `is_pos` everywhere, so its name isn't that long.
+
+* Rewrote `Console.get_args()` for a different parsing functionality:
+ - Flagged values are now too saved to lists, so now only the `values` attribute is used for all argument types.
+ - The results of parsed command-line arguments are also no longer differentiated between regular flagged arguments and positional `"before"`/`"after"` arguments.
+ - The param `allow_spaces` was removed, and therefore a new param `flag_value_sep` was added, which specifies the character/s used to separate flags from their values.
+ This means, flags can new **only** receive values when the separator is present (*e.g.* `--flag=value` *or* `--flag = value`).
+* Combined the custom TypedDict classes `ArgResultRegular` and `ArgResultPositional` into a single TypedDict class `ArgData`, which is now used for all parsed command-line arguments.
+* Renamed the classes `Args` and `ArgResult` to `ParsedArgs` and `ParsedArgData`, to better describe their purpose.
+* Renamed the attribute `is_positional` to `is_pos` everywhere, so its name isn't that long.
## 06.01.2026โ`v1.9.4`
-* Added a new base module `base.decorators` which contains custom decorators used throughout the library.
-* Made `mypy_extensions` an optional dependency by wrapping all uses of `mypy_extensions.mypyc_attr` in a custom decorator that acts as a no-op if `mypy_extensions` is not installed.
-* The methods from the `env_path` module that modify the PATH environment variable, no longer sort all paths alphabetically, but keep the original order, to not mess with the user's intended PATH order.
-* Added a new TypeAlias `PathsList` to the `base.types` module, which matches a list of paths as strings or `pathlib.Path` objects.
+* Added a new base module `base.decorators` which contains custom decorators used throughout the library.
+* Made `mypy_extensions` an optional dependency by wrapping all uses of `mypy_extensions.mypyc_attr` in a custom decorator that acts as a no-op if `mypy_extensions` is not installed.
+* The methods from the `env_path` module that modify the PATH environment variable, no longer sort all paths alphabetically, but keep the original order, to not mess with the user's intended PATH order.
+* Added a new TypeAlias `PathsList` to the `base.types` module, which matches a list of paths as strings or `pathlib.Path` objects.
**BREAKING CHANGES:**
-* Renamed the module `path` to `file_sys` and its main class `Path` to `FileSys`, so you can better use it alongside the built-in `pathlib.Path` class without always needing to import one of them under an alias.
-* Renamed most `FileSys` methods to better describe their functionality:
- - `Path.extend()` is now `FileSys.extend_path()`
- - `Path.extend_or_make()` is now `FileSys.extend_or_make_path()`
-* Renamed the param `use_closest_match` in `FileSys.extend_path()` and `FileSys.extend_or_make_path()` to `fuzzy_match`, since that name is more commonly used for that functionality.
-* Updated all library methods that work with paths to accept `pathlib.Path` objects additionally to strings, as path inputs.
-* Also, all library methods that return paths now return `pathlib.Path` objects instead of strings.
+
+* Renamed the module `path` to `file_sys` and its main class `Path` to `FileSys`, so you can better use it alongside the built-in `pathlib.Path` class without always needing to import one of them under an alias.
+* Renamed most `FileSys` methods to better describe their functionality:
+ - `Path.extend()` is now `FileSys.extend_path()`
+ - `Path.extend_or_make()` is now `FileSys.extend_or_make_path()`
+* Renamed the param `use_closest_match` in `FileSys.extend_path()` and `FileSys.extend_or_make_path()` to `fuzzy_match`, since that name is more commonly used for that functionality.
+* Updated all library methods that work with paths to accept `pathlib.Path` objects additionally to strings, as path inputs.
+* Also, all library methods that return paths now return `pathlib.Path` objects instead of strings.
@@ -110,110 +153,117 @@
**๐๐ช๐น๐น๐ ๐๐๐๐ ๐**
-* Added a new method `Color.str_to_hsla()` to parse HSLA colors from strings.
-* Changed the default syntax highlighting for `Data.to_str()` and therefore also `Data.print()` to use console default colors.
-* Added the missing but needed dunder methods to the `Args` and `ArgResult` classes and the `rgba`, `hsla` and `hexa` color objects for better usability and type checking.
-* Added three new methods to `Args`:
- - `get()` returns the argument result for a given alias, or a default value if not found
- - `existing()` yields only the existing arguments as tuples of `(alias, ArgResult)`
- - `missing()` yields only the missing arguments as tuples of `(alias, ArgResult)`
-* Added a new attribute `is_positional` to `ArgResult`, which indicates whether the argument is a positional argument or not.
-* The `ArgResult` class now also has a `dict()` method, which returns the argument result as a dictionary.
-* Added new properties `is_tty` and `supports_color` to the `Console` class, `home` to the `Path` class and `is_win` to the `System` class.
-* Added the option to add format specifiers to the `{current}`, `{total}` and `{percentage}` placeholders in the `bar_format` and `limited_bar_format` of `ProgressBar`.
-* Finally fixed the `C901 'Console.get_args' is too complex (39)` linting error by refactoring the method into its own helper class.
-* Changed the string- and repr-representations of the `rgba` and `hsla` color objects and newly implemented it for the `Args` and `ArgResult` classes.
-* Made internal, global constants, which's values never change, into `Final` constants for better type checking.
-* The names of all internal classes and methods are all no longer prefixed with a double underscore (`__`), but a single underscore (`_`) instead.
-* Changed all methods defined as `@staticmethod` to `@classmethod` where applicable, to improve inheritance capabilities.
-* Adjusted the whole library's type hints to be way more strict and accurate, using `mypy` as static type checker.
-* Change the class-property definitions to be defined via `metaclass` and using `@property` decorators, to make them compatible with `mypyc`.
-* Unnest all the nested methods in the whole library for compatibility with `mypyc`.
-* The library is now compiled using `mypyc` when installing, which makes it run significantly faster. Benchmarking results:
- - Simple methods like data and color operations had a speed improvement of around 50%.
- - Complex methods like console logging had a speed improvement of up to 230%!
+* Added a new method `Color.str_to_hsla()` to parse HSLA colors from strings.
+* Changed the default syntax highlighting for `Data.to_str()` and therefore also `Data.print()` to use console default colors.
+* Added the missing but needed dunder methods to the `Args` and `ArgResult` classes and the `rgba`, `hsla` and `hexa` color objects for better usability and type checking.
+* Added three new methods to `Args`:
+ - `get()` returns the argument result for a given alias, or a default value if not found
+ - `existing()` yields only the existing arguments as tuples of `(alias, ArgResult)`
+ - `missing()` yields only the missing arguments as tuples of `(alias, ArgResult)`
+* Added a new attribute `is_positional` to `ArgResult`, which indicates whether the argument is a positional argument or not.
+* The `ArgResult` class now also has a `dict()` method, which returns the argument result as a dictionary.
+* Added new properties `is_tty` and `supports_color` to the `Console` class, `home` to the `Path` class and `is_win` to the `System` class.
+* Added the option to add format specifiers to the `{current}`, `{total}` and `{percentage}` placeholders in the `bar_format` and `limited_bar_format` of `ProgressBar`.
+* Finally fixed the `C901 'Console.get_args' is too complex (39)` linting error by refactoring the method into its own helper class.
+* Changed the string- and repr-representations of the `rgba` and `hsla` color objects and newly implemented it for the `Args` and `ArgResult` classes.
+* Made internal, global constants, which's values never change, into `Final` constants for better type checking.
+* The names of all internal classes and methods are all no longer prefixed with a double underscore (`__`), but a single underscore (`_`) instead.
+* Changed all methods defined as `@staticmethod` to `@classmethod` where applicable, to improve inheritance capabilities.
+* Adjusted the whole library's type hints to be way more strict and accurate, using `mypy` as static type checker.
+* Change the class-property definitions to be defined via `metaclass` and using `@property` decorators, to make them compatible with `mypyc`.
+* Unnest all the nested methods in the whole library for compatibility with `mypyc`.
+* The library is now compiled using `mypyc` when installing, which makes it run significantly faster. Benchmarking results:
+ - Simple methods like data and color operations had a speed improvement of around 50%.
+ - Complex methods like console logging had a speed improvement of up to 230%!
**BREAKING CHANGES:**
-* Renamed `Data.to_str()` to `Data.render()`, since that describes its functionality better (*especially with the syntax highlighting option*).
-* Renamed the constant `ANSI.ESCAPED_CHAR` to `ANSI.CHAR_ESCAPED` for better consistency with the other constant names.
-* Removed the general `Pattern` and `Match` type aliases from the `base.types` module (*they are pointless since you should always use a specific type and not "type1 OR typeB"*).
-* Removed the `_` prefix from the param `_syntax_highlighting` in `Data.render()`, since it's no longer just for internal use.
+
+* Renamed `Data.to_str()` to `Data.render()`, since that describes its functionality better (*especially with the syntax highlighting option*).
+* Renamed the constant `ANSI.ESCAPED_CHAR` to `ANSI.CHAR_ESCAPED` for better consistency with the other constant names.
+* Removed the general `Pattern` and `Match` type aliases from the `base.types` module (*they are pointless since you should always use a specific type and not "type1 OR typeB"*).
+* Removed the `_` prefix from the param `_syntax_highlighting` in `Data.render()`, since it's no longer just for internal use.
## 16.12.2025โ`v1.9.2`
-* Added a new class `LazyRegex` to the `regex` module, which is used to define regex patterns that are only compiled when they are used for the first time.
-* Removed unnecessary character escaping in the precompiled regex patterns in the `console` module.
-* Removed all the runtime type-checks that can also be checked using static type-checking tools, since you're supposed to use type checkers in modern python anyway, and to improve performance.
-* Renamed the internal class method `FormatCodes.__config_console()` to `FormatCodes._config_console()` to make it callable, but still indicate that it's internal.
-* Fixed a small bug where `Console.log_box_โฆ()` would crash, when calling it without providing any `*values` (*content for inside the box*).
+* Added a new class `LazyRegex` to the `regex` module, which is used to define regex patterns that are only compiled when they are used for the first time.
+* Removed unnecessary character escaping in the precompiled regex patterns in the `console` module.
+* Removed all the runtime type-checks that can also be checked using static type-checking tools, since you're supposed to use type checkers in modern python anyway, and to improve performance.
+* Renamed the internal class method `FormatCodes.__config_console()` to `FormatCodes._config_console()` to make it callable, but still indicate that it's internal.
+* Fixed a small bug where `Console.log_box_โฆ()` would crash, when calling it without providing any `*values` (*content for inside the box*).
**BREAKING CHANGES:**
-* The arguments when calling `Console.get_args()` are no longer specified in a single dictionary, but now each argument is passed as a separate keyword argument.
- You can still use a dictionary just fine by simply unpacking it with `**`, like this:
- ```python
- Console.get_args(**{"arg": {"-a", "--arg"}})
- ```
-* Replaced the internal `_COMPILED` regex pattern dictionaries with `LazyRegex` objects so it won't compile all regex patterns on library import, but only when they are used for the first time, which improves the library's import time.
-* Renamed the internal `_COMPILED` regex pattern dictionaries to `_PATTERNS` for better clarity.
-* Removed the import of the `ProgressBar` class from the `__init__.py` file, since it's not an important main class that should be imported directly.
-* Renamed the constant `CLR` to `CLI_COLORS` and the constant `HELP` to `CLI_HELP` in the `cli.help` module.
-* Changed the default value of the `strip_spaces` param in `Regex.brackets()` from `True` to `False`, since this is more intuitive behavior.
+
+* The arguments when calling `Console.get_args()` are no longer specified in a single dictionary, but now each argument is passed as a separate keyword argument.
+ You can still use a dictionary just fine by simply unpacking it with `**`, like this:
+
+ ```python
+ Console.get_args(**{"arg": {"-a", "--arg"}})
+ ```
+
+* Replaced the internal `_COMPILED` regex pattern dictionaries with `LazyRegex` objects so it won't compile all regex patterns on library import, but only when they are used for the first time, which improves the library's import time.
+* Renamed the internal `_COMPILED` regex pattern dictionaries to `_PATTERNS` for better clarity.
+* Removed the import of the `ProgressBar` class from the `__init__.py` file, since it's not an important main class that should be imported directly.
+* Renamed the constant `CLR` to `CLI_COLORS` and the constant `HELP` to `CLI_HELP` in the `cli.help` module.
+* Changed the default value of the `strip_spaces` param in `Regex.brackets()` from `True` to `False`, since this is more intuitive behavior.
## 26.11.2025โ`v1.9.1`
-* Unified the module and class docstring styles throughout the whole library.
-* Moved the Protocol `ProgressUpdater` from the `console` module to the `types` module.
-* Added throttling to the `ProgressBar` update methods to impact the actual process' performance as little as possible.
-* Added a new class `Spinner` to the `console` module, which is used to display a spinner animation in the console during an ongoing process.
+* Unified the module and class docstring styles throughout the whole library.
+* Moved the Protocol `ProgressUpdater` from the `console` module to the `types` module.
+* Added throttling to the `ProgressBar` update methods to impact the actual process' performance as little as possible.
+* Added a new class `Spinner` to the `console` module, which is used to display a spinner animation in the console during an ongoing process.
**BREAKING CHANGES:**
-* Made the value input into the params `bar_format` and `limited_bar_format` of `ProgressBar` be a list/tuple of strings instead of a single string, so the user can define multiple formats for different console widths.
-* Added a new param
sep: *str* = " " to the `ProgressBar` class, which is used to join multiple bar-format strings.
-* Renamed the class property `Console.wh` to `Console.size`, since it describes the property better.
-* Renamed the class property `Console.usr` to `Console.user`, since it describes the property better.
-* Added missing type checking to methods in the `path` module.
+
+* Made the value input into the params `bar_format` and `limited_bar_format` of `ProgressBar` be a list/tuple of strings instead of a single string, so the user can define multiple formats for different console widths.
+* Added a new param
sep: *str* = " " to the `ProgressBar` class, which is used to join multiple bar-format strings.
+* Renamed the class property `Console.wh` to `Console.size`, since it describes the property better.
+* Renamed the class property `Console.usr` to `Console.user`, since it describes the property better.
+* Added missing type checking to methods in the `path` module.
## 21.11.2025โ`v1.9.0`โBig Update ๐
-* Standardized the docstrings for all public methods in the whole library to use the same style and structure.
-* Replaced left over single quotes with double quotes for consistency.
-* Fixed a bug inside `Data.remove_empty_items()`, where types other than strings where passed to `String.is_empty()`, which caused an exception.
-* Refactored/reformatted the code of the whole library, to introduce more clear code structure with more room to breathe.
-* Made the really complex regex patterns in the `Regex` class all multi-line for better readability.
-* Added a new internal method `Regex._clean()`, which is used to clean up the regex patterns, defined as multi-line strings.
-* Moved custom exception classes to their own file `base/exceptions.py`, so the user can easily import them all from the same place.
-* Moved custom types to their own file `base/types.py`, so the user can easily import them all from the same place.
-* Removed unnecessary duplicate code in several methods throughout the library.
-* Introduced some minor performance improvements in a few methods, that might be called very often in a short time span.
-* Added a small description to the docstrings of all modules and their main classes.
+* Standardized the docstrings for all public methods in the whole library to use the same style and structure.
+* Replaced left over single quotes with double quotes for consistency.
+* Fixed a bug inside `Data.remove_empty_items()`, where types other than strings where passed to `String.is_empty()`, which caused an exception.
+* Refactored/reformatted the code of the whole library, to introduce more clear code structure with more room to breathe.
+* Made the really complex regex patterns in the `Regex` class all multi-line for better readability.
+* Added a new internal method `Regex._clean()`, which is used to clean up the regex patterns, defined as multi-line strings.
+* Moved custom exception classes to their own file `base/exceptions.py`, so the user can easily import them all from the same place.
+* Moved custom types to their own file `base/types.py`, so the user can easily import them all from the same place.
+* Removed unnecessary duplicate code in several methods throughout the library.
+* Introduced some minor performance improvements in a few methods, that might be called very often in a short time span.
+* Added a small description to the docstrings of all modules and their main classes.
**BREAKING CHANGES:**
-* The `find_args` param from the method `Console.get_args()` now only accepts sets for the flags instead of lists/tuples, since the order of flags doesn't matter and sets have better performance for lookups.
-* Added missing type checking to all public methods in the whole library, so now they will all throw errors if the params aren't of the expected type.
-* Removed the second definitions of constants in with lowercase names in the `ANSI` class inside the `consts` module, so now you can only access them with their uppercase names (*e.g.* `ANSI.CHAR` instead of `ANSI.char`).
+
+* The `find_args` param from the method `Console.get_args()` now only accepts sets for the flags instead of lists/tuples, since the order of flags doesn't matter and sets have better performance for lookups.
+* Added missing type checking to all public methods in the whole library, so now they will all throw errors if the params aren't of the expected type.
+* Removed the second definitions of constants in with lowercase names in the `ANSI` class inside the `consts` module, so now you can only access them with their uppercase names (*e.g.* `ANSI.CHAR` instead of `ANSI.char`).
## 14.11.2025โ`v1.8.5`
-* Made the help command `xulbux-help` new primarily use console default colors so it fits the user's console theme.
-* Changed the default `box_bg_color` in `Console.log_box_filled()` from `green` to `br:green`.
-* Fixed a bug in all methods of `FormatCodes`, where as soon as you used more than a single modifier format code (*e.g.* `[ll]` *or* `[++]`), it was treated as invalid and ignored.
-* Added a new method `FormatCodes.escape()` which will escape all valid formatting codes in a string.
-* Again refactored the whole `CHANGELOG.md` to use actual sentences and add a `BREAKING CHANGES` section to more clearly highlight breaking changes.
+* Made the help command `xulbux-help` new primarily use console default colors so it fits the user's console theme.
+* Changed the default `box_bg_color` in `Console.log_box_filled()` from `green` to `br:green`.
+* Fixed a bug in all methods of `FormatCodes`, where as soon as you used more than a single modifier format code (*e.g.* `[ll]` *or* `[++]`), it was treated as invalid and ignored.
+* Added a new method `FormatCodes.escape()` which will escape all valid formatting codes in a string.
+* Again refactored the whole `CHANGELOG.md` to use actual sentences and add a `BREAKING CHANGES` section to more clearly highlight breaking changes.
**BREAKING CHANGES:**
-* Removed the `*c` and `*color` formatting codes, since the user should just use the code `default` to achieve the exact same instead.
-* Renamed the method `FormatCodes.remove_formatting()` to `FormatCodes.remove()`.
+
+* Removed the `*c` and `*color` formatting codes, since the user should just use the code `default` to achieve the exact same instead.
+* Renamed the method `FormatCodes.remove_formatting()` to `FormatCodes.remove()`.
@@ -222,348 +272,362 @@
**๐ข๐ฒ๐ท๐ฐ๐ต๐ฎ๐ผ ๐๐ช๐ ๐ฅ๐**
-* Adjusted `Regex.hsla_str()` to not include optional degree (`ยฐ`) and percent (`%`) symbols in the captured groups.
-* Fixed that `Regex.hexa_str()` couldn't match HEXA colors anywhere inside a string, but only if the whole string was just the HEXA color.
-* Added `_ArgResultRegular` and `_ArgResultPositional` TypedDict classes for better type hints in `Args.dict()` and `Args.items()` methods.
+* Adjusted `Regex.hsla_str()` to not include optional degree (`ยฐ`) and percent (`%`) symbols in the captured groups.
+* Fixed that `Regex.hexa_str()` couldn't match HEXA colors anywhere inside a string, but only if the whole string was just the HEXA color.
+* Added `_ArgResultRegular` and `_ArgResultPositional` TypedDict classes for better type hints in `Args.dict()` and `Args.items()` methods.
**BREAKING CHANGES:**
-* The method `Console.get_args()` no longer tries to convert found arg values to their respective types, since that caused too many unwanted, wrong type conversions.
-* `ArgResult` now has separate properties for different argument types to improve type safety and eliminate the need for type casting when accessing argument values:
- -
value: Optional[*str*] for regular flagged arguments
- -
values: list[*str*] for positional `"before"`/`"after"` arguments
+
+* The method `Console.get_args()` no longer tries to convert found arg values to their respective types, since that caused too many unwanted, wrong type conversions.
+* `ArgResult` now has separate properties for different argument types to improve type safety and eliminate the need for type casting when accessing argument values:
+ -
value: Optional[*str*] for regular flagged arguments
+ -
values: list[*str*] for positional `"before"`/`"after"` arguments
## 08.10.2025โ`v1.8.3`
-* Adjusted the look of the prompts and inputs of the `System.check_libs()` method.
-* Added a new param to `System.check_libs()`:
-
missing_libs_msgs: tuple[str, str] = (โฆ) two messages: The first one is displayed when missing libraries are found. The second one is the confirmation message before installing missing libraries.
-* Adjusted error messages throughout the whole library to all be structured about the same.
-* Fixed a small bug in `FormatCodes.__config_console()`, where it would cause an exception, because it tried to configure Windows specific console settings on non-Windows systems.
-* The `Console.get_args()` method will now treat everything as values (*even if it starts with* `-` *or* `--`) unless it's specified in the `find_args` param.
-* Added two new params to all the `Console.log()` presets:
- -
exit_code: *int* = 0 the exit code to use if `exit` is true
- -
reset_ansi: *bool* = True whether to reset all ANSI formatting after pausing/exiting or not
-* Made the type hints and value checks for `Console.get_args()` more strict.
-* You can now insert horizontal rules inside a `Console.log_box_bordered()` by putting `{hr}` in the text.
-* Made it possible to also update the title within a `ProgressBar.progress_context()` using the returned callable with the new kwarg `label`.
+* Adjusted the look of the prompts and inputs of the `System.check_libs()` method.
+* Added a new param to `System.check_libs()`:
+
missing_libs_msgs: tuple[str, str] = (โฆ) two messages: The first one is displayed when missing libraries are found. The second one is the confirmation message before installing missing libraries.
+* Adjusted error messages throughout the whole library to all be structured about the same.
+* Fixed a small bug in `FormatCodes.__config_console()`, where it would cause an exception, because it tried to configure Windows specific console settings on non-Windows systems.
+* The `Console.get_args()` method will now treat everything as values (*even if it starts with* `-` *or* `--`) unless it's specified in the `find_args` param.
+* Added two new params to all the `Console.log()` presets:
+ -
exit_code: *int* = 0 the exit code to use if `exit` is true
+ -
reset_ansi: *bool* = True whether to reset all ANSI formatting after pausing/exiting or not
+* Made the type hints and value checks for `Console.get_args()` more strict.
+* You can now insert horizontal rules inside a `Console.log_box_bordered()` by putting `{hr}` in the text.
+* Made it possible to also update the title within a `ProgressBar.progress_context()` using the returned callable with the new kwarg `label`.
**BREAKING CHANGES:**
-* Reordered the params of `Console.pause_exit()` to be more logical.
+
+* Reordered the params of `Console.pause_exit()` to be more logical.
## 11.09.2025โ`v1.8.2`
-* The client command `xulbux-help` now tells you that there's a newer version of the library available, if you're not using the latest version.
-* Added two new params to `Console.input()`:
- -
default_val: Optional[T] = None the default value to return if the input is empty
- -
output_type: type[T] = *str* the type (*class*) to convert the input to before returning it
-* Added a new class to `ProgressBar` to the `console` module.
-* Made small performance improvement in `FormatCodes.to_ansi()`.
-* Added missing docstrings to several public class variables.
-* Added the missing tests for methods in the `console` module.
-* Added test for the last two modules that didn't have test until now: `regex` and `system`.
+* The client command `xulbux-help` now tells you that there's a newer version of the library available, if you're not using the latest version.
+* Added two new params to `Console.input()`:
+ -
default_val: Optional[T] = None the default value to return if the input is empty
+ -
output_type: type[T] = *str* the type (*class*) to convert the input to before returning it
+* Added a new class to `ProgressBar` to the `console` module.
+* Made small performance improvement in `FormatCodes.to_ansi()`.
+* Added missing docstrings to several public class variables.
+* Added the missing tests for methods in the `console` module.
+* Added test for the last two modules that didn't have test until now: `regex` and `system`.
**BREAKING CHANGES:**
-* Spaces between a format code and the auto-reset-brackets are no longer allowed, so `[red]โฃ(text)` will not be automatically reset and output as `โฃ(text)`.
+
+* Spaces between a format code and the auto-reset-brackets are no longer allowed, so `[red]โฃ(text)` will not be automatically reset and output as `โฃ(text)`.
## 20.08.2025โ`v1.8.1`
-* **
HOTFIX: Fixed a critical bug which caused the package to not install properly and make the whole library not work.**
-* Fixed several small bugs regarding the tabs and text wrapping inside `Console.log()`.
-* Added two new params to `Console.log()`:
- -
title_px: *int* = 1 the horizontal padding (*in chars*) to the title (*if* `title_bg_color` *is set*)
- -
title_mx: *int* = 2 the horizontal margin (*in chars*) to the title
+* **
HOTFIX: Fixed a critical bug which caused the package to not install properly and make the whole library not work.**
+* Fixed several small bugs regarding the tabs and text wrapping inside `Console.log()`.
+* Added two new params to `Console.log()`:
+ -
title_px: *int* = 1 the horizontal padding (*in chars*) to the title (*if* `title_bg_color` *is set*)
+ -
title_mx: *int* = 2 the horizontal margin (*in chars*) to the title
**BREAKING CHANGES:**
-* Renamed the param `_console_tabsize` form the method `Console.log()` to `tab_size`, and it will now just set the size for the log directly instead of specifying, what the console's tab size is.
+
+* Renamed the param `_console_tabsize` form the method `Console.log()` to `tab_size`, and it will now just set the size for the log directly instead of specifying, what the console's tab size is.
## 28.08.2025โ`v1.8.0`โ**โ ๏ธ This release is broken!**
-* New options for the param `find_args` from the method `Console.get_args()`:
- Previously you could only input a dictionary with items like `"alias_name": ["-f", "--flag"]` that specify an arg's alias and the flags that correspond to it.
- New, instead of flags, you can also once use the literal `"before"` and once `"after"`, which corresponds to all non-flagged values before or after all flagged values.
-* Changed the default `default_color` for all `Console` class input methods to `None`.
-* Now `prompt` from `Console.pause_exit()` also supports custom formatting codes and the method new pauses per default (*without exiting*).
+* New options for the param `find_args` from the method `Console.get_args()`:
+ Previously you could only input a dictionary with items like `"alias_name": ["-f", "--flag"]` that specify an arg's alias and the flags that correspond to it.
+ New, instead of flags, you can also once use the literal `"before"` and once `"after"`, which corresponds to all non-flagged values before or after all flagged values.
+* Changed the default `default_color` for all `Console` class input methods to `None`.
+* Now `prompt` from `Console.pause_exit()` also supports custom formatting codes and the method new pauses per default (*without exiting*).
**BREAKING CHANGES:**
-* The method `Console.restricted_input()` now returns an empty string instead of `None` if the user didn't input anything.
-* Completely rewrote `Console.restricted_input()`, so now it's actually usable, and renamed it to just `Console.input()`.
-* Removed method `Console.pwd_input()`, since you can now simply use `Console.input(mask_char="*")` instead, which does the exact same thing.
-* Removed the CLI command `xx-help`, since it was redundant because there's already the CLI command `xulbux-help`.
-* Renamed the previously internal module `_consts_` to `consts` and made it accessible via `from xulbux.base.consts import โฆ`, since you should be able to use library constants without them being ยซinternalยป.
-* Removed the `xx_` from all the library modules since it's redundant, and without it the imports look more professional and cleaner.
-* The constants form inside the `consts` module are now all uppercase (*except the class methods*), to make clear that they're constants.
-* Removed the wildcard imports from the `__init__.py` file, so now you can only access the main classes directly with `from xulbux import โฆ` and for the rest you have to import the specific module first.
+
+* The method `Console.restricted_input()` now returns an empty string instead of `None` if the user didn't input anything.
+* Completely rewrote `Console.restricted_input()`, so now it's actually usable, and renamed it to just `Console.input()`.
+* Removed method `Console.pwd_input()`, since you can now simply use `Console.input(mask_char="*")` instead, which does the exact same thing.
+* Removed the CLI command `xx-help`, since it was redundant because there's already the CLI command `xulbux-help`.
+* Renamed the previously internal module `_consts_` to `consts` and made it accessible via `from xulbux.base.consts import โฆ`, since you should be able to use library constants without them being ยซinternalยป.
+* Removed the `xx_` from all the library modules since it's redundant, and without it the imports look more professional and cleaner.
+* The constants form inside the `consts` module are now all uppercase (*except the class methods*), to make clear that they're constants.
+* Removed the wildcard imports from the `__init__.py` file, so now you can only access the main classes directly with `from xulbux import โฆ` and for the rest you have to import the specific module first.
## 29.07.2025โ`v1.7.3`
-* Added a new param to the methods `Console.log_box_filled()` and `Console.log_box_bordered()`:
-
indent: *int* = 0 the indentation of the box (in chars)
-* Fixed a bug in `Console.log_box_filled()` where the box background color would sometimes not stop at the box's edge, but would continue to the end of the console line.
+* Added a new param to the methods `Console.log_box_filled()` and `Console.log_box_bordered()`:
+
indent: *int* = 0 the indentation of the box (in chars)
+* Fixed a bug in `Console.log_box_filled()` where the box background color would sometimes not stop at the box's edge, but would continue to the end of the console line.
**BREAKING CHANGES:**
-* Removed the param `title_bg_color` from the `Console.log()` preset methods, since that is part of the preset and doesn't need to be changed by the user.
+
+* Removed the param `title_bg_color` from the `Console.log()` preset methods, since that is part of the preset and doesn't need to be changed by the user.
## 17.06.2025โ`v1.7.2`
-* The `Console.w`, `Console.h` and `Console.wh` class properties now return a default size if there is no console, instead of throwing an error.
-* It wasn't actually possible to use default console-colors (*e.g.* `"red"`, `"green"`, โฆ) for the color params in `Console.log()` so that option was completely removed again.
-* Upgraded the speed of `FormatCodes.to_ansi()` by adding the internal ability to skip the `default_color` validation.
-* Fixed type hints for the whole library.
-* Fixed a small bug in `Console.pause_exit()`, where the key, pressed to unpause wasn't suppressed, so it was written into the next console input after unpausing.
+* The `Console.w`, `Console.h` and `Console.wh` class properties now return a default size if there is no console, instead of throwing an error.
+* It wasn't actually possible to use default console-colors (*e.g.* `"red"`, `"green"`, โฆ) for the color params in `Console.log()` so that option was completely removed again.
+* Upgraded the speed of `FormatCodes.to_ansi()` by adding the internal ability to skip the `default_color` validation.
+* Fixed type hints for the whole library.
+* Fixed a small bug in `Console.pause_exit()`, where the key, pressed to unpause wasn't suppressed, so it was written into the next console input after unpausing.
## 11.06.2025โ`v1.7.1`
-* Fixed an issue with the `Color.is_valid_โฆ()` and `Color.is_valid()` methods, where you were not able to input any color without a type mismatch.
-* Added a new method `Console.log_box_bordered()`, which does the same as `Console.log_box_filled()`, but with a border instead of a background color.
-* The module `xx_format_codes` now treats the `[*]` to-default-color-reset as a normal full-reset, when no `default_color` is set, instead of just counting it as an invalid format code.
-* Fixed bug where entering a color as HEX integer in the color params of the methods `Console.log()`, `Console.log_box_filled()` and `Console.log_box_bordered()` would not work, because it was not properly converted to a format code.
-* You can now use default console colors (*e.g.* `"red"`, `"green"`, โฆ) for the color params in `Console.log()`.
-* The methods `Console.log_box_filled()` and `Console.log_box_bordered()` no longer right-strip spaces, so you can make multiple log boxes the same width, by adding spaces to the end of the text.
+* Fixed an issue with the `Color.is_valid_โฆ()` and `Color.is_valid()` methods, where you were not able to input any color without a type mismatch.
+* Added a new method `Console.log_box_bordered()`, which does the same as `Console.log_box_filled()`, but with a border instead of a background color.
+* The module `xx_format_codes` now treats the `[*]` to-default-color-reset as a normal full-reset, when no `default_color` is set, instead of just counting it as an invalid format code.
+* Fixed bug where entering a color as HEX integer in the color params of the methods `Console.log()`, `Console.log_box_filled()` and `Console.log_box_bordered()` would not work, because it was not properly converted to a format code.
+* You can now use default console colors (*e.g.* `"red"`, `"green"`, โฆ) for the color params in `Console.log()`.
+* The methods `Console.log_box_filled()` and `Console.log_box_bordered()` no longer right-strip spaces, so you can make multiple log boxes the same width, by adding spaces to the end of the text.
**BREAKING CHANGES:**
-* Renamed the method `Console.log_box()` to `Console.log_box_filled()`.
+
+* Renamed the method `Console.log_box()` to `Console.log_box_filled()`.
## 28.05.2025โ`v1.7.0`
-* Fixed a small bug in `Console.log()` where empty linebreaks where removed.
-* Corrected and added missing type hints for the whole library.
-* Fixed possibly unbound variables for the whole library.
-* Updated the client command `xulbux-help`.
+* Fixed a small bug in `Console.log()` where empty linebreaks where removed.
+* Corrected and added missing type hints for the whole library.
+* Fixed possibly unbound variables for the whole library.
+* Updated the client command `xulbux-help`.
## 30.04.2025โ`v1.6.9`
-* Added a new param to the methods `FormatCodes.remove_ansi()` and `FormatCodes.remove_formatting()`:
-
_ignore_linebreaks: *bool* = False whether to include linebreaks in the removal positions or not
-* Added a new param to method `Color.luminance()` and to the `.grayscale()` method of all color types:
-
method: *str* = "wcag2" the luminance calculation method to use
-* Added a new param to the method `File.rename_extension()`:
-
full_extension: *bool* = False whether to treat everything behind the first `.` as the extension or everything behind the last `.`
-* Fixed a small bug in `Console.log_box()` where the leading spaces where removed from the box content.
-* You can now assign default values to args in `Console.get_args()`.
+* Added a new param to the methods `FormatCodes.remove_ansi()` and `FormatCodes.remove_formatting()`:
+
_ignore_linebreaks: *bool* = False whether to include linebreaks in the removal positions or not
+* Added a new param to method `Color.luminance()` and to the `.grayscale()` method of all color types:
+
method: *str* = "wcag2" the luminance calculation method to use
+* Added a new param to the method `File.rename_extension()`:
+
full_extension: *bool* = False whether to treat everything behind the first `.` as the extension or everything behind the last `.`
+* Fixed a small bug in `Console.log_box()` where the leading spaces where removed from the box content.
+* You can now assign default values to args in `Console.get_args()`.
**BREAKING CHANGES:**
-* Changed the params in `Json.create()`:
- -
new_file: *str* = "config" is now the first param and
content: *dict* the second one
- -
new_file: *str* = "config" is now called
json_file: *str* with no default value
-* The methods `Json.update()` and `Data.set_value_by_path_id()` now intake a dictionary as `update_values` param, instead of a list of strings.
-* Renamed param `correct_path` in `Path.extend()` and param `correct_paths` in `File.extend_or_make_path()` to `use_closest_match`, since this name describes its functionality better.
-* Moved the method `extend_or_make_path()` from the `xx_file` module to the `xx_path` module and renamed it to `extend_or_make()`.
+
+* Changed the params in `Json.create()`:
+ -
new_file: *str* = "config" is now the first param and
content: *dict* the second one
+ -
new_file: *str* = "config" is now called
json_file: *str* with no default value
+* The methods `Json.update()` and `Data.set_value_by_path_id()` now intake a dictionary as `update_values` param, instead of a list of strings.
+* Renamed param `correct_path` in `Path.extend()` and param `correct_paths` in `File.extend_or_make_path()` to `use_closest_match`, since this name describes its functionality better.
+* Moved the method `extend_or_make_path()` from the `xx_file` module to the `xx_path` module and renamed it to `extend_or_make()`.
## 18.03.2025โ`v1.6.8`
-* Made it possible to escape formatting codes by putting a slash (`/` *or* `\\`) at the beginning inside the brackets (*e.g.* `[/red]`).
-* New methods for `Args` (*the returned object from* `Console.get_args()`):
- - The `len()` function can now be used on `Args` (*the returned object from* `Console.get_args()`).
- - The `Args` object now also has the dict like methods `.keys()`, `.values()` and `.items()`.
- - You can also get the args as a dict with the `.dict()` method.
- - You can now use the `in` operator on `Args`.
-* New methods for `ArgResult` (*a single arg-object from inside `Args`):
- - You can now use the `bool()` function on `ArgResult` to directly see if the arg exists.
-* The methods `FormatCodes.remove_ansi()` and `FormatCodes.remove_formatting()` now have a second param
get_removals: *bool* = False:
- If this param is true additionally to the cleaned string, a list of tuples will be returned, where tuple contains the position of the removed formatting/ansi code and the removed code.
-* Fixed a bug in the line wrapping in all logging methods from the `xx_console` module.
-* Added a new param to the method `Console.get_args()`:
-
allow_spaces: *bool* = False whether to take spaces as separator of arg values or as part of an arg value
+* Made it possible to escape formatting codes by putting a slash (`/` *or* `\\`) at the beginning inside the brackets (*e.g.* `[/red]`).
+* New methods for `Args` (*the returned object from* `Console.get_args()`):
+ - The `len()` function can now be used on `Args` (*the returned object from* `Console.get_args()`).
+ - The `Args` object now also has the dict like methods `.keys()`, `.values()` and `.items()`.
+ - You can also get the args as a dict with the `.dict()` method.
+ - You can now use the `in` operator on `Args`.
+* New methods for `ArgResult` (*a single arg-object from inside `Args`):
+ - You can now use the `bool()` function on `ArgResult` to directly see if the arg exists.
+* The methods `FormatCodes.remove_ansi()` and `FormatCodes.remove_formatting()` now have a second param
get_removals: *bool* = False:
+ If this param is true additionally to the cleaned string, a list of tuples will be returned, where tuple contains the position of the removed formatting/ansi code and the removed code.
+* Fixed a bug in the line wrapping in all logging methods from the `xx_console` module.
+* Added a new param to the method `Console.get_args()`:
+
allow_spaces: *bool* = False whether to take spaces as separator of arg values or as part of an arg value
## 26.02.2025โ`v1.6.7`
-* Made the staticmethod `System.is_elevated()` into a class property, which now can be accessed as `System.is_elevated`.
-* The method `File.create()` now throws a custom `SameContentFileExistsError` exception if a file with the same name and content already exists.
-* Added a bunch more docstrings to class properties and library constants.
+* Made the staticmethod `System.is_elevated()` into a class property, which now can be accessed as `System.is_elevated`.
+* The method `File.create()` now throws a custom `SameContentFileExistsError` exception if a file with the same name and content already exists.
+* Added a bunch more docstrings to class properties and library constants.
**BREAKING CHANGES:**
-* Restructured the object returned from `Console.get_args()`:
- Before, you accessed an arg's result with `args["
"]["value"]` and `args[""]["exists"]`.
- Now, you can directly access the result with `args..value` and `args..exists`.
-* Made the method Path.get(*cwd*=True) or Path.get(*base_dir*=True) into two class properties, which now can be accessed as `Path.cwd` and `Path.script_dir`.
+
+* Restructured the object returned from `Console.get_args()`:
+ Before, you accessed an arg's result with `args[""]["value"]` and `args[""]["exists"]`.
+ Now, you can directly access the result with `args..value` and `args..exists`.
+* Made the method Path.get(*cwd*=True) or Path.get(*base_dir*=True) into two class properties, which now can be accessed as `Path.cwd` and `Path.script_dir`.
## 17.02.2025โ`v1.6.6`
-* Added a new method `Console.multiline_input()`.
-* Added two new params to the method `Console.log_box()`:
- - w_padding: *int* = 2 the horizontal padding (*in chars*) to the box content
- - w_full: *bool* = False whether to make the box be the full console width or not
-* Fixed a small bug in `Data.print()` where two params passed to `Data.to_str()` where swapped, which caused an error.
-* The method `Data.print()` now per default syntax highlights the console output:
- The syntax highlighting colors and styles can now be customized via the new param syntax_highlighting: dict[*str*, *str*] = {โฆ}.
-* Added two new methods `Data.serialize_bytes()` and `Data.deserialize_bytes()`.
-* Made the method `String.to_type()` be able to also interpret and convert large complex structures.
-* Added the new parameter strip_spaces: *bool* = True to the method `Regex.brackets()` which makes it possible to not ignore spaces around the content inside the brackets.
-* Adjusted the `Console.log_box()` method, so the box background can't be reset to nothing any more.
-* Added a new param to `Console.log()` (*and therefore also to every* `Console.log()` *preset*):
- format_linebreaks: *bool* = True when true, indents the text after every linebreak to the level of the previous text
+* Added a new method `Console.multiline_input()`.
+* Added two new params to the method `Console.log_box()`:
+ - w_padding: *int* = 2 the horizontal padding (*in chars*) to the box content
+ - w_full: *bool* = False whether to make the box be the full console width or not
+* Fixed a small bug in `Data.print()` where two params passed to `Data.to_str()` where swapped, which caused an error.
+* The method `Data.print()` now per default syntax highlights the console output:
+ The syntax highlighting colors and styles can now be customized via the new param syntax_highlighting: dict[*str*, *str*] = {โฆ}.
+* Added two new methods `Data.serialize_bytes()` and `Data.deserialize_bytes()`.
+* Made the method `String.to_type()` be able to also interpret and convert large complex structures.
+* Added the new parameter strip_spaces: *bool* = True to the method `Regex.brackets()` which makes it possible to not ignore spaces around the content inside the brackets.
+* Adjusted the `Console.log_box()` method, so the box background can't be reset to nothing any more.
+* Added a new param to `Console.log()` (*and therefore also to every* `Console.log()` *preset*):
+ format_linebreaks: *bool* = True when true, indents the text after every linebreak to the level of the previous text
**BREAKING CHANGES:**
-* Restructured the `_consts_` library constants to use `@dataclass` classes (*and simpler structured classes*) as much as possible.
-* Renamed the `DEFAULT` class from the `_consts_` to `COLOR`, whose colors are now directly accessible as variables (*e.g.* `COLOR.red`) and not through dictionary keys.
-* Changed the methods `Console.w()`, `Console.h()`, `Console.wh()` and `Console.user()` to modern class properties instead:
- `Console.w` current console columns (*in text characters*)
- `Console.h` current console lines
- `Console.wh` a tuple with the console size as (columns, lines)
- `Console.usr` the current username
+
+* Restructured the `_consts_` library constants to use `@dataclass` classes (*and simpler structured classes*) as much as possible.
+* Renamed the `DEFAULT` class from the `_consts_` to `COLOR`, whose colors are now directly accessible as variables (*e.g.* `COLOR.red`) and not through dictionary keys.
+* Changed the methods `Console.w()`, `Console.h()`, `Console.wh()` and `Console.user()` to modern class properties instead:
+ `Console.w` current console columns (*in text characters*)
+ `Console.h` current console lines
+ `Console.wh` a tuple with the console size as (columns, lines)
+ `Console.usr` the current username
## 29.01.2025โ`v1.6.5`
-* Now the method `FormatCodes.to_ansi()` automatically converts the param `string` to a *`str`* if it isn't one already.
-* Added a new method `FormatCodes.remove_codes()`.
-* Added a new method `FormatCodes.remove_ansi()`.
-* Added a new method `Console.log_box()`.
-* Changed the default values of two params in the `Console.log()` method and every log preset:
- - start: *str* = "\n" changed to start: *str* = ""
- - end: *str* = "\n\n" changed to end: *str* = "\n"
-* Added the params start: *str* = "", end: *str* = "\n" and default_color: *rgba* | *hexa* = DEFAULT.color["cyan"] to `Console.restricted_input()` and `Console.pwd_input()`.
+* Now the method `FormatCodes.to_ansi()` automatically converts the param `string` to a *`str`* if it isn't one already.
+* Added a new method `FormatCodes.remove_codes()`.
+* Added a new method `FormatCodes.remove_ansi()`.
+* Added a new method `Console.log_box()`.
+* Changed the default values of two params in the `Console.log()` method and every log preset:
+ - start: *str* = "\n" changed to start: *str* = ""
+ - end: *str* = "\n\n" changed to end: *str* = "\n"
+* Added the params start: *str* = "", end: *str* = "\n" and default_color: *rgba* | *hexa* = DEFAULT.color["cyan"] to `Console.restricted_input()` and `Console.pwd_input()`.
## 22.01.2025โ`v1.6.4`
-* **HOTFIX: Fixed a heavy bug, where the library could not be imported after the last update, because of a bug in `xx_format_codes`.**
+* **HOTFIX: Fixed a heavy bug, where the library could not be imported after the last update, because of a bug in `xx_format_codes`.**
## 22.01.2025โ`v1.6.3`โ**โ ๏ธ This release is broken!**
-* Fixed a small bug in `xx_format_codes`:
- Inside print-strings, if there was a `'` or `"` inside an auto-reset-formatting (*e.g.* `[u](there's a quote)`), that caused it to not be recognized as valid, and therefore not be automatically reset.
- Now this is fixed and auto-reset-formatting works as expected.
-* Added a new param ignore_in_strings: *bool* = True to `Regex.brackets()`:
- If this param is true and a bracket is inside a string (e.g. `'โฆ'` or `"โฆ"`), it will not be counted as the matching closing bracket.
-* Removed `lru_cache` from the `xx_format_codes` module's internal methods, since it was unnecessary.
-* Adjusted `FormatCodes.__config_console()` so it can only be called once per process.
+* Fixed a small bug in `xx_format_codes`:
+ Inside print-strings, if there was a `'` or `"` inside an auto-reset-formatting (*e.g.* `[u](there's a quote)`), that caused it to not be recognized as valid, and therefore not be automatically reset.
+ Now this is fixed and auto-reset-formatting works as expected.
+* Added a new param ignore_in_strings: *bool* = True to `Regex.brackets()`:
+ If this param is true and a bracket is inside a string (e.g. `'โฆ'` or `"โฆ"`), it will not be counted as the matching closing bracket.
+* Removed `lru_cache` from the `xx_format_codes` module's internal methods, since it was unnecessary.
+* Adjusted `FormatCodes.__config_console()` so it can only be called once per process.
## 20.01.2025โ`v1.6.2`
-* Added a new method `elevate()` to `xx_system`, which is used to request elevated privileges.
-* Fixed a bug in `rgba()`, `hsla()` and `hexa()`:
- Previously, when initializing a color with the alpha channel set to `0.0` (*100% transparent*), it was saved correctly, but when converted to a different color type or when returned, the alpha channel got ignored, just like if it was `None` or `1.0` (*opaque*).
- Now when initializing a color with the alpha channel set to `0.0`, this doesn't happen and when converted or returned, the alpha channel is still `0.0`.
-* Huge speed and efficiency improvements in `xx_color`, due to newly added option to initialize a color without validation, which saves time when initializing colors, when we know, that the values are valid.
-* Added a new param reset_ansi: *bool* = False to `FormatCodes.input()`:
- If this param is true, all formatting will be reset after the user confirmed the input and the program continues.
+* Added a new method `elevate()` to `xx_system`, which is used to request elevated privileges.
+* Fixed a bug in `rgba()`, `hsla()` and `hexa()`:
+ Previously, when initializing a color with the alpha channel set to `0.0` (*100% transparent*), it was saved correctly, but when converted to a different color type or when returned, the alpha channel got ignored, just like if it was `None` or `1.0` (*opaque*).
+ Now when initializing a color with the alpha channel set to `0.0`, this doesn't happen and when converted or returned, the alpha channel is still `0.0`.
+* Huge speed and efficiency improvements in `xx_color`, due to newly added option to initialize a color without validation, which saves time when initializing colors, when we know, that the values are valid.
+* Added a new param reset_ansi: *bool* = False to `FormatCodes.input()`:
+ If this param is true, all formatting will be reset after the user confirmed the input and the program continues.
**BREAKING CHANGES:**
-* Moved the method `is_admin()` from `xx_console` to `xx_system`.
-* Method `hex_int_to_rgba()` from `xx_color` now returns an `rgba()` object instead of the separate values `r`, `g`, `b` and `a`.
+
+* Moved the method `is_admin()` from `xx_console` to `xx_system`.
+* Method `hex_int_to_rgba()` from `xx_color` now returns an `rgba()` object instead of the separate values `r`, `g`, `b` and `a`.
## 15.01.2025โ`v1.6.1`
-* Changed the params in `File.make_path()`:
- Previously there were the params filename: *str* and filetype: *str* = "" where `filename` didn't have to have the file extension included, as long as the `filetype` was set.
- Now these params have become one param file: *str* which is the file with file extension.
-* Removed all line breaks and other Markdown formatting from docstrings, since not all IDEs support them.
+* Changed the params in `File.make_path()`:
+ Previously there were the params filename: *str* and filetype: *str* = "" where `filename` didn't have to have the file extension included, as long as the `filetype` was set.
+ Now these params have become one param file: *str* which is the file with file extension.
+* Removed all line breaks and other Markdown formatting from docstrings, since not all IDEs support them.
**BREAKING CHANGES:**
-* Changed the order the params in `File.create()`:
- Until now the param content: *str* = "" was the first param and file: *str* = "".
- New the param file: *str* = "" is the first param and content: *str* = "" is the second.
-* Renamed `File.make_path()` to a more descriptive name `File.extend_or_make_path()` and adjusted the usages of `File.create()` and `File.make_path()` inside `xx_json` accordingly.
+
+* Changed the order the params in `File.create()`:
+ Until now the param content: *str* = "" was the first param and file: *str* = "".
+ New the param file: *str* = "" is the first param and content: *str* = "" is the second.
+* Renamed `File.make_path()` to a more descriptive name `File.extend_or_make_path()` and adjusted the usages of `File.create()` and `File.make_path()` inside `xx_json` accordingly.
## 07.01.2025โ`v1.6.0`
-* Fixed a small bug in `to_camel_case()` in the `xx_string` module:
- Previously, it would return only the first part of the decomposed string.
- Now it correctly returns all decomposed string parts, joined in CamelCase.
+* Fixed a small bug in `to_camel_case()` in the `xx_string` module:
+ Previously, it would return only the first part of the decomposed string.
+ Now it correctly returns all decomposed string parts, joined in CamelCase.
## 21.12.2024โ`v1.5.9`
-* Fixed bugs in method `to_ansi()` in module `xx_format_codes`:
- 1. The method always returned an empty string, because the color validation was broken, and it would identify all colors as invalid.
- Now the validation `Color.is_valid_rgba()` and `Color.is_valid_hexa()` are fixed and now, if a color is identified as invalid, the method returns the original string instead of an empty string.
- 2. Previously the method `to_ansi()` couldn't handle formats inside `[]` because everything inside the brackets was recognized as an invalid format.
- Now you are able to use formats inside `[]` (*e.g.* `"[[red](Red text [b](inside) square brackets!)]"`).
-* Introduced a new test for the `xx_format_codes` module.
-* Fixed a small bug in the help client-command:
- Added back the default text color.
+* Fixed bugs in method `to_ansi()` in module `xx_format_codes`:
+ 1. The method always returned an empty string, because the color validation was broken, and it would identify all colors as invalid.
+ Now the validation `Color.is_valid_rgba()` and `Color.is_valid_hexa()` are fixed and now, if a color is identified as invalid, the method returns the original string instead of an empty string.
+ 2. Previously the method `to_ansi()` couldn't handle formats inside `[]` because everything inside the brackets was recognized as an invalid format.
+ Now you are able to use formats inside `[]` (*e.g.* `"[[red](Red text [b](inside) square brackets!)]"`).
+* Introduced a new test for the `xx_format_codes` module.
+* Fixed a small bug in the help client-command:
+ Added back the default text color.
## 21.11.2024โ`v1.5.8`
-* Added method `String.is_empty()` to check if the string is empty.
-* Added method `String.escape()` to escape special characters in a string.
-* Introduced a new test for `xx_data` (*all methods*).
-* Added doc-strings to all the methods in `xx_data`.
-* Made all the methods from `xx_data` work wih all the types of data structures (*`list`, `tuple`, `set`, `frozenset`, `dict`*).
-* Added method `EnvPath.remove_path()`.
-* Introduced a new test for `xx_env_vars` (*all methods*).
-* Added a `hexa_str()` preset to the `xx_regex` module.
-* Introduced a new test for the methods from the `Color` class in `xx_color`.
+* Added method `String.is_empty()` to check if the string is empty.
+* Added method `String.escape()` to escape special characters in a string.
+* Introduced a new test for `xx_data` (*all methods*).
+* Added doc-strings to all the methods in `xx_data`.
+* Made all the methods from `xx_data` work wih all the types of data structures (*`list`, `tuple`, `set`, `frozenset`, `dict`*).
+* Added method `EnvPath.remove_path()`.
+* Introduced a new test for `xx_env_vars` (*all methods*).
+* Added a `hexa_str()` preset to the `xx_regex` module.
+* Introduced a new test for the methods from the `Color` class in `xx_color`.
**BREAKING CHANGES:**
-* Renamed the library from `XulbuX` to `xulbux` for better naming conventions.
-* Renamed the module `xx_cmd`, and it's class `Cmd` to `xx_console` and `Console`.
-* Renamed the module `xx_env_vars`, and it's class `EnvVars` to `xx_env_path` and `EnvPath`.
+
+* Renamed the library from `XulbuX` to `xulbux` for better naming conventions.
+* Renamed the module `xx_cmd`, and it's class `Cmd` to `xx_console` and `Console`.
+* Renamed the module `xx_env_vars`, and it's class `EnvVars` to `xx_env_path` and `EnvPath`.
## 15.11.2024โ`v1.5.7`
-* Change the testing modules to be able to run together with the library `pytest`.
-* Added formatting checks, using `black`, `isort` and `flake8`.
-* Added the script (*command*) `xx-help` or `xulbux-help`.
-* Structured `Cmd.restricted_input()` a bit nicer, so it appears less complex.
-* Corrected code after `Lint with flake8` formatting suggestions.
-* Added additional tests for the custom color types.
-* Updated the whole `xx_format_codes` module for more efficiency and speed.
+* Change the testing modules to be able to run together with the library `pytest`.
+* Added formatting checks, using `black`, `isort` and `flake8`.
+* Added the script (*command*) `xx-help` or `xulbux-help`.
+* Structured `Cmd.restricted_input()` a bit nicer, so it appears less complex.
+* Corrected code after `Lint with flake8` formatting suggestions.
+* Added additional tests for the custom color types.
+* Updated the whole `xx_format_codes` module for more efficiency and speed.
**BREAKING CHANGES:**
-* Moved the `help()` function to the file `_cli_.py`, because that's where all the scripts are located (*It also was renamed to* `help_command()`).
-* Moved the method `normalize_spaces()` to `xx_string`.
+
+* Moved the `help()` function to the file `_cli_.py`, because that's where all the scripts are located (*It also was renamed to* `help_command()`).
+* Moved the method `normalize_spaces()` to `xx_string`.
@@ -572,8 +636,8 @@
**Again ๐ข๐ฒ๐ท๐ฐ๐ต๐ฎ๐ผ ๐๐ช๐ ๐ฅ๐**
-* Moved the whole library to its own repository: **[python-lib-xulbux](https://github.com/xulbux/python-lib-xulbux)**
-* Updated all connections and links correspondingly.
+* Moved the whole library to its own repository: **[python-lib-xulbux](https://github.com/xulbux/python-lib-xulbux)**
+* Updated all connections and links correspondingly.
@@ -582,216 +646,221 @@
**๐ข๐ฒ๐ท๐ฐ๐ต๐ฎ๐ผ ๐๐ช๐ ๐ฅ๐**
-* Added methods to get the width and height of the console (*in characters and lines*):
- - Cmd.w() -> *int* how many text characters the console is wide
- - Cmd.h() -> *int* how many lines the console is high
- - Cmd.wh() -> tuple[*int*, *int*] a tuple with width and height
-* Added the method split_count(*string*, *count*) -> list[*str*] to `xx_string`.
-* Added doc-strings to every method in `xx_string`.
-* Updated the `Cmd.restricted_input()` method to be able to:
- - paste text from the clipboard,
- - select all text to delete everything at once,
- - write and backspace over multiple lines and
- - added support for custom formatting codes in the prompt.
-* Added required non-standard libraries to the project file.
-* Added more metadata to the project file.
+* Added methods to get the width and height of the console (*in characters and lines*):
+ - Cmd.w() -> *int* how many text characters the console is wide
+ - Cmd.h() -> *int* how many lines the console is high
+ - Cmd.wh() -> tuple[*int*, *int*] a tuple with width and height
+* Added the method split_count(*string*, *count*) -> list[*str*] to `xx_string`.
+* Added doc-strings to every method in `xx_string`.
+* Updated the `Cmd.restricted_input()` method to be able to:
+ - paste text from the clipboard,
+ - select all text to delete everything at once,
+ - write and backspace over multiple lines and
+ - added support for custom formatting codes in the prompt.
+* Added required non-standard libraries to the project file.
+* Added more metadata to the project file.
## 06.11.2024โ`v1.5.4`
-* Added a new method normalize_spaces(*code*) -> *str* to `Code`.
-* Added new doc-strings to `xx_code` and `xx_cmd`.
-* Added a custom `input()` method to `Cmd`, which lets you specify the allowed text characters the user can type, as well as the minimum and maximum length of the input.
-* Added the method `pwd_input()` to `Cmd`, which works just like the `Cmd.restricted_input()` but masks the input characters with `*`.
-* Restructured the whole library's imports, so the custom types won't get displayed as `Any` when hovering over a method/function.
-* Fixed bug when trying to get the base directory from a compiled script (*EXE*):
- Would previously get the path to the temporary extracted directory, which is created when running the EXE file.
- Now it gets the actual base directory of the currently running file.
+* Added a new method normalize_spaces(*code*) -> *str* to `Code`.
+* Added new doc-strings to `xx_code` and `xx_cmd`.
+* Added a custom `input()` method to `Cmd`, which lets you specify the allowed text characters the user can type, as well as the minimum and maximum length of the input.
+* Added the method `pwd_input()` to `Cmd`, which works just like the `Cmd.restricted_input()` but masks the input characters with `*`.
+* Restructured the whole library's imports, so the custom types won't get displayed as `Any` when hovering over a method/function.
+* Fixed bug when trying to get the base directory from a compiled script (*EXE*):
+ Would previously get the path to the temporary extracted directory, which is created when running the EXE file.
+ Now it gets the actual base directory of the currently running file.
**BREAKING CHANGES:**
-* Made the `blend()` method from all the color types modify the *`self`* object as well as returning the result.
+
+* Made the `blend()` method from all the color types modify the *`self`* object as well as returning the result.
## 30.10.2024โ`v1.5.3`
-* Added the default text color to the `_consts_.py` so it's easier to change it (*and used it in the library*).
-* Added a bunch of other default colors to the `_consts_.py` (*and used them in the library*).
-* Refactored the whole library's code after the **[PEPs](https://peps.python.org)** and **[The Zen of Python](https://peps.python.org/pep-0020/#the-zen-of-python)** ๐ซก:
- - Changed the indent to 4 spaces.
- - No more inline control statements.
-* Added new methods to `Color`:
- - rgba_to_hex(*r*, *g*, *b*, *a*) -> *int*
- - hex_to_rgba(*hex_int*) -> *tuple*
- - luminance(*r*, *g*, *b*, *precision*, *round_to*) -> *float*|*int*
-* Fixed the `grayscale()` method of `rgba()`, `hsla()` and `hexa()`:
- The method would previously just return the color, fully desaturated (*not grayscale*).
- Now this is fixed, and the method uses the luminance formula, to get the actual grayscale value.
-* All the methods in the `xx_color` module now support HEX integers (*e.g.* `0x8085FF` *instead of only strings:* `"#8085FF"` `"0x8085FF"`).
+* Added the default text color to the `_consts_.py` so it's easier to change it (*and used it in the library*).
+* Added a bunch of other default colors to the `_consts_.py` (*and used them in the library*).
+* Refactored the whole library's code after the **[PEPs](https://peps.python.org)** and **[The Zen of Python](https://peps.python.org/pep-0020/#the-zen-of-python)** ๐ซก:
+ - Changed the indent to 4 spaces.
+ - No more inline control statements.
+* Added new methods to `Color`:
+ - rgba_to_hex(*r*, *g*, *b*, *a*) -> *int*
+ - hex_to_rgba(*hex_int*) -> *tuple*
+ - luminance(*r*, *g*, *b*, *precision*, *round_to*) -> *float*|*int*
+* Fixed the `grayscale()` method of `rgba()`, `hsla()` and `hexa()`:
+ The method would previously just return the color, fully desaturated (*not grayscale*).
+ Now this is fixed, and the method uses the luminance formula, to get the actual grayscale value.
+* All the methods in the `xx_color` module now support HEX integers (*e.g.* `0x8085FF` *instead of only strings:* `"#8085FF"` `"0x8085FF"`).
**BREAKING CHANGES:**
-* Restructured the values in `_consts_.py`.
+
+* Restructured the values in `_consts_.py`.
## 28.10.2024โ`v1.5.2`
-* New parameter correct_path:*bool* in `Path.extend()`:
- This makes sure, that typos in the path will only be corrected if this parameter is true.
-* Fixed bug in `Path.extend()`, where an empty string was taken as a valid path for the current directory `./`.
-* Fixed color validation bug:
- `Color.is_valid_rgba()` and `Color.is_valid_hsla()` would not accept an alpha channel of `None`.
- `Color.is_valid_rgba()` was still checking for an alpha channel from `0` to `255` instead of `0` to `1`.
-* Fixed bug for `Color.has_alpha()`:
- Previously, it would return `True` if the alpha channel was `None`.
- Now in such cases it will return `False`.
+* New parameter correct_path:*bool* in `Path.extend()`:
+ This makes sure, that typos in the path will only be corrected if this parameter is true.
+* Fixed bug in `Path.extend()`, where an empty string was taken as a valid path for the current directory `./`.
+* Fixed color validation bug:
+ `Color.is_valid_rgba()` and `Color.is_valid_hsla()` would not accept an alpha channel of `None`.
+ `Color.is_valid_rgba()` was still checking for an alpha channel from `0` to `255` instead of `0` to `1`.
+* Fixed bug for `Color.has_alpha()`:
+ Previously, it would return `True` if the alpha channel was `None`.
+ Now in such cases it will return `False`.
## 28.10.2024โ`v1.5.1`
-* Now all methods in `xx_color` support both HEX prefixes (`#` *and* `0x`).
-* Added the default HEX prefix to `_consts_.py`.
-* Fixed bug when initializing a `hexa()` object:
- Would throw an error, even if the color was valid.
+* Now all methods in `xx_color` support both HEX prefixes (`#` *and* `0x`).
+* Added the default HEX prefix to `_consts_.py`.
+* Fixed bug when initializing a `hexa()` object:
+ Would throw an error, even if the color was valid.
**BREAKING CHANGES:**
-* Renamed all library files for a better naming convention.
+
+* Renamed all library files for a better naming convention.
## 27.10.2024โ`v1.5.0`โBig Update ๐
-* Added a `__help__.py` file, which will show some information about the library and how to use it, when it's run as a script or when the `help()` function is called.
-* Added a lot more metadata to the library:
- `__version__` (*was already added in update [v1.4.2](#v1-4-2)*)
- `__author__`
- `__email__`
- `__license__`
- `__copyright__`
- `__url__`
- `__description__`
- `__all__`
+* Added a `__help__.py` file, which will show some information about the library and how to use it, when it's run as a script or when the `help()` function is called.
+* Added a lot more metadata to the library:
+ `__version__` (*was already added in update [v1.4.2](#v1-4-2)*)
+ `__author__`
+ `__email__`
+ `__license__`
+ `__copyright__`
+ `__url__`
+ `__description__`
+ `__all__`
**BREAKING CHANGES:**
-* Split all classes into separate files, so users can download only parts of the library more easily.
+
+* Split all classes into separate files, so users can download only parts of the library more easily.
## 27.10.2024โ`v1.4.2`โ`v1.4.3`
-* Path.extend(*rel_path*) -> *abs_path* now also extends system variables like `%USERPROFILE%` and `%APPDATA%`.
-* Removed unnecessary parts when checking for missing required libraries.
-* You can now get the libraries current version by accessing the attribute `XulbuX.__version__`.
+* Path.extend(*rel_path*) -> *abs_path* now also extends system variables like `%USERPROFILE%` and `%APPDATA%`.
+* Removed unnecessary parts when checking for missing required libraries.
+* You can now get the libraries current version by accessing the attribute `XulbuX.__version__`.
## 26.10.2024โ`v1.4.1`
-* Added methods to each color type:
- - is_grayscale() -> *self*
- - is_opaque() -> *self*
-* Added additional error checking to the color types.
-* Made error messages for the color types clearer.
-* Updated the blend(*other*, *ratio*) method of all color types to use additive blending except for the alpha-channel.
-* Fixed problem with method-chaining for all color types.
+* Added methods to each color type:
+ - is_grayscale() -> *self*
+ - is_opaque() -> *self*
+* Added additional error checking to the color types.
+* Made error messages for the color types clearer.
+* Updated the blend(*other*, *ratio*) method of all color types to use additive blending except for the alpha-channel.
+* Fixed problem with method-chaining for all color types.
## 25.10.2024โ`v1.4.0`โBig Update ๐
-* Huge update to the custom color types:
- - Now all type-methods support chaining.
- - Added new methods to each type:
- lighten(*amount*) -> *self*
- darken(*amount*) -> *self*
- saturate(*amount*) -> *self*
- desaturate(*amount*) -> *self*
- rotate(*hue*) -> *self*
- invert() -> *self*
- grayscale() -> *self*
- blend(*other*, *ratio*) -> *self*
- is_dark() -> *bool*
- is_light() -> *bool*
- with_alpha(*alpha*) -> *self*
- complementary() -> *self*
+* Huge update to the custom color types:
+ - Now all type-methods support chaining.
+ - Added new methods to each type:
+ lighten(*amount*) -> *self*
+ darken(*amount*) -> *self*
+ saturate(*amount*) -> *self*
+ desaturate(*amount*) -> *self*
+ rotate(*hue*) -> *self*
+ invert() -> *self*
+ grayscale() -> *self*
+ blend(*other*, *ratio*) -> *self*
+ is_dark() -> *bool*
+ is_light() -> *bool*
+ with_alpha(*alpha*) -> *self*
+ complementary() -> *self*
## 23.10.2024โ`v1.3.1`
-* Now the alpha channel will be rounded to maximal 2 decimals, if converting from `hexa()` to `rgba()` or `hsla()`.
+* Now the alpha channel will be rounded to maximal 2 decimals, if converting from `hexa()` to `rgba()` or `hsla()`.
## 21.10.2024โ`v1.3.0`โBig Update ๐
-* Fixed the custom types `rgba()`, `hsla()` and `hexa()`:
- - `rgba()`:
- Fixed `to_hsla()` and `to_hexa()`.
- - `hsla()`:
- Fixed `to_rgba()` and `to_hexa()`.
- - `hexa()`:
- Fixed `to_rgba()` and `to_hsla()`.
-* Fixed methods from the `Color` class:
- `Color.has_alpha()`
- `Color.to_rgba()`
- `Color.to_hsla()`
- `Color.to_hexa()`
-* Set default value for param allow_alpha: *bool* to `True` for methods:
- `Color.is_valid_rgba()`
- `Color.is_valid_hsla()`
- `Color.is_valid_hexa()`
- `Color.is_valid()`
+* Fixed the custom types `rgba()`, `hsla()` and `hexa()`:
+ - `rgba()`:
+ Fixed `to_hsla()` and `to_hexa()`.
+ - `hsla()`:
+ Fixed `to_rgba()` and `to_hexa()`.
+ - `hexa()`:
+ Fixed `to_rgba()` and `to_hsla()`.
+* Fixed methods from the `Color` class:
+ `Color.has_alpha()`
+ `Color.to_rgba()`
+ `Color.to_hsla()`
+ `Color.to_hexa()`
+* Set default value for param allow_alpha: *bool* to `True` for methods:
+ `Color.is_valid_rgba()`
+ `Color.is_valid_hsla()`
+ `Color.is_valid_hexa()`
+ `Color.is_valid()`
## 18.10.2024โ`v1.2.4`โ`v1.2.5`
-* Added more info to the `README.md` as well as additional links.
-* Adjusted the structure inside `CHANGELOG.md` for a better overview and readability.
+* Added more info to the `README.md` as well as additional links.
+* Adjusted the structure inside `CHANGELOG.md` for a better overview and readability.
**BREAKING CHANGES:**
-* Renamed the class `rgb()` to `rgba()` to communicate, more clearly, that it supports an alpha channel.
-* Renamed the class `hsl()` to `hsla()` to communicate, more clearly, that it supports an alpha channel.
+
+* Renamed the class `rgb()` to `rgba()` to communicate, more clearly, that it supports an alpha channel.
+* Renamed the class `hsl()` to `hsla()` to communicate, more clearly, that it supports an alpha channel.
## 18.10.2024โ`v1.2.3`
-* Added project links to the Python-project-file.
-* Made some `CHANGELOG.md` improvements.
-* Improved `README.md`.
+* Added project links to the Python-project-file.
+* Made some `CHANGELOG.md` improvements.
+* Improved `README.md`.
## 18.10.2024โ`v1.2.1`โ`v1.2.2`
-* Fixed bug in method Path.get(*base_dir*=True):
- Previously, setting `base_dir` to `True` would not return the actual base directory or even cause an error.
- Setting `base_dir` to `True` now will return the actual base directory of the current program (*except if not running from a file*).
+* Fixed bug in method Path.get(*base_dir*=True):
+ Previously, setting `base_dir` to `True` would not return the actual base directory or even cause an error.
+ Setting `base_dir` to `True` now will return the actual base directory of the current program (*except if not running from a file*).
## 17.10.2024โ`v1.2.0`
-* New method in the `Path` class:
- `Path.remove()`
+* New method in the `Path` class:
+ `Path.remove()`
@@ -799,96 +868,101 @@
## 17.10.2024โ`v1.1.9`
**BREAKING CHANGES:**
-* Corrected the naming of classes to comply with Python naming standards.
+
+* Corrected the naming of classes to comply with Python naming standards.
## 17.10.2024โ`v1.1.8`
-* Added support for all OSes to the OS-dependent methods.
+* Added support for all OSes to the OS-dependent methods.
## 17.10.2024โ`v1.1.6`โ`v1.1.7`
-* Fixed the `Cmd.cls()` method:
- There was a bug where only on Windows 10, the ANSI formats weren't cleared.
+* Fixed the `Cmd.cls()` method:
+ There was a bug where only on Windows 10, the ANSI formats weren't cleared.
## 17.10.2024โ`v1.1.4`โ`v1.1.5`
-* Added links to the `CHANGELOG.md` and `README.md` files.
+* Added links to the `CHANGELOG.md` and `README.md` files.
## 17.10.2024โ`v1.1.3`
-* Changed the default value of the param compactness: *int* in the method `Data.print()` to `1` instead of `0`.
+* Changed the default value of the param compactness: *int* in the method `Data.print()` to `1` instead of `0`.
## 17.10.2024โ`v1.1.1`โ`v1.1.2`
-* Adjusted the library's description.
+* Adjusted the library's description.
## 16.10.2024โ`v1.1.0`
-* Made it possible to also auto-reset the color and not only the predefined formats, using the [auto-reset-format](#auto-reset-format) (`[format](Automatically resetting)`).
+* Made it possible to also auto-reset the color and not only the predefined formats, using the [auto-reset-format](#auto-reset-format) (`[format](Automatically resetting)`).
## 16.10.2024โ`v1.0.9`
-* Added a library description, which gets shown if the library base-import is run directly.
-* Made it possible to escape an auto-reset-format (`[format](Automatically resetting)`) with a slash, so you can still have `()` brackets behind a `[format]`:
- ```python
- FormatCodes.print('[u](Automatically resetting) following text')
- ```
- prints: Automatically resetting following text
+* Added a library description, which gets shown if the library base-import is run directly.
+* Made it possible to escape an auto-reset-format (`[format](Automatically resetting)`) with a slash, so you can still have `()` brackets behind a `[format]`:
- ```python
- FormatCodes.print('[u]/(Automatically resetting) following text')
- ```
- prints: (Automatically resetting) following text
+ ```python
+ FormatCodes.print('[u](Automatically resetting) following text')
+ ```
+
+ prints: Automatically resetting following text
+
+ ```python
+ FormatCodes.print('[u]/(Automatically resetting) following text')
+ ```
+
+ prints: (Automatically resetting) following text
## 16.10.2024โ`v1.0.7`โ`v1.0.8`
-* Added `input()` method to the `FormatCodes` class, so you can make pretty looking input prompts.
-* Added warning for no network connection when trying to [install missing libraries](#improved-lib-importing).
+* Added `input()` method to the `FormatCodes` class, so you can make pretty looking input prompts.
+* Added warning for no network connection when trying to [install missing libraries](#improved-lib-importing).
## 15.10.2024โ`v1.0.6`
-* Improved **$\color{#8085FF}\textsf{XulbuX}$** library importing:
- Checks for missing required libraries and gives you the option to directly install them, if there are any.
-* Fixed issue where configuration file wasn't created and loaded correctly.
+* Improved **$\color{#8085FF}\textsf{XulbuX}$** library importing:
+ Checks for missing required libraries and gives you the option to directly install them, if there are any.
+* Fixed issue where configuration file wasn't created and loaded correctly.
**BREAKING CHANGES:**
-* Moved constant variables into a separate file.
+
+* Moved constant variables into a separate file.
## 15.10.2024โ`v1.0.1`โ`v1.0.2`โ`v1.0.3`โ`v1.0.4`โ`v1.0.5`
-* Fixed `f-string` issues for Python 3.10:
- 1. Not making use of same quotes inside f-strings any more.
- 2. No backslash escaping in f-strings.
+* Fixed `f-string` issues for Python 3.10:
+ 1. Not making use of same quotes inside f-strings any more.
+ 2. No backslash escaping in f-strings.
@@ -897,6 +971,7 @@
$\color{#F90}\Huge\textsf{INITIAL RELEASE!\ ๐คฉ๐}$
**At initial release**, the library **$\color{#8085FF}\textsf{XulbuX}$** looks like this:
+
```python
# GENERAL LIBRARY
import XulbuX as xx
@@ -905,71 +980,71 @@ from XulbuX import rgb, hsl, hexa
```
-
-
- | Features |
- class, type, function, โฆ |
-
-
-
-
- | Custom Types: |
-
-rgb(int, int, int, float)
-hsl(int, int, int, float)
-hexa(str)
- |
-
- | Directory Operations |
- xx.Dir |
-
- | File Operations |
- xx.File |
-
- | JSON File Operations |
- xx.Json |
-
- | System Actions |
- xx.System |
-
- | Manage Environment Vars |
- xx.EnvVars |
-
- | CMD Log And Actions |
- xx.Cmd |
-
- | Pretty Printing |
- xx.FormatCodes |
-
- | Color Operations |
- xx.Color |
-
- | Data Operations |
- xx.Data |
-
- | String Operations |
- xx.String |
-
- | Code String Operations |
- xx.Code |
-
- | Regex Pattern Templates |
- xx.Regex |
-
-
+
+
+ | Features |
+ class, type, function, โฆ |
+
+
+
+
+ | Custom Types: |
+
+ rgb(int, int, int, float)
+ hsl(int, int, int, float)
+ hexa(str)
+ |
+
+ | Directory Operations |
+ xx.Dir |
+
+ | File Operations |
+ xx.File |
+
+ | JSON File Operations |
+ xx.Json |
+
+ | System Actions |
+ xx.System |
+
+ | Manage Environment Vars |
+ xx.EnvVars |
+
+ | CMD Log And Actions |
+ xx.Cmd |
+
+ | Pretty Printing |
+ xx.FormatCodes |
+
+ | Color Operations |
+ xx.Color |
+
+ | Data Operations |
+ xx.Data |
+
+ | String Operations |
+ xx.String |
+
+ | Code String Operations |
+ xx.Code |
+
+ | Regex Pattern Templates |
+ xx.Regex |
+
+
diff --git a/README.md b/README.md
index 2533e61..368ee72 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
[](https://pypi.org/project/xulbux) [](https://clickpy.clickhouse.com/dashboard/xulbux) [](https://github.com/xulbux/python-lib-xulbux/blob/main/LICENSE) [](https://github.com/xulbux/python-lib-xulbux/commits) [](https://github.com/xulbux/python-lib-xulbux/issues) [](https://github.com/xulbux/python-lib-xulbux/stargazers)
**`xulbux`** is a library that contains many useful classes, types, and functions,
-ranging from console logging and working with colors to file management and system operations.
+ranging from terminal logging and working with colors to file management and system operations.
The library is designed to simplify common programming tasks and improve code readability through its collection of tools.
For precise information about the library, see the library's [**documentation**](https://github.com/xulbux/python-lib-xulbux/wiki).
@@ -17,15 +17,17 @@ For the libraries latest changes and updates, see the [**change log**](https://g
## Installation
-Run the following commands in a console with administrator privileges, so the actions take effect for all users.
+Run the following commands in a terminal with administrator privileges, so the actions take effect for all users.
Install the library and all its dependencies with the command:
-```console
+
+```shell
pip install xulbux
```
Upgrade the library and all its dependencies to their latest available version with the command:
-```console
+
+```shell
pip install --upgrade xulbux
```
@@ -33,23 +35,24 @@ pip install --upgrade xulbux
## CLI Commands
-When the library is installed, the following commands are available in the console:
+When the library is installed, the following commands are available in the terminal:
-| Command | Description |
-| :---------------- | :--------------------------------------------------------------- |
-| `xulbux-lib` | Show some information about the library. |
-| `xulbux-lib fc` | Parse and render a string's format codes as ANSI console output. |
+| Command | Description |
+| :----------- | :--------------------------------------- |
+| `xulbux-lib` | Show some information about the library. |
## Usage
Import the full library under the alias `xx`, so its modules and main classes are accessible with `xx.module.Class`, `xx.MainClass.method()`:
+
```python
import xulbux as xx
```
So you don't have to import the full library under an alias, you can also import only certain parts of the library's contents:
+
```python
# LIBRARY SUB MODULES
from xulbux.base.consts import COLOR, CHARS, ANSI
@@ -104,12 +107,12 @@ from xulbux.color import rgba, hsla, hexa
 |
- rgbaโhslaโhexaโColor classes, which include methods to work with
+ | rgba hsla hexa Color classes, which include methods to work with
colors in various formats. |
 |
- ConsoleโProgressBar classes, which include methods for logging
+ | Console ProgressBar classes, which include methods for logging
and other actions within the console. |
@@ -130,8 +133,9 @@ from xulbux.color import rgba, hsla, hexa
 |
- FormatCodes class, which includes methods to print and work with strings that contain
- special formatting codes, which are then converted to ANSI codes for pretty console output. |
+ Format (alias F) FormatCodes (alias FC) Term classes for building richly formatted
+ terminal output via a typed, operator-based syntax and for emitting common cursor- and
+ screen-control sequences. |
 |
@@ -159,6 +163,7 @@ from xulbux.color import rgba, hsla, hexa
## Example Usage
This is what it could look like using this library for a simple but ultra good-looking color converter:
+
```python
from xulbux.base.consts import COLOR, CHARS
from xulbux.color import hexa
diff --git a/pyproject.toml b/pyproject.toml
index db56544..326a173 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,14 +5,16 @@ requires = [
"mypy>=1.19.0",
"mypy-extensions>=1.1.0",
# TYPES FOR MyPy
+ "types-setuptools",
"types-regex",
+ "types-toml",
"prompt_toolkit>=3.0.41",
]
build-backend = "setuptools.build_meta"
[project]
name = "xulbux"
-version = "1.9.7"
+version = "1.9.8"
description = "A Python library to simplify common programming tasks."
readme = "README.md"
authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }]
@@ -117,7 +119,11 @@ max-complexity = 12
max-line-length = 127
select = ["E", "F", "W", "C90"]
extend-ignore = ["E124", "E203", "E266", "E502", "W503"]
-per-file-ignores = ["__init__.py:F403,F405", "types.py:E302,E305"]
+per-file-ignores = [
+ "__init__.py:F403,F405",
+ "src/xulbux/base/types.py:E302,E305",
+ "src/xulbux/cli/help.py:E501",
+]
[tool.setuptools]
package-dir = { "" = "src" }
@@ -132,22 +138,7 @@ xulbux = ["py.typed", "*.pyi", "**/*.pyi"]
minversion = "7.0"
addopts = "-ra -q"
pythonpath = ["src"]
-testpaths = [
- "tests/test_code.py",
- "tests/test_color_types.py",
- "tests/test_color.py",
- "tests/test_console.py",
- "tests/test_data.py",
- "tests/test_env_path.py",
- "tests/test_file_sys.py",
- "tests/test_file.py",
- "tests/test_format_codes.py",
- "tests/test_json.py",
- "tests/test_metadata_consistency.py",
- "tests/test_regex.py",
- "tests/test_string.py",
- "tests/test_system.py",
-]
+testpaths = ["tests/test_*.py"]
[tool.pyright]
include = ["src", "tests"]
diff --git a/setup.py b/setup.py
index 50eeefc..d181ed1 100644
--- a/setup.py
+++ b/setup.py
@@ -61,8 +61,8 @@ def generate_stubs_for_package():
print(f"\nStub generation complete. ({generated_count} generated, {skipped_count} copied)\n")
- except Exception as e:
- fmt_error = "\n ".join(str(e).splitlines())
+ except Exception as exc:
+ fmt_error = "\n ".join(str(exc).splitlines())
print(f"[WARNING] Could not generate stubs:\n {fmt_error}\n")
@@ -73,8 +73,13 @@ def delete_project_stub_files():
ext_modules = []
+# ONLY COMPILE AND GENERATE STUBS WHEN ACTUALLY BUILDING, NOT DURING METADATA-ONLY
+# PHASES (egg_info, dist_info) THAT PIP INVOKES AS PART OF PEP 517 PREPARATION.
+_BUILD_COMMANDS = {"bdist_wheel", "build_ext", "build", "develop", "editable_wheel", "install"}
+_is_building = bool(set(sys.argv[1:]) & _BUILD_COMMANDS)
+
# OPTIONALLY USE MYPYC COMPILATION
-if os.environ.get("XULBUX_USE_MYPYC", "1") == "1":
+if os.environ.get("XULBUX_USE_MYPYC", "1") == "1" and _is_building:
try:
from mypyc.build import mypycify
@@ -97,4 +102,5 @@ def delete_project_stub_files():
ext_modules=ext_modules,
)
-delete_project_stub_files()
+if _is_building:
+ delete_project_stub_files()
diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py
index ed48776..e9efd12 100644
--- a/src/xulbux/__init__.py
+++ b/src/xulbux/__init__.py
@@ -1,5 +1,5 @@
__package_name__ = "xulbux"
-__version__ = "1.9.7"
+__version__ = "1.9.8"
__description__ = "A Python library to simplify common programming tasks."
__status__ = "Production/Stable"
@@ -25,6 +25,7 @@
"File",
"FileSys",
"FormatCodes",
+ "deprFormatCodes",
"Json",
"Regex",
"String",
@@ -39,6 +40,7 @@
from .file import File
from .file_sys import FileSys
from .format_codes import FormatCodes
+from .depr_format_codes import deprFormatCodes
from .json import Json
from .regex import Regex
from .string import String
diff --git a/src/xulbux/base/consts.py b/src/xulbux/base/consts.py
index 0bd6d13..ff36a61 100644
--- a/src/xulbux/base/consts.py
+++ b/src/xulbux/base/consts.py
@@ -80,26 +80,32 @@ class ANSI:
"""Printable ANSI escape character."""
CHAR: Final = "\x1b"
"""ANSI escape character."""
+
START: Final = "["
- """Start of an ANSI escape sequence."""
+ """**DEPRECATED** โ only used by `depr_format_codes`. Will be removed together with that module.\n
+ Start of an ANSI escape sequence."""
SEP: Final = ";"
- """Separator between ANSI escape sequence parts."""
+ """**DEPRECATED** โ only used by `depr_format_codes`. Will be removed together with that module.\n
+ Separator between ANSI escape sequence parts."""
END: Final = "m"
- """End of an ANSI escape sequence."""
+ """**DEPRECATED** โ only used by `depr_format_codes`. Will be removed together with that module.\n
+ End of an ANSI escape sequence."""
@classmethod
def seq(cls, placeholders: int = 1, /) -> FormattableString:
- """Generates an ANSI escape sequence with the specified number of placeholders."""
+ """**DEPRECATED** โ only used by `depr_format_codes`. Will be removed together with that module.\n
+ 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
- SEQ_COLOR: Final[FormattableString] = CHAR + START + "38" + SEP + "2" + SEP + "{}" + SEP + "{}" + SEP + "{}" + END
- """ANSI escape sequence with three placeholders for setting the RGB text color."""
- SEQ_BG_COLOR: Final[FormattableString] = CHAR + START + "48" + SEP + "2" + SEP + "{}" + SEP + "{}" + SEP + "{}" + END
- """ANSI escape sequence with three placeholders for setting the RGB background color."""
+ SEQ_FG_COLOR: Final[FormattableString] = f"{CHAR}[38;2;{{}};{{}};{{}}m"
+ """RGB foreground color sequence with placeholders for red, green, and blue values."""
+ SEQ_BG_COLOR: Final[FormattableString] = f"{CHAR}[48;2;{{}};{{}};{{}}m"
+ """RGB background color sequence with placeholders for red, green, and blue values."""
- SEQ_LINK_OPEN: Final[FormattableString] = CHAR + "]8;;{}" + CHAR + "\\"
+ SEQ_LINK_OPEN: Final[FormattableString] = f"{CHAR}]8;;{{}}{CHAR}\\"
"""OSC 8 hyperlink opening sequence with a placeholder for the URL."""
- SEQ_LINK_CLOSE: Final[str] = CHAR + "]8;;" + CHAR + "\\"
+ SEQ_LINK_CLOSE: Final[str] = f"{CHAR}]8;;{CHAR}\\"
"""OSC 8 hyperlink closing sequence."""
COLOR_MAP: Final[set[str]] = {
@@ -112,7 +118,8 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString:
"cyan",
"white",
}
- """The standard terminal color names."""
+ """**DEPRECATED** โ only used by `depr_format_codes`. Will be removed together with that module.\n
+ The standard terminal color names."""
COLOR_VARIANTS_MAP: Final[set[str]] = COLOR_MAP | {
"br:black",
@@ -123,16 +130,9 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString:
"br:magenta",
"br:cyan",
"br:white",
- "bright:black",
- "bright:red",
- "bright:green",
- "bright:yellow",
- "bright:blue",
- "bright:magenta",
- "bright:cyan",
- "bright:white",
}
- """All color variants that can be used in formatting."""
+ """**DEPRECATED** โ only used by `depr_format_codes`. Will be removed together with that module.\n
+ All color variants that can be used in formatting."""
CODES_MAP: Final[dict[str | tuple[str, ...], int]] = {
################# SPECIFIC RESETS ##################
@@ -193,4 +193,5 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString:
"bg:br:cyan": 106,
"bg:br:white": 107,
}
- """Dictionary mapping format keys to their corresponding ANSI code numbers."""
+ """**DEPRECATED** โ only used by `depr_format_codes`. Will be removed together with that module.\n
+ Dictionary mapping format keys to their corresponding ANSI code numbers."""
diff --git a/src/xulbux/base/decorators.py b/src/xulbux/base/decorators.py
index 7d3d092..18213be 100644
--- a/src/xulbux/base/decorators.py
+++ b/src/xulbux/base/decorators.py
@@ -18,8 +18,9 @@ def mypyc_attr(**kwargs: Any) -> Callable[[T], T]:
or acts as a no-op decorator when `mypy_extensions` is not installed.\n
This allows the use of `mypyc` compilation hints for compiling without making
`mypy_extensions` a required dependency.\n
- -----------------------------------------------------------------------------------------
- - `**kwargs` -โ keyword arguments to pass to `mypy_extensions.mypyc_attr` if available"""
+ -------------------------------------------------------------------------------------------
+ * `**kwargs` โ keyword arguments to pass to `mypy_extensions.mypyc_attr` if available"""
+
try:
from mypy_extensions import mypyc_attr as _mypyc_attr
return _mypyc_attr(**kwargs)
diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py
index af8cc2d..e737f41 100644
--- a/src/xulbux/base/types.py
+++ b/src/xulbux/base/types.py
@@ -40,24 +40,24 @@
class _RgbaObj(Protocol):
"""Protocol for rgba-like color objects (structurally matches `rgba`)."""
- r: int
- g: int
- b: int
- a: Optional[float]
+ red: int
+ green: int
+ blue: int
+ alpha: Optional[float]
class _HslaObj(Protocol):
"""Protocol for hsla-like color objects (structurally matches `hsla`)."""
- h: int
- s: int
- l: int
- a: Optional[float]
+ hue: int
+ sat: int
+ light: int
+ alpha: Optional[float]
class _HexaObj(Protocol):
"""Protocol for hexa-like color objects (structurally matches `hexa`)."""
- r: int
- g: int
- b: int
- a: Optional[float]
+ red: int
+ green: int
+ blue: int
+ alpha: Optional[float]
Rgba: TypeAlias = Union[
tuple[Int_0_255, Int_0_255, Int_0_255],
@@ -113,29 +113,29 @@ class ArgData(TypedDict):
"""Schema for the resulting data of parsing a single command-line argument."""
exists: bool
is_pos: bool
- values: list[str]
+ values: tuple[str, ...]
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]
+ red: Int_0_255
+ green: Int_0_255
+ blue: Int_0_255
+ alpha: 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]
+ hue: Int_0_360
+ sat: Int_0_100
+ light: Int_0_100
+ alpha: Optional[Float_0_1]
class HexaDict(TypedDict):
"""Dictionary schema for HEXA color components."""
- r: str
- g: str
- b: str
- a: Optional[str]
+ red: str
+ green: str
+ blue: str
+ alpha: Optional[str]
class MissingLibsMsgs(TypedDict):
"""Configuration schema for custom messages in `System.check_libs()` when checking library dependencies."""
@@ -146,7 +146,7 @@ class MissingLibsMsgs(TypedDict):
################################################## Protocol ##################################################
class ProgressUpdater(Protocol):
- """Protocol for a progress updater function used in console progress bars."""
+ """Protocol for a progress updater function used in terminal progress bars."""
def __call__(self, current: Optional[int] = None, label: Optional[str] = None) -> None:
"""Update the current progress value and/or label."""
diff --git a/src/xulbux/cli/__init__.py b/src/xulbux/cli/__init__.py
index 998352d..28b83c8 100644
--- a/src/xulbux/cli/__init__.py
+++ b/src/xulbux/cli/__init__.py
@@ -3,10 +3,8 @@
def main() -> None:
"""Main entry point for the `xulbux-lib` CLI command."""
+
match sys.argv[1] if len(sys.argv) > 1 else "":
- case "fc":
- from .tools import render_format_codes
- render_format_codes()
case _:
from .help import show_help
show_help()
diff --git a/src/xulbux/cli/help.py b/src/xulbux/cli/help.py
index 6bf2d89..7164535 100644
--- a/src/xulbux/cli/help.py
+++ b/src/xulbux/cli/help.py
@@ -1,5 +1,6 @@
from .. import __version__
-from ..format_codes import FormatCodes
+from ..base.decorators import mypyc_attr
+from ..format_codes import FC, F
from ..console import Console
from urllib.error import HTTPError
@@ -10,23 +11,27 @@
def get_latest_version() -> Optional[str]:
"""Fetches the latest version of the library from PyPI."""
+
with _request.urlopen(URL) as response:
if response.status == 200:
- data = _json.load(response)
- return data["info"]["version"]
+ return _json.load(response)["info"]["version"]
else:
raise HTTPError(URL, response.status, "Failed to fetch latest version info", response.headers, None)
def is_latest_version() -> Optional[bool]:
- """Checks if the currently installed version of the
+ """Checks if the currently installed version of the
library is the latest one available on PyPI."""
+
try:
if (latest := get_latest_version()) in {"", None}:
return None
+
latest_v_parts = tuple(int(part) for part in (latest or "").lower().lstrip("v").split("."))
installed_v_parts = tuple(int(part) for part in __version__.lower().lstrip("v").split("."))
+
return latest_v_parts <= installed_v_parts
+
except Exception:
return None
@@ -34,55 +39,67 @@ def is_latest_version() -> Optional[bool]:
URL = "https://pypi.org/pypi/xulbux/json"
IS_LATEST_VERSION = is_latest_version()
-CLI_COLORS = {
- "border": "dim|br:black",
- "class": "br:cyan",
- "cmd": "green",
- "const": "br:blue",
- "fn": "br:green",
- "heading": "br:white",
- "import": "magenta",
- "lib": "br:magenta",
- "link": "br:blue",
- "notice": "br:yellow",
- "punctuator": "br:black",
- "text": "white",
-}
-CLI_HELP = FormatCodes.to_ansi(
- rf"""[_]
- [b|#7075FF] __ __
- [b|#7075FF] _ __ __ __/ / / /_ __ ___ __
- [b|#7075FF] | |/ // / / / / / __ \/ / / | |/ /
- [b|#7075FF] > , /_/ / /_/ /_/ / /_/ /> , <
- [b|#7075FF]/_/|_|\____/\__/\____/\____//_/|_| [*|#000|BG:#8085FF] v[b]{__version__} [*|dim|{CLI_COLORS["notice"]}]({"" if IS_LATEST_VERSION else " (newer available)"})[*]
-
- [i|#9095FF]Simplify common programming tasks![*]
-
- [b|{CLI_COLORS["heading"]}](Commands:)[*]
- [{CLI_COLORS["border"]}](โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ)[*]
- [{CLI_COLORS["border"]}](โ) [{CLI_COLORS["cmd"]}]xulbux-lib[*] [{CLI_COLORS["text"]}]Show library info and usage[*] [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โ) [{CLI_COLORS["cmd"]}]xulbux-lib [b|{CLI_COLORS["fn"]}](fc) [{CLI_COLORS["text"]}]Render a string's format codes[*] [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ)[*]
- [b|{CLI_COLORS["heading"]}](Usage:)[*]
- [{CLI_COLORS["border"]}](โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ)[*]
- [{CLI_COLORS["border"]}](โ) [i|{CLI_COLORS["punctuator"]}](# LIBRARY CONSTANTS)[*] [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โ) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]base[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]consts [{CLI_COLORS["import"]}]import [{CLI_COLORS["const"]}]COLOR[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["const"]}]CHARS[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["const"]}]ANSI[*] [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โ) [i|{CLI_COLORS["punctuator"]}](# Main Classes)[*] [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โ) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux [{CLI_COLORS["import"]}]import [{CLI_COLORS["class"]}]Code[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["class"]}]Color[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["class"]}]Console[{CLI_COLORS["punctuator"]}], ...[*] [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โ) [i|{CLI_COLORS["punctuator"]}](# module specific imports)[*] [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โ) [{CLI_COLORS["import"]}]from [{CLI_COLORS["lib"]}]xulbux[{CLI_COLORS["punctuator"]}].[{CLI_COLORS["lib"]}]color [{CLI_COLORS["import"]}]import [{CLI_COLORS["fn"]}]rgba[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["fn"]}]hsla[{CLI_COLORS["punctuator"]}], [{CLI_COLORS["fn"]}]hexa[*] [{CLI_COLORS["border"]}](โ)
- [{CLI_COLORS["border"]}](โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ)[*]
- [b|{CLI_COLORS["heading"]}](Documentation:)[*]
- [{CLI_COLORS["border"]}](โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ)[*]
- [{CLI_COLORS["border"]}](โ) [{CLI_COLORS["text"]}]For more information see the GitHub wiki page: [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โ) [{CLI_COLORS["link"]}|link:https://github.com/xulbux/python-lib-xulbux/wiki](github.com/xulbux/python-lib-xulbux/wiki) [{CLI_COLORS["border"]}](โ)[*]
- [{CLI_COLORS["border"]}](โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ)[*]
- [_]"""
+
+@mypyc_attr(native_class=False)
+class S:
+ """Styling constants for the CLI help message."""
+
+ BORDER = F.DIM | F.BR.BLACK
+ CLS = F.BR.CYAN
+ CMD = F.GREEN
+ CONST = F.BR.BLUE
+ FN = F.BR.GREEN
+ HEADING = F.BOLD | F.BR.WHITE
+ IMPORT = F.MAGENTA
+ LIB = F.BR.MAGENTA
+ META = F.DIM | F.BR.WHITE
+ PUNCT = F.BR.BLACK
+ TEXT = F.WHITE
+
+
+# fmt: OFF
+CLI_HELP = FC(
+ F.RESET,
+ (
+ (F.BOLD | F.hex("#7075FF"))(
+ " __ __ \n"
+ " _ __ __ __/ / / /_ __ ___ __\n"
+ " | |/ // / / / / / __ \\/ / / | |/ /\n"
+ " > , /_/ / /_/ /_/ / /_/ /> , < \n"
+ " /_/|_|\\____/\\__/\\____/\\____//_/|_| ",
+ (F.hex("#000") | F.BG.hex("#8085FF"))(f" v{__version__} "),
+ ),
+ "" if IS_LATEST_VERSION else (F.DIM | F.YELLOW)(" (", F.ITALIC("newer available"), ")"),
+ ),
+ "",
+ (F.ITALIC | F.hex("#9095FF"))(" Simplify common programming tasks!"),
+ "",
+ S.HEADING(" Commands:"),
+ S.BORDER(" โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ"),
+ (S.BORDER(" โ "), S.CMD("xulbux-lib "), S.TEXT("Show library info and usage "), S.BORDER("โ")),
+ S.BORDER(" โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ"),
+ S.HEADING(" Usage:"),
+ S.BORDER(" โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ"),
+ (S.BORDER(" โ "), S.PUNCT("# ", F.ITALIC("LIBRARY CONSTANTS ")), S.BORDER("โ")),
+ (S.BORDER(" โ "), S.IMPORT("from "), S.LIB("xulbux"), (F.DIM | S.LIB)("."), S.LIB("base"), (F.DIM | S.LIB)("."), S.LIB("consts "), S.IMPORT("import "), S.CONST("COLOR"), S.PUNCT(", "), S.CONST("CHARS"), S.PUNCT(", "), S.CONST("ANSI "), S.BORDER("โ")),
+ (S.BORDER(" โ "), S.PUNCT("# ", F.ITALIC("Main Classes ")), S.BORDER("โ")),
+ (S.BORDER(" โ "), S.IMPORT("from "), S.LIB("xulbux "), S.IMPORT("import "), S.CLS("Code"), S.PUNCT(", "), S.CLS("Color"), S.PUNCT(", "), S.CLS("Console"), S.PUNCT(", "), S.META("... "), S.BORDER("โ")),
+ (S.BORDER(" โ "), S.PUNCT("# ", F.ITALIC("module specific imports ")), S.BORDER("โ")),
+ (S.BORDER(" โ "), S.IMPORT("from "), S.LIB("xulbux"), (F.DIM | S.LIB)("."), S.LIB("color "), S.IMPORT("import "), S.FN("rgba"), S.PUNCT(", "), S.FN("hsla"), S.PUNCT(", "), S.FN("hexa "), S.BORDER("โ")),
+ S.BORDER(" โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ"),
+ S.HEADING(" Documentation:"),
+ S.BORDER(" โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ"),
+ (S.BORDER(" โ "), S.TEXT("For more information see the GitHub wiki page: "), S.BORDER("โ")),
+ (S.BORDER(" โ "), (F.BR.BLUE | F.link("https://github.com/xulbux/python-lib-xulbux/wiki"))("github.com/xulbux/python-lib-xulbux/wiki"), " ", S.BORDER("โ")),
+ S.BORDER(" โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ"),
+ "",
)
+# fmt: ON
def show_help() -> None:
- """CLI command function for `xulbux-lib` command, which shows some information about the library."""
- FormatCodes._config_console()
- print(CLI_HELP)
+ """CLI command function for `xulbux-lib` command,
+ which shows some information about the library."""
+
+ CLI_HELP.print()
Console.pause_exit(" [dim](Press any key to exit...)\n\n", pause=True)
diff --git a/src/xulbux/cli/tools.py b/src/xulbux/cli/tools.py
deleted file mode 100644
index 410c584..0000000
--- a/src/xulbux/cli/tools.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from ..format_codes import FormatCodes
-from ..console import Console
-
-
-def render_format_codes():
- """CLI command function for `xulbux-lib fc` command, which allows you to parse
- and render a given string's format codes as ANSI console output."""
- args = Console.get_args({"input": "before"})
- vals = args.input.values[1:] # EXCLUDE THE COMMAND ITSELF
-
- if not vals:
- FormatCodes.print("\n[_|i|dim]Provide a string to parse and render\n"
- "its format codes as ANSI console output.[_]\n")
-
- else:
- ansi = FormatCodes.to_ansi("".join(vals))
- ansi_escaped = FormatCodes.escape_ansi(ansi)
- ansi_stripped = FormatCodes.remove_ansi(ansi)
-
- print(f"\n{ansi}\n")
-
- if len(ansi) != len(ansi_stripped):
- FormatCodes.print(f"[_|i|dim]{ansi_escaped}[_]\n")
- else:
- FormatCodes.print("[_|i|dim](The provided string doesn't contain any valid format codes.)\n")
diff --git a/src/xulbux/code.py b/src/xulbux/code.py
index dc789ed..7780a32 100644
--- a/src/xulbux/code.py
+++ b/src/xulbux/code.py
@@ -16,9 +16,10 @@ class Code:
@classmethod
def add_indent(cls, code: str, indent: int, /) -> str:
"""Adds `indent` spaces at the beginning of each line.\n
- --------------------------------------------------------------------------
- - `code` -โ the code to indent
- - `indent` -โ the amount of spaces to add at the beginning of each line"""
+ -----------------------------------------------------------------------------
+ * `code` โ The code to indent.
+ * `indent` โ The amount of spaces to add at the beginning of each line."""
+
if indent < 0:
raise ValueError(f"The 'indent' parameter must be non-negative, got {indent!r}")
@@ -28,17 +29,19 @@ def add_indent(cls, code: str, indent: int, /) -> str:
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"""
+ * `code` โ The code to analyze."""
+
indents = [len(line) - len(line.lstrip()) for line in String.get_lines(code, remove_empty_lines=True)]
- return min(non_zero_indents) if (non_zero_indents := [i for i in indents if i > 0]) else 0
+ return min(non_zero_indents) if (non_zero_indents := [indt for indt in indents if indt > 0]) else 0
@classmethod
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
- - `new_tab_size` -โ the new amount of spaces per tab
- - `remove_empty_lines` -โ is true, empty lines will be removed in the process"""
+ -----------------------------------------------------------------------------------
+ * `code` โ The code to modify the tab size of.
+ * `new_tab_size` โ The new amount of spaces per tab.
+ * `remove_empty_lines` โ If true, empty lines will be removed in the process."""
+
if new_tab_size < 0:
raise ValueError(f"The 'new_tab_size' parameter must be non-negative, got {new_tab_size!r}")
@@ -60,7 +63,8 @@ def change_tab_size(cls, code: str, new_tab_size: int, /, *, remove_empty_lines:
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"""
+ * `code` โ The code to analyze."""
+
nested_func_calls: list[list[Any]] = []
for _, func_attrs in (funcs := _rx.findall(r"(?i)" + Regex.func_call(), code)):
@@ -72,14 +76,15 @@ def get_func_calls(cls, code: str, /) -> list[list[Any]]:
@classmethod
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
- - `funcs` -โ a list of custom function names to check for"""
+ ---------------------------------------------------------------
+ * `code` โ The code to analyze.
+ * `funcs` โ A list of custom function names to check for."""
+
if len(code.strip()) < 3:
return False
- for func in funcs:
- if _rx.match(r"^[\s\n]*" + _rx.escape(func) + r"\([^\)]*\)[\s\n]*$", code):
+ for fn in funcs:
+ if _rx.match(r"^[\s\n]*" + _rx.escape(fn) + r"\([^\)]*\)[\s\n]*$", code):
return True
direct_js_patterns = [
@@ -105,7 +110,7 @@ def is_js(cls, code: str, /, *, funcs: set[str] = {"__", "$t", "$lang"}) -> bool
return True
js_score = 0.0
- funcs_pattern = r"(" + "|".join(_rx.escape(func) for func in funcs) + r")" + Regex.brackets("()")
+ funcs_pattern = r"(" + "|".join(_rx.escape(fn) for fn in funcs) + r")" + Regex.brackets("()")
js_indicators: list[tuple[str, float]] = [
(r"\b(var|let|const)\s+[\w_$]+", 2.0), # JS VARIABLE DECLARATIONS
(r"\$[\w_$]+\s*=", 2.0), # jQuery-STYLE VARIABLES
diff --git a/src/xulbux/color.py b/src/xulbux/color.py
index dd912ed..d8a3a0a 100644
--- a/src/xulbux/color.py
+++ b/src/xulbux/color.py
@@ -18,61 +18,63 @@
class rgba:
"""An RGB/RGBA color object that includes a bunch of methods to manipulate the color.\n
----------------------------------------------------------------------------------------
- - `r` -โ the red channel in range [0, 255] inclusive
- - `g` -โ the green channel in range [0, 255] inclusive
- - `b` -โ the blue channel in range [0, 255] inclusive
- - `a` -โ the alpha channel in range [0.0, 1.0] inclusive
- or `None` if the color has no alpha channel\n
+ * `red` โ The red channel in range [0, 255] inclusive.
+ * `green` โ The green channel in range [0, 255] inclusive.
+ * `blue` โ The blue channel in range [0, 255] inclusive.
+ * `alpha` โ The alpha channel in range [0.0, 1.0] inclusive
+ or `None` if the color has no alpha channel.
----------------------------------------------------------------------------------------
Includes methods:
- - `to_hsla()` to convert to HSL color
- - `to_hexa()` to convert to HEX color
- - `has_alpha()` to check if the color has an alpha channel
- - `lighten(amount)` to create a lighter version of the color
- - `darken(amount)` to create a darker version of the color
- - `saturate(amount)` to increase color saturation
- - `desaturate(amount)` to decrease color saturation
- - `rotate(degrees)` to rotate the hue by degrees
- - `invert()` to get the inverse color
- - `grayscale()` to convert to grayscale
- - `blend(other, ratio)` to blend with another color
- - `is_dark()` to check if the color is considered dark
- - `is_light()` to check if the color is considered light
- - `is_grayscale()` to check if the color is grayscale
- - `is_opaque()` to check if the color has no transparency
- - `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):
- self.r: int
+ * `to_hsla()` to convert to HSL color
+ * `to_hexa()` to convert to HEX color
+ * `has_alpha()` to check if the color has an alpha channel
+ * `lighten(amount)` to create a lighter version of the color
+ * `darken(amount)` to create a darker version of the color
+ * `saturate(amount)` to increase color saturation
+ * `desaturate(amount)` to decrease color saturation
+ * `rotate(degrees)` to rotate the hue by degrees
+ * `invert()` to get the inverse color
+ * `grayscale()` to convert to grayscale
+ * `blend(other, ratio)` to blend with another color
+ * `is_dark()` to check if the color is considered dark
+ * `is_light()` to check if the color is considered light
+ * `is_grayscale()` to check if the color is grayscale
+ * `is_opaque()` to check if the color has no transparency
+ * `with_alpha(alpha)` to create a new color with different alpha
+ * `complementary()` to get the complementary color"""
+
+ def __init__(self, red: int, green: int, blue: int, alpha: Optional[float] = None, /, *, _validate: bool = True):
+ self.red: int
"""The red channel in range [0, 255] inclusive."""
- self.g: int
+ self.green: int
"""The green channel in range [0, 255] inclusive."""
- self.b: int
+ self.blue: int
"""The blue channel in range [0, 255] inclusive."""
- self.a: Optional[float]
+ self.alpha: Optional[float]
"""The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
if not _validate:
- self.r, self.g, self.b, self.a = r, g, b, a
+ self.red, self.green, self.blue, self.alpha = red, green, blue, alpha
return
- if not all((0 <= x <= 255) for x in (r, g, b)):
+ if not all((0 <= ch <= 255) for ch in (red, green, blue)):
raise ValueError(
- f"The 'r', 'g' and 'b' parameters must be integers in range [0, 255] inclusive, got {r=} {g=} {b=}"
+ "The 'red', 'green' and 'blue' parameters must be integers "
+ f"in range [0, 255] inclusive, got {red=!r} {green=!r} {blue=!r}"
)
- if a is not None and not (0.0 <= a <= 1.0):
- raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}")
+ if alpha is not None and not (0.0 <= alpha <= 1.0):
+ raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}")
- self.r, self.g, self.b = r, g, b
- self.a = None if a is None else (1.0 if a > 1.0 else float(a))
+ self.red, self.green, self.blue = red, green, blue
+ self.alpha = None if alpha is None else (1.0 if alpha > 1.0 else float(alpha))
def __len__(self) -> int:
"""The number of components in the color (3 or 4)."""
- return 3 if self.a is None else 4
+
+ return 3 if self.alpha is None else 4
def __iter__(self) -> Iterator[int | Optional[float]]:
- return iter((self.r, self.g, self.b) + (() if self.a is None else (self.a, )))
+ return iter((self.red, self.green, self.blue) + (() if self.alpha is None else (self.alpha, )))
@overload
def __getitem__(self, index: Literal[0, 1, 2], /) -> int:
@@ -87,244 +89,269 @@ 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]
+ return ((self.red, self.green, self.blue) + (() if self.alpha is None else (self.alpha, )))[index]
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)
+ return (self.red, self.green, self.blue, self.alpha) == (other.red, other.green, other.blue, other.alpha)
def __ne__(self, other: object, /) -> bool:
"""Check if two `rgba` objects are different colors."""
+
return not self.__eq__(other)
def __repr__(self) -> str:
- return f"rgba({self.r}, {self.g}, {self.b}{'' if self.a is None else f', {self.a}'})"
+ return f"rgba({self.red}, {self.green}, {self.blue}{'' if self.alpha is None else f', {self.alpha}'})"
def __str__(self) -> str:
return self.__repr__()
def dict(self) -> RgbaDict:
- """Returns the color components as a dictionary with keys `"r"`, `"g"`, `"b"` and optionally `"a"`."""
- return {"r": self.r, "g": self.g, "b": self.b, "a": self.a}
+ """Returns the color components as a dictionary with keys `"red"`, `"green"`, `"blue"` and optionally `"alpha"`."""
+
+ return RgbaDict(red=self.red, green=self.green, blue=self.blue, alpha=self.alpha)
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
+ """Returns the color components as separate values `red, green, blue, alpha`."""
+
+ return self.red, self.green, self.blue, self.alpha
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)
+
+ hue, sat, light = self._rgb_to_hsl(self.red, self.green, self.blue)
+ return hsla(hue, sat, light, self.alpha, _validate=False)
def to_hexa(self) -> hexa:
"""Returns the color as `hexa()` color object."""
- return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a)
+
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
def has_alpha(self) -> bool:
"""Returns `True` if the color has an alpha channel and `False` otherwise."""
- return self.a is not None
+
+ return self.alpha is not None
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}")
- 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)
+ self.red, self.green, self.blue, self.alpha = self.to_hsla().lighten(amount).to_rgba().values()
+ return rgba(self.red, self.green, self.blue, self.alpha, _validate=False)
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}")
- 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)
+ self.red, self.green, self.blue, self.alpha = self.to_hsla().darken(amount).to_rgba().values()
+ return rgba(self.red, self.green, self.blue, self.alpha, _validate=False)
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}")
- 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)
+ self.red, self.green, self.blue, self.alpha = self.to_hsla().saturate(amount).to_rgba().values()
+ return rgba(self.red, self.green, self.blue, self.alpha, _validate=False)
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}")
- 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)
+ self.red, self.green, self.blue, self.alpha = self.to_hsla().desaturate(amount).to_rgba().values()
+ return rgba(self.red, self.green, self.blue, self.alpha, _validate=False)
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)
+
+ self.red, self.green, self.blue, self.alpha = self.to_hsla().rotate(degrees).to_rgba().values()
+ return rgba(self.red, self.green, self.blue, self.alpha, _validate=False)
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)
+
+ self.red, self.green, self.blue = 255 - self.red, 255 - self.green, 255 - self.blue
+ if invert_alpha and self.alpha is not None:
+ self.alpha = 1 - self.alpha
+ return rgba(self.red, self.green, self.blue, self.alpha, _validate=False)
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:
- * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
- * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
- * `"simple"` Simple arithmetic mean (less accurate)
- * `"bt601"` ITU-R BT.601 standard (older TV standard)"""
+ -------------------------------------------------------------------------------
+ * `method` โ The luminance calculation method to use:
+ - `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
+ - `"wcag3"` draft WCAG 3.0 standard with improved coefficients
+ - `"simple"` simple arithmetic mean (less accurate)
+ - `"bt601"` ITU-R BT.601 standard (older TV standard)"""
+
# THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
- self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method))
- return rgba(self.r, self.g, self.b, self.a, _validate=False)
+ self.red = self.green = self.blue = int(Color.luminance(self.red, self.green, self.blue, method=method))
+ return rgba(self.red, self.green, self.blue, self.alpha, _validate=False)
def blend(self, other: Rgba, /, ratio: float = 0.5, *, additive_alpha: bool = False) -> rgba:
"""Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n
----------------------------------------------------------------------------------------------------------
- - `other` -โ the other RGBA color to blend with
- - `ratio` -โ the blend ratio between the two colors:
- * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
- * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
- * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
- - `additive_alpha` -โ whether to blend the alpha channels additively or not"""
+ * `other` โ The other RGBA color to blend with.
+ * `ratio` โ The blend ratio between the two colors:
+ - If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture).
+ - If `ratio` is `0.5` it means 50% of both colors (1:1 mixture).
+ - If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture).
+ * `additive_alpha` โ Whether to blend the alpha channels additively or not."""
+
if not (0.0 <= ratio <= 1.0):
raise ValueError(f"The 'ratio' parameter must be in range [0.0, 1.0] inclusive, got {ratio!r}")
other_rgba = Color.to_rgba(other)
-
ratio *= 2
- 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)
+
+ self.red = int(max(0, min(255, int((self.red * (2 - ratio)) + (other_rgba.red * ratio) + 0.5))))
+ self.green = int(max(0, min(255, int((self.green * (2 - ratio)) + (other_rgba.green * ratio) + 0.5))))
+ self.blue = int(max(0, min(255, int((self.blue * (2 - ratio)) + (other_rgba.blue * ratio) + 0.5))))
+ none_alpha = self.alpha is None and (len(other_rgba) <= 3 or other_rgba[3] is None)
if not none_alpha:
- self_a: float = 1.0 if self.a is None else self.a
+ self_a: float = 1.0 if self.alpha is None else self.alpha
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)))
+ self.alpha = max(0, min(1, (self_a * (2 - ratio)) + (other_a * ratio)))
else:
- self.a = max(0, min(1, (self_a * (1 - (ratio / 2))) + (other_a * (ratio / 2))))
+ self.alpha = max(0, min(1, (self_a * (1 - (ratio / 2))) + (other_a * (ratio / 2))))
else:
- self.a = None
+ self.alpha = None
- return rgba(self.r, self.g, self.b, None if none_alpha else self.a, _validate=False)
+ return rgba(self.red, self.green, self.blue, None if none_alpha else self.alpha, _validate=False)
def is_dark(self) -> bool:
"""Returns `True` if the color is considered dark (`lightness < 50%`)."""
+
return self.to_hsla().is_dark()
def is_light(self) -> bool:
"""Returns `True` if the color is considered light (`lightness >= 50%`)."""
+
return not self.is_dark()
def is_grayscale(self) -> bool:
"""Returns `True` if the color is grayscale."""
- return self.r == self.g == self.b
+
+ return self.red == self.green == self.blue
def is_opaque(self) -> bool:
"""Returns `True` if the color has no transparency."""
- return self.a == 1 or self.a is None
+
+ return self.alpha == 1 or self.alpha is None
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}")
- return rgba(self.r, self.g, self.b, alpha, _validate=False)
+ return rgba(self.red, self.green, self.blue, alpha, _validate=False)
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[int, int, int]:
+ def _rgb_to_hsl(red: int, green: int, blue: 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)
- l = (max_c + min_c) / 2
+
+ _red, _green, _blue = red / 255.0, green / 255.0, blue / 255.0
+ max_c, min_c = max(_red, _green, _blue), min(_red, _green, _blue)
+ light = (max_c + min_c) / 2
if max_c == min_c:
- h = s = 0.0
+ hue = sat = 0.0
+
else:
delta = max_c - min_c
- s = delta / (1 - abs(2 * l - 1))
+ sat = delta / (1 - abs(2 * light - 1))
- if max_c == _r:
- h = ((_g - _b) / delta) % 6
- elif max_c == _g:
- h = ((_b - _r) / delta) + 2
+ if max_c == _red:
+ hue = ((_green - _blue) / delta) % 6
+ elif max_c == _green:
+ hue = ((_blue - _red) / delta) + 2
else:
- h = ((_r - _g) / delta) + 4
- h /= 6
+ hue = ((_red - _green) / delta) + 4
+
+ hue /= 6
- return int(round(h * 360)), int(round(s * 100)), int(round(l * 100))
+ return int(round(hue * 360)), int(round(sat * 100)), int(round(light * 100))
class hsla:
"""A HSL/HSLA color object that includes a bunch of methods to manipulate the color.\n
---------------------------------------------------------------------------------------
- - `h` -โ the hue channel in range [0, 360] inclusive
- - `s` -โ the saturation channel in range [0, 100] inclusive
- - `l` -โ the lightness channel in range [0, 100] inclusive
- - `a` -โ the alpha channel in range [0.0, 1.0] inclusive
- or `None` if the color has no alpha channel\n
+ * `hue` โ The hue channel in range [0, 360] inclusive.
+ * `sat` โ The saturation channel in range [0, 100] inclusive.
+ * `light` โ The lightness channel in range [0, 100] inclusive.
+ * `alpha` โ The alpha channel in range [0.0, 1.0] inclusive
+ or `None` if the color has no alpha channel.
---------------------------------------------------------------------------------------
Includes methods:
- - `to_rgba()` to convert to RGB color
- - `to_hexa()` to convert to HEX color
- - `has_alpha()` to check if the color has an alpha channel
- - `lighten(amount)` to create a lighter version of the color
- - `darken(amount)` to create a darker version of the color
- - `saturate(amount)` to increase color saturation
- - `desaturate(amount)` to decrease color saturation
- - `rotate(degrees)` to rotate the hue by degrees
- - `invert()` to get the inverse color
- - `grayscale()` to convert to grayscale
- - `blend(other, ratio)` to blend with another color
- - `is_dark()` to check if the color is considered dark
- - `is_light()` to check if the color is considered light
- - `is_grayscale()` to check if the color is grayscale
- - `is_opaque()` to check if the color has no transparency
- - `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):
- self.h: int
+ * `to_rgba()` to convert to RGB color
+ * `to_hexa()` to convert to HEX color
+ * `has_alpha()` to check if the color has an alpha channel
+ * `lighten(amount)` to create a lighter version of the color
+ * `darken(amount)` to create a darker version of the color
+ * `saturate(amount)` to increase color saturation
+ * `desaturate(amount)` to decrease color saturation
+ * `rotate(degrees)` to rotate the hue by degrees
+ * `invert()` to get the inverse color
+ * `grayscale()` to convert to grayscale
+ * `blend(other, ratio)` to blend with another color
+ * `is_dark()` to check if the color is considered dark
+ * `is_light()` to check if the color is considered light
+ * `is_grayscale()` to check if the color is grayscale
+ * `is_opaque()` to check if the color has no transparency
+ * `with_alpha(alpha)` to create a new color with different alpha
+ * `complementary()` to get the complementary color"""
+
+ def __init__(self, hue: int, sat: int, light: int, alpha: Optional[float] = None, /, *, _validate: bool = True):
+ self.hue: int
"""The hue channel in range [0, 360] inclusive."""
- self.s: int
+ self.sat: int
"""The saturation channel in range [0, 100] inclusive."""
- self.l: int
+ self.light: int
"""The lightness channel in range [0, 100] inclusive."""
- self.a: Optional[float]
+ self.alpha: Optional[float]
"""The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
if not _validate:
- self.h, self.s, self.l, self.a = h, s, l, a
+ self.hue, self.sat, self.light, self.alpha = hue, sat, light, alpha
return
- if not (0 <= h <= 360):
- raise ValueError(f"The 'h' parameter must be in range [0, 360] inclusive, got {h!r}")
- if not all((0 <= x <= 100) for x in (s, l)):
- raise ValueError(f"The 's' and 'l' parameters must be in range [0, 100] inclusive, got {s=} {l=}")
- if a is not None and not (0.0 <= a <= 1.0):
- raise ValueError(f"The 'a' parameter must be in range [0.0, 1.0] inclusive, got {a!r}")
+ if not (0 <= hue <= 360):
+ raise ValueError(f"The 'hue' parameter must be in range [0, 360] inclusive, got {hue!r}")
+ if not all((0 <= ch <= 100) for ch in (sat, light)):
+ raise ValueError(f"The 'sat' and 'light' parameters must be in range [0, 100] inclusive, got {sat=!r} {light=!r}")
+ if alpha is not None and not (0.0 <= alpha <= 1.0):
+ raise ValueError(f"The 'alpha' parameter must be in range [0.0, 1.0] inclusive, got {alpha!r}")
- self.h, self.s, self.l = h, s, l
- self.a = None if a is None else (1.0 if a > 1.0 else float(a))
+ self.hue, self.sat, self.light = hue, sat, light
+ self.alpha = None if alpha is None else (1.0 if alpha > 1.0 else float(alpha))
def __len__(self) -> int:
"""The number of components in the color (3 or 4)."""
- return 3 if self.a is None else 4
+
+ return 3 if self.alpha is None else 4
def __iter__(self) -> Iterator[int | Optional[float]]:
- return iter((self.h, self.s, self.l) + (() if self.a is None else (self.a, )))
+ return iter((self.hue, self.sat, self.light) + (() if self.alpha is None else (self.alpha, )))
@overload
def __getitem__(self, index: Literal[0, 1, 2], /) -> int:
@@ -339,236 +366,258 @@ 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]
+ return ((self.hue, self.sat, self.light) + (() if self.alpha is None else (self.alpha, )))[index]
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)
+ return (self.hue, self.sat, self.light, self.alpha) == (other.hue, other.sat, other.light, other.alpha)
def __ne__(self, other: object, /) -> bool:
"""Check if two `hsla` objects are different colors."""
+
return not self.__eq__(other)
def __repr__(self) -> str:
- return f"hsla({self.h}ยฐ, {self.s}%, {self.l}%{'' if self.a is None else f', {self.a}'})"
+ return f"hsla({self.hue}ยฐ, {self.sat}%, {self.light}%{'' if self.alpha is None else f', {self.alpha}'})"
def __str__(self) -> str:
return self.__repr__()
def dict(self) -> HslaDict:
- """Returns the color components as a dictionary with keys `"h"`, `"s"`, `"l"` and optionally `"a"`."""
- return {"h": self.h, "s": self.s, "l": self.l, "a": self.a}
+ """Returns the color components as a dictionary with keys `"hue"`, `"sat"`, `"light"` and optionally `"alpha"`."""
+
+ return HslaDict(hue=self.hue, sat=self.sat, light=self.light, alpha=self.alpha)
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
+ """Returns the color components as separate values `hue, sat, light, alpha`."""
+
+ return self.hue, self.sat, self.light, self.alpha
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)
+
+ red, green, blue = self._hsl_to_rgb(self.hue, self.sat, self.light)
+ return rgba(red, green, blue, self.alpha, _validate=False)
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=r, _g=g, _b=b, _a=self.a)
+
+ red, green, blue = self._hsl_to_rgb(self.hue, self.sat, self.light)
+ return hexa(_red=red, _green=green, _blue=blue, _alpha=self.alpha)
def has_alpha(self) -> bool:
"""Returns `True` if the color has an alpha channel and `False` otherwise."""
- return self.a is not None
+
+ return self.alpha is not None
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}")
- self.l = int(min(100, self.l + (100 - self.l) * amount))
- return hsla(self.h, self.s, self.l, self.a, _validate=False)
+ self.light = int(min(100, self.light + (100 - self.light) * amount))
+ return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False)
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}")
- self.l = int(max(0, self.l * (1 - amount)))
- return hsla(self.h, self.s, self.l, self.a, _validate=False)
+ self.light = int(max(0, self.light * (1 - amount)))
+ return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False)
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}")
- self.s = int(min(100, self.s + (100 - self.s) * amount))
- return hsla(self.h, self.s, self.l, self.a, _validate=False)
+ self.sat = int(min(100, self.sat + (100 - self.sat) * amount))
+ return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False)
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}")
- self.s = int(max(0, self.s * (1 - amount)))
- return hsla(self.h, self.s, self.l, self.a, _validate=False)
+ self.sat = int(max(0, self.sat * (1 - amount)))
+ return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False)
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)
+
+ self.hue = (self.hue + degrees) % 360
+ return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False)
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
- if invert_alpha and self.a is not None:
- self.a = 1 - self.a
- return hsla(self.h, self.s, self.l, self.a, _validate=False)
+ self.hue = (self.hue + 180) % 360
+ self.light = 100 - self.light
+ if invert_alpha and self.alpha is not None:
+ self.alpha = 1 - self.alpha
+ return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False)
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:
- * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
- * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
- * `"simple"` Simple arithmetic mean (less accurate)
- * `"bt601"` ITU-R BT.601 standard (older TV standard)"""
+ -------------------------------------------------------------------------------
+ * `method` โ the luminance calculation method to use:
+ - `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
+ - `"wcag3"` draft WCAG 3.0 standard with improved coefficients
+ - `"simple"` simple arithmetic mean (less accurate)
+ - `"bt601"` ITU-R BT.601 standard (older TV standard)"""
+
# THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
- r, g, b = self._hsl_to_rgb(self.h, self.s, self.l)
- l = int(Color.luminance(r, g, b, output_type=None, method=method))
- self.h, self.s, self.l, _ = rgba(l, l, l, _validate=False).to_hsla().values()
- return hsla(self.h, self.s, self.l, self.a, _validate=False)
+ red, green, blue = self._hsl_to_rgb(self.hue, self.sat, self.light)
+ light = int(Color.luminance(red, green, blue, output_type=None, method=method))
+
+ self.hue, self.sat, self.light, _ = rgba(light, light, light, _validate=False).to_hsla().values()
+ return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False)
def blend(self, other: Hsla, /, ratio: float = 0.5, *, additive_alpha: bool = False) -> hsla:
"""Blends the current color with another color using the specified ratio in range [0.0, 1.0] inclusive.\n
----------------------------------------------------------------------------------------------------------
- - `other` -โ the other HSLA color to blend with
- - `ratio` -โ the blend ratio between the two colors:
- * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
- * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
- * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
- - `additive_alpha` -โ whether to blend the alpha channels additively or not"""
+ * `other` โ The other HSLA color to blend with.
+ * `ratio` โ The blend ratio between the two colors:
+ - If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture).
+ - If `ratio` is `0.5` it means 50% of both colors (1:1 mixture).
+ - If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture).
+ * `additive_alpha` โ whether to blend the alpha channels additively or not."""
+
if not Color.is_valid_hsla(other):
raise TypeError(f"The 'other' parameter must be a valid HSLA color, got {type(other)}")
if not (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=additive_alpha,
+ self.hue, self.sat, self.light, self.alpha = 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)
+ return hsla(self.hue, self.sat, self.light, self.alpha, _validate=False)
def is_dark(self) -> bool:
"""Returns `True` if the color is considered dark (`lightness < 50%`)."""
- return self.l < 50
+
+ return self.light < 50
def is_light(self) -> bool:
"""Returns `True` if the color is considered light (`lightness >= 50%`)."""
+
return not self.is_dark()
def is_grayscale(self) -> bool:
"""Returns `True` if the color is considered grayscale."""
- return self.s == 0
+
+ return self.sat == 0
def is_opaque(self) -> bool:
"""Returns `True` if the color has no transparency."""
- return self.a == 1 or self.a is None
+
+ return self.alpha == 1 or self.alpha is None
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)}")
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 hsla(self.h, self.s, self.l, alpha, _validate=False)
+ return hsla(self.hue, self.sat, self.light, alpha, _validate=False)
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)
+
+ return hsla((self.hue + 180) % 360, self.sat, self.light, self.alpha, _validate=False)
@classmethod
- def _hsl_to_rgb(cls, h: int, s: int, l: int) -> tuple[int, int, int]:
+ def _hsl_to_rgb(cls, hue: int, sat: int, light: int) -> tuple[int, int, int]:
"""Internal method to convert HSL to RGB color space."""
- _h, _s, _l = h / 360, s / 100, l / 100
- if _s == 0:
- r = g = b = int(_l * 255)
+ _hue, _sat, _light = hue / 360, sat / 100, light / 100
+
+ if _sat == 0:
+ red = green = blue = int(_light * 255)
+
else:
- q = _l * (1 + _s) if _l < 0.5 else _l + _s - _l * _s
- p = 2 * _l - q
- r = int(round(cls._hue_to_rgb(p, q, _h + 1 / 3) * 255))
- g = int(round(cls._hue_to_rgb(p, q, _h) * 255))
- b = int(round(cls._hue_to_rgb(p, q, _h - 1 / 3) * 255))
+ chroma_max = _light * (1 + _sat) if _light < 0.5 else _light + _sat - _light * _sat
+ chroma_min = 2 * _light - chroma_max
+
+ red = int(round(cls._hue_to_rgb(chroma_min, chroma_max, _hue + 1 / 3) * 255))
+ green = int(round(cls._hue_to_rgb(chroma_min, chroma_max, _hue) * 255))
+ blue = int(round(cls._hue_to_rgb(chroma_min, chroma_max, _hue - 1 / 3) * 255))
- return r, g, b
+ return red, green, blue
@staticmethod
- def _hue_to_rgb(p: float, q: float, t: float) -> float:
- if t < 0:
- t += 1
- if t > 1:
- t -= 1
- if t < 1 / 6:
- return p + (q - p) * 6 * t
- if t < 1 / 2:
- return q
- if t < 2 / 3:
- return p + (q - p) * (2 / 3 - t) * 6
- return p
+ def _hue_to_rgb(chroma_min: float, chroma_max: float, hue_pos: float) -> float:
+ if hue_pos < 0:
+ hue_pos += 1
+ if hue_pos > 1:
+ hue_pos -= 1
+ if hue_pos < 1 / 6:
+ return chroma_min + (chroma_max - chroma_min) * 6 * hue_pos
+ if hue_pos < 1 / 2:
+ return chroma_max
+ if hue_pos < 2 / 3:
+ return chroma_min + (chroma_max - chroma_min) * (2 / 3 - hue_pos) * 6
+ return chroma_min
class hexa:
"""A HEXA color object that includes a bunch of methods to manipulate the color.\n
- --------------------------------------------------------------------------------------------
- - `color` -โ the HEXA color string (prefix optional) or HEX integer, that can be in formats:
- * `RGB` short format without alpha (only for strings)
- * `RGBA` short format with alpha (only for strings)
- * `RRGGBB` long format without alpha (for strings and HEX integers)
- * `RRGGBBAA` long format with alpha (for strings and HEX integers)
- --------------------------------------------------------------------------------------------
+ ----------------------------------------------------------------------------------------------
+ * `color` โ The HEXA color string (prefix optional) or HEX integer, that can be in formats:
+ - `RGB` short format without alpha (only for strings)
+ - `RGBA` short format with alpha (only for strings)
+ - `RRGGBB` long format without alpha (for strings and HEX integers)
+ - `RRGGBBAA` long format with alpha (for strings and HEX integers)
+ ----------------------------------------------------------------------------------------------
Includes methods:
- - `to_rgba()` to convert to RGB color
- - `to_hsla()` to convert to HSL color
- - `has_alpha()` to check if the color has an alpha channel
- - `lighten(amount)` to create a lighter version of the color
- - `darken(amount)` to create a darker version of the color
- - `saturate(amount)` to increase color saturation
- - `desaturate(amount)` to decrease color saturation
- - `rotate(degrees)` to rotate the hue by degrees
- - `invert()` to get the inverse color
- - `grayscale()` to convert to grayscale
- - `blend(other, ratio)` to blend with another color
- - `is_dark()` to check if the color is considered dark
- - `is_light()` to check if the color is considered light
- - `is_grayscale()` to check if the color is grayscale
- - `is_opaque()` to check if the color has no transparency
- - `with_alpha(alpha)` to create a new color with different alpha
- - `complementary()` to get the complementary color"""
+ * `to_rgba()` to convert to RGB color
+ * `to_hsla()` to convert to HSL color
+ * `has_alpha()` to check if the color has an alpha channel
+ * `lighten(amount)` to create a lighter version of the color
+ * `darken(amount)` to create a darker version of the color
+ * `saturate(amount)` to increase color saturation
+ * `desaturate(amount)` to decrease color saturation
+ * `rotate(degrees)` to rotate the hue by degrees
+ * `invert()` to get the inverse color
+ * `grayscale()` to convert to grayscale
+ * `blend(other, ratio)` to blend with another color
+ * `is_dark()` to check if the color is considered dark
+ * `is_light()` to check if the color is considered light
+ * `is_grayscale()` to check if the color is grayscale
+ * `is_opaque()` to check if the color has no transparency
+ * `with_alpha(alpha)` to create a new color with different alpha
+ * `complementary()` to get the complementary color"""
def __init__(
self,
color: Optional[str | int] = None,
/,
*,
- _r: Optional[int] = None,
- _g: Optional[int] = None,
- _b: Optional[int] = None,
- _a: Optional[float] = None,
+ _red: Optional[int] = None,
+ _green: Optional[int] = None,
+ _blue: Optional[int] = None,
+ _alpha: Optional[float] = None,
) -> None:
- self.r: int
+ self.red: int
"""The red channel in range [0, 255] inclusive."""
- self.g: int
+ self.green: int
"""The green channel in range [0, 255] inclusive."""
- self.b: int
+ self.blue: int
"""The blue channel in range [0, 255] inclusive."""
- self.a: Optional[float]
+ self.alpha: Optional[float]
"""The alpha channel in range [0.0, 1.0] inclusive or `None` if not set."""
- if all(x is not None for x in (_r, _g, _b)):
- self.r, self.g, self.b, self.a = cast(int, _r), cast(int, _g), cast(int, _b), _a
+ if all(ch is not None for ch in (_red, _green, _blue)):
+ self.red, self.green, self.blue, self.alpha = cast(int, _red), cast(int, _green), cast(int, _blue), _alpha
return
if isinstance(color, hexa):
@@ -581,28 +630,28 @@ def __init__(
color = color[2:].upper()
if len(color) == 3: # RGB
- self.r, self.g, self.b, self.a = (
+ self.red, self.green, self.blue, self.alpha = (
int(color[0] * 2, 16),
int(color[1] * 2, 16),
int(color[2] * 2, 16),
None,
)
elif len(color) == 4: # RGBA
- self.r, self.g, self.b, self.a = (
+ self.red, self.green, self.blue, self.alpha = (
int(color[0] * 2, 16),
int(color[1] * 2, 16),
int(color[2] * 2, 16),
int(color[3] * 2, 16) / 255.0,
)
elif len(color) == 6: # RRGGBB
- self.r, self.g, self.b, self.a = (
+ self.red, self.green, self.blue, self.alpha = (
int(color[0:2], 16),
int(color[2:4], 16),
int(color[4:6], 16),
None,
)
elif len(color) == 8: # RRGGBBAA
- self.r, self.g, self.b, self.a = (
+ self.red, self.green, self.blue, self.alpha = (
int(color[0:2], 16),
int(color[2:4], 16),
int(color[4:6], 16),
@@ -612,170 +661,198 @@ def __init__(
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()
+ self.red, self.green, self.blue, self.alpha = 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
+
+ return 3 if self.alpha is None else 4
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}", )))
+ return iter((f"{self.red:02X}", f"{self.green:02X}", f"{self.blue:02X}")
+ + (() if self.alpha is None else (f"{int(self.alpha * 255):02X}", )))
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]
+ return ((f"{self.red:02X}", f"{self.green:02X}", f"{self.blue:02X}") \
+ + (() if self.alpha is None else (f"{int(self.alpha * 255):02X}", )))[index]
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)
+ return (self.red, self.green, self.blue, self.alpha) == (other.red, other.green, other.blue, other.alpha)
def __ne__(self, other: object, /) -> bool:
"""Check if two `hexa` objects are different colors."""
+
return not self.__eq__(other)
def __repr__(self) -> str:
- return f"hexa(#{self.r:02X}{self.g:02X}{self.b:02X}{'' if self.a is None else f'{int(self.a * 255):02X}'})"
+ alpha = "" if self.alpha is None else f"{int(self.alpha * 255):02X}"
+ return f"hexa(#{self.red:02X}{self.green:02X}{self.blue:02X}{alpha})"
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}'}"
+ alpha = "" if self.alpha is None else f"{int(self.alpha * 255):02X}"
+ return f"#{self.red:02X}{self.green:02X}{self.blue:02X}{alpha}"
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}"
- }
+ """Returns the color components as a dictionary with hex string values
+ for keys `"red"`, `"green"`, `"blue"` and optionally `"alpha"`."""
+
+ return HexaDict(
+ red=f"{self.red:02X}",
+ green=f"{self.green:02X}",
+ blue=f"{self.blue:02X}",
+ alpha=(None if self.alpha is None else f"{int(self.alpha * 255):02X}"),
+ )
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)
+ """Returns the color components as separate values `red, green, blue, alpha`."""
+
+ return self.red, self.green, self.blue, None if self.alpha is None else (
+ round(self.alpha, 2) if round_alpha else self.alpha
+ )
def to_rgba(self, *, round_alpha: bool = True) -> rgba:
"""Returns the color as `rgba()` color object."""
+
return rgba(
- self.r,
- self.g,
- self.b,
- None if self.a is None else (round(self.a, 2) if round_alpha else self.a),
+ self.red,
+ self.green,
+ self.blue,
+ None if self.alpha is None else (round(self.alpha, 2) if round_alpha else self.alpha),
_validate=False,
)
def to_hsla(self, *, round_alpha: bool = True) -> hsla:
"""Returns the color as `hsla()` color object."""
+
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
+
+ return self.alpha is not None
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(round_alpha=False).lighten(amount).values()
- return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a)
+ self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).lighten(amount).values()
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
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(round_alpha=False).darken(amount).values()
- return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a)
+ self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).darken(amount).values()
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
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(round_alpha=False).saturate(amount).values()
- return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a)
+ self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).saturate(amount).values()
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
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(round_alpha=False).desaturate(amount).values()
- return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a)
+ self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).desaturate(amount).values()
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
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(round_alpha=False).rotate(degrees).values()
- return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a)
+
+ self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).rotate(degrees).values()
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
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(round_alpha=False).invert().values()
- if invert_alpha and self.a is not None:
- self.a = 1 - self.a
- return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a)
+ self.red, self.green, self.blue, self.alpha = self.to_rgba(round_alpha=False).invert().values()
+ if invert_alpha and self.alpha is not None:
+ self.alpha = 1 - self.alpha
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
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:
- * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
- * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
- * `"simple"` Simple arithmetic mean (less accurate)
- * `"bt601"` ITU-R BT.601 standard (older TV standard)"""
+ -------------------------------------------------------------------------------
+ * `method` โ The luminance calculation method to use:
+ - `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
+ - `"wcag3"` draft WCAG 3.0 standard with improved coefficients
+ - `"simple"` simple arithmetic mean (less accurate)
+ - `"bt601"` ITU-R BT.601 standard (older TV standard)"""
+
# THE 'method' PARAM IS CHECKED IN 'Color.luminance()'
- self.r = self.g = self.b = int(Color.luminance(self.r, self.g, self.b, method=method))
- return hexa(_r=self.r, _g=self.g, _b=self.b, _a=self.a)
+ self.red = self.green = self.blue = int(Color.luminance(self.red, self.green, self.blue, method=method))
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
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
- - `ratio` -โ the blend ratio between the two colors:
- * if `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture)
- * if `ratio` is `0.5` it means 50% of both colors (1:1 mixture)
- * if `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture)
- - `additive_alpha` -โ whether to blend the alpha channels additively or not"""
+ * `other` โ The other HEXA color to blend with.
+ * `ratio` โ The blend ratio between the two colors:
+ - If `ratio` is `0.0` it means 100% of the current color and 0% of the `other` color (2:0 mixture).
+ - If `ratio` is `0.5` it means 50% of both colors (1:1 mixture).
+ - If `ratio` is `1.0` it means 0% of the current color and 100% of the `other` color (0:2 mixture).
+ * `additive_alpha` โ Whether to blend the alpha channels additively or not."""
+
if not Color.is_valid_hexa(other):
raise TypeError(f"The 'other' parameter must be a valid HEXA color, got {type(other)}")
if not (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(round_alpha=False).blend(
+ self.red, self.green, self.blue, self.alpha = 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)
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=self.alpha)
def is_dark(self) -> bool:
"""Returns `True` if the color is considered dark (`lightness < 50%`)."""
+
return self.to_hsla(round_alpha=False).is_dark()
def is_light(self) -> bool:
"""Returns `True` if the color is considered light (`lightness >= 50%`)."""
+
return not self.is_dark()
def is_grayscale(self) -> bool:
"""Returns `True` if the color is grayscale (`saturation == 0`)."""
+
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
+
+ return self.alpha == 1 or self.alpha is None
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(_r=self.r, _g=self.g, _b=self.b, _a=alpha)
+ return hexa(_red=self.red, _green=self.green, _blue=self.blue, _alpha=alpha)
def complementary(self) -> hexa:
"""Returns the complementary color (180 degrees on the color wheel)."""
+
return self.to_hsla(round_alpha=False).complementary().to_hexa()
@@ -785,9 +862,9 @@ class Color:
@classmethod
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)
- - `allow_alpha` -โ whether to allow alpha channel in the color"""
+ --------------------------------------------------------------------
+ * `color` โ The color to check (can be in any supported format).
+ * `allow_alpha` โ Whether to allow alpha channel in the color."""
try:
if isinstance(color, rgba):
return True
@@ -814,15 +891,15 @@ def is_valid_rgba(cls, color: AnyRgba, /, *, allow_alpha: bool = True) -> bool:
if (allow_alpha \
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)))
+ and all(isinstance(dict_color.get(ch), int) for ch in ("red", "green", "blue"))
+ and isinstance(dict_color.get("alpha", "no alpha"), (float, type(None)))
):
return (
- 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)
+ 0 <= dict_color["red"] <= 255 and 0 <= dict_color["green"] <= 255 and 0 <= dict_color["blue"] <= 255
+ and (dict_color["alpha"] is None or 0 <= dict_color["alpha"] <= 1)
)
- 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
+ elif len(dict_color) == 3 and all(isinstance(dict_color.get(ch), int) for ch in ("red", "green", "blue")):
+ return 0 <= dict_color["red"] <= 255 and 0 <= dict_color["green"] <= 255 and 0 <= dict_color["blue"] <= 255
else:
return False
@@ -836,9 +913,10 @@ def is_valid_rgba(cls, color: AnyRgba, /, *, allow_alpha: bool = True) -> bool:
@classmethod
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)
- - `allow_alpha` -โ whether to allow alpha channel in the color"""
+ --------------------------------------------------------------------
+ * `color` โ The color to check (can be in any supported format).
+ * `allow_alpha` โ Whether to allow alpha channel in the color."""
+
try:
if isinstance(color, hsla):
return True
@@ -865,15 +943,15 @@ def is_valid_hsla(cls, color: AnyHsla, /, *, allow_alpha: bool = True) -> bool:
if (allow_alpha \
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)))
+ and all(isinstance(dict_color.get(ch), int) for ch in ("hue", "sat", "light"))
+ and isinstance(dict_color.get("alpha", "no alpha"), (float, type(None)))
):
return (
- 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)
+ 0 <= dict_color["hue"] <= 360 and 0 <= dict_color["sat"] <= 100 and 0 <= dict_color["light"] <= 100
+ and (dict_color["alpha"] is None or 0 <= dict_color["alpha"] <= 1)
)
- 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
+ elif len(dict_color) == 3 and all(isinstance(dict_color.get(ch), int) for ch in ("hue", "sat", "light")):
+ return 0 <= dict_color["hue"] <= 360 and 0 <= dict_color["sat"] <= 100 and 0 <= dict_color["light"] <= 100
else:
return False
@@ -930,10 +1008,11 @@ def is_valid_hexa(
get_prefix: bool = False,
) -> bool | tuple[bool, Optional[Literal["#", "0x"]]]:
"""Check if the given color is a valid HEXA color.\n
- ---------------------------------------------------------------------------------------------------
- - `color` -โ the color to check (can be in any supported format)
- - `allow_alpha` -โ whether to allow alpha channel in the color
- - `get_prefix` -โ if true, the prefix used in the color (if any) is returned along with validity"""
+ ------------------------------------------------------------------------------------------------------
+ * `color` โ The color to check (can be in any supported format).
+ * `allow_alpha` โ Whether to allow alpha channel in the color.
+ * `get_prefix` โ If true, the prefix used in the color (if any) is returned along with validity."""
+
try:
if isinstance(color, hexa):
return (True, "#") if get_prefix else True
@@ -958,9 +1037,10 @@ def is_valid_hexa(
@classmethod
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"""
+ --------------------------------------------------------------------
+ * `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=allow_alpha) \
or cls.is_valid_hsla(color, allow_alpha=allow_alpha) \
@@ -970,8 +1050,9 @@ def is_valid(cls, color: AnyRgba | AnyHsla | AnyHexa, /, *, allow_alpha: bool =
@classmethod
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)"""
+ ----------------------------------------------------------------------
+ * `color` โ The color to check (can be in any supported format)."""
+
if isinstance(color, (rgba, hsla, hexa)):
return color.has_alpha()
@@ -1002,8 +1083,9 @@ def has_alpha(cls, color: Rgba | Hsla | Hexa, /) -> bool:
@classmethod
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)"""
+ ------------------------------------------------------------------------
+ * `color` โ The color to convert (can be in any supported format)."""
+
if isinstance(color, (hsla, hexa)):
return color.to_rgba()
elif cls.is_valid_hsla(color):
@@ -1017,8 +1099,9 @@ def to_rgba(cls, color: Rgba | Hsla | Hexa, /) -> rgba:
@classmethod
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)"""
+ ------------------------------------------------------------------------
+ * `color` โ The color to convert (can be in any supported format)."""
+
if isinstance(color, (rgba, hexa)):
return color.to_hsla()
elif cls.is_valid_rgba(color):
@@ -1032,8 +1115,9 @@ def to_hsla(cls, color: Rgba | Hsla | Hexa, /) -> hsla:
@classmethod
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)"""
+ ------------------------------------------------------------------------
+ * `color` โ The color to convert (can be in any supported format)."""
+
if isinstance(color, (rgba, hsla)):
return color.to_hexa()
elif cls.is_valid_rgba(color):
@@ -1062,12 +1146,14 @@ def str_to_rgba(cls, string: str, /, *, only_first: bool = False) -> Optional[rg
@classmethod
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
- - `only_first` -โ if true, only the first found color will be returned, otherwise a list of all found colors"""
+ ------------------------------------------------------------------------------------------------------------------
+ * `string` โ The string to search for RGBA colors.
+ * `only_first` โ If true, only the first found color will be returned, otherwise a list of all found colors."""
+
if only_first:
if not (match := _re.search(Regex.rgba_str(allow_alpha=True), string)):
return None
+
groups = match.groups()
return rgba(
int(groups[0]),
@@ -1080,6 +1166,7 @@ def str_to_rgba(cls, string: str, /, *, only_first: bool = False) -> Optional[rg
else:
if not (matches := _re.findall(Regex.rgba_str(allow_alpha=True), string)):
return None
+
return [
rgba(
int(match[0]),
@@ -1108,73 +1195,80 @@ def str_to_hsla(cls, string: str, /, *, only_first: bool = False) -> Optional[hs
@classmethod
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
- - `only_first` -โ if true, only the first found color will be returned, otherwise a list of all found colors"""
+ ------------------------------------------------------------------------------------------------------------------
+ * `string` โ The string to search for HSLA colors.
+ * `only_first` โ If true, only the first found color will be returned, otherwise a list of all found colors."""
+
if only_first:
if not (match := _re.search(Regex.hsla_str(allow_alpha=True), string)):
return None
- m = match.groups()
+
+ groups = match.groups()
return hsla(
- 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(groups[0]),
+ int(groups[1]),
+ int(groups[2]),
+ ((int(groups[3]) if "." not in groups[3] else float(groups[3])) if groups[3] else None),
_validate=False,
)
else:
if not (matches := _re.findall(Regex.hsla_str(allow_alpha=True), string)):
return None
+
return [
hsla(
- 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
]
@classmethod
def rgba_to_hex_int(
cls,
- r: int,
- g: int,
- b: int,
- a: Optional[float] = None,
+ red: int,
+ green: int,
+ blue: int,
+ alpha: Optional[float] = None,
/,
*,
preserve_original: bool = False,
) -> int:
"""Convert RGBA channels to a HEXA integer (alpha is optional).\n
- --------------------------------------------------------------------------------------------
- - `r`, `g`, `b` -โ the red, green and blue channels (`0` โ `255`)
- - `a` -โ the alpha channel (`0.0` โ `1.0`) or `None` if not set
- - `preserve_original` -โ whether to preserve the original color exactly (explained below)\n
- --------------------------------------------------------------------------------------------
- To preserve leading zeros, the function will add a `1` at the beginning, if the HEX integer
- would start with a `0`.
- This could affect the color a little bit, but will make sure, that it won't be interpreted
- as a completely different color, when initializing it as a `hexa()` color or changing it
+ -----------------------------------------------------------------------------------------------
+ * `red`, `green`, `blue` โ The red, green, and blue channels in range [0, 255] inclusive.
+ * `alpha` โ The alpha channel in range [0.0, 1.0] inclusive or `None` if not set.
+ * `preserve_original` โ Whether to preserve the original color exactly (explained below).
+ -----------------------------------------------------------------------------------------------
+ To preserve leading zeros, the function will add a `1` at the beginning,
+ if the HEX integer would start with a `0`.\n
+ This could affect the color a little bit, but will make sure, that it won't be interpreted
+ as a completely different color, when initializing it as a `hexa()` color or changing it
back to RGBA using `Color.hex_int_to_rgba()`."""
- if not all((0 <= c <= 255) for c in (r, g, b)):
- raise ValueError(f"The 'r', 'g' and 'b' parameters must be integers in [0, 255], got {r=} {g=} {b=}")
- if a is not None and not (0.0 <= a <= 1.0):
- raise ValueError(f"The 'a' parameter must be a float in [0.0, 1.0] or None, got {a!r}")
- r = max(0, min(255, int(r)))
- g = max(0, min(255, int(g)))
- b = max(0, min(255, int(b)))
+ if not all((0 <= ch <= 255) for ch in (red, green, blue)):
+ raise ValueError(
+ "The 'red', 'green' and 'blue' parameters must be integers "
+ f"in [0, 255], got {red=!r} {green=!r} {blue=!r}"
+ )
+ if alpha is not None and not (0.0 <= alpha <= 1.0):
+ raise ValueError(f"The 'alpha' parameter must be a float in [0.0, 1.0] or None, got {alpha!r}")
+
+ red = max(0, min(255, int(red)))
+ green = max(0, min(255, int(green)))
+ blue = max(0, min(255, int(blue)))
- if a is None:
- hex_int = (r << 16) | (g << 8) | b
+ if alpha is None:
+ hex_int = (red << 16) | (green << 8) | blue
if not preserve_original and (hex_int & 0xF00000) == 0:
hex_int |= 0x010000
else:
- a = max(0, min(255, int(a * 255)))
- hex_int = (r << 24) | (g << 16) | (b << 8) | a
- if not preserve_original and r == 0:
+ alpha = max(0, min(255, int(alpha * 255)))
+ hex_int = (red << 24) | (green << 16) | (blue << 8) | alpha
+ if not preserve_original and red == 0:
hex_int |= 0x01000000
return hex_int
@@ -1182,20 +1276,21 @@ def rgba_to_hex_int(
@classmethod
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
- - `preserve_original` -โ whether to preserve the original color exactly (explained below)\n
- -------------------------------------------------------------------------------------------
- If the red channel is `1` after conversion, it will be set to `0`, because when converting
- from RGBA to a HEX integer, the first `0` will be set to `1` to preserve leading zeros.
+ -----------------------------------------------------------------------------------------------
+ * `hex_int` โ The HEX integer to convert.
+ * `preserve_original` โ Whether to preserve the original color exactly (explained below).
+ -----------------------------------------------------------------------------------------------
+ If the red channel is `1` after conversion, it will be set to `0`, because when converting
+ from RGBA to a HEX integer, the first `0` will be set to `1` to preserve leading zeros.\n
This is the correction, so the color doesn't even look slightly different."""
+
if not (0 <= hex_int <= 0xFFFFFFFF):
raise ValueError(f"Expected HEX integer in range [0x000000, 0xFFFFFFFF] inclusive, got 0x{hex_int:X}")
if len(hex_str := f"{hex_int:X}") <= 6:
hex_str = hex_str.zfill(6)
return rgba(
- r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
+ red if (red := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
int(hex_str[2:4], 16),
int(hex_str[4:6], 16),
None,
@@ -1205,7 +1300,7 @@ def hex_int_to_rgba(cls, hex_int: int, /, *, preserve_original: bool = False) ->
elif len(hex_str) <= 8:
hex_str = hex_str.zfill(8)
return rgba(
- r if (r := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
+ red if (red := int(hex_str[0:2], 16)) != 1 or preserve_original else 0,
int(hex_str[2:4], 16),
int(hex_str[4:6], 16),
int(hex_str[6:8], 16) / 255.0,
@@ -1219,9 +1314,9 @@ def hex_int_to_rgba(cls, hex_int: int, /, *, preserve_original: bool = False) ->
@classmethod
def luminance(
cls,
- r: int,
- g: int,
- b: int,
+ red: int,
+ green: int,
+ blue: int,
/,
*,
output_type: type[int],
@@ -1233,9 +1328,9 @@ def luminance(
@classmethod
def luminance(
cls,
- r: int,
- g: int,
- b: int,
+ red: int,
+ green: int,
+ blue: int,
/,
*,
output_type: type[float],
@@ -1247,9 +1342,9 @@ def luminance(
@classmethod
def luminance(
cls,
- r: int,
- g: int,
- b: int,
+ red: int,
+ green: int,
+ blue: int,
/,
*,
output_type: None = None,
@@ -1261,9 +1356,9 @@ def luminance(
@classmethod
def luminance(
cls,
- r: int,
- g: int,
- b: int,
+ red: int,
+ green: int,
+ blue: int,
/,
*,
output_type: Optional[type[int | float]] = None,
@@ -1274,45 +1369,49 @@ def luminance(
@classmethod
def luminance(
cls,
- r: int,
- g: int,
- b: int,
+ red: int,
+ green: int,
+ blue: int,
/,
*,
output_type: Optional[type[int | float]] = None,
method: Literal["wcag2", "wcag3", "simple", "bt601"] = "wcag2",
) -> int | float:
"""Calculates the relative luminance of a color according to various standards.\n
- ----------------------------------------------------------------------------------
- - `r`, `g`, `b` -โ the red, green and blue channels in range [0, 255] inclusive
- - `output_type` -โ the range of the returned luminance value:
- * `int` returns integer in range [0, 100] inclusive
- * `float` returns float in range [0.0, 1.0] inclusive
- * `None` returns integer in range [0, 255] inclusive
- - `method` -โ the luminance calculation method to use:
- * `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
- * `"wcag3"` Draft WCAG 3.0 standard with improved coefficients
- * `"simple"` Simple arithmetic mean (less accurate)
- * `"bt601"` ITU-R BT.601 standard (older TV standard)"""
- if not all(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=}")
-
- _r, _g, _b = r / 255.0, g / 255.0, b / 255.0
+ -------------------------------------------------------------------------------------------
+ * `red`, `green`, `blue` โ The red, green and blue channels in range [0, 255] inclusive.
+ * `output_type` โ The range of the returned luminance value:
+ - `int` returns integer in range [0, 100] inclusive.
+ - `float` returns float in range [0.0, 1.0] inclusive.
+ - `None` returns integer in range [0, 255] inclusive.
+ * `method` โ The luminance calculation method to use:
+ - `"wcag2"` WCAG 2.0 standard (default and most accurate for perception)
+ - `"wcag3"` draft WCAG 3.0 standard with improved coefficients
+ - `"simple"` simple arithmetic mean (less accurate)
+ - `"bt601"` ITU-R BT.601 standard (older TV standard)"""
+
+ if not all(0 <= ch <= 255 for ch in (red, green, blue)):
+ raise ValueError(
+ "The 'red', 'green' and 'blue' parameters must be integers "
+ f"in [0, 255], got {red=!r} {green=!r} {blue=!r}"
+ )
+
+ _red, _green, _blue = red / 255.0, green / 255.0, blue / 255.0
if method == "simple":
- luminance = (_r + _g + _b) / 3
+ luminance = (_red + _green + _blue) / 3
elif method == "bt601":
- luminance = 0.299 * _r + 0.587 * _g + 0.114 * _b
+ luminance = 0.299 * _red + 0.587 * _green + 0.114 * _blue
elif method == "wcag3":
- _r = cls._linearize_srgb(_r)
- _g = cls._linearize_srgb(_g)
- _b = cls._linearize_srgb(_b)
- luminance = 0.2126729 * _r + 0.7151522 * _g + 0.0721750 * _b
+ _red = cls._linearize_srgb(_red)
+ _green = cls._linearize_srgb(_green)
+ _blue = cls._linearize_srgb(_blue)
+ luminance = 0.2126729 * _red + 0.7151522 * _green + 0.0721750 * _blue
else:
- _r = cls._linearize_srgb(_r)
- _g = cls._linearize_srgb(_g)
- _b = cls._linearize_srgb(_b)
- luminance = 0.2126 * _r + 0.7152 * _g + 0.0722 * _b
+ _red = cls._linearize_srgb(_red)
+ _green = cls._linearize_srgb(_green)
+ _blue = cls._linearize_srgb(_blue)
+ luminance = 0.2126 * _red + 0.7152 * _green + 0.0722 * _blue
if output_type == int:
return round(luminance * 100)
@@ -1345,105 +1444,105 @@ def text_color_for_on_bg(cls, text_bg_color: Rgba | Hexa, /) -> rgba | hexa | in
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)"""
+ * `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_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(_r=255, _g=255, _b=255)) if was_hexa \
+ (0xFFFFFF if was_int else hexa(_red=255, _green=255, _blue=255)) if was_hexa \
else rgba(255, 255, 255, _validate=False)
) if brightness < 128 else (
- (0x000 if was_int else hexa(_r=0, _g=0, _b=0)) if was_hexa \
+ (0x000 if was_int else hexa(_red=0, _green=0, _blue=0)) if was_hexa \
else rgba(0, 0, 0, _validate=False)
)
@overload
@classmethod
- def adjust_lightness(cls, color: rgba, lightness_change: float, /) -> rgba:
+ def adjust_lightness(cls, color: rgba, light_change: float, /) -> rgba:
...
@overload
@classmethod
- def adjust_lightness(cls, color: hexa, lightness_change: float, /) -> hexa:
+ def adjust_lightness(cls, color: hexa, light_change: float, /) -> hexa:
...
@overload
@classmethod
- def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float, /) -> rgba | hexa:
+ def adjust_lightness(cls, color: Rgba | Hexa, light_change: float, /) -> rgba | hexa:
...
@classmethod
- def adjust_lightness(cls, color: Rgba | Hexa, lightness_change: float, /) -> rgba | hexa:
+ def adjust_lightness(cls, color: Rgba | Hexa, light_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)
- - `lightness_change` -โ the amount to change the lightness by,
- in range `-1.0` (darken by 100%) and `1.0` (lighten by 100%)"""
- 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}"
- )
+ ---------------------------------------------------------------------
+ * `color` โ The color to adjust (can be in RGBA or HEXA format).
+ * `light_change` โ The amount to change the lightness by,
+ in range `-1.0` (darken by 100%) and `1.0` (lighten by 100%)."""
+
+ if not (-1.0 <= light_change <= 1.0):
+ raise ValueError(f"The 'light_change' parameter must be in range [-1.0, 1.0] inclusive, got {light_change!r}")
was_hexa = cls.is_valid_hexa(color)
hsla_color = cls.to_hsla(color)
- h, s, l, a = (
+ hue, sat, light, alpha = (
int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \
hsla_color[3] if hsla_color.has_alpha() else None
)
- l = int(max(0, min(100, l + lightness_change * 100)))
+ light = int(max(0, min(100, light + light_change * 100)))
return (
- hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \
- else hsla(h, s, l, a, _validate=False).to_rgba()
+ hsla(hue, sat, light, alpha, _validate=False).to_hexa() if was_hexa \
+ else hsla(hue, sat, light, alpha, _validate=False).to_rgba()
)
@overload
@classmethod
- def adjust_saturation(cls, color: rgba, saturation_change: float, /) -> rgba:
+ def adjust_saturation(cls, color: rgba, sat_change: float, /) -> rgba:
...
@overload
@classmethod
- def adjust_saturation(cls, color: hexa, saturation_change: float, /) -> hexa:
+ def adjust_saturation(cls, color: hexa, sat_change: float, /) -> hexa:
...
@overload
@classmethod
- def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float, /) -> rgba | hexa:
+ def adjust_saturation(cls, color: Rgba | Hexa, sat_change: float, /) -> rgba | hexa:
...
@classmethod
- def adjust_saturation(cls, color: Rgba | Hexa, saturation_change: float, /) -> rgba | hexa:
+ def adjust_saturation(cls, color: Rgba | Hexa, sat_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)
- - `saturation_change` -โ the amount to change the saturation by,
- in range `-1.0` (saturate by 100%) and `1.0` (desaturate by 100%)"""
- 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}"
- )
+ --------------------------------------------------------------------------
+ * `color` โ The color to adjust (can be in RGBA or HEXA format).
+ * `sat_change` โ The amount to change the saturation by,
+ in range `-1.0` (saturate by 100%) and `1.0` (desaturate by 100%)."""
+
+ if not (-1.0 <= sat_change <= 1.0):
+ raise ValueError(f"The 'sat_change' parameter must be in range [-1.0, 1.0] inclusive, got {sat_change!r}")
was_hexa = cls.is_valid_hexa(color)
hsla_color = cls.to_hsla(color)
- h, s, l, a = (
+ hue, sat, light, alpha = (
int(hsla_color[0]), int(hsla_color[1]), int(hsla_color[2]), \
hsla_color[3] if hsla_color.has_alpha() else None
)
- s = int(max(0, min(100, s + saturation_change * 100)))
+ sat = int(max(0, min(100, sat + sat_change * 100)))
return (
- hsla(h, s, l, a, _validate=False).to_hexa() if was_hexa \
- else hsla(h, s, l, a, _validate=False).to_rgba()
+ hsla(hue, sat, light, alpha, _validate=False).to_hexa() if was_hexa \
+ else hsla(hue, sat, light, alpha, _validate=False).to_rgba()
)
@classmethod
def _parse_rgba(cls, color: Rgba, /) -> rgba:
"""Internal method to parse a color to an RGBA object."""
+
if isinstance(color, rgba):
return color
@@ -1451,7 +1550,11 @@ def _parse_rgba(cls, color: Rgba, /) -> rgba:
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
+ 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)
@@ -1459,7 +1562,13 @@ def _parse_rgba(cls, color: Rgba, /) -> rgba:
elif isinstance(color, dict):
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)
+ return rgba(
+ int(dict_color["red"]),
+ int(dict_color["green"]),
+ int(dict_color["blue"]),
+ dict_color.get("alpha"),
+ _validate=False,
+ )
elif isinstance(color, str):
if parsed := cls.str_to_rgba(color, only_first=True):
@@ -1470,6 +1579,7 @@ def _parse_rgba(cls, color: Rgba, /) -> rgba:
@classmethod
def _parse_hsla(cls, color: Hsla, /) -> hsla:
"""Internal method to parse a color to an HSLA object."""
+
if isinstance(color, hsla):
return color
@@ -1477,7 +1587,11 @@ def _parse_hsla(cls, color: Hsla, /) -> hsla:
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
+ 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)
@@ -1485,7 +1599,13 @@ def _parse_hsla(cls, color: Hsla, /) -> hsla:
elif isinstance(color, dict):
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)
+ return hsla(
+ int(dict_color["hue"]),
+ int(dict_color["sat"]),
+ int(dict_color["light"]),
+ dict_color.get("alpha"),
+ _validate=False,
+ )
elif isinstance(color, str):
if parsed := cls.str_to_hsla(color, only_first=True):
@@ -1494,12 +1614,13 @@ def _parse_hsla(cls, color: Hsla, /) -> hsla:
raise ValueError(f"Could not parse HSLA color: {color!r}")
@staticmethod
- def _linearize_srgb(c: float, /) -> float:
+ def _linearize_srgb(component: 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}")
- if c <= 0.03928:
- return c / 12.92
+ if not (0.0 <= component <= 1.0):
+ raise ValueError(f"The 'component' parameter must be in range [0.0, 1.0] inclusive, got {component!r}")
+
+ if component <= 0.03928:
+ return component / 12.92
else:
- return ((c + 0.055) / 1.055)**2.4
+ return ((component + 0.055) / 1.055)**2.4
diff --git a/src/xulbux/console.py b/src/xulbux/console.py
index 0b8eec7..ff36d74 100644
--- a/src/xulbux/console.py
+++ b/src/xulbux/console.py
@@ -1,24 +1,25 @@
"""
This module provides the `Console`, `ProgressBar`, and `Throbber` classes
-which offer methods for logging and other actions within the console.
+which offer methods for logging and other actions within the terminal.
"""
from .base.types import ProgressUpdater, AllTextChars, ArgParseConfigs, ArgParseConfig, ArgData, Rgba, Hexa
from .base.decorators import mypyc_attr
-from .base.consts import COLOR, CHARS, ANSI
+from .base.consts import CHARS, ANSI
-from .format_codes import _PATTERNS as _FC_PATTERNS, FormatCodes
+from .depr_format_codes import _PATTERNS as _FC_PATTERNS, deprFormatCodes
from .string import String
from .color import Color
from .regex import LazyRegex
-from typing import ValuesView, Generator, Callable, KeysView, Optional, Literal, TypeVar, TextIO, Any, overload, cast
+from typing import ValuesView, Generator, Callable, KeysView, Optional, Literal, TypeVar, TextIO, Final, 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
+from itertools import chain
from io import StringIO
import prompt_toolkit as _pt
import subprocess as _subprocess
@@ -51,29 +52,31 @@
class ParsedArgData:
"""Represents the result of a parsed command-line argument, containing the attributes listed below.\n
------------------------------------------------------------------------------------------------------------
- - `exists` - whether the argument was found in the command-line arguments or not
- - `is_pos` - whether the argument is a positional `"before"`/`"after"` argument or not
- - `values` - the list of values associated with the argument
- - `flag` - the specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args\n
+ * `exists` โ Whether the argument was found in the command-line arguments or not.
+ * `is_pos` โ Whether the argument is a positional `"before"`/`"after"` argument or not.
+ * `values` โ The tuple of values associated with the argument.
+ * `flag` โ The specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args.
------------------------------------------------------------------------------------------------------------
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):
- self.exists: bool = exists
+ self.exists: Final[bool] = exists
"""Whether the argument was found or not."""
- self.is_pos: bool = is_pos
+ self.is_pos: Final[bool] = is_pos
"""Whether the argument is a positional argument or not."""
- self.values: list[str] = values
- """The list of values associated with the argument."""
- self.flag: Optional[str] = flag
+ self.values: Final[tuple[str, ...]] = tuple(values)
+ """The tuple of values associated with the argument."""
+ self.flag: Final[Optional[str]] = flag
"""The specific flag that was found (e.g. `-v`, `-vv`, `-vvv`), or `None` for positional args."""
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:
"""Check if two `ParsedArgData` objects are equal by comparing their attributes."""
+
if not isinstance(other, ParsedArgData):
return False
return (
@@ -85,25 +88,53 @@ def __eq__(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)
def __repr__(self) -> str:
- return f"ParsedArgData(\n exists = {self.exists!r},\n is_pos = {self.is_pos!r},\n values = {self.values!r},\n flag = {self.flag!r}\n)"
+ return (
+ "ParsedArgData(\n"
+ f" exists = {self.exists!r},\n"
+ f" is_pos = {self.is_pos!r},\n"
+ f" values = {self.values!r},\n"
+ f" flag = {self.flag!r}\n"
+ ")"
+ )
def __str__(self) -> str:
return self.__repr__()
+ def _update(self, **kwargs: Any) -> None:
+ """Internal method to update the attributes of the `ParsedArgData` instance."""
+
+ for k, v in kwargs.items():
+ object.__setattr__(self, k, v)
+
def dict(self) -> ArgData:
"""Returns the argument result as a dictionary."""
+
return ArgData(exists=self.exists, is_pos=self.is_pos, values=self.values, flag=self.flag)
+ @overload
+ def get(self, index: int, /) -> Optional[str]:
+ ...
+
+ @overload
+ def get(self, index: int, /, default: None) -> Optional[str]:
+ ...
+
+ @overload
+ def get(self, index: int, /, default: str) -> str:
+ ...
+
def get(self, index: int, /, default: Optional[str] = None) -> Optional[str]:
"""Safely access a value from the `values` list by index.\n
-------------------------------------------------------------------
- - `index` -โ the index of the value to access
- - `default` -โ the fallback value if the index is out of range\n
+ * `index` โ The index of the value to access.
+ * `default` โ The fallback value if the index is out of range.
-------------------------------------------------------------------
Returns the value at `index` if it exists, otherwise `default`."""
+
if 0 <= index < len(self.values):
return self.values[index]
return default
@@ -112,28 +143,52 @@ def get(self, index: int, /, default: Optional[str] = None) -> Optional[str]:
@mypyc_attr(native_class=False)
class ParsedArgs:
"""Container for parsed command-line arguments, allowing attribute-style access.\n
- -----------------------------------------------------------------------------------
- - `**parsed_args` -โ a mapping of argument aliases to their corresponding data
- saved in an `ParsedArgData` object\n
- -----------------------------------------------------------------------------------
- For example, if an argument `foo` was parsed, it can be accessed via `args.foo`.
+ -------------------------------------------------------------------------------------
+ * `unknown_flags` โ A list of all found flags that were not defined in the config.
+ * `**parsed_args` โ A mapping of argument aliases to their corresponding data
+ saved in an `ParsedArgData` object.
+ -------------------------------------------------------------------------------------
+ For example, if an argument `foo` was parsed, it can be accessed via `args.foo`.
Each such attribute (e.g. `args.foo`) is an instance of `ParsedArgData`."""
- def __init__(self, **parsed_args: ParsedArgData):
+ # KEEP THESE ATTRS OUT OF __dict__ SO THAT vars(self) ONLY CONTAINS THE ParsedArgData INSTANCES
+ __slots__ = ('__dict__', 'all_exist', 'any_exist', 'is_empty', 'unknown_flags')
+
+ RESERVED_ALIASES: frozenset[str] = frozenset({
+ "all_exist", "any_exist", "dict", "existing", "get", "is_empty", "items", "keys", "missing", "unknown_flags", "values"
+ })
+ """Alias names that are reserved and cannot be used as argument aliases."""
+
+ def __init__(self, unknown_flags: Optional[list[str]] = None, **parsed_args: ParsedArgData):
for alias_name, parsed_arg_data in parsed_args.items():
setattr(self, alias_name, parsed_arg_data)
+ _parsed_args = cast(dict[str, ParsedArgData], vars(self)).values()
+
+ self.is_empty: Final[bool] = all(not arg.exists and not arg.values for arg in _parsed_args)
+ """Whether no argument was found and none have any values (not even defaults)."""
+ self.any_exist: Final[bool] = any(arg.exists for arg in _parsed_args)
+ """Whether at least one argument was explicitly found."""
+ self.all_exist: Final[bool] = all(arg.exists for arg in _parsed_args)
+ """Whether all arguments were explicitly found."""
+ self.unknown_flags: Final[frozenset[str]] = frozenset() if unknown_flags is None else frozenset(unknown_flags)
+ """Unknown flags found in the command-line arguments
+ (args that look like flags but are not defined in the config)."""
+
def __len__(self):
"""The number of arguments stored in the `ParsedArgs` object."""
+
return len(vars(self))
def __contains__(self, key: str, /) -> bool:
"""Checks if an argument with the given alias exists in the `ParsedArgs` object."""
+
return key in vars(self)
def __bool__(self) -> bool:
- """Whether the `ParsedArgs` object contains any arguments."""
- return len(self) > 0
+ """Whether the `ParsedArgs` object contains any arguments or unknown flags."""
+
+ return len(self) > 0 or bool(self.unknown_flags)
def __getattr__(self, name: str, /) -> ParsedArgData:
raise AttributeError(f"'{type(self).__name__}' object has no attribute {name}")
@@ -149,54 +204,68 @@ def __iter__(self) -> Generator[tuple[str, ParsedArgData], None, None]:
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)
+ return vars(self) == vars(other) and self.unknown_flags == other.unknown_flags
def __ne__(self, other: object, /) -> bool:
"""Check if two `ParsedArgs` objects are not equal by comparing their stored arguments."""
+
return not self.__eq__(other)
def __repr__(self) -> str:
- if not self:
- return "ParsedArgs()"
- return "ParsedArgs(\n " + ",\n ".join(
+ items: list[str] = [
f"{key} = " + "\n ".join(repr(val).splitlines()) \
for key, val in self.__iter__()
- ) + "\n)"
+ ]
+
+ if self.unknown_flags:
+ items.append(f"unknown_flags = {self.unknown_flags!r}")
+ elif not items:
+ return "ParsedArgs()"
+
+ return "ParsedArgs(\n " + ",\n ".join(items) + "\n)"
def __str__(self) -> str:
return self.__repr__()
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:
"""Returns the argument result for the given alias, or `default` if not found."""
+
return getattr(self, key, default)
def keys(self) -> KeysView[str]:
"""Returns the argument aliases as `dict_keys([โฆ])`."""
+
return vars(self).keys()
def values(self) -> ValuesView[ParsedArgData]:
"""Returns the argument results as `dict_values([โฆ])`."""
+
return vars(self).values()
def items(self) -> Generator[tuple[str, ParsedArgData], None, None]:
"""Yields tuples of `(alias, ParsedArgData)`."""
+
for key, val in self.__iter__():
yield (key, val)
def existing(self) -> Generator[tuple[str, ParsedArgData], None, None]:
"""Yields tuples of `(alias, ParsedArgData)` for existing arguments only."""
+
for key, val in self.__iter__():
if val.exists:
yield (key, val)
def missing(self) -> Generator[tuple[str, ParsedArgData], None, None]:
"""Yields tuples of `(alias, ParsedArgData)` for missing arguments only."""
+
for key, val in self.__iter__():
if not val.exists:
yield (key, val)
@@ -206,16 +275,18 @@ def missing(self) -> Generator[tuple[str, ParsedArgData], None, None]:
class _ConsoleMeta(type):
@property
- def w(cls) -> int:
- """The width of the console in characters."""
+ def width(cls) -> int:
+ """The terminal width in characters."""
+
try:
return _os.get_terminal_size().columns
except OSError:
return 80
@property
- def h(cls) -> int:
- """The height of the console in lines."""
+ def height(cls) -> int:
+ """The terminal height in lines."""
+
try:
return _os.get_terminal_size().lines
except OSError:
@@ -223,7 +294,8 @@ def h(cls) -> int:
@property
def size(cls) -> tuple[int, int]:
- """A tuple with the width and height of the console in characters and lines."""
+ """A tuple with the terminal width and height in characters and lines."""
+
try:
size = _os.get_terminal_size()
return (size.columns, size.lines)
@@ -233,72 +305,92 @@ def size(cls) -> tuple[int, int]:
@property
def user(cls) -> str:
"""The name of the current user."""
+
return _os.getenv("USER") or _os.getenv("USERNAME") or _getpass.getuser()
@property
def is_tty(cls) -> bool:
- """Whether the current output is a terminal/console or not."""
+ """Whether the terminal is connected to a TTY or not."""
+
return _sys.stdout.isatty()
@property
def encoding(cls) -> str:
- """The encoding used by the console (e.g. `utf-8`, `cp1252`, โฆ)."""
+ """The encoding used by the terminal (e.g. `utf-8`, `cp1252`, โฆ)."""
+
try:
encoding = _sys.stdout.encoding
- return encoding if encoding is not None else "utf-8"
+ return "utf-8" if encoding is None else encoding
except (AttributeError, Exception):
return "utf-8"
@property
def supports_color(cls) -> bool:
"""Whether the terminal supports ANSI color codes or not."""
+
if not cls.is_tty:
return False
+
if _os.name == "nt":
# CHECK IF VT100 MODE IS ENABLED ON WINDOWS
try:
kernel32 = getattr(_ctypes, "windll").kernel32
- h = kernel32.GetStdHandle(-11)
+ handle = kernel32.GetStdHandle(-11)
mode = _ctypes.c_ulong()
- if kernel32.GetConsoleMode(h, _ctypes.byref(mode)):
+ if kernel32.GetConsoleMode(handle, _ctypes.byref(mode)):
return (mode.value & 0x0004) != 0
except Exception:
pass
+
return False
+
return _os.getenv("TERM", "").lower() not in {"", "dumb"}
class Console(metaclass=_ConsoleMeta):
- """This class provides methods for logging and other actions within the console."""
+ """This class provides methods for logging and other actions within the terminal."""
@classmethod
- def get_args(cls, arg_parse_configs: ArgParseConfigs, /, *, flag_value_sep: str = "=") -> ParsedArgs:
+ def get_args(
+ cls,
+ arg_parse_configs: ArgParseConfigs,
+ /,
+ *,
+ skip: int = 0,
+ flag_value_sep: Optional[str] = "=",
+ allow_space_value: bool = True,
+ ) -> ParsedArgs:
"""Will search for the specified args in the command-line arguments
and return the results as a special `ParsedArgs` object.\n
- -------------------------------------------------------------------------------------------------
- - `arg_parse_configs` - a dictionary where each key is an alias name for the argument
- and the key's value is the parsing configuration for that argument
- - `flag_value_sep` - the character/s used to separate flags from their values\n
- -------------------------------------------------------------------------------------------------
+ ---------------------------------------------------------------------------------------------------------
+ * `arg_parse_configs` โ A dictionary where each key is an alias name for the argument
+ and the key's value is the parsing configuration for that argument.
+ * `skip` โ The number of leading command-line arguments to skip before parsing;
+ useful when the first N args are a command/subcommand and not relevant to the caller.
+ * `flag_value_sep` โ The character/s used to separate flags from their values;
+ pass `None` to disable separator-based syntax (e.g. `--flag=value`) entirely.
+ * `allow_space_value` โ Whether to allow space-separated flag values (e.g. `--flag value`)
+ in addition to the separator-based syntax; enabled by default.
+ ---------------------------------------------------------------------------------------------------------
The `arg_parse_configs` dictionary can have the following structures for each item:
1. Simple set of flags (when no default value is needed):
- ```python
+ ```python
"alias_name": {"-f", "--flag"}
- ```
+ ```
2. Dictionary with the`"flags"` set, plus a specified `"default"` value:
- ```python
+ ```python
"alias_name": {
"flags": {"-f", "--flag"},
"default": "some_value",
}
- ```
+ ```
3. Positional value collection using the literals `"before"` or `"after"`:
- ```python
+ ```python
# COLLECT ALL NON-FLAGGED VALUES THAT APPEAR BEFORE THE FIRST FLAG
"alias_name": "before"
# COLLECT ALL NON-FLAGGED VALUES THAT APPEAR AFTER THE LAST FLAG'S VALUE
"alias_name": "after"
- ```
+ ```
#### Example usage:
If you call the `get_args()` method in your script like this:
```python
@@ -327,13 +419,21 @@ def get_args(cls, arg_parse_configs: ArgParseConfigs, /, *, flag_value_sep: str
text_after = ParsedArgData(exists=True, is_pos=True, values=["Goodbye"], flag=None),
)
```
- -------------------------------------------------------------------------------------------------
- NOTE: Flags can ONLY receive values when the separator is present
- (e.g. `--flag=value` or `--flag = value`)."""
- if not flag_value_sep:
- raise ValueError("The 'flag_value_sep' parameter must be a non-empty string.")
-
- return _ConsoleArgsParseHelper(arg_parse_configs, flag_value_sep)()
+ ---------------------------------------------------------------------------------------------------------
+ NOTE: When `allow_space_value` is `True`, a value that directly follows a flag (e.g. `--flag value`)
+ is consumed as that flag's value and is not available as a positional `"after"` argument."""
+
+ if skip < 0:
+ raise ValueError(f"The 'skip' parameter must be a non-negative integer, got {skip!r}")
+ if flag_value_sep is not None and not flag_value_sep:
+ raise ValueError(f"The 'flag_value_sep' parameter must be a non-empty string or None, got {flag_value_sep!r}")
+
+ return _ConsoleArgsParseHelper(
+ arg_parse_configs,
+ skip=skip,
+ flag_value_sep=flag_value_sep,
+ allow_space_value=allow_space_value,
+ )()
@classmethod
def pause_exit(
@@ -347,15 +447,16 @@ def pause_exit(
reset_ansi: bool = False,
) -> None:
"""Will print the `prompt` and then pause and/or exit the program based on the given options.\n
- --------------------------------------------------------------------------------------------------
- - `prompt` -โ the message to print before pausing/exiting
- - `pause` -โ whether to pause and wait for a key press after printing the prompt
- - `exit` -โ whether to exit the program after printing the prompt (and pausing if `pause` is true)
- - `exit_code` -โ the exit code to use when exiting the program
- - `reset_ansi` -โ whether to reset the ANSI formatting after printing the prompt"""
- FormatCodes.print(prompt, end="", flush=True)
+ -----------------------------------------------------------------------------------------------------
+ * `prompt` โ The message to print before pausing/exiting.
+ * `pause` โ Whether to pause and wait for a key press after printing the prompt.
+ * `exit` โ Whether to exit the program after printing the prompt (and pausing if `pause` is true).
+ * `exit_code` โ The exit code to use when exiting the program.
+ * `reset_ansi` โ Whether to reset the ANSI formatting after printing the prompt."""
+
+ deprFormatCodes.print(prompt, end="", flush=True)
if reset_ansi:
- FormatCodes.print("[_]", end="")
+ deprFormatCodes.print("[_]", end="")
if pause:
cls._read_single_key()
if exit:
@@ -363,7 +464,8 @@ def pause_exit(
@classmethod
def cls(cls) -> None:
- """Will clear the console in addition to completely resetting the ANSI formats."""
+ """Will clear the terminal in addition to completely resetting the ANSI formats."""
+
if _shutil.which("cls"):
_subprocess.run(["cls"])
elif _shutil.which("clear"):
@@ -377,7 +479,6 @@ def log(
prompt: object = "",
/,
*,
- format_linebreaks: bool = True,
start: str = "",
end: str = "\n",
title_bg_color: Optional[str | Rgba | Hexa] = None,
@@ -387,29 +488,30 @@ def log(
title_mx: int = 2,
) -> None:
"""Prints a nicely formatted log message.\n
- -------------------------------------------------------------------------------------------
- - `title` -โ the title of the log message (e.g. `DEBUG`, `WARN`, `FAIL`, etc.)
- - `prompt` -โ the log message
- - `format_linebreaks` -โ whether to format (indent after) the line breaks or not
- - `start` -โ something to print before the log is printed
- - `end` -โ something to print after the log is printed (e.g. `\\n`)
- - `title_bg_color` -โ the background color of the `title` (console color, RGBA, or HEXA)
- - `default_color` -โ the default text color of the `prompt` (RGBA or HEXA)
- - `tab_size` -โ the tab size used for the log (default is 8 like console tabs)
- - `title_px` -โ the horizontal padding (in chars) to the title (if `title_bg_color` is set)
- - `title_mx` -โ the horizontal margin (in chars) to the title\n
- -------------------------------------------------------------------------------------------
- The log message can be formatted with special formatting codes. For more detailed
+ ----------------------------------------------------------------------------------------------
+ * `title` โ The title of the log message (e.g. `DEBUG`, `WARN`, `FAIL`, โฆ).
+ * `prompt` โ The log message.
+ * `format_linebreaks` โ Whether to format (indent after) the line breaks or not.
+ * `start` โ Something to print before the log is printed.
+ * `end` โ Something to print after the log is printed (e.g. `\\n`).
+ * `title_bg_color` โ The background color of the `title` (terminal color, RGBA, or HEXA).
+ * `default_color` โ The default text color of the `prompt` (RGBA or HEXA).
+ * `tab_size` โ The tab size used for the log (default is 8 โ matches terminal tabs).
+ * `title_px` โ The horizontal padding (in chars) to the title (if `title_bg_color` is set).
+ * `title_mx` โ The horizontal margin (in chars) to the title.
+ ----------------------------------------------------------------------------------------------
+ The log message can be formatted with special formatting codes. For more detailed
information about formatting codes, see `format_codes` module documentation."""
+
if tab_size < 0:
- raise ValueError("The 'tab_size' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'tab_size' parameter must be a non-negative integer, got {tab_size!r}")
if title_px < 0:
- raise ValueError("The 'title_px' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'title_px' parameter must be a non-negative integer, got {title_px!r}")
if title_mx < 0:
- raise ValueError("The 'title_mx' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'title_mx' parameter must be a non-negative integer, got {title_mx!r}")
title_fg: str = "_c"
- title = "" if title is None else title.strip().upper()
+ title = "" if title is None else title.strip()
if has_title_bg := title_bg_color is not None:
if str(title_bg_color).replace(" ", "").lower() in ANSI.COLOR_VARIANTS_MAP:
@@ -418,34 +520,44 @@ def log(
title_bg_color = Color.to_hexa(title_bg_color)
title_fg = str(Color.text_color_for_on_bg(title_bg_color))
else:
- raise ValueError("The 'title_bg_color' parameter must be a valid console color, RGBA value, or HEXA value.")
-
- px, mx = (" " * title_px) if has_title_bg else "", " " * title_mx
- tab = " " * (tab_size - 1 - ((len(mx) + (title_len := len(title) + 2 * len(px))) % tab_size))
-
- if format_linebreaks:
- clean_prompt, removals = *FormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True),
- prompt_lst: list[str] = [
- item for lst in [
- 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)
- ]
- prompt = f"\n{mx}{' ' * title_len}{mx}{tab}".join(cls._add_back_removed_parts(prompt_lst, removals))
-
- if title == "":
- FormatCodes.print(
- f"{start} {f'[{default_color}]' if default_color else ''}{prompt}[_]",
- default_color=default_color,
- end=end,
- )
+ raise ValueError(
+ "The 'title_bg_color' parameter must be a valid terminal color, "
+ f"RGBA value, or HEXA value, got {title_bg_color!r}"
+ )
else:
- FormatCodes.print(
- f"{start}{mx}[b|{title_fg}{f'|bg:{title_bg_color}' if has_title_bg else ''}]{px}{title}{px}[_]{mx}"
- f"{tab}{f'[{default_color}]' if default_color else ''}{prompt}[_]",
- default_color=default_color,
- end=end,
- )
+ title_px = 0 # REMOVE PADDING IF TITLE HAS NO BG COLOR
+
+ # PADDING = SPACE INSIDE TITLE BG COLOR
+ # MARGIN = SPACE OUTSIDE TITLE BG COLOR
+ px, mx = " " * title_px, " " * title_mx
+
+ # TITLE LENGTH INCLUDING PADDING AND MARGIN
+ title_len: int = len(deprFormatCodes.remove(title)) + (title_px * 2) + (title_mx * 2)
+
+ # CALCULATE DISTANCE TO NEXT TAB STOP
+ tab: str = " " * (-title_len % tab_size)
+
+ # POSITION WHERE PROMPT NEEDS TO WRAP TO NEXT LINE
+ wrap_len: int = cls.width - (title_len + len(tab))
+
+ # REMOVE ALL FORMAT CODES AS THEY WON'T AFFECT THE VISIBLE LENGTH OF THE PROMPT
+ clean_prompt, removals = (*deprFormatCodes.remove(str(prompt), get_removals=True, _ignore_linebreaks=True), )
+
+ # SPLIT PROMPT INTO LINES AND THEN SPLIT EACH LINE INTO CHUNKS THAT FIT WITHIN THE WRAP LENGTH
+ prompt_lst: list[str] = list(chain.from_iterable(cls._process_lines(clean_prompt, wrap_len)))
+
+ # ADD BACK REMOVED FORMAT CODES TO THEIR ORIGINAL POSITIONS IN THE PROMPT
+ prompt = f"\n{' ' * title_len}{tab}".join(cls._add_back_removed_parts(prompt_lst, removals))
+
+ out: str = (
+ # LOG WITHOUT A TITLE
+ f"{start}{mx}{f'[{default_color}]' if default_color else ''}{prompt}[_]" if title == "" else
+ # LOG WITH A TITLE
+ f"{start}{mx}[b|{title_fg}{f'|bg:{title_bg_color}' if has_title_bg else ''}]{px}{title}{px}[_]{mx}"
+ f"{tab}{f'[{default_color}]' if default_color else ''}{prompt}[_]"
+ )
+
+ deprFormatCodes.print(out, default_color=default_color, end=end)
@classmethod
def debug(
@@ -454,7 +566,6 @@ def debug(
/,
*,
active: bool = True,
- format_linebreaks: bool = True,
start: str = "",
end: str = "\n",
default_color: Optional[Rgba | Hexa] = None,
@@ -463,14 +574,14 @@ def debug(
exit_code: int = 0,
reset_ansi: bool = True,
) -> None:
- """A preset for `log()`: `DEBUG` log message with the options to pause
- at the message and exit the program after the message was printed.
+ """A preset for `log()`: `DEBUG` log message with the options to pause
+ at the message and exit the program after the message was printed.\n
If `active` is false, no debug message will be printed."""
+
if active:
cls.log(
"DEBUG",
prompt,
- format_linebreaks=format_linebreaks,
start=start,
end=end,
title_bg_color="br:yellow",
@@ -484,7 +595,6 @@ def info(
prompt: object = "Program running.",
/,
*,
- format_linebreaks: bool = True,
start: str = "",
end: str = "\n",
default_color: Optional[Rgba | Hexa] = None,
@@ -493,12 +603,12 @@ def info(
exit_code: int = 0,
reset_ansi: bool = True,
) -> None:
- """A preset for `log()`: `INFO` log message with the options to pause
+ """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(
"INFO",
prompt,
- format_linebreaks=format_linebreaks,
start=start,
end=end,
title_bg_color="br:blue",
@@ -512,7 +622,6 @@ def done(
prompt: object = "Program finished.",
/,
*,
- format_linebreaks: bool = True,
start: str = "",
end: str = "\n",
default_color: Optional[Rgba | Hexa] = None,
@@ -521,12 +630,12 @@ def done(
exit_code: int = 0,
reset_ansi: bool = True,
) -> None:
- """A preset for `log()`: `DONE` log message with the options to pause
+ """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(
"DONE",
prompt,
- format_linebreaks=format_linebreaks,
start=start,
end=end,
title_bg_color="br:green",
@@ -540,7 +649,6 @@ def warn(
prompt: object = "Important message.",
/,
*,
- format_linebreaks: bool = True,
start: str = "",
end: str = "\n",
default_color: Optional[Rgba | Hexa] = None,
@@ -549,15 +657,15 @@ def warn(
exit_code: int = 1,
reset_ansi: bool = True,
) -> None:
- """A preset for `log()`: `WARN` log message with the options to pause
+ """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(
"WARN",
prompt,
- format_linebreaks=format_linebreaks,
start=start,
end=end,
- title_bg_color=COLOR.ORANGE,
+ title_bg_color="br:yellow",
default_color=default_color,
)
cls.pause_exit("", pause=pause, exit=exit, exit_code=exit_code, reset_ansi=reset_ansi)
@@ -568,7 +676,6 @@ def fail(
prompt: object = "Program error.",
/,
*,
- format_linebreaks: bool = True,
start: str = "",
end: str = "\n",
default_color: Optional[Rgba | Hexa] = None,
@@ -577,12 +684,12 @@ def fail(
exit_code: int = 1,
reset_ansi: bool = True,
) -> None:
- """A preset for `log()`: `FAIL` log message with the options to pause
+ """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(
"FAIL",
prompt,
- format_linebreaks=format_linebreaks,
start=start,
end=end,
title_bg_color="br:red",
@@ -596,7 +703,6 @@ def exit(
prompt: object = "Program ended.",
/,
*,
- format_linebreaks: bool = True,
start: str = "",
end: str = "\n",
default_color: Optional[Rgba | Hexa] = None,
@@ -605,12 +711,12 @@ def exit(
exit_code: int = 0,
reset_ansi: bool = True,
) -> None:
- """A preset for `log()`: `EXIT` log message with the options to pause
+ """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(
"EXIT",
prompt,
- format_linebreaks=format_linebreaks,
start=start,
end=end,
title_bg_color="br:magenta",
@@ -631,22 +737,23 @@ def log_box_filled(
indent: int = 0,
) -> None:
"""Will print a box with a colored background, containing a formatted log message.\n
- -------------------------------------------------------------------------------------
- - `*values` -โ the box content (each value is on a new line)
- - `start` -โ something to print before the log box is printed (e.g. `\\n`)
- - `end` -โ something to print after the log box is printed (e.g. `\\n`)
- - `box_bg_color` -โ the background color of the box (console color, RGBA, or HEXA)
- - `default_color` -โ the default text color of the `*values`
- - `w_padding` -โ the horizontal padding (in chars) to the box content
- - `w_full` -โ whether to make the box be the full console width or not
- - `indent` -โ the indentation of the box (in chars)\n
- -------------------------------------------------------------------------------------
- The box content can be formatted with special formatting codes. For more detailed
+ --------------------------------------------------------------------------------------
+ * `*values` โ The box content (each value is on a new line).
+ * `start` โ Something to print before the log box is printed (e.g. `\\n`).
+ * `end` โ Something to print after the log box is printed (e.g. `\\n`).
+ * `box_bg_color` โ The background color of the box (terminal color, RGBA, or HEXA).
+ * `default_color` โ The default text color of the `*values`.
+ * `w_padding` โ The horizontal padding (in chars) to the box content.
+ * `w_full` โ Whether to make the box be the full terminal width or not.
+ * `indent` โ The indentation of the box (in chars).
+ --------------------------------------------------------------------------------------
+ The box content can be formatted with special formatting codes. For more detailed
information about formatting codes, see `format_codes` module documentation."""
+
if w_padding < 0:
- raise ValueError("The 'w_padding' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'w_padding' parameter must be a non-negative integer, got {w_padding!r}")
if indent < 0:
- raise ValueError("The 'indent' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'indent' parameter must be a non-negative integer, got {indent!r}")
if box_bg_color is not None:
if str(box_bg_color).replace(" ", "").lower() in ANSI.COLOR_VARIANTS_MAP:
@@ -654,13 +761,16 @@ def log_box_filled(
elif Color.is_valid_rgba(box_bg_color) or Color.is_valid_hexa(box_bg_color):
box_bg_color = Color.to_hexa(box_bg_color)
else:
- raise ValueError("The 'box_bg_color' parameter must be a valid console color, RGBA value, or HEXA value.")
+ raise ValueError(
+ "The 'box_bg_color' parameter must be a valid terminal color, "
+ f"RGBA value, or HEXA value, got {box_bg_color!r}"
+ )
lines, unfmt_lines, max_line_len = cls._prepare_log_box(values, default_color)
spaces_l = " " * indent
- pady = " " * (cls.w if w_full else max_line_len + (2 * w_padding))
- pad_w_full = (cls.w - (max_line_len + (2 * w_padding))) if w_full else 0
+ pady = " " * (cls.width if w_full else max_line_len + (2 * w_padding))
+ pad_w_full = (cls.width - (max_line_len + (2 * w_padding))) if w_full else 0
default_color = default_color or "#000"
bg_fc = f"_c|invert|bg:{default_color}" if box_bg_color is None else f"bg:{box_bg_color}"
@@ -672,7 +782,7 @@ def log_box_filled(
+ "[*]"
) for line, unfmt in zip(lines, unfmt_lines)]
- FormatCodes.print(
+ deprFormatCodes.print(
( \
f"{start}{spaces_l}[{bg_fc}]{pady}[*]\n"
+ "\n".join(lines)
@@ -691,7 +801,7 @@ def log_box_bordered(
start: str = "",
end: str = "\n",
border_type: Literal["standard", "rounded", "strong", "double"] = "rounded",
- border_style: str | Rgba | Hexa = f"dim|{COLOR.GRAY}",
+ border_style: str | Rgba | Hexa = "br:black",
default_color: Optional[Rgba | Hexa] = None,
w_padding: int = 1,
w_full: bool = False,
@@ -700,27 +810,27 @@ def log_box_bordered(
) -> None:
"""Will print a bordered box, containing a formatted log message.\n
---------------------------------------------------------------------------------------------
- - `*values` -โ the box content (each value is on a new line)
- - `start` -โ something to print before the log box is printed (e.g. `\\n`)
- - `end` -โ something to print after the log box is printed (e.g. `\\n`)
- - `border_type` -โ one of the predefined border character sets
- - `border_style` -โ the style of the border (special formatting codes)
- - `default_color` -โ the default text color of the `*values`
- - `w_padding` -โ the horizontal padding (in chars) to the box content
- - `w_full` -โ whether to make the box be the full console width or not
- - `indent` -โ the indentation of the box (in chars)
- - `_border_chars` -โ define your own border characters set (overwrites `border_type`)\n
+ * `*values` โ The box content (each value is on a new line).
+ * `start` โ Something to print before the log box is printed (e.g. `\\n`).
+ * `end` โ Something to print after the log box is printed (e.g. `\\n`).
+ * `border_type` โ One of the predefined border character sets.
+ * `border_style` โ The style of the border (special formatting codes).
+ * `default_color` โ The default text color of the `*values`.
+ * `w_padding` โ The horizontal padding (in chars) to the box content.
+ * `w_full` โ Whether to make the box be the full terminal width or not.
+ * `indent` โ The indentation of the box (in chars).
+ * `_border_chars` โ Define your own border characters set (overwrites `border_type`).
---------------------------------------------------------------------------------------------
You can insert horizontal rules to split the box content by using `{hr}` in the `*values`.\n
---------------------------------------------------------------------------------------------
- The box content can be formatted with special formatting codes. For more detailed
+ The box content can be formatted with special formatting codes. For more detailed
information about formatting codes, see `format_codes` module documentation.\n
---------------------------------------------------------------------------------------------
The `border_type` can be one of the following:
- - `"standard" = ('โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โค')`
- - `"rounded" = ('โญ', 'โ', 'โฎ', 'โ', 'โฏ', 'โ', 'โฐ', 'โ', 'โ', 'โ', 'โค')`
- - `"strong" = ('โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โฃ', 'โ', 'โซ')`
- - `"double" = ('โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ ', 'โ', 'โฃ')`\n
+ * `"standard" = ('โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โค')`
+ * `"rounded" = ('โญ', 'โ', 'โฎ', 'โ', 'โฏ', 'โ', 'โฐ', 'โ', 'โ', 'โ', 'โค')`
+ * `"strong" = ('โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โฃ', 'โ', 'โซ')`
+ * `"double" = ('โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ', 'โ ', 'โ', 'โฃ')`\n
The order of the characters is always:
1. top-left corner
2. top border
@@ -733,15 +843,18 @@ def log_box_bordered(
9. left horizontal rule connector
10. horizontal rule
11. right horizontal rule connector"""
+
if w_padding < 0:
- raise ValueError("The 'w_padding' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'w_padding' parameter must be a non-negative integer, got {w_padding!r}")
if indent < 0:
- raise ValueError("The 'indent' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'indent' parameter must be a non-negative integer, got {indent!r}")
if _border_chars is not None:
if len(_border_chars) != 11:
raise ValueError(f"The '_border_chars' parameter must contain exactly 11 characters, got {len(_border_chars)}")
if not all(len(char) == 1 for char in _border_chars):
- raise ValueError("The '_border_chars' parameter must only contain single-character strings.")
+ raise ValueError(
+ f"The '_border_chars' parameter must only contain single-character strings, got {_border_chars!r}"
+ )
if Color.is_valid(border_style):
border_style = Color.to_hexa(border_style)
@@ -757,14 +870,22 @@ def log_box_bordered(
lines, unfmt_lines, max_line_len = cls._prepare_log_box(values, default_color, has_rules=True)
spaces_l = " " * indent
- pad_w_full = (cls.w - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0
+ pad_w_full = (cls.width - (max_line_len + (2 * w_padding)) - (len(border_chars[1] * 2))) if w_full else 0
+
+ border_t_line = border_chars[1] * (
+ cls.width - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding)
+ )
+ border_b_line = border_chars[5] * (
+ cls.width - (len(border_chars[5] * 2)) if w_full else max_line_len + (2 * w_padding)
+ )
+ h_rule_line = border_chars[9] * (cls.width - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding))
border_l = f"[{border_style}]{border_chars[7]}[*]"
border_r = f"[{border_style}]{border_chars[3]}[_]"
- border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_chars[1] * (cls.w - (len(border_chars[1] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[2]}[_]"
- border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_chars[5] * (cls.w - (len(border_chars[5] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[4]}[_]"
+ border_t = f"{spaces_l}[{border_style}]{border_chars[0]}{border_t_line}{border_chars[2]}[_]"
+ border_b = f"{spaces_l}[{border_style}]{border_chars[6]}{border_b_line}{border_chars[4]}[_]"
- h_rule = f"{spaces_l}[{border_style}]{border_chars[8]}{border_chars[9] * (cls.w - (len(border_chars[9] * 2)) if w_full else max_line_len + (2 * w_padding))}{border_chars[10]}[_]"
+ h_rule = f"{spaces_l}[{border_style}]{border_chars[8]}{h_rule_line}{border_chars[10]}[_]"
lines = [( \
h_rule if _PATTERNS.hr.match(line) else f"{spaces_l}{border_l}{' ' * w_padding}{line}[_]"
@@ -772,7 +893,7 @@ def log_box_bordered(
+ border_r
) for line, unfmt in zip(lines, unfmt_lines)]
- FormatCodes.print(
+ deprFormatCodes.print(
( \
f"{start}{border_t}[_]\n"
+ "\n".join(lines)
@@ -797,23 +918,24 @@ def confirm(
) -> bool:
"""Ask a yes/no question.\n
------------------------------------------------------------------------------------
- - `prompt` -โ the input prompt
- - `start` -โ something to print before the input
- - `end` -โ something to print after the input (e.g. `\\n`)
- - `default_color` -โ the default text color of the `prompt`
- - `default_is_yes` -โ the default answer if the user just presses enter
+ * `prompt` โ The input prompt.
+ * `start` โ Something to print before the input.
+ * `end` โ Something to print after the input (e.g. `\\n`).
+ * `default_color` โ The default text color of the `prompt`.
+ * `default_is_yes` โ The default answer if the user just presses enter.
------------------------------------------------------------------------------------
- The prompt can be formatted with special formatting codes. For more detailed
+ The prompt can be formatted with special formatting codes. For more detailed
information about formatting codes, see the `format_codes` module documentation."""
+
confirmed = cls.input(
- FormatCodes.to_ansi(
+ deprFormatCodes.to_ansi(
f"{start}{str(prompt)} [_|dim](({'Y' if default_is_yes else 'y'}/{'n' if default_is_yes else 'N'}): )",
default_color=default_color,
)
).strip().lower() in ({"", "y", "yes"} if default_is_yes else {"y", "yes"})
if end:
- FormatCodes.print(end, end="")
+ deprFormatCodes.print(end, end="")
return confirmed
@classmethod
@@ -831,24 +953,25 @@ def multiline_input(
) -> str:
"""An input where users can write (and paste) text over multiple lines.\n
---------------------------------------------------------------------------------------
- - `prompt` -โ the input prompt
- - `start` -โ something to print before the input
- - `end` -โ something to print after the input (e.g. `\\n`)
- - `default_color` -โ the default text color of the `prompt`
- - `show_keybindings` -โ whether to show the special keybindings or not
- - `input_prefix` -โ the prefix of the input line
- - `reset_ansi` -โ whether to reset the ANSI codes after the input or not
+ * `prompt` โ The input prompt.
+ * `start` โ Something to print before the input.
+ * `end` โ Something to print after the input (e.g. `\\n`).
+ * `default_color` โ The default text color of the `prompt`.
+ * `show_keybindings` โ Whether to show the special keybindings or not.
+ * `input_prefix` โ The prefix of the input line.
+ * `reset_ansi` โ Whether to reset the ANSI codes after the input or not.
---------------------------------------------------------------------------------------
- The input prompt can be formatted with special formatting codes. For more detailed
+ The input prompt can be formatted with special formatting codes. For more detailed
information about formatting codes, see the `format_codes` module documentation."""
+
kb = KeyBindings()
kb.add("c-d", eager=True)(cls._multiline_input_submit)
- FormatCodes.print(start + str(prompt), default_color=default_color)
+ deprFormatCodes.print(start + str(prompt), default_color=default_color)
if show_keybindings:
- FormatCodes.print("[dim][[b](CTRL+D)[dim] : end of input][_dim]")
+ deprFormatCodes.print("[dim][[b](CTRL+D)[dim] : end of input][_dim]")
input_string = _pt.prompt(input_prefix, multiline=True, wrap_lines=True, key_bindings=kb)
- FormatCodes.print("[_]" if reset_ansi else "", end=end[1:] if end.startswith("\n") else end)
+ deprFormatCodes.print("[_]" if reset_ansi else "", end=end[1:] if end.startswith("\n") else end)
return input_string
@@ -916,31 +1039,32 @@ def input(
output_type: type[Any] = str,
) -> Any:
"""Acts like a standard Python `input()` a bunch of cool extra features.\n
- ------------------------------------------------------------------------------------
- - `prompt` -โ the input prompt
- - `start` -โ something to print before the input
- - `end` -โ something to print after the input (e.g. `\\n`)
- - `default_color` -โ the default text color of the `prompt`
- - `placeholder` -โ a placeholder text that is shown when the input is empty
- - `mask_char` -โ if set, the input will be masked with this character
- - `min_len` -โ the minimum length of the input (required to submit)
- - `max_len` -โ the maximum length of the input (can't write further if reached)
- - `allowed_chars` -โ a string of characters that are allowed to be inputted
- (default allows all characters)
- - `allow_paste` -โ whether to allow pasting text into the input or not
- - `validator` -โ a function that takes the input string and returns a string error
- message if invalid, or nothing if valid
- - `default_val` -โ the default value to return if the input is empty
- - `output_type` -โ the type (class) to convert the input to before returning it\n
- ------------------------------------------------------------------------------------
- The input prompt can be formatted with special formatting codes. For more detailed
+ ----------------------------------------------------------------------------------------
+ * `prompt` โ The input prompt.
+ * `start` โ Something to print before the input.
+ * `end` โ Something to print after the input (e.g. `\\n`).
+ * `default_color` โ The default text color of the `prompt`.
+ * `placeholder` โ A placeholder text that is shown when the input is empty.
+ * `mask_char` โ If set, the input will be masked with this character.
+ * `min_len` โ The minimum length of the input (required to submit).
+ * `max_len` โ The maximum length of the input (can't write further if reached).
+ * `allowed_chars` โ A string of characters that are allowed to be inputted
+ (default allows all characters).
+ * `allow_paste` โ Whether to allow pasting text into the input or not.
+ * `validator` โ A function that takes the input string and returns a string error
+ message if invalid, or nothing if valid.
+ * `default_val` โ The default value to return if the input is empty.
+ * `output_type` โ The type (class) to convert the input to before returning it.
+ ----------------------------------------------------------------------------------------
+ The input prompt can be formatted with special formatting codes. For more detailed
information about formatting codes, see the `format_codes` module documentation."""
+
if mask_char is not None and len(mask_char) != 1:
raise ValueError(f"The 'mask_char' parameter must be a single character, got {mask_char!r}")
if min_len is not None and min_len < 0:
- raise ValueError("The 'min_len' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'min_len' parameter must be a non-negative integer, got {min_len!r}")
if max_len is not None and max_len < 0:
- raise ValueError("The 'max_len' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'max_len' parameter must be a non-negative integer, got {max_len!r}")
helper = _ConsoleInputHelper(
mask_char=mask_char,
@@ -960,7 +1084,7 @@ def input(
custom_style = Style.from_dict({"bottom-toolbar": "noreverse"})
session: _pt.PromptSession[str] = _pt.PromptSession(
- message=_pt.formatted_text.ANSI(FormatCodes.to_ansi(str(prompt), default_color=default_color)),
+ message=_pt.formatted_text.ANSI(deprFormatCodes.to_ansi(str(prompt), default_color=default_color)),
validator=_ConsoleInputValidator(
helper.get_text,
mask_char=mask_char,
@@ -970,22 +1094,22 @@ def input(
validate_while_typing=True,
key_bindings=kb,
bottom_toolbar=helper.bottom_toolbar,
- placeholder=_pt.formatted_text.ANSI(FormatCodes.to_ansi(f"[i|br:black]{placeholder}[_i|_c]"))
+ placeholder=_pt.formatted_text.ANSI(deprFormatCodes.to_ansi(f"[i|br:black]{placeholder}[_i|_c]"))
if placeholder else "",
style=custom_style,
)
- FormatCodes.print(start, end="")
+ deprFormatCodes.print(start, end="")
session.prompt()
- FormatCodes.print(end, end="")
+ deprFormatCodes.print(end, end="")
- result_text = helper.get_text()
- if result_text in {"", None}:
+ if (result_text := helper.get_text()) in {"", None}:
if default_val is not None:
return default_val
result_text = ""
if output_type == str:
return result_text
+
else:
try:
return output_type(result_text) # type: ignore[call-arg]
@@ -998,26 +1122,40 @@ def input(
def _read_single_key() -> None:
"""Wait for a single key press without requiring elevated privileges.
Falls back to reading a line when stdin is not a TTY (e.g. piped input)."""
+
if not _sys.stdin.isatty():
_sys.stdin.readline()
return
+
if _sys.platform == "win32":
import msvcrt as _msvcrt # type: ignore[import-not-found]
_msvcrt.getch() # type: ignore[attr-defined]
+
else:
import tty as _tty # type: ignore[import-not-found]
import termios as _termios # type: ignore[import-not-found]
+
fd = _sys.stdin.fileno()
old_settings = _termios.tcgetattr(fd) # type: ignore[attr-defined]
+
try:
_tty.setraw(fd) # type: ignore[attr-defined]
_sys.stdin.read(1)
finally:
_termios.tcsetattr(fd, _termios.TCSADRAIN, old_settings) # type: ignore[attr-defined]
+ @staticmethod
+ def _process_lines(clean_prompt: str, wrap_len: int) -> Generator[tuple[Literal[""]] | list[str], Any, None]:
+ """Splits the clean prompt into lines and then splits each line into chunks that fit within the wrap length."""
+
+ for line in clean_prompt.splitlines():
+ lst = String.split_count(line, wrap_len)
+ yield ("", ) if not lst else lst
+
@classmethod
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]:
cumulative_pos.append(cumulative_pos[-1] + length)
@@ -1041,15 +1179,19 @@ def _add_back_removed_parts(cls, split_string: list[str], removals: tuple[tuple[
@staticmethod
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:
mid = (left + right) // 2
+
if cumulative_pos[mid] <= pos < cumulative_pos[mid + 1]:
return mid
elif pos < cumulative_pos[mid]:
right = mid
else:
left = mid + 1
+
return left
@staticmethod
@@ -1061,6 +1203,7 @@ def _prepare_log_box(
has_rules: bool = False,
) -> tuple[list[str], list[str], int]:
"""Prepares the log box content and returns it along with the max line length."""
+
if has_rules:
lines: list[str] = []
@@ -1081,6 +1224,7 @@ def _prepare_log_box(
current_pos = end
else:
current_pos = start
+
else:
if should_split_after:
result_parts.append(val_str[current_pos:end])
@@ -1094,11 +1238,13 @@ def _prepare_log_box(
for part in result_parts:
lines.extend(part.splitlines())
+
else:
lines = [line for val in values for line in str(val).splitlines()]
- unfmt_lines = [FormatCodes.remove(line, default_color) for line in lines]
+ unfmt_lines = [deprFormatCodes.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
@@ -1109,15 +1255,25 @@ def _multiline_input_submit(event: KeyPressEvent, /) -> None:
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,
+ /,
+ *,
+ skip: int = 0,
+ flag_value_sep: Optional[str],
+ allow_space_value: bool = True,
+ ):
self.arg_parse_configs = arg_parse_configs
self.flag_value_sep = flag_value_sep
+ self.allow_space_value = allow_space_value
self.parsed_args: dict[str, ParsedArgData] = {}
self.positional_configs: dict[str, str] = {}
self.arg_lookup: dict[str, str] = {}
+ self.unknown_flags: list[str] = []
- self.args = _sys.argv[1:]
+ self.args = _sys.argv[1 + skip:]
self.args_len = len(self.args)
self.pos_before_configured = False
self.pos_after_configured = False
@@ -1130,14 +1286,21 @@ def __call__(self) -> ParsedArgs:
self.process_flagged_args()
self.process_positional_args()
- return ParsedArgs(**self.parsed_args)
+ return ParsedArgs(self.unknown_flags, **self.parsed_args)
def parse_arg_configs(self) -> None:
"""Parse the `arg_parse_configs` configuration and build lookup structures."""
+
for alias, config in self.arg_parse_configs.items():
if not alias.isidentifier():
raise ValueError(f"Invalid argument alias '{alias}'.\n"
"Aliases must be valid Python identifiers.")
+ elif alias in ParsedArgs.RESERVED_ALIASES:
+ raise ValueError(
+ f"Invalid argument alias '{alias}'.\n"
+ f"The following names are reserved and cannot be used as aliases:\n"
+ f"{', '.join(sorted(ParsedArgs.RESERVED_ALIASES))}"
+ )
# PARSE ARG CONFIG & BUILD FLAG LOOKUP FOR NON-POSITIONAL ARGS
if (flags := self._parse_arg_config(alias, config)) is not None:
@@ -1150,6 +1313,7 @@ def parse_arg_configs(self) -> None:
def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[set[str]]:
"""Parse an individual argument configuration."""
+
# POSITIONAL ARGUMENT CONFIGURATION
if isinstance(config, str):
if config == "before":
@@ -1165,8 +1329,10 @@ def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[s
f"Invalid positional argument type '{config}' under alias '{alias}'.\n"
"Must be either 'before' or 'after'."
)
+
self.positional_configs[alias] = config
self.parsed_args[alias] = ParsedArgData(exists=False, values=[], is_pos=True)
+
return None # NO FLAGS TO RETURN FOR POSITIONAL ARGS
# NORMAL SET OF FLAGS
@@ -1176,7 +1342,9 @@ def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[s
f"The flag set under alias '{alias}' is empty.\n"
"The set must contain at least one flag to search for."
)
+
self.parsed_args[alias] = ParsedArgData(exists=False, values=[], is_pos=False)
+
return config
# SET OF FLAGS WITH SPECIFIED DEFAULT VALUE
@@ -1186,21 +1354,24 @@ def _parse_arg_config(self, alias: str, config: ArgParseConfig, /) -> Optional[s
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=[config["default"]],
is_pos=False,
)
+
return config["flags"]
def find_flag_positions(self) -> None:
"""Find positions of first and last flags for positional argument collection."""
+
i = 0
while i < self.args_len:
arg = self.args[i]
# CHECK FOR FLAG WITH INLINE SEPARATOR ('--flag=value')
- if self.flag_value_sep in arg:
+ if self.flag_value_sep and self.flag_value_sep in arg:
if arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup:
if self.first_flag_pos is None:
self.first_flag_pos = i
@@ -1215,7 +1386,7 @@ def find_flag_positions(self) -> None:
self.last_flag_pos = i
# CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value')
- if i + 1 < self.args_len and self.args[i + 1] == self.flag_value_sep:
+ if self.flag_value_sep and i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep:
if i + 2 < self.args_len:
i += 3 # SKIP FLAG, SEPARATOR, AND VALUE
continue
@@ -1223,10 +1394,18 @@ def find_flag_positions(self) -> None:
i += 2 # SKIP FLAG AND SEPARATOR
continue
+ # CHECK FOR SPACE-SEPARATED VALUE ('--flag value')
+ if self.allow_space_value and i + 1 < self.args_len:
+ next_arg = self.args[i + 1]
+ if self._is_flag_value(next_arg):
+ i += 2 # SKIP FLAG AND ITS SPACE-SEPARATED VALUE
+ continue
+
i += 1
def process_positional_args(self) -> None:
"""Collect positional `"before"`/`"after"` arguments."""
+
for alias, pos_type in self.positional_configs.items():
if pos_type == "before":
self._collect_before_arg(alias)
@@ -1240,93 +1419,151 @@ def process_positional_args(self) -> 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
+ end_pos: int = self.args_len if self.first_flag_pos is None else self.first_flag_pos
for i in range(end_pos):
if self._is_positional_arg(arg := self.args[i], allow_separator=False):
before_args.append(arg)
if before_args:
- self.parsed_args[alias].values = before_args
- self.parsed_args[alias].exists = len(before_args) > 0
+ self.parsed_args[alias]._update(values=tuple(before_args), exists=True)
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
+ start_pos: int = 0 if self.last_flag_pos is None else (self.last_flag_pos + 1)
# SKIP THE VALUE AFTER THE LAST FLAG IF IT HAS A SEPARATOR
if self.last_flag_pos is not None:
# CHECK IF LAST FLAG HAS INLINE VALUE ('--flag=value')
- if self.flag_value_sep in self.args[self.last_flag_pos]:
+ if self.flag_value_sep and self.flag_value_sep in self.args[self.last_flag_pos]:
start_pos = self.last_flag_pos + 1 # VALUE IS INLINE, START AFTER THIS POSITION
# CHECK IF NEXT TOKEN IS SEPARATOR ('--flag', '=', 'value')
- elif start_pos < self.args_len and self.args[start_pos].strip() == self.flag_value_sep:
+ elif self.flag_value_sep and start_pos < self.args_len and self.args[start_pos].strip() == self.flag_value_sep:
if start_pos + 1 < self.args_len:
start_pos += 2 # SKIP SEPARATOR AND VALUE
else:
start_pos += 1 # SKIP SEPARATOR ONLY
+ # CHECK IF NEXT TOKEN IS SPACE-SEPARATED VALUE ('--flag value')
+ elif self.allow_space_value and start_pos < self.args_len and self._is_flag_value(self.args[start_pos]):
+ start_pos += 1 # SKIP SPACE-SEPARATED VALUE
# NO SEPARATOR = FLAG HAS NO VALUE = START COLLECTING FROM NEXT POSITION
for i in range(start_pos, self.args_len):
+ arg = self.args[i]
# DON'T INCLUDE FLAGS OR SEPARATORS
- if (arg := self.args[i]) == self.flag_value_sep:
+ if self.flag_value_sep and arg == self.flag_value_sep:
continue
elif self._is_positional_arg(arg):
after_args.append(arg)
if after_args:
- self.parsed_args[alias].values = after_args
- self.parsed_args[alias].exists = len(after_args) > 0
+ self.parsed_args[alias]._update(values=tuple(after_args), exists=True)
+
+ @staticmethod
+ def _looks_like_flag(arg: str, /) -> bool:
+ """Returns `True` if the arg resembles a flag (starts with `--` or `-`).
+ Arguments that look like negative numbers (e.g. `-42`, `-.5`) are not flags."""
+
+ if arg.startswith("--"):
+ return True
+ if len(arg) >= 2 and arg[0] == "-" and not arg[1].isdigit() and arg[1] != ".":
+ return True
+
+ return False
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:
+
+ if (self.flag_value_sep \
+ and self.flag_value_sep in arg
+ and arg.split(self.flag_value_sep, 1)[0].strip() not in self.arg_lookup):
+ if self._looks_like_flag(arg.split(self.flag_value_sep, 1)[0].strip()):
+ return False
return True
- if arg not in self.arg_lookup and (allow_separator or arg != self.flag_value_sep):
+
+ if arg not in self.arg_lookup and (allow_separator or not self.flag_value_sep or arg != self.flag_value_sep):
+ if self._looks_like_flag(arg):
+ return False
return True
+
return False
+ def _is_flag_value(self, arg: str, /) -> bool:
+ """Check if an argument can be treated as a space-separated flag value
+ (i.e. it is not a known flag, not the separator, not a `flag=value` token, and does not look like a flag itself)."""
+
+ if arg in self.arg_lookup:
+ return False
+ if self.flag_value_sep and arg.strip() == self.flag_value_sep:
+ return False
+ if (self.flag_value_sep \
+ and self.flag_value_sep in arg
+ and arg.split(self.flag_value_sep, 1)[0].strip() in self.arg_lookup):
+ return False
+ if self._looks_like_flag(arg):
+ return False
+
+ return True
+
def process_flagged_args(self) -> None:
"""Process flagged arguments."""
+
i = 0
while i < self.args_len:
arg = self.args[i]
# CASE 1: FLAG WITH INLINE SEPARATOR ('--flag=value')
- if self.flag_value_sep in arg:
+ if self.flag_value_sep and self.flag_value_sep in arg:
parts = arg.split(self.flag_value_sep, 1)
+ potential_flag = parts[0].strip()
- if (potential_flag := (parts := arg.split(self.flag_value_sep, 1))[0].strip()) in self.arg_lookup:
+ if potential_flag in self.arg_lookup:
alias = self.arg_lookup[potential_flag]
- self.parsed_args[alias].exists = True
- self.parsed_args[alias].flag = potential_flag
+ self.parsed_args[alias]._update(exists=True, flag=potential_flag)
if len(parts) > 1 and (val := parts[1].strip()):
- self.parsed_args[alias].values = [val]
+ self.parsed_args[alias]._update(values=(val, ))
i += 1
continue
- # CASE 2: STANDALONE FLAG
+ elif self._looks_like_flag(potential_flag):
+ # UNKNOWN FLAG WITH INLINE SEPARATOR (e.g. '--unknown=value')
+ self.unknown_flags.append(arg)
+ i += 1
+ continue
+
+ # CASE 2: STANDALONE KNOWN FLAG
if arg in self.arg_lookup:
alias = self.arg_lookup[arg]
- self.parsed_args[alias].exists = True
- self.parsed_args[alias].flag = arg
+ self.parsed_args[alias]._update(exists=True, flag=arg)
# CHECK FOR SEPARATOR IN NEXT TOKENS ('--flag', '=', 'value')
- if i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep:
+ if self.flag_value_sep and i + 1 < self.args_len and self.args[i + 1].strip() == self.flag_value_sep:
if i + 2 < self.args_len:
if (val := self.args[i + 2]) not in self.arg_lookup and val != self.flag_value_sep:
- self.parsed_args[alias].values = [val]
+ self.parsed_args[alias]._update(values=(val, ))
i += 3
continue
i += 2
continue
+
+ # CHECK FOR SPACE-SEPARATED VALUE ('--flag value')
+ if self.allow_space_value and i + 1 < self.args_len and self._is_flag_value(next_arg := self.args[i + 1]):
+ self.parsed_args[alias]._update(values=(next_arg, ))
+ i += 2
+ continue
# NO SEPARATOR = JUST A FLAG WITHOUT VALUE
+ elif self._looks_like_flag(arg):
+ # CASE 3: UNKNOWN STANDALONE FLAG (e.g. '--unknown', '-u')
+ self.unknown_flags.append(arg)
+
i += 1
@@ -1336,8 +1573,8 @@ class _ConsoleLogBoxBgReplacer:
def __init__(self, bg_fc: str, /) -> None:
self.bg_fc = bg_fc
- def __call__(self, m: _rx.Match[str], /) -> str:
- return f"{m.group(0)}[{self.bg_fc}]"
+ def __call__(self, match: _rx.Match[str], /) -> str:
+ return f"{match.group(0)}[{self.bg_fc}]"
class _ConsoleInputHelper:
@@ -1365,10 +1602,12 @@ def __init__(
def get_text(self) -> str:
"""Returns the current result text."""
+
return self.result_text
def bottom_toolbar(self) -> _pt.formatted_text.ANSI:
"""Generates the bottom toolbar text based on the current input state."""
+
try:
if self.mask_char:
text_to_check = self.result_text
@@ -1393,19 +1632,20 @@ def bottom_toolbar(self) -> _pt.formatted_text.ANSI:
if self.max_len and len(text_to_check) == self.max_len:
toolbar_msgs.append("[b|#000|bg:br:yellow]( Maximum length reached )")
- return _pt.formatted_text.ANSI(FormatCodes.to_ansi(" ".join(toolbar_msgs)))
+ return _pt.formatted_text.ANSI(deprFormatCodes.to_ansi(" ".join(toolbar_msgs)))
except Exception:
return _pt.formatted_text.ANSI("")
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()
if not text:
return "", removed_chars
- processed_text = "".join(c for c in text if ord(c) >= 32)
+ processed_text = "".join(char for char in text if ord(char) >= 32)
if self.allowed_chars is not CHARS.ALL:
filtered_text = ""
for char in processed_text:
@@ -1426,6 +1666,7 @@ def process_insert_text(self, text: str, /) -> tuple[str, set[str]]:
def insert_text_event(self, event: KeyPressEvent, /) -> None:
"""Handles text insertion events (typing/pasting)."""
+
try:
if not (insert_text := event.data):
return
@@ -1447,6 +1688,7 @@ def insert_text_event(self, event: KeyPressEvent, /) -> 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
cursor_pos = buffer.cursor_position
@@ -1510,6 +1752,8 @@ def __init__(
self.validator = validator
def validate(self, document: Document) -> None:
+ """Validates the input text according to the minimum length and custom validator function."""
+
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))
@@ -1518,34 +1762,34 @@ def validate(self, document: Document) -> None:
class ProgressBar:
- """A console progress bar with smooth transitions and customizable appearance.\n
- --------------------------------------------------------------------------------------------------
- - `min_width` -โ the min width of the progress bar in chars
- - `max_width` -โ the max width of the progress bar in chars
- - `bar_format` -โ the format strings used to render the progress bar, containing placeholders:
- * `{label}` `{l}`
- * `{bar}` `{b}`
- * `{current}` `{c}` (optional `:` format specifier for thousands separator, e.g. `{c:,}`)
- * `{total}` `{t}` (optional `:` format specifier for thousands separator, e.g. `{t:,}`)
- * `{percentage}` `{percent}` `{p}` (optional `:.f` format specifier to round
- to specified number of decimal places, e.g. `{p:.1f}`)
- - `limited_bar_format` -โ a simplified format string used when the console width is too small
- for the normal `bar_format`
- - `chars` -โ a tuple of characters ordered from full to empty progress
- The first character represents completely filled sections, intermediate
- characters create smooth transitions, and the last character represents
- empty sections. Default is a set of Unicode block characters.
- --------------------------------------------------------------------------------------------------
- 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."""
+ """A terminal progress bar with smooth transitions and customizable appearance.\n
+ ------------------------------------------------------------------------------------------------------
+ * `min_width` โ The min width of the progress bar in chars.
+ * `max_width` โ The max width of the progress bar in chars.
+ * `bar_format` โ The format strings used to render the progress bar, containing placeholders:
+ - `{label}` `{l}`
+ - `{bar}` `{b}`
+ - `{current}` `{c}` (optional `:` format specifier for thousands separator, e.g. `{c:,}`)
+ - `{total}` `{t}` (optional `:` format specifier for thousands separator, e.g. `{t:,}`)
+ - `{percentage}` `{percent}` `{p}` (optional `:.f` format specifier to round
+ to specified number of decimal places, e.g. `{p:.1f}`)
+ * `limited_bar_format` โ A simplified format string used when the terminal width is too small
+ for the normal `bar_format`.
+ * `chars` โ A tuple of characters ordered from full to empty progress:
+ The first character represents completely filled sections.
+ Intermediate characters create smooth transitions
+ The last character represents empty sections.
+ ------------------------------------------------------------------------------------------------------
+ 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."""
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}%)))"],
- limited_bar_format: list[str] | tuple[str, ...] = ["โ{b}โ"],
+ bar_format: list[str] | tuple[str, ...] = ["{l}", "[bg:black]({b})", "[b]({c:,})/{t:,}", "[dim](([i]({p}%)))"],
+ limited_bar_format: list[str] | tuple[str, ...] = ["[bg:black]({b})"],
sep: str = " ",
chars: tuple[str, ...] = ("โ", "โ", "โ", "โ", "โ", "โ", "โ", "โ", " "),
):
@@ -1558,7 +1802,7 @@ def __init__(
self.bar_format: list[str] | tuple[str, ...]
"""The format strings used to render the progress bar (joined by `sep`)."""
self.limited_bar_format: list[str] | tuple[str, ...]
- """The simplified format strings used when the console width is too small."""
+ """The simplified format strings used when the terminal width is too small."""
self.sep: str
"""The separator string used to join multiple bar-format strings."""
self.chars: tuple[str, ...]
@@ -1577,9 +1821,10 @@ def __init__(
def set_width(self, min_width: Optional[int] = None, max_width: Optional[int] = None) -> None:
"""Set the width of the progress bar.\n
- --------------------------------------------------------------
- - `min_width` -โ the min width of the progress bar in chars
- - `max_width` -โ the max width of the progress bar in chars"""
+ -----------------------------------------------------------------
+ * `min_width` โ The min width of the progress bar in chars.
+ * `max_width` โ The max width of the progress bar in chars."""
+
if min_width is not None:
if min_width < 1:
raise ValueError(f"The 'min_width' parameter must be a positive integer, got {min_width!r}")
@@ -1600,28 +1845,35 @@ def set_bar_format(
sep: Optional[str] = None,
) -> None:
"""Set the format string used to render the progress bar.\n
- --------------------------------------------------------------------------------------------------
- - `bar_format` -โ the format strings used to render the progress bar, containing placeholders:
- * `{label}` `{l}`
- * `{bar}` `{b}`
- * `{current}` `{c}` (optional `:` format specifier for thousands separator, e.g. `{c:,}`)
- * `{total}` `{t}` (optional `:` format specifier for thousands separator, e.g. `{t:,}`)
- * `{percentage}` `{percent}` `{p}` (optional `:.f` format specifier to round
- to specified number of decimal places, e.g. `{p:.1f}`)
- - `limited_bar_format` -โ a simplified format strings used when the console width is too small
- - `sep` -โ the separator string used to join multiple format strings
- --------------------------------------------------------------------------------------------------
- The bar format (also limited) can additionally be formatted with special formatting codes. For
- more detailed information about formatting codes, see the `format_codes` module documentation."""
+ ------------------------------------------------------------------------------------------------------
+ * `bar_format` โ The format strings used to render the progress bar, containing placeholders:
+ - `{label}` `{l}`
+ - `{bar}` `{b}`
+ - `{current}` `{c}` (optional `:` format specifier for thousands separator, e.g. `{c:,}`)
+ - `{total}` `{t}` (optional `:` format specifier for thousands separator, e.g. `{t:,}`)
+ - `{percentage}` `{percent}` `{p}` (optional `:.f` format specifier to round
+ to specified number of decimal places, e.g. `{p:.1f}`)
+ * `limited_bar_format` โ A simplified format strings used when the terminal width is too small.
+ * `sep` โ The separator string used to join multiple format strings.
+ ------------------------------------------------------------------------------------------------------
+ The bar format (also limited) can additionally be formatted with special formatting codes.
+ For more detailed information about formatting codes, see the `format_codes` module documentation."""
+
if bar_format is not None:
if not any(_PATTERNS.bar.search(part) for part in bar_format):
- raise ValueError("The 'bar_format' parameter value must contain the '{bar}' or '{b}' placeholder.")
+ raise ValueError(
+ "The 'bar_format' parameter value must contain the "
+ f"'{{bar}}' or '{{b}}' placeholder, got {bar_format!r}"
+ )
self.bar_format = bar_format
if limited_bar_format is not None:
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.")
+ raise ValueError(
+ "The 'limited_bar_format' parameter value must contain the "
+ f"'{{bar}}' or '{{b}}' placeholder, got {limited_bar_format!r}"
+ )
self.limited_bar_format = limited_bar_format
@@ -1630,24 +1882,27 @@ def set_bar_format(
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
- The first character represents completely filled sections, intermediate
- characters create smooth transitions, and the last character represents
- empty sections. If None, uses default Unicode block characters."""
+ -----------------------------------------------------------------------------
+ * `chars` โ A tuple of characters ordered from full to empty progress:
+ The first character represents completely filled sections.
+ Intermediate characters create smooth transitions.
+ The last character represents 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).")
+ raise ValueError(f"The 'chars' parameter must contain at least two characters (full and empty), got {chars!r}")
elif not all(len(char) == 1 for char in chars):
- raise ValueError("All elements of 'chars' must be single-character strings.")
+ raise ValueError(f"All elements of 'chars' must be single-character strings, got {chars!r}")
self.chars = chars
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)
- - `total` -โ the total value representing 100% progress (must be greater than `0`)
- - `label` -โ an optional label which is inserted at the `{label}` or `{l}` placeholder"""
+ ----------------------------------------------------------------------------------------------
+ * `current` โ The current progress value (below `0` or greater than `total` hides the bar).
+ * `total` โ The total value representing 100% progress (must be greater than `0`).
+ * `label` โ An optional label which is inserted at the `{label}` or `{l}` placeholder."""
+
# THROTTLE UPDATES (UNLESS IT'S THE FIRST/FINAL UPDATE)
current_time = _time.time()
if (
@@ -1658,9 +1913,9 @@ def show_progress(self, current: int, total: int, /, label: Optional[str] = None
self._last_update_time = current_time
if current < 0:
- raise ValueError("The 'current' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'current' parameter must be a non-negative integer, got {current!r}")
if total <= 0:
- raise ValueError("The 'total' parameter must be a positive integer.")
+ raise ValueError(f"The 'total' parameter must be a positive integer, got {total!r}")
try:
if not self.active:
@@ -1674,7 +1929,8 @@ def show_progress(self, current: int, total: int, /, label: Optional[str] = None
raise
def hide_progress(self) -> None:
- """Hide the progress bar and restore normal console output."""
+ """Hide the progress bar and restore normal terminal output."""
+
if self.active:
self._clear_progress_line()
self._stop_intercepting()
@@ -1682,13 +1938,14 @@ def hide_progress(self) -> None:
@contextmanager
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`)
- - `label` -โ an optional label which is inserted at the `{label}` or `{l}` placeholder
- ----------------------------------------------------------------------------------------------------
- The returned callable accepts keyword arguments. At least one of these parameters must be provided:
- - `current` -โ update the current progress value
- - `label` -โ update the progress label\n
+ -----------------------------------------------------------------------------------------
+ * `total` โ The total value representing 100% progress (must be greater than `0`).
+ * `label` โ An optional label which is inserted at the `{label}` or `{l}` placeholder.
+ -----------------------------------------------------------------------------------------
+ The returned callable accepts keyword arguments.
+ At least one of these parameters must be provided:
+ * `current` โ Update the current progress value.
+ * `label` โ Update the progress label.
#### Example usage:
```python
@@ -1705,8 +1962,9 @@ def progress_context(self, total: int, /, label: Optional[str] = None) -> Genera
# Do some work...
update_progress(i, f"Finalizing ({i})") # Update both
```"""
+
if total <= 0:
- raise ValueError("The 'total' parameter must be a positive integer.")
+ raise ValueError(f"The 'total' parameter must be a positive integer, got {total!r}")
try:
yield _ProgressContextHelper(self, total, label)
@@ -1729,7 +1987,7 @@ def _draw_progress_bar(self, current: int, total: int, /, label: Optional[str] =
)
bar = f"{self._create_bar(current, total, max(1, bar_width))}[*]"
- progress_text = _PATTERNS.bar.sub(FormatCodes.to_ansi(bar), formatted)
+ progress_text = _PATTERNS.bar.sub(deprFormatCodes.to_ansi(bar), formatted)
self._current_progress_str = progress_text
self._last_line_len = len(progress_text)
@@ -1756,9 +2014,9 @@ def _get_formatted_info_and_bar_width(
fmt_parts.append(fmt_part)
fmt_str = self.sep.join(fmt_parts)
- fmt_str = FormatCodes.to_ansi(fmt_str)
+ fmt_str = deprFormatCodes.to_ansi(fmt_str)
- bar_space = Console.w - len(FormatCodes.remove_ansi(_PATTERNS.bar.sub("", fmt_str)))
+ bar_space = Console.width - len(deprFormatCodes.remove_ansi(_PATTERNS.bar.sub("", fmt_str)))
bar_width = min(bar_space, self.max_width) if bar_space > 0 else 0
return fmt_str, bar_width
@@ -1796,6 +2054,7 @@ def _stop_intercepting(self) -> None:
def _emergency_cleanup(self) -> None:
"""Emergency cleanup to restore stdout in case of exceptions."""
+
try:
self._stop_intercepting()
except Exception:
@@ -1822,11 +2081,11 @@ def _redraw_display(self) -> None:
class _ProgressContextHelper:
"""Internal, callable helper class to update the progress bar's current value and/or label.\n
- ----------------------------------------------------------------------------------------------
- - `current` -โ the current progress value
- - `label` -โ the progress label
- - `type_checking` -โ whether to check the parameters' types:
- Is false per default to save performance, but can be set to true for debugging purposes."""
+ ------------------------------------------------------------------------------------------------
+ * `current` โ The current progress value.
+ * `label` โ The progress label.
+ * `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], /):
self.progress_bar = progress_bar
@@ -1895,17 +2154,17 @@ def __call__(self, match: _rx.Match[str], /) -> str:
class Throbber:
- """A console throbber for indeterminate processes with customizable appearance.
+ """A terminal 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
- - `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 `throbber_format` can additionally be formatted with special formatting codes. For more
+ ------------------------------------------------------------------------------------------------
+ * `label` โ The current label text.
+ * `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 `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__(
@@ -1945,14 +2204,16 @@ def __init__(
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:
- * `{label}` `{l}`
- * `{animation}` `{a}`
- - `sep` -โ the separator string used to join multiple format strings"""
+ -------------------------------------------------------------------------------------------------
+ * `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 throbber_format):
raise ValueError(
- "At least one format string in 'throbber_format' must contain the '{animation}' or '{a}' placeholder."
+ "At least one format string in 'throbber_format' must contain the "
+ f"'{{animation}}' or '{{a}}' placeholder, got {throbber_format!r}"
)
self.throbber_format = throbber_format
@@ -1960,26 +2221,29 @@ def set_format(self, throbber_format: list[str] | tuple[str, ...], *, sep: Optio
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"""
+ ------------------------------------------------------------------------
+ * `frames` โ A tuple of strings representing the animation frames."""
+
if len(frames) < 2:
- raise ValueError("The 'frames' parameter must contain at least two frames.")
+ raise ValueError(f"The 'frames' parameter must contain at least two frames, got {frames!r}")
self.frames = frames
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"""
+ ----------------------------------------------------------------------
+ * `interval` โ The time in seconds between each animation frame."""
+
if interval <= 0:
- raise ValueError("The 'interval' parameter must be a positive number.")
+ raise ValueError(f"The 'interval' parameter must be a positive number, got {interval!r}")
self.interval = interval
def start(self, label: Optional[str] = None, /) -> None:
"""Start the throbber animation and intercept stdout.\n
- ----------------------------------------------------------
- - `label` -โ the label to display alongside the throbber"""
+ --------------------------------------------------------------
+ * `label` โ The label to display alongside the throbber."""
+
if self.active:
return
@@ -1990,7 +2254,8 @@ def start(self, label: Optional[str] = None, /) -> None:
self._animation_thread.start()
def stop(self) -> None:
- """Stop and hide the throbber and restore normal console output."""
+ """Stop and hide the throbber and restore normal terminal output."""
+
if self.active:
if self._stop_event:
self._stop_event.set()
@@ -2006,18 +2271,19 @@ def stop(self) -> None:
def update_label(self, label: Optional[str], /) -> None:
"""Update the throbber's label text.\n
- --------------------------------------
- - `new_label` -โ the new label text"""
+ -----------------------------------------
+ * `new_label` โ The new label text."""
+
self.label = label
@contextmanager
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
- -----------------------------------------------------------------------------------------------
+ ------------------------------------------------------------------------------------
+ * `label` โ The label to display alongside the throbber.
+ ------------------------------------------------------------------------------------
The returned callable accepts a single parameter:
- - `new_label` -โ the new label text\n
+ * `new_label` โ The new label text.
#### Example usage:
```python
@@ -2028,6 +2294,7 @@ def context(self, label: Optional[str] = None, /) -> Generator[Callable[[str], N
update_label("Finishing...")
time.sleep(2)
```"""
+
try:
self.start(label)
yield self.update_label
@@ -2039,6 +2306,7 @@ def context(self, label: Optional[str] = None, /) -> Generator[Callable[[str], N
def _animation_loop(self) -> None:
"""The internal thread target that runs the animation loop."""
+
self._frame_index = 0
while self._stop_event and not self._stop_event.is_set():
try:
@@ -2047,8 +2315,8 @@ def _animation_loop(self) -> None:
self._flush_buffer()
- frame = FormatCodes.to_ansi(f"{self.frames[self._frame_index % len(self.frames)]}[*]")
- formatted = FormatCodes.to_ansi(self.sep.join(
+ frame = deprFormatCodes.to_ansi(f"{self.frames[self._frame_index % len(self.frames)]}[*]")
+ formatted = deprFormatCodes.to_ansi(self.sep.join(
fmt_part for part in self.throbber_format if \
(fmt_part := _PATTERNS.animation.sub(frame, _PATTERNS.label.sub(self.label or "", part)))
))
@@ -2081,6 +2349,7 @@ def _stop_intercepting(self) -> None:
def _emergency_cleanup(self) -> None:
"""Emergency cleanup to restore stdout in case of exceptions."""
+
try:
self._stop_intercepting()
except Exception:
diff --git a/src/xulbux/data.py b/src/xulbux/data.py
index 8d742fa..b2795a8 100644
--- a/src/xulbux/data.py
+++ b/src/xulbux/data.py
@@ -5,7 +5,7 @@
from .base.types import IndexIterableTT, IndexIterable, DataObjTT, DataObj as DataObjType
-from .format_codes import FormatCodes
+from .depr_format_codes import deprFormatCodes
from .string import String
from .regex import Regex
@@ -34,7 +34,7 @@ class Data:
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"""
+ * `data` โ The bytes or bytearray to serialize."""
key = "bytearray" if isinstance(data, bytearray) else "bytes"
try:
@@ -48,9 +48,9 @@ def serialize_bytes(cls, data: bytes | bytearray, /) -> dict[str, str]:
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
+ * `obj` โ The dictionary to deserialize.
--------------------------------------------------------------------------------------------------------
- If the serialized object was created with `Data.serialize_bytes()`, it will work.
+ If the serialized object was created with `Data.serialize_bytes()`, it will work.
If it fails to decode the data, it will raise a `ValueError`."""
for key in ("bytes", "bytearray"):
if key in obj and "encoding" in obj:
@@ -69,7 +69,7 @@ def deserialize_bytes(cls, obj: dict[str, str], /) -> bytes | bytearray:
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"""
+ * `data` โ The data structure to count the characters from."""
chars_count = 0
if isinstance(data, dict):
@@ -93,7 +93,7 @@ def chars_count(cls, data: DataObjType, /) -> int:
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"""
+ * `data` โ The data structure to strip the items from."""
if isinstance(data, dict):
return type(data)({key.strip(): (
cls.strip(cast(DataObjType, val)) \
@@ -112,9 +112,9 @@ def strip(cls, data: DataObj, /) -> DataObj:
@classmethod
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"""
+ ------------------------------------------------------------------------------------
+ * `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 type(data)({
key: (
@@ -140,8 +140,8 @@ def remove_empty_items(cls, data: DataObj, /, *, spaces_are_empty: bool = False)
@classmethod
def remove_duplicates(cls, data: DataObj, /) -> DataObj:
"""Removes all duplicates from the data structure.\n
- -----------------------------------------------------------
- - `data` -โ the data structure to remove duplicates from"""
+ --------------------------------------------------------------
+ * `data` โ The data structure to remove duplicates from."""
if isinstance(data, dict):
return type(data)({
key: cls.remove_duplicates(cast(DataObjType, val)) if isinstance(val, DataObjTT) else val
@@ -149,18 +149,20 @@ def remove_duplicates(cls, data: DataObj, /) -> DataObj:
})
elif isinstance(data, (list, tuple)):
- result: list[Any] = []
- for item in data:
- processed_item = cls.remove_duplicates(cast(DataObjType, item)) if isinstance(item, DataObjTT) else item
- is_duplicate: bool = False
+ processed: list[Any] = [
+ cls.remove_duplicates(cast(DataObjType, item)) if isinstance(item, DataObjTT) else item \
+ for item in data
+ ]
- for existing_item in result:
- if processed_item == existing_item:
- is_duplicate = True
- break
+ try:
+ result: list[Any] = list(dict.fromkeys(processed))
- if not is_duplicate:
- result.append(processed_item)
+ except TypeError:
+ # UNHASHABLE ITEMS (LISTS, DICTS, SETS) โ FALL BACK TO O(nยฒ) EQUALITY CHECK
+ result = []
+ for item in processed:
+ if item not in result:
+ result.append(item)
return cast(DataObj, type(data)(result))
@@ -182,12 +184,12 @@ def remove_comments(
comment_sep: str = "",
) -> DataObj:
"""Remove comments from a list, tuple or dictionary.\n
- ---------------------------------------------------------------------------------------------------------------
- - `data` -โ list, tuple or dictionary, where the comments should get removed from
- - `comment_start` -โ the string that marks the start of a comment inside `data`
- - `comment_end` -โ the string that marks the end of a comment inside `data`
- - `comment_sep` -โ the string with which a comment will be replaced, if it is in the middle of a value\n
- ---------------------------------------------------------------------------------------------------------------
+ -----------------------------------------------------------------------------------------------------------------
+ * `data` โ List, tuple or dictionary, where the comments should get removed from.
+ * `comment_start` โ The string that marks the start of a comment inside `data`.
+ * `comment_end` โ The string that marks the end of a comment inside `data`.
+ * `comment_sep` โ The string with which a comment will be replaced, if it is in the middle of a value.
+ -----------------------------------------------------------------------------------------------------------------
#### Examples:
```python
data = {
@@ -211,8 +213,8 @@ def remove_comments(
comment_end="<<",
comment_sep="__",
)
- ```\n
- ---------------------------------------------------------------------------------------------------------------
+ ```
+ -----------------------------------------------------------------------------------------------------------------
For this example, `processed_data` will be:
```python
{
@@ -223,14 +225,15 @@ def remove_comments(
],
"key3": None,
}
- ```\n
- - For `key1`, all the comments will just be removed, except at `value3` and `value4`:
- * `value3` The comment is removed and the parts left and right are joined through `comment_sep`.
- * `value4` The whole value is removed, since the whole value was a comment.
- - For `key2`, the key, including its whole values will be removed.
- - For `key3`, since all its values are just comments, the key will still exist, but with a value of `None`."""
- if len(comment_start) == 0:
- raise ValueError("The 'comment_start' parameter string must not be empty.")
+ ```
+ * For `key1`, all the comments will just be removed, except at `value3` and `value4`:
+ - `value3` The comment is removed and the parts left and right are joined through `comment_sep`.
+ - `value4` The whole value is removed, since the whole value was a comment.
+ * For `key2`, the key, including its whole values will be removed.
+ * For `key3`, since all its values are just comments, the key will still exist, but with a value of `None`."""
+
+ if not comment_start:
+ raise ValueError(f"The 'comment_start' parameter must be a non-empty string, got {comment_start!r}")
return cast(
DataObj,
@@ -256,20 +259,21 @@ def is_equal(
) -> bool:
"""Compares two structures and returns `True` if they are equal and `False` otherwise.\n
โพโWill not detect, if a key-name has changed, only if removed or added.\n
- ------------------------------------------------------------------------------------------------
- - `data1` -โ the first data structure to compare
- - `data2` -โ the second data structure to compare
- - `ignore_paths` -โ a path or list of paths to key/s and item/s to ignore during comparison:
- Comments are not ignored when comparing. `comment_start` and `comment_end` are only used
- to correctly recognize the keys in the `ignore_paths`.
- - `path_sep` -โ the separator between the keys/indexes in the `ignore_paths`
- - `comment_start` -โ the string that marks the start of a comment inside `data1` and `data2`
- - `comment_end` -โ the string that marks the end of a comment inside `data1` and `data2`\n
- ------------------------------------------------------------------------------------------------
- The paths from `ignore_paths` and the `path_sep` parameter work exactly the same way as for
+ ---------------------------------------------------------------------------------------------------
+ * `data1` โ The first data structure to compare.
+ * `data2` โ The second data structure to compare.
+ * `ignore_paths` โ A path or list of paths to key/s and item/s to ignore during comparison:
+ Comments are not ignored when comparing. `comment_start` and `comment_end` are only used
+ to correctly recognize the keys in the `ignore_paths`.
+ * `path_sep` โ The separator between the keys/indexes in the `ignore_paths`.
+ * `comment_start` โ The string that marks the start of a comment inside `data1` and `data2`.
+ * `comment_end` โ The string that marks the end of a comment inside `data1` and `data2`.
+ ---------------------------------------------------------------------------------------------------
+ The paths from `ignore_paths` and the `path_sep` parameter work exactly the same way as for
the method `Data.get_path_id()`. See its documentation for more details."""
- if len(path_sep) == 0:
- raise ValueError("The 'path_sep' parameter string must not be empty.")
+
+ if not path_sep:
+ raise ValueError(f"The 'path_sep' parameter must be a non-empty string, got {path_sep!r}")
if isinstance(ignore_paths, str):
ignore_paths = [ignore_paths]
@@ -338,15 +342,15 @@ def get_path_id(
ignore_not_found: bool = False,
) -> Optional[str | list[Optional[str]]]:
"""Generates a unique ID based on the path to a specific value within a nested data structure.\n
- --------------------------------------------------------------------------------------------------
- -`data` -โ the list, tuple, or dictionary, which the id should be generated for
- - `value_paths` -โ a path or list of paths to the value/s to generate the id for (explained below)
- - `path_sep` -โ the separator between the keys/indexes in the `value_paths`
- - `comment_start` -โ the string that marks the start of a comment inside `data`
- - `comment_end` -โ the string that marks the end of a comment inside `data`
- - `ignore_not_found` -โ if true, the function will return `None` if the value is not found
- instead of raising an error\n
- --------------------------------------------------------------------------------------------------
+ -----------------------------------------------------------------------------------------------------
+ * `data` โ The list, tuple, or dictionary, which the id should be generated for.
+ * `value_paths` โ A path or list of paths to the value/s to generate the id for (explained below).
+ * `path_sep` โ The separator between the keys/indexes in the `value_paths`.
+ * `comment_start` โ The string that marks the start of a comment inside `data`.
+ * `comment_end` โ The string that marks the end of a comment inside `data`.
+ * `ignore_not_found` โ If true, the function will return `None` if the value is not found
+ instead of raising an error.
+ -----------------------------------------------------------------------------------------------------
The param `value_path` is a sort of path (or a list of paths) to the value/s to be updated.
#### In this example:
```python
@@ -357,13 +361,15 @@ def get_path_id(
}
}
```
- โฆ if you want to change the value of `"apples"` to `"strawberries"`, the value path would be
- `healthy->fruit->apples` or if you don't know that the value is `"apples"` you can also use the
- index of the value, so `healthy->fruit->0`."""
- if len(path_sep) == 0:
- raise ValueError("The 'path_sep' parameter string must not be empty.")
+ โฆ if you want to change the value of `"apples"` to `"strawberries"`, the value path would be
+ `healthy->fruit->apples` or if you don't know that the value is `"apples"` you can also use
+ the index of the value, so `healthy->fruit->0`."""
+
+ if not path_sep:
+ raise ValueError(f"The 'path_sep' parameter must be a non-empty string, got {path_sep!r}")
data = cls.remove_comments(data, comment_start=comment_start, comment_end=comment_end)
+
if isinstance(value_paths, str):
return _DataGetPathIdHelper(value_paths, path_sep=path_sep, data_obj=data, ignore_not_found=ignore_not_found)()
@@ -371,16 +377,18 @@ def get_path_id(
_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: 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"""
+ """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[DataObjType] = None
path = cls._sep_path_id(path_id)
current_data: Any = data
@@ -410,16 +418,17 @@ def get_value_by_path_id(cls, data: DataObjType, path_id: str, /, *, get_key: bo
@classmethod
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
- -----------------------------------------------------------------------------------------
- - `data` -โ the list, tuple, or dictionary to update the value/s in
- - `update_values` -โ a dictionary where keys are path IDs and values are the new values
- to insert, for example:
- ```python
- { "1>012": "new value", "1>31": ["new value 1", "new value 2"], โฆ }
- ```
- The path IDs should have been created using `Data.get_path_id()`."""
+ """Updates the value/s from `update_values` in the `data`, as long as the
+ data structure hasn't changed since creating the path ID to that value.\n
+ ------------------------------------------------------------------------------
+ * `data` โ The list, tuple, or dictionary to update the value/s in.
+ * `update_values` โ A dictionary where keys are path IDs
+ and values are the new values to insert, for example:
+ ```python
+ { "1>012": "new value", "1>31": ["new value 1", "new value 2"], ... }
+ ```
+ The path IDs should have been created using `Data.get_path_id()`."""
+
if not (valid_update_values := [(path_id, new_val) for path_id, new_val in update_values.items()]):
raise ValueError(f"No valid 'update_values' found in dictionary:\n{update_values!r}")
@@ -442,38 +451,39 @@ def render(
syntax_highlighting: dict[str, str] | bool = False,
) -> str:
"""Get nicely formatted data structure-strings.\n
- ---------------------------------------------------------------------------------------------------------------
- - `data` -โ the data structure to format
- - `indent` -โ the amount of spaces to use for indentation
- - `compactness` -โ the level of compactness for the output (explained below โ section 1)
- - `max_width` -โ the maximum width of a line before expanding (only used if `compactness` is `1`)
- - `sep` -โ the separator between items in the data structure
- - `as_json` -โ if true, the output will be in valid JSON format
- - `syntax_highlighting` -โ a dictionary defining the syntax highlighting styles (explained below โ section 2)
- or `True` to apply default syntax highlighting styles or `False`/`None` to disable syntax highlighting\n
- ---------------------------------------------------------------------------------------------------------------
+ -------------------------------------------------------------------------------------------------------------------
+ * `data` โ The data structure to format.
+ * `indent` โ The amount of spaces to use for indentation.
+ * `compactness` โ The level of compactness for the output (explained below โ section 1).
+ * `max_width` โ The maximum width of a line before expanding (only used if `compactness` is `1`).
+ * `sep` โ The separator between items in the data structure.
+ * `as_json` โ if true, the output will be in valid JSON format.
+ * `syntax_highlighting` โ A dictionary defining the syntax highlighting styles (explained below โ section 2)
+ or `True` to apply default syntax highlighting styles or `False`/`None` to disable syntax highlighting.
+ -------------------------------------------------------------------------------------------------------------------
There are three different levels of `compactness`:
- - `0` expands everything possible
- - `1` only expands if there's other lists, tuples or dicts inside of data or,
- if the data's content is longer than `max_width`
- - `2` keeps everything collapsed (all on one line)\n
- ---------------------------------------------------------------------------------------------------------------
+ * `0` expands everything possible.
+ * `1` only expands if there's other lists, tuples or dicts inside of data,
+ or if the data's content is longer than `max_width`.
+ * `2` keeps everything collapsed (all on one line).
+ -------------------------------------------------------------------------------------------------------------------
The `syntax_highlighting` dictionary has 5 keys for each part of the data.
The key's values are the formatting codes to apply to this data part.
- The formatting can be changed by simply adding the key with the new value
+ The formatting can be changed by simply adding the key with the new value
inside the `syntax_highlighting` dictionary.\n
The keys with their default values are:
- - `str: "br:blue"`
- - `number: "br:magenta"`
- - `literal: "magenta"`
- - `type: "i|green"`
- - `punctuation: "br:black"`\n
- ---------------------------------------------------------------------------------------------------------------
+ * `str: "br:blue"`
+ * `number: "br:magenta"`
+ * `literal: "magenta"`
+ * `type: "i|green"`
+ * `punctuation: "br:black"
+ -------------------------------------------------------------------------------------------------------------------
For more detailed information about formatting codes, see the `format_codes` module documentation."""
+
if indent < 0:
- raise ValueError("The 'indent' parameter must be a non-negative integer.")
+ raise ValueError(f"The 'indent' parameter must be a non-negative integer, got {indent!r}")
if max_width <= 0:
- raise ValueError("The 'max_width' parameter must be a positive integer.")
+ raise ValueError(f"The 'max_width' parameter must be a positive integer, got {max_width!r}")
return _DataRenderHelper(
cls,
@@ -501,36 +511,37 @@ def print(
syntax_highlighting: dict[str, str] | bool = {},
) -> None:
"""Print nicely formatted data structures.\n
- ---------------------------------------------------------------------------------------------------------------
- - `data` -โ the data structure to format and print
- - `indent` -โ the amount of spaces to use for indentation
- - `compactness` -โ the level of compactness for the output (explained below โ section 1)
- - `max_width` -โ the maximum width of a line before expanding (only used if `compactness` is `1`)
- - `sep` -โ the separator between items in the data structure
- - `end` -โ the string appended after the last value, default a newline `\\n`
- - `as_json` -โ if true, the output will be in valid JSON format
- - `syntax_highlighting` -โ a dictionary defining the syntax highlighting styles (explained below โ section 2)\n
- ---------------------------------------------------------------------------------------------------------------
+ ----------------------------------------------------------------------------------------------------------------
+ * `data` โ The data structure to format and print.
+ * `indent` โ The amount of spaces to use for indentation.
+ * `compactness` โ The level of compactness for the output (explained below โ section 1).
+ * `max_width` โ The maximum width of a line before expanding (only used if `compactness` is `1`).
+ * `sep` โ The separator between items in the data structure.
+ * `end` โ The string appended after the last value, default a newline `\\n`.
+ * `as_json` โ If true, the output will be in valid JSON format.
+ * `syntax_highlighting` โ A dictionary defining the syntax highlighting styles (explained below โ section 2).
+ ----------------------------------------------------------------------------------------------------------------
There are three different levels of `compactness`:
- - `0` expands everything possible
- - `1` only expands if there's other lists, tuples or dicts inside of data or,
- if the data's content is longer than `max_width`
- - `2` keeps everything collapsed (all on one line)\n
- ---------------------------------------------------------------------------------------------------------------
+ * `0` expands everything possible.
+ * `1` only expands if there's other lists, tuples or dicts inside of data,
+ or if the data's content is longer than `max_width`.
+ * `2` keeps everything collapsed (all on one line).
+ ----------------------------------------------------------------------------------------------------------------
The `syntax_highlighting` parameter is a dictionary with 5 keys for each part of the data.
The key's values are the formatting codes to apply to this data part.
- The formatting can be changed by simply adding the key with the new value inside the
- `syntax_highlighting` dictionary.\n
+ The formatting can be changed by simply adding the key with the new value
+ inside the `syntax_highlighting` dictionary.\n
The keys with their default values are:
- - `str: "br:blue"`
- - `number: "br:magenta"`
- - `literal: "magenta"`
- - `type: "i|green"`
- - `punctuation: "br:black"`\n
+ * `str: "br:blue"`
+ * `number: "br:magenta"`
+ * `literal: "magenta"`
+ * `type: "i|green"`
+ * `punctuation: "br:black"`\n
For no syntax highlighting, set `syntax_highlighting` to `False` or `None`.\n
- ---------------------------------------------------------------------------------------------------------------
+ ----------------------------------------------------------------------------------------------------------------
For more detailed information about formatting codes, see the `format_codes` module documentation."""
- FormatCodes.print(
+
+ deprFormatCodes.print(
cls.render(
data,
indent=indent,
@@ -552,6 +563,8 @@ def _compare_nested(
ignore_paths: list[list[str]],
current_path: list[str] = [],
) -> bool:
+ """Internal method to recursively compare two nested data structures while ignoring specified paths."""
+
if any(current_path == path[:len(current_path)] for path in ignore_paths):
return True
@@ -588,6 +601,7 @@ def _compare_nested(
@staticmethod
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
@@ -602,6 +616,7 @@ def _sep_path_id(path_id: str, /) -> list[int]:
@classmethod
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
if len(id_path) == 1:
@@ -656,12 +671,14 @@ def __call__(self) -> DataObjType:
return self.remove_nested_comments(self.data)
def remove_nested_comments(self, item: Any, /) -> Any:
+ """Recursively removes comments from the given item, which can be a dictionary, list, tuple, or string."""
+
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 dict_item.items() \
+ (self.remove_nested_comments(key), self.remove_nested_comments(val)) for key, val in dict_item.items() \
] if key is not None
}
@@ -705,6 +722,7 @@ def __call__(self) -> Optional[str]:
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):
@@ -722,6 +740,7 @@ def process_key(self, key: str, /) -> bool:
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:
return None
@@ -738,6 +757,7 @@ def process_dict_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)
self.current_data = list(self.current_data)[idx]
@@ -782,19 +802,20 @@ def __init__(
if self.do_syntax_hl:
if syntax_highlighting is True:
syntax_highlighting = {}
- elif not isinstance(syntax_highlighting, dict):
- raise TypeError(f"Expected 'syntax_highlighting' to be a dict or bool. Got: {type(syntax_highlighting)}")
- self.syntax_hl.update({
- key: (f"[{val}]", "[_]") if key in self.syntax_hl and val not in {"", None} else ("", "")
- for key, val in syntax_highlighting.items()
- })
+ elif isinstance(syntax_highlighting, dict):
+ self.syntax_hl.update({
+ 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]}"
- sep = f"{self.syntax_hl['punctuation'][0]}{sep}{self.syntax_hl['punctuation'][1]}"
+ else:
+ raise TypeError(f"The 'syntax_highlighting' parameter must be a dict or bool, got {type(syntax_highlighting)}")
self.sep = sep
- punct_map: dict[str, str | tuple[str, str]] = {"(": ("/(", "("), **{c: c for c in "'\":)[]{}"}}
+ punct_map: dict[str, str | tuple[str, str]] = {"(": ("/(", "("), **{char: char for char in "'\":)[]{}"}}
self.punct: dict[str, str] = {
key: (
(
@@ -818,12 +839,17 @@ def __call__(self) -> str:
)
def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> str:
+ """Formats a single value based on its type and the current indentation level."""
+
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, 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)
return (
@@ -834,12 +860,15 @@ def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> s
+ self.format_sequence((obj_dict[key], obj_dict["encoding"]), current_indent + self.indent)
)
)
+
elif isinstance(value, bool):
val = str(value).lower() if self.as_json else str(value)
return f"{self.syntax_hl['literal'][0]}{val}{self.syntax_hl['literal'][1]}" if self.do_syntax_hl else val
+
elif isinstance(value, (int, float)):
val = "null" if self.as_json and (_math.isinf(value) or _math.isnan(value)) else str(value)
return f"{self.syntax_hl['number'][0]}{val}{self.syntax_hl['number'][1]}" if self.do_syntax_hl else val
+
elif current_indent is not None and isinstance(value, complex):
return (
self.format_value(str(value).strip("()")) if self.as_json else (
@@ -848,9 +877,11 @@ def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> s
f"complex{self.format_sequence((value.real, value.imag), current_indent + self.indent)}"
)
)
+
elif value is None:
val = "null" if self.as_json else "None"
return f"{self.syntax_hl['literal'][0]}{val}{self.syntax_hl['literal'][1]}" if self.do_syntax_hl else val
+
else:
return ((
self.punct['"'] + self.syntax_hl["str"][0] + String.escape(str(value), '"') + self.syntax_hl["str"][1]
@@ -861,6 +892,8 @@ def format_value(self, value: Any, /, current_indent: Optional[int] = None) -> s
))
def should_expand(self, seq: IndexIterable, /) -> bool:
+ """Determines whether a sequence should be expanded based on its content and the current compactness settings."""
+
if self.compactness == 0:
return True
if self.compactness == 2:
@@ -877,6 +910,8 @@ def should_expand(self, seq: IndexIterable, /) -> bool:
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:
+ """Formats a dictionary as a string, applying indentation and compactness rules."""
+
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)}"
@@ -891,6 +926,8 @@ 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:
+ """Formats a list or tuple as a string, applying indentation and compactness rules."""
+
if self.as_json:
seq = list(seq)
diff --git a/src/xulbux/depr_format_codes.py b/src/xulbux/depr_format_codes.py
new file mode 100644
index 0000000..e9f7a8b
--- /dev/null
+++ b/src/xulbux/depr_format_codes.py
@@ -0,0 +1,1092 @@
+"""
+**DEPRECATED MODULE** โ use the operator-based API in `xulbux.format_codes` (`F`, `FormatCodes`, `Term`) instead.
+
+This module is kept temporarily so existing internal callers and downstream code
+that relies on the string-based bracket-syntax (`"[b](Hello)"`) keep working
+until they are migrated to the new operator API. It will be removed in a
+future release.
+
+--------------------------------------------------------------------------------------------------------------------
+
+This module provides the `deprFormatCodes` class, which includes methods to print and work with strings that
+contain special formatting codes, which are then converted to ANSI codes for pretty terminal output.
+
+--------------------------------------------------------------------------------------------------------------------
+### The Easy Formatting
+
+First, let's take a look at a small example of what a highly styled
+print string with formatting could look like using this module:
+```
+This here is just unformatted text. [b|u|br:blue](Next we have text that is bright blue + bold + underlined.)\\n
+[#000|bg:#F67](Then there's also black text with a red background.) And finally the ([i](boring)) plain text again.
+```
+
+How all of this exactly works is explained in the sections below.โ๐ ซ
+
+--------------------------------------------------------------------------------------------------------------------
+#### Formatting Codes and Keys
+
+In this module, you can apply styles and colors using simple formatting codes.
+These formatting codes consist of one or multiple different formatting keys in between square brackets.
+
+If a formatting code is placed in a print-string, the formatting of that code
+will be applied to everything behind it until its formatting is reset.
+If applying multiple styles and colors in the same place, instead of writing
+the formatting keys all into separate brackets (e.g. `[x][y][z]`),
+they can also be put in a single pair of brackets, separated by pipes (e.g. `[x|y|z]`).
+
+A list of all possible formatting keys can be found under all possible formatting keys.
+
+--------------------------------------------------------------------------------------------------------------------
+#### Auto Resetting Formatting Codes
+
+Certain formatting can automatically be reset, behind a certain
+amount of text, just like shown in the following example:
+```
+This is plain text, [br:blue](which is bright blue now.) Now it was automatically reset to plain again.
+```
+
+This will only reset formatting codes, that have a specific reset listed below.
+That means if you use it where another formatting is already applied,
+that formatting is still there after the automatic reset:
+```
+[cyan]This is cyan text, [dim](which is dimmed now.) Now it's not dimmed any more but still cyan.
+```
+
+If you want to ignore the auto-reset functionality of `()` brackets,
+you can put a `\\` or `/` between them and the formatting code:
+```
+[cyan]This is cyan text, [u]/(which is underlined now.) And now it is still underlined and cyan.
+```
+
+--------------------------------------------------------------------------------------------------------------------
+#### All possible Formatting Keys
+
+* RGB colors:
+ Change the text color directly with an RGB color inside the square brackets.
+ (With or without `rgb()` brackets doesn't matter.)
+ Examples:
+ - `[rgb(115, 117, 255)]`
+ - `[(255, 0, 136)]`
+ - `[255, 0, 136]`
+* HEX colors:
+ Change the text color directly with a HEX color inside the square brackets.
+ (Whether the `RGB` or `RRGGBB` HEX format is used,
+ and if there's a `#` or `0x` prefix, doesn't matter.)
+ Examples:
+ - `[0x7788FF]`
+ - `[#7788FF]`
+ - `[7788FF]`
+ - `[0x78F]`
+ - `[#78F]`
+ - `[78F]`
+* Background RGB / HEX colors:
+ Change the background color directly with an RGB or HEX color inside
+ the square brackets, using the `background:` `BG:` prefix.
+ (Same RGB / HEX formatting code rules as for text color.)
+ Examples:
+ - `[bg:rgb(115, 117, 255)]`
+ - `[bg:(255, 0, 136)]`
+ - `[bg:#7788FF]`
+ - `[bg:#78F]`
+* Standard terminal colors:
+ Change the text color to one of the standard terminal colors
+ by just writing the color name in the square brackets.
+ - `[black]`
+ - `[red]`
+ - `[green]`
+ - `[yellow]`
+ - `[blue]`
+ - `[magenta]`
+ - `[cyan]`
+ - `[white]`
+* Bright terminal colors:
+ Use the prefix `br:` to use the bright variant of the standard terminal color.
+ Examples:
+ - `[br:black]`
+ - `[br:red]`
+ - โฆ
+* Background terminal colors:
+ Use the prefix `bg:` to set the background to a standard terminal color.
+ Examples:
+ - `[bg:black]`
+ - `[bg:red]`
+ - โฆ
+* Bright background terminal colors:
+ Combine the prefixes `bg:` and `br:` to set the background to a bright terminal color.
+ Examples:
+ - `[bg:br:black]`
+ - `[bg:br:red]`
+ - โฆ
+* Text styles:
+ Use the built-in text formatting to change the style of the text.
+ There are long and short forms for each formatting code.
+ (Not all terminals support all text styles.)
+ - `[bold]` `[b]`
+ - `[dim]`
+ - `[italic]` `[i]`
+ - `[underline]` `[u]`
+ - `[inverse]` `[invert]` `[in]`
+ - `[hidden]` `[hide]` `[h]`
+ - `[strikethrough]` `[s]`
+ - `[double-underline]` `[du]`
+* Specific reset:
+ Use these reset codes to remove a specific style, color or background.
+ Again, there are long and short forms for each reset code.
+ - `[_bold]` `[_b]`
+ - `[_dim]`
+ - `[_italic]` `[_i]`
+ - `[_underline]` `[_u]`
+ - `[_inverse]` `[_invert]` `[_in]`
+ - `[_hidden]` `[_hide]` `[_h]`
+ - `[_strikethrough]` `[_s]`
+ - `[_double-underline]` `[_du]`
+ - `[_color]` `[_c]`
+ - `[_background]` `[_bg]`
+* Total reset:
+ This will reset all previously applied formatting codes.
+ - `[_]`
+* Hyperlinks:
+ Create a clickable hyperlink using the `link:` prefix followed by any URL.
+ Auto-reset braces are required to define the visible, clickable text.
+ Examples:
+ - `[link:file:///path/to/file.txt](open file)`
+ - `[link:https://example.com|br:blue](click here)`
+
+--------------------------------------------------------------------------------------------------------------------
+#### Additional Formatting Codes when a `default_color` is set
+
+1. `[*]` resets everything, just like `[_]`, but the text color will remain in `default_color`
+ (if no `default_color` is set, it resets everything, exactly like `[_]`)
+2. `[default]` will just color the text in `default_color`
+ (if no `default_color` is set, it's treated as an invalid formatting code)
+3. `[background:default]` `[BG:default]` will color the background in `default_color`
+ (if no `default_color` is set, both are treated as invalid formatting codes)\n
+
+Unlike the standard terminal colors, the default color can be changed by using the following modifiers:
+
+* `[l]` will lighten the `default_color` text by `brightness_steps`%.
+* `[ll]` will lighten the `default_color` text by `2 ร brightness_steps`%.
+* `[lll]` will lighten the `default_color` text by `3 ร brightness_steps`%.
+* โฆ
+* Same thing for darkening:
+* `[d]` will darken the `default_color` text by `brightness_steps`%.
+* `[dd]` will darken the `default_color` text by `2 ร brightness_steps`%.
+* `[ddd]` will darken the `default_color` text by `3 ร brightness_steps`%.
+* โฆ
+
+Per default, you can also use `+` and `-` to get lighter and darker `default_color` versions.
+All of these lighten/darken formatting codes are treated as invalid if no `default_color` is set.
+"""
+
+from .base.types import FormattableString, Rgba, Hexa
+from .base.consts import ANSI
+
+from .string import String
+from .regex import LazyRegex, Regex
+from .color import Color, rgba, hexa
+
+from typing import Optional, Literal, Final, overload, cast
+from itertools import chain as _chain
+import ctypes as _ctypes
+import regex as _rx
+import sys as _sys
+import os as _os
+
+
+_TERMINAL_ANSI_CONFIGURED: bool = False
+"""Whether the terminal was already configured to be able to interpret and render ANSI formatting."""
+
+_ANSI_SEQ_1: Final[FormattableString] = ANSI.seq(1)
+"""ANSI escape sequence with a single placeholder."""
+_DEFAULT_COLOR_MODS: Final[dict[str, str]] = {
+ "lighten": "+l",
+ "darken": "-d",
+}
+"""Formatting codes for lightening and darkening the `default_color`."""
+_PREFIX: Final[dict[str, set[str]]] = {
+ "bg": {"bg"},
+ "br": {"br"},
+}
+"""Formatting code prefixes for setting background- and bright-colors."""
+_PREFIX_VALUES: Final[frozenset[str]] = frozenset(_chain.from_iterable(_PREFIX.values()))
+"""Flat frozenset of all prefix values, precomputed for fast membership tests."""
+_PREFIX_RX: Final[dict[str, str]] = {
+ "bg": rf"(?:{'|'.join(_PREFIX['bg'])})\s*:",
+ "br": rf"(?:{'|'.join(_PREFIX['br'])})\s*:",
+}
+"""Regex patterns for matching background- and bright-color prefixes."""
+
+_PATTERNS = LazyRegex(
+ star_reset=r"\[\s*([^]_]*?)\s*\*\s*([^]_]*?)\]",
+ star_reset_inside=r"([^|]*?)\s*\*\s*([^|]*)",
+ ansi_seq=ANSI.CHAR + r"(?:\].*?(?:\x1b\\|\x07)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])",
+ link=r"(?i)^\s*link\s*:\s*(.+?)\s*$",
+ formatting=(
+ Regex.brackets("[", "]", is_group=True, ignore_in_strings=False) + r"(?:([/\\]?)"
+ + Regex.brackets("(", ")", is_group=True, strip_spaces=False, ignore_in_strings=False) + r")?"
+ ),
+ escape_char=r"(\s*)(\/|\\)",
+ escape_char_cond=r"(\s*\[\s*)(\/|\\)(?!\2+)",
+ bg_opt_default=r"(?i)((?:" + _PREFIX_RX["bg"] + r")?)\s*default",
+ bg_default=r"(?i)" + _PREFIX_RX["bg"] + r"\s*default",
+ modifier=(
+ r"(?i)^((?:BG\s*:)?)\s*("
+ + "|".join([f"{_rx.escape(m)}+" for m in _DEFAULT_COLOR_MODS["lighten"] + _DEFAULT_COLOR_MODS["darken"]]) + r")$"
+ ),
+ rgb=r"(?i)^\s*(" + _PREFIX_RX["bg"] + r")?\s*(?:rgb|rgba)?\s*\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?\s*$",
+ hex=r"(?i)^\s*(" + _PREFIX_RX["bg"] + r")?\s*(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})\s*$",
+)
+
+
+def _build_ansi_flat() -> dict[str, str]:
+ """Build flat mapping from every individual format key (including all aliases)
+ to its fully-formed ANSI escape sequence string (precomputed once)."""
+
+ flat: dict[str, str] = {}
+
+ for map_key, code in ANSI.CODES_MAP.items():
+ ansi_str = _ANSI_SEQ_1.format(code)
+ if isinstance(map_key, tuple):
+ for k in map_key:
+ flat[k] = ansi_str
+ else:
+ flat[map_key] = ansi_str
+
+ return flat
+
+
+_ANSI_FLAT: Final[dict[str, str]] = _build_ansi_flat()
+"""Precomputed direct-lookup table from format key to ANSI escape sequence."""
+
+_NORMALIZE_KEY_CACHE: dict[str, str] = {}
+"""Cache for `deprFormatCodes._normalize_key` results."""
+_NORMALIZE_KEY_CACHE_MAX: Final[int] = 4096
+
+_REPLACEMENT_CACHE: dict[str, str] = {}
+"""Cache for `deprFormatCodes._get_replacement` results when no `default_color` is set."""
+_REPLACEMENT_CACHE_MAX: Final[int] = 4096
+
+_TO_ANSI_CACHE: dict[tuple[str, Optional[tuple[int, int, int]], int], str] = {}
+"""Cache for full `deprFormatCodes.to_ansi` results on the public entry path."""
+_TO_ANSI_CACHE_MAX: Final[int] = 1024
+_TO_ANSI_CACHE_MAX_LEN: Final[int] = 8192
+"""Strings longer than this are not cached end-to-end."""
+
+
+class deprFormatCodes:
+ """This class provides methods to print and work with strings that contain special formatting codes,
+ which are then converted to ANSI codes for pretty terminal output."""
+
+ @classmethod
+ def print(
+ cls,
+ *values: object,
+ default_color: Optional[Rgba | Hexa] = None,
+ brightness_steps: int = 20,
+ sep: str = " ",
+ end: str = "\n",
+ flush: bool = True,
+ ) -> None:
+ """A print function, whose print `values` can be formatted using formatting codes.\n
+ -----------------------------------------------------------------------------------------------------
+ * `values` โ The values to print.
+ * `default_color` โ The default text color to use if no other text color was applied.
+ * `brightness_steps` โ The amount to increase/decrease default-color brightness per modifier code.
+ * `sep` โ The separator to use between multiple values.
+ * `end` โ The string to append at the end of the printed values.
+ * `flush` โ Whether to flush the output buffer after printing.
+ -----------------------------------------------------------------------------------------------------
+ For exact information about how to use special formatting codes,
+ see the `format_codes` module documentation."""
+
+ cls._config_terminal()
+ _sys.stdout.write(cls.to_ansi(sep.join(map(str, values)) + end, default_color, brightness_steps))
+
+ if flush:
+ _sys.stdout.flush()
+
+ @classmethod
+ 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
+ ------------------------------------------------------------------------------------------------------
+ * `prompt` โ The prompt to show to the user.
+ * `default_color` โ The default text color to use if no other text color was applied.
+ * `brightness_steps` โ The amount to increase/decrease default-color brightness per modifier code.
+ * `reset_ansi` โ If true, all ANSI formatting will be reset, after the user confirmed the input
+ and the program continues to run.
+ ------------------------------------------------------------------------------------------------------
+ For exact information about how to use special formatting codes,
+ see the `format_codes` module documentation."""
+
+ cls._config_terminal()
+ user_input = input(cls.to_ansi(str(prompt), default_color, brightness_steps))
+
+ if reset_ansi:
+ _sys.stdout.write(f"{ANSI.CHAR}[0m")
+
+ return user_input
+
+ @classmethod
+ 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:
+ """Convert the formatting codes inside a string to ANSI formatting.\n
+ -----------------------------------------------------------------------------------------------------
+ * `string` โ The string that contains the formatting codes to convert.
+ * `default_color` โ The default text color to use if no other text color was applied.
+ * `brightness_steps` โ The amount to increase/decrease default-color brightness per modifier code.
+ * `_default_start` โ Whether to start the string with the `default_color` ANSI code, if set.
+ * `_validate_default` โ Whether to validate the `default_color` before use
+ (expects valid RGBA color or None, if not validated).
+ -----------------------------------------------------------------------------------------------------
+ For exact information about how to use special formatting codes,
+ see the `format_codes` module documentation."""
+
+ if not (0 < brightness_steps <= 100):
+ raise ValueError(f"The 'brightness_steps' parameter must be in range [1, 100] inclusive, got {brightness_steps!r}")
+
+ # FAST PATH: NO FORMATTING CODES POSSIBLE WITHOUT '['
+ if "[" not in string:
+ return cls._no_bracket_fast_path(
+ string,
+ default_color,
+ _default_start=_default_start,
+ _validate_default=_validate_default,
+ )
+
+ # END-TO-END CACHE LOOKUP (PUBLIC ENTRY PATH ONLY)
+ cache_key = (
+ cls._build_cache_key(string, default_color, brightness_steps) \
+ if _default_start and _validate_default else None
+ )
+ if cache_key is not None and (cached := _TO_ANSI_CACHE.get(cache_key)) is not None:
+ return cached
+
+ if _validate_default:
+ use_default, default_color = cls._validate_default_color(default_color)
+ else:
+ use_default = default_color is not None
+ default_color = cast(Optional[rgba], default_color)
+
+ string = cls._apply_star_reset(string, use_default)
+
+ string = "\n".join(
+ _PATTERNS.formatting.sub(
+ _ReplaceKeysHelper(
+ cls,
+ use_default=use_default,
+ default_color=default_color,
+ brightness_steps=brightness_steps,
+ ), line
+ ) for line in string.split("\n")
+ )
+
+ result = (
+ ((cls._get_default_ansi(default_color) or "") if _default_start else "") \
+ + string
+ ) if default_color is not None else string
+
+ if cache_key is not None:
+ cls._store_in_cache(cache_key, result)
+
+ return result
+
+ @classmethod
+ 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
+ to the terminal using `deprFormatCodes.print()`. Invalid formatting codes remain unchanged.\n
+ -----------------------------------------------------------------------------------------
+ * `string` โ The string that contains the formatting codes to escape.
+ * `default_color` โ The default text color to use if no other text color was applied.
+ * `_escape_char` โ The character to use to escape formatting codes (`/` or `\\`).
+ -----------------------------------------------------------------------------------------
+ For exact information about how to use special formatting codes,
+ see the `format_codes` module documentation."""
+
+ use_default, default_color = cls._validate_default_color(default_color)
+
+ return "\n".join(
+ _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:
+ """Escapes all ANSI codes in the string, so they are visible when output to the terminal.\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:
+ ...
+
+ @overload
+ @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], ...]]:
+ ...
+
+ @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], ...]]:
+ """Removes all formatting codes from the string with optional tracking of removed codes.\n
+ -----------------------------------------------------------------------------------------------------------
+ * `string` โ The string that contains the formatting codes to remove.
+ * `default_color` โ The default text color to use if no other text color was applied.
+ * `get_removals` โ If true, additionally to the cleaned string, a list of tuples will be returned,
+ where each tuple contains the position of the removed formatting code and the removed formatting code.
+ * `_ignore_linebreaks` โ Whether to ignore line breaks for the removal positions."""
+
+ return cls.remove_ansi(
+ cls.to_ansi(string, default_color=default_color),
+ get_removals=get_removals,
+ _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:
+ ...
+
+ @overload
+ @classmethod
+ def remove_ansi(
+ cls,
+ ansi_string: str,
+ /,
+ *,
+ get_removals: bool = False,
+ _ignore_linebreaks: bool = False,
+ ) -> str | tuple[str, tuple[tuple[int, str], ...]]:
+ ...
+
+ @classmethod
+ def remove_ansi(
+ cls,
+ ansi_string: str,
+ /,
+ *,
+ get_removals: bool = False,
+ _ignore_linebreaks: bool = False,
+ ) -> str | tuple[str, tuple[tuple[int, str], ...]]:
+ """Removes all ANSI codes from the string with optional tracking of removed codes.\n
+ ---------------------------------------------------------------------------------------------------------
+ * `ansi_string` โ The string that contains the ANSI codes to remove.
+ * `get_removals` โ If true, additionally to the cleaned string, a list of tuples will be returned,
+ where each tuple contains the position of the removed ansi code and the removed ansi code.
+ * `_ignore_linebreaks` โ Whether to ignore line breaks for the removal positions."""
+
+ if get_removals:
+ removals: list[tuple[int, str]] = []
+
+ clean_string = _PATTERNS.ansi_seq.sub(
+ _RemAnsiSeqHelper(removals),
+ ansi_string.replace("\n", "") if _ignore_linebreaks else ansi_string # REMOVE LINEBREAKS FOR POSITIONS
+ )
+ if _ignore_linebreaks:
+ clean_string = _PATTERNS.ansi_seq.sub("", ansi_string) # BUT KEEP LINEBREAKS IN RETURNED CLEAN STRING
+
+ return clean_string, tuple(removals)
+
+ else:
+ return _PATTERNS.ansi_seq.sub("", ansi_string)
+
+ @classmethod
+ def _config_terminal(cls) -> None:
+ """Internal method which configures the terminal to be able to interpret and render ANSI formatting.\n
+ -------------------------------------------------------------------------------------------------------
+ This method will only do something the first time it's called. Subsequent calls will do nothing."""
+
+ global _TERMINAL_ANSI_CONFIGURED
+ if not _TERMINAL_ANSI_CONFIGURED:
+ _sys.stdout.flush()
+ if _os.name == "nt":
+ try:
+ # ENABLE VT100 MODE ON WINDOWS TO BE ABLE TO USE ANSI CODES
+ kernel32 = getattr(_ctypes, "windll").kernel32
+ handle = kernel32.GetStdHandle(-11)
+ mode = _ctypes.c_ulong()
+ kernel32.GetConsoleMode(handle, _ctypes.byref(mode))
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
+ except Exception:
+ pass
+ _TERMINAL_ANSI_CONFIGURED = True # type: ignore[assignment]
+
+ @classmethod
+ def _no_bracket_fast_path(
+ cls,
+ string: str,
+ default_color: Optional[Rgba | Hexa],
+ *,
+ _default_start: bool,
+ _validate_default: bool,
+ ) -> str:
+ """Handle fast path when the string contains no `[` bracket."""
+
+ if _validate_default:
+ _, default_color = cls._validate_default_color(default_color)
+
+ if _default_start and default_color is not None:
+ prefix = cls._get_default_ansi(cast(rgba, default_color))
+ if prefix:
+ return prefix + string
+
+ return string
+
+ @classmethod
+ def _build_cache_key(
+ cls,
+ string: str,
+ default_color: Optional[Rgba | Hexa],
+ brightness_steps: int,
+ ) -> Optional[tuple[str, Optional[tuple[int, int, int]], int]]:
+ """Build a cache key for `to_ansi`, returning None if caching should be skipped."""
+
+ if len(string) > _TO_ANSI_CACHE_MAX_LEN:
+ return None
+
+ if default_color is None:
+ return (string, None, brightness_steps)
+
+ if isinstance(default_color, tuple) and len(default_color) >= 3:
+ try:
+ dc_key = (int(default_color[0]), int(default_color[1]), int(default_color[2]))
+ return (string, dc_key, brightness_steps)
+ except (TypeError, ValueError):
+ return None
+
+ return None # HEX STRINGS AND OTHER TYPES SKIP CACHE
+
+ @staticmethod
+ def _store_in_cache(
+ cache_key: tuple[str, Optional[tuple[int, int, int]], int],
+ result: str,
+ /,
+ ) -> None:
+ """Store a `to_ansi` result in the end-to-end cache, evicting all entries if at capacity."""
+
+ if len(_TO_ANSI_CACHE) >= _TO_ANSI_CACHE_MAX:
+ _TO_ANSI_CACHE.clear()
+
+ _TO_ANSI_CACHE[cache_key] = result
+
+ @staticmethod
+ 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, allow_alpha=False):
+ return True, hexa(cast(str | int, default_color)).to_rgba()
+ elif Color.is_valid_rgba(default_color, allow_alpha=False):
+ return True, Color._parse_rgba(cast(Rgba, default_color))
+ raise ValueError(
+ f"The 'default_color' parameter must be either a valid RGBA or HEXA color, or None, got {default_color!r}"
+ )
+
+ @staticmethod
+ def _apply_star_reset(string: str, use_default: bool, /) -> str:
+ """Replace `[*]` star-reset tokens with the appropriate reset sequences."""
+
+ if "*" not in string:
+ return string
+ if use_default:
+ return _PATTERNS.star_reset.sub(r"[\1_|default\2]", string) # REPLACE `[โฆ|*|โฆ]` WITH `[โฆ|_|default|โฆ]`
+
+ return _PATTERNS.star_reset.sub(r"[\1_\2]", string) # REPLACE `[โฆ|*|โฆ]` WITH `[โฆ|_|โฆ]`
+
+ @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 [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:
+ """Internal method that gives you the corresponding ANSI code for the given format key.\n
+ 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)"""
+
+ # FAST PATH WHEN NO DEFAULT COLOR: USE CACHED RESULTS
+ if default_color is None and (cached := _REPLACEMENT_CACHE.get(format_key)) is not None:
+ return cached
+
+ _format_key = format_key
+ format_key = cls._normalize_key(format_key) # NORMALIZE KEY AND SAVE ORIGINAL
+
+ # DIRECT LOOKUP IN PRECOMPUTED FLAT TABLE (NO O(N) SCAN OVER CODES_MAP)
+ flat_hit = _ANSI_FLAT.get(format_key)
+
+ if default_color is not None and ( \
+ new_default_color := cls._get_default_ansi(default_color, format_key, brightness_steps)
+ ):
+ return new_default_color
+
+ if flat_hit is not None:
+ return flat_hit
+
+ rgb_match = _PATTERNS.rgb.match(format_key)
+ hex_match = _PATTERNS.hex.match(format_key)
+
+ result = _format_key
+ try:
+ if rgb_match:
+ is_bg = rgb_match.group(1)
+ red, green, blue = map(int, rgb_match.groups()[1:])
+ if Color.is_valid_rgba((red, green, blue)):
+ result = ANSI.SEQ_BG_COLOR.format(red, green,
+ blue) if is_bg else ANSI.SEQ_FG_COLOR.format(red, green, blue)
+
+ elif hex_match:
+ is_bg = hex_match.group(1)
+ rgb = Color.to_rgba(hex_match.group(2))
+ result = (
+ ANSI.SEQ_BG_COLOR.format(rgb[0], rgb[1], rgb[2])
+ if is_bg else ANSI.SEQ_FG_COLOR.format(rgb[0], rgb[1], rgb[2])
+ )
+
+ except Exception:
+ pass
+
+ if default_color is None:
+ if len(_REPLACEMENT_CACHE) >= _REPLACEMENT_CACHE_MAX:
+ _REPLACEMENT_CACHE.clear()
+ _REPLACEMENT_CACHE[_format_key] = result
+
+ return result
+
+ @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."""
+
+ _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_FG_COLOR).format(
+ *_default_color
+ )
+
+ if format_key is None or not (match := _PATTERNS.modifier.match(format_key)):
+ return None
+
+ is_bg, modifiers = match.groups()
+ adjust = 0
+
+ for mod in _modifiers[0] + _modifiers[1]:
+ adjust = String.single_char_repeats(modifiers, mod)
+ if adjust and adjust > 0:
+ modifiers = mod
+ break
+
+ new_rgb = _default_color
+
+ if adjust == 0:
+ return None
+
+ elif modifiers in _modifiers[0]:
+ 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]:
+ 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_FG_COLOR).format(*new_rgb[:3])
+
+ @staticmethod
+ def _normalize_key(format_key: str, /) -> str:
+ """Internal method to normalize the given format key."""
+
+ if (cached := _NORMALIZE_KEY_CACHE.get(format_key)) is not None:
+ return cached
+
+ k_parts = format_key.replace(" ", "").lower().split(":")
+
+ prefix_str = "".join(
+ f"{prefix_key.lower()}:" for prefix_key, prefix_values in _PREFIX.items()
+ if any(k_part in prefix_values for k_part in k_parts)
+ )
+
+ result = prefix_str + ":".join(
+ part for part in k_parts \
+ if part not in _PREFIX_VALUES
+ )
+
+ if len(_NORMALIZE_KEY_CACHE) >= _NORMALIZE_KEY_CACHE_MAX:
+ _NORMALIZE_KEY_CACHE.clear()
+ _NORMALIZE_KEY_CACHE[format_key] = result
+
+ return result
+
+
+class _EscapeFormatCodeHelper:
+ """Internal, callable helper class to escape formatting codes."""
+
+ def __init__(
+ self,
+ cls: type[deprFormatCodes],
+ *,
+ use_default: bool,
+ default_color: Optional[rgba],
+ escape_char: Literal["/", "\\"],
+ ):
+ self.cls = cls
+ self.use_default = use_default
+ self.default_color = default_color
+ self.escape_char: Literal["/", "\\"] = escape_char
+
+ 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
+ if not formats or _PATTERNS.escape_char_cond.match(match.group(0)):
+ return match.group(0)
+
+ # TEMPORARILY REPLACE `*` FOR VALIDATION
+ _formats = formats
+ if self.use_default:
+ _formats = _PATTERNS.star_reset_inside.sub(r"\1_|default\2", formats)
+ else:
+ _formats = _PATTERNS.star_reset_inside.sub(r"\1_\2", formats)
+
+ has_link = False
+ has_invalid_key = False
+ for format_key in self.cls._formats_to_keys(_formats):
+ if _PATTERNS.link.match(format_key):
+ has_link = True
+ elif self.cls._get_replacement(format_key, self.default_color) == format_key:
+ has_invalid_key = True
+
+ if has_link or not has_invalid_key:
+ # 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, _escape_char=self.escape_char)
+ escaped += f"({escaped_auto_reset})"
+ return escaped
+
+ else:
+ # KEEP INVALID FORMATTING CODES AS-IS
+ 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, _escape_char=self.escape_char)
+ result += f"({escaped_auto_reset})"
+ return result
+
+
+class _RemAnsiSeqHelper:
+ """Internal, callable helper class to remove ANSI sequences and track their removal positions."""
+
+ def __init__(self, removals: list[tuple[int, str]], /):
+ self.removals = removals
+
+ 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]
+
+ self.removals.append((start_pos, match.group()))
+
+ return ""
+
+
+class _ReplaceKeysHelper:
+ """Internal, callable helper class to replace formatting keys with their respective ANSI codes."""
+
+ def __init__(
+ self,
+ cls: type[deprFormatCodes],
+ *,
+ use_default: bool,
+ default_color: Optional[rgba],
+ brightness_steps: int,
+ ):
+ self.cls = cls
+ self.use_default = use_default
+ self.default_color = default_color
+ self.brightness_steps = brightness_steps
+
+ # INSTANCE VARIABLES FOR CURRENT PROCESSING STATE
+ self.formats: str = ""
+ self.original_formats: str = ""
+ self.formats_escaped: bool = False
+ self.auto_reset_escaped: bool = False
+ self.auto_reset_txt: Optional[str] = None
+ self.format_keys: list[str] = []
+ self.ansi_formats: list[str] = []
+ self.ansi_resets: list[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)
+
+ # CHECK IF THERE'S ESCAPED FORMAT CODES
+ self.formats_escaped = bool(_PATTERNS.escape_char_cond.match(match.group(0)))
+ if self.formats_escaped:
+ self.original_formats = self.formats = _PATTERNS.escape_char.sub(r"\1", self.formats)
+
+ # HANDLE HYPERLINK FORMAT
+ all_keys = self.cls._formats_to_keys(self.formats)
+ if (result := self.handle_link(match, all_keys)) is not None:
+ return result
+
+ self.process_formats_and_auto_reset()
+
+ # IF THERE ARE NO FORMATS OR ALL FORMATS ARE INVALID, RETURN THE ORIGINAL STRING
+ if not self.formats:
+ return match.group(0)
+
+ self.convert_to_ansi()
+ return self.build_output(match)
+
+ def handle_link(self, match: _rx.Match[str], all_keys: list[str], /) -> Optional[str]:
+ """Handle a hyperlink format code, returning the OSC 8 sequence or None if not a link."""
+
+ link_key = next((key for key in all_keys if _PATTERNS.link.match(key)), None)
+
+ if link_key is None:
+ return None
+ if self.auto_reset_txt is None:
+ return match.group(0) # LINK WITHOUT DISPLAY BRACES IS INVALID
+ if self.formats_escaped:
+ return f"[{self.original_formats}]({self.auto_reset_txt})"
+
+ link_url = _PATTERNS.link.match(link_key).group(1) # type: ignore[union-attr]
+ display = self.auto_reset_txt
+
+ if other_keys := [key for key in all_keys if key != link_key]:
+ # APPLY REMAINING FORMAT CODES TO DISPLAY TEXT WITH AUTO-RESET
+ display = "[{}]({})".format("|".join(other_keys), display)
+
+ if other_keys or ("[" in display and "]" in display):
+ display = self.cls.to_ansi(
+ display,
+ self.default_color,
+ self.brightness_steps,
+ _default_start=False,
+ _validate_default=False,
+ )
+
+ return ANSI.SEQ_LINK_OPEN.format(link_url) + display + ANSI.SEQ_LINK_CLOSE
+
+ def process_formats_and_auto_reset(self) -> None:
+ """Process nested formatting in both formats and auto-reset text."""
+
+ # PROCESS AUTO-RESET TEXT IF IT CONTAINS NESTED FORMATTING
+ if self.auto_reset_txt and self.auto_reset_txt.count("[") > 0 and self.auto_reset_txt.count("]") > 0:
+ self.auto_reset_txt = self.cls.to_ansi(
+ self.auto_reset_txt,
+ self.default_color,
+ self.brightness_steps,
+ _default_start=False,
+ _validate_default=False,
+ )
+
+ # PROCESS NESTED FORMATTING IN FORMATS
+ if self.formats and self.formats.count("[") > 0 and self.formats.count("]") > 0:
+ self.formats = self.cls.to_ansi(
+ self.formats,
+ self.default_color,
+ self.brightness_steps,
+ _default_start=False,
+ _validate_default=False,
+ )
+
+ 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 = [(
+ ansi_code \
+ if (ansi_code := self.cls._get_replacement(format_key, self.default_color, self.brightness_steps)) != format_key
+ 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:
+ self.gen_reset_codes()
+ else:
+ self.ansi_resets = []
+
+ def gen_reset_codes(self) -> None:
+ """Generate appropriate ANSI reset codes for each format key."""
+
+ default_color_resets = ("_bg", "default") if self.use_default else ("_bg", "_c")
+ reset_keys: list[str] = []
+
+ 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(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(format_key)):
+ if self.is_valid_color(format_key[i:]):
+ reset_keys.append("_bg")
+ break
+
+ # TEXT COLOR FORMAT
+ 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"_{format_key}")
+
+ # CONVERT RESET KEYS TO ANSI CODES
+ self.ansi_resets = [
+ ansi_code for reset_key in reset_keys if ( \
+ ansi_code := self.cls._get_replacement(reset_key, self.default_color, self.brightness_steps)
+ ).startswith(f"{ANSI.CHAR}{ANSI.START}")
+ ]
+
+ 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
+ all_formats_valid = all(ansi_format.startswith(f"{ANSI.CHAR}{ANSI.START}") for ansi_format in self.ansi_formats)
+
+ if not has_single_valid_ansi and not all_formats_valid:
+ return match.group(0)
+
+ # HANDLE ESCAPED FORMATTING
+ if self.formats_escaped:
+ return f"[{self.original_formats}]({self.auto_reset_txt})" if self.auto_reset_txt else f"[{self.original_formats}]"
+
+ # BUILD NORMAL OUTPUT WITH FORMATS AND RESETS
+ output = "".join(self.ansi_formats)
+
+ # ADD AUTO-RESET TEXT
+ if self.auto_reset_escaped and self.auto_reset_txt:
+ output = self.cls.to_ansi(
+ self.auto_reset_txt,
+ self.default_color,
+ self.brightness_steps,
+ _default_start=False,
+ _validate_default=False,
+ )
+ output += f"({output})"
+ elif self.auto_reset_txt:
+ output += self.auto_reset_txt
+
+ # ADD RESET CODES IF NOT ESCAPED
+ if not self.auto_reset_escaped:
+ output += "".join(self.ansi_resets)
+
+ return output
+
+ 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/env_path.py b/src/xulbux/env_path.py
index 7831182..3d0e562 100644
--- a/src/xulbux/env_path.py
+++ b/src/xulbux/env_path.py
@@ -33,42 +33,52 @@ def paths(cls, *, as_list: bool = False) -> Path | list[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`"""
+ ---------------------------------------------------------------------------------------------------
+ * `as_list` โ If true, returns the paths as a list of `Path`s; otherwise, as a single `Path`."""
+
paths_str = _os.environ.get("PATH", "")
+
if as_list:
return [Path(path) for path in paths_str.split(_os.pathsep) if path]
+
return Path(paths_str)
@classmethod
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=cwd, base_dir=base_dir).resolve()
- return check_path in {path.resolve() for path in cls.paths(as_list=True)}
+ ---------------------------------------------------------------------------
+ * `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."""
+
+ return bool(
+ cls._get(path, cwd=cwd, base_dir=base_dir).resolve() \
+ 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:
"""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` โ 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=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:
"""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` โ 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=cwd, base_dir=base_dir)
+
if cls.has_path(path_obj):
cls._persistent(path_obj, remove=True)
@@ -77,6 +87,7 @@ def _get(path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: b
"""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."""
+
if cwd:
if base_dir:
raise ValueError("Both 'cwd' and 'base_dir' cannot be True at the same time.")
@@ -91,8 +102,9 @@ def _get(path: Optional[Path | str] = None, /, *, cwd: bool = False, base_dir: b
@classmethod
def _persistent(cls, path: Path, /, *, remove: bool = False) -> None:
- """Internal method to add or remove a path from the PATH environment variable,
+ """Internal method to add or remove a path from the PATH environment variable,
persistently, across sessions, as well as the current session."""
+
current_paths = cls.paths(as_list=True)
path_resolved = path.resolve()
@@ -114,8 +126,9 @@ def _persistent(cls, path: Path, /, *, remove: bool = False) -> None:
key = _winreg.OpenKey(_winreg.HKEY_CURRENT_USER, "Environment", 0, _winreg.KEY_ALL_ACCESS)
_winreg.SetValueEx(key, "PATH", 0, _winreg.REG_EXPAND_SZ, new_path)
_winreg.CloseKey(key)
- except Exception as e:
- raise RuntimeError("Failed to update PATH in registry:\n " + str(e).replace("\n", " \n"))
+
+ except Exception as exc:
+ raise RuntimeError("Failed to update PATH in registry:\n " + str(exc).replace("\n", " \n"))
else: # UNIX-LIKE (LINUX/macOS)
home_path = Path.home()
diff --git a/src/xulbux/file.py b/src/xulbux/file.py
index a199fbc..5f720dc 100644
--- a/src/xulbux/file.py
+++ b/src/xulbux/file.py
@@ -23,13 +23,14 @@ def rename_extension(
camel_case_filename: bool = False,
) -> Path:
"""Rename the extension of a file.\n
- ----------------------------------------------------------------------------
- - `file_path` -โ the path to the file whose extension should be changed
- - `new_extension` -โ the new extension for the file (with or without dot)
- - `full_extension` -โ whether to replace the full extension (e.g. `.tar.gz`)
- or just the last part of it (e.g. `.gz`)
- - `camel_case_filename` -โ whether to convert the filename to CamelCase
- in addition to changing the files extension"""
+ ----------------------------------------------------------------------------------
+ * `file_path` โ The path to the file whose extension should be changed.
+ * `new_extension` โ The new extension for the file (with or without dot).
+ * `full_extension` โ Whether to replace the full extension (e.g. `.tar.gz`)
+ or just the last part of it (e.g. `.gz`).
+ * `camel_case_filename` โ Whether to convert the filename to CamelCase
+ in addition to changing the files extension."""
+
path = Path(file_path)
filename_with_ext = path.name
@@ -52,15 +53,16 @@ def rename_extension(
@classmethod
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
- - `content` -โ the content to write into the file
- - `force` -โ if true, will overwrite existing files
- without throwing an error (errors explained below)\n
- ------------------------------------------------------------------
- The method will throw a `FileExistsError` if a file with the same
- name already exists and a `SameContentFileExistsError` if a file
+ ----------------------------------------------------------------------
+ * `file_path` โ The path where the file should be created.
+ * `content` โ The content to write into the file.
+ * `force` โ If true, will overwrite existing files without
+ throwing an error (errors explained below).
+ ----------------------------------------------------------------------
+ The method will throw a `FileExistsError` if a file with the same
+ name already exists and a `SameContentFileExistsError` if a file
with the same name and same content already exists."""
+
path = Path(file_path)
if path.exists() and not force:
diff --git a/src/xulbux/file_sys.py b/src/xulbux/file_sys.py
index 5caa309..911f6e0 100644
--- a/src/xulbux/file_sys.py
+++ b/src/xulbux/file_sys.py
@@ -22,16 +22,19 @@ class _FileSysMeta(type):
@property
def cwd(cls) -> Path:
"""The path to the current working directory."""
+
return Path.cwd()
@property
def home(cls) -> Path:
"""The path to the user's home directory."""
+
return Path.home()
@property
def script_dir(cls) -> Path:
"""The path to the directory of the current script."""
+
if getattr(_sys, "frozen", False):
base_path = Path(_sys.executable).parent
else:
@@ -59,19 +62,20 @@ def extend_path(
raise_error: bool = False,
) -> Optional[Path]:
"""Tries to resolve and extend a relative path to an absolute path.\n
- -------------------------------------------------------------------------------------------
- - `rel_path` -โ the relative path to extend
- - `search_in` -โ a directory or a list of directories to search in,
- in addition to the predefined directories (see exact procedure below)
- - `fuzzy_match` -โ if true, it will try to find the closest matching file/folder
- names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`
- - `raise_error` -โ if true, raises a `PathNotFoundError` if
- the path couldn't be found (otherwise it returns `None`)\n
- -------------------------------------------------------------------------------------------
- If the `rel_path` couldn't be located in predefined directories,
- it will be searched in the `search_in` directory/s.
- If the `rel_path` is still not found, it returns `None` or
+ ----------------------------------------------------------------------------------------------
+ * `rel_path` โ The relative path to extend.
+ * `search_in` โ A directory or a list of directories to search in,
+ in addition to the predefined directories (see exact procedure below).
+ * `fuzzy_match` โ If true, it will try to find the closest matching file/folder
+ names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`.
+ * `raise_error` โ If true, raises a `PathNotFoundError` if
+ the path couldn't be found (otherwise it returns `None`).
+ ----------------------------------------------------------------------------------------------
+ If the `rel_path` couldn't be located in predefined directories,
+ it will be searched in the `search_in` directory/s.\n
+ If the `rel_path` is still not found, it returns `None` or
raises a `PathNotFoundError` if `raise_error` is true."""
+
search_dirs: list[Path] = []
path: Path
@@ -106,27 +110,28 @@ def extend_or_make_path(
prefer_script_dir: bool = True,
fuzzy_match: bool = False,
) -> Path:
- """Tries to locate and extend a relative path to an absolute path, and if the `rel_path`
- couldn't be located, it generates a path, as if it was located.\n
- -------------------------------------------------------------------------------------------
- - `rel_path` -โ the relative path to extend or make
- - `search_in` -โ a directory or a list of directories to search in,
- in addition to the predefined directories (see exact procedure below)
- - `prefer_script_dir` -โ if true, the script directory is preferred
- when making a new path (otherwise the CWD is preferred)
- - `fuzzy_match` -โ if true, it will try to find the closest matching file/folder
- names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`\n
- -------------------------------------------------------------------------------------------
- If the `rel_path` couldn't be located in predefined directories,
- it will be searched in the `search_in` directory/s.
- If the `rel_path` is still not found, it will makes a path
- that points to where the `rel_path` would be in the script directory,
- even though the `rel_path` doesn't exist there.
- If `prefer_script_dir` is false, it will instead make a path
+ """Tries to locate and extend a relative path to an absolute path, and if
+ the `rel_path` couldn't be located, it generates a path, as if it was located.\n
+ ----------------------------------------------------------------------------------------------
+ * `rel_path` โ The relative path to extend or make.
+ * `search_in` โ A directory or a list of directories to search in,
+ in addition to the predefined directories (see exact procedure below).
+ * `prefer_script_dir` โ If true, the script directory is preferred
+ when making a new path (otherwise the CWD is preferred).
+ * `fuzzy_match` โ If true, it will try to find the closest matching file/folder
+ names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`.
+ ----------------------------------------------------------------------------------------------
+ If the `rel_path` couldn't be located in predefined directories,
+ it will be searched in the `search_in` directory/s.\n
+ If the `rel_path` is still not found, it will makes a path
+ that points to where the `rel_path` would be in the script directory,
+ even though the `rel_path` doesn't exist there.\n
+ 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, search_in=search_in, raise_error=True, fuzzy_match=fuzzy_match)
- return result if result is not None else Path()
+ return Path() if result is None else result
except PathNotFoundError:
path = Path(str(rel_path))
@@ -136,10 +141,10 @@ def extend_or_make_path(
@classmethod
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
- - `only_content` -โ if true, only the content of the directory is removed
- and the directory itself is kept"""
+ ----------------------------------------------------------------------------------------------------------------
+ * `path` โ The path to the directory or file to remove.
+ * `only_content` โ If true, only the content of the directory is removed and the directory itself is kept."""
+
if not (path_obj := Path(path)).exists():
return None
@@ -156,9 +161,10 @@ def remove(cls, path: Path | str, /, *, only_content: bool = False) -> None:
item.unlink()
elif item.is_dir():
_shutil.rmtree(item)
- except Exception as e:
- fmt_error = "\n ".join(str(e).splitlines())
- raise Exception(f"Failed to delete {item!r}:\n {fmt_error}") from e
+
+ except Exception as exc:
+ fmt_error = "\n ".join(str(exc).splitlines())
+ raise Exception(f"Failed to delete {item!r}:\n {fmt_error}") from exc
class _ExtendPathHelper:
@@ -182,6 +188,7 @@ def __init__(
def __call__(self) -> Optional[Path]:
"""Execute the path extension logic."""
+
expanded_path = self.expand_env_vars(self.rel_path)
if expanded_path.is_absolute():
@@ -192,6 +199,7 @@ def __call__(self) -> Optional[Path]:
self.search_dirs.extend([Path(_os.sep)])
# REMOVE ROOT FROM PATH PARTS FOR SEARCHING
expanded_path = Path(*expanded_path.parts[1:])
+
else:
# ADD PREDEFINED SEARCH DIRS
self.search_dirs.extend([
@@ -206,6 +214,7 @@ def __call__(self) -> Optional[Path]:
@staticmethod
def expand_env_vars(path: Path, /) -> Path:
"""Expand all environment variables in the given path."""
+
if "%" not in (str_path := str(path)):
return path
@@ -217,6 +226,7 @@ def expand_env_vars(path: Path, /) -> 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
@@ -226,11 +236,13 @@ def search_in_dirs(self, path: Path, /) -> Optional[Path]:
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]:
- """Find a path by traversing the given parts from the base directory,
+ """Find a path by traversing the given parts from the base directory,
optionally using closest matches for each part."""
+
current_path: Path = base_dir
for part in target_path.parts:
@@ -244,10 +256,13 @@ def find_path(self, base_dir: Path, target_path: Path, /, *, fuzzy_match: bool)
@staticmethod
def get_closest_match(dir: Path, path_part: str, /) -> Optional[str]:
- """Internal method to get the closest matching file or folder name
+ """Internal method to get the closest matching file or folder name
in the given directory for the given path part."""
+
try:
- items = [item.name for item in dir.iterdir()]
- return matches[0] if (matches := _difflib.get_close_matches(path_part, items, n=1, cutoff=0.6)) else None
+ return matches[0] if (
+ matches := _difflib.get_close_matches(path_part, [item.name for item in dir.iterdir()], n=1, cutoff=0.6)
+ ) else None
+
except Exception:
return None
diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py
index 3490a88..82c3902 100644
--- a/src/xulbux/format_codes.py
+++ b/src/xulbux/format_codes.py
@@ -1,870 +1,904 @@
"""
-This module provides the `FormatCodes` class, which includes methods to print and work with strings that
-contain special formatting codes, which are then converted to ANSI codes for pretty console output.
+This module provides the `FormatCodes` (alias `FC`) class together with the `Format` (alias `F`)
+and `Term` classes for building richly formatted terminal output using a typed, operator-based syntax.
-------------------------------------------------------------------------------------------------------------------------------------
+-----------------------------------------------------------------------------------------------------------
### The Easy Formatting
-First, let's take a look at a small example of what a highly styled print string with formatting could look like using this module:
-```
-This here is just unformatted text. [b|u|br:blue](Next we have text that is bright blue + bold + underlined.)\\n
-[#000|bg:#F67](Then there's also black text with a red background.) And finally the ([i](boring)) plain text again.
+First, let's take a look at a small example of what a
+highly styled output could look like using this module:
+
+```python
+FormatCodes(
+ "This here is just unformatted text. " + (F.BOLD | F.UNDERLINE | F.BR.BLUE)(
+ "Next we have text that is bright blue + bold + underlined."
+ ),
+ (F.hex("#000") | F.BG.hex("#F67"))(
+ "Then there's also black text with a red background."
+ ) + " And finally the " + F.ITALIC("(boring)") + " plain text again.",
+).print()
```
-How all of this exactly works is explained in the sections below.โ๐ ซ
+How all of this exactly works is explained in the sections below. ๐ ซ
-------------------------------------------------------------------------------------------------------------------------------------
-#### Formatting Codes and Keys
+-----------------------------------------------------------------------------------------------------------
+#### Format Codes and Groups
-In this module, you can apply styles and colors using simple formatting codes.
-These formatting codes consist of one or multiple different formatting keys in between square brackets.
+In this module, you apply styles and colors using `Format` (or its short alias `F`) attributes.
+Every format attribute supports two operators:
-If a formatting code is placed in a print-string, the formatting of that code will be applied to everything behind it until its
-formatting is reset. If applying multiple styles and colors in the same place, instead of writing the formatting keys all into
-separate brackets (e.g. `[x][y][z]`), they can also be put in a single pair of brackets, separated by pipes (e.g. `[x|y|z]`).
+* `|` combines two or more format codes into a single immutable group, e.g.
+ `F.BOLD | F.RED` โ bold + red foreground
+* `()` applies the format (or group) to the given text and auto-resets the formatting after it, e.g.
+ `F.BOLD("hello")` โ bold "hello", reset back to normal afterwards
+ `(F.BOLD | F.RED)("hello")` โ same idea, combined
-A list of all possible formatting keys can be found under all possible formatting keys.
+A list of all possible format attributes can be found below.
-------------------------------------------------------------------------------------------------------------------------------------
-#### Auto Resetting Formatting Codes
+-----------------------------------------------------------------------------------------------------------
+#### Auto Resetting Formats
-Certain formatting can automatically be reset, behind a certain amount of text, just like shown in the following example:
-```
-This is plain text, [br:blue](which is bright blue now.) Now it was automatically reset to plain again.
-```
+Every `_Fmt`, `_FmtGroup`, `_ColorFmt` or `_LinkFmt` call automatically generates the
+matching reset sequence behind its text, just like shown in the following example:
-This will only reset formatting codes, that have a specific reset listed below.
-That means if you use it where another formatting is already applied, that formatting is still there after the automatic reset:
+```python
+FormatCodes(
+ "This is plain text, "
+ + F.BR.BLUE("which is bright blue now.")
+ + " Now it was automatically reset to plain again.",
+).print()
```
-[cyan]This is cyan text, [dim](which is dimmed now.) Now it's not dimmed any more but still cyan.
+
+Only the specific formats that were applied are reset; other formatting in scope is left intact:
+
+```python
+FormatCodes(
+ F.CYAN("This is cyan text, ", F.DIM("which is dimmed now."),
+ " Now it's not dimmed any more but still cyan."),
+).print()
```
-If you want to ignore the auto-reset functionality of `()` brackets, you can put a `\\` or `/` between them and
-the formatting code:
+-----------------------------------------------------------------------------------------------------------
+#### Bare (Open-Only) Formats
+
+Passing a format object *without calling it* emits only its opening ANSI sequence at that
+position, with no matching close/reset appended. This is the typed equivalent of `[โฆ]`
+(open bracket without closing braces) from the legacy string syntax:
+
+```python
+FormatCodes(
+ F.RED, "error: something went wrong ", F.RESET,
+ "back to normal",
+).print()
```
-[cyan]This is cyan text, [u]/(which is underlined now.) And now it is still underlined and cyan.
+
+Any format type supports bare usage: `F.RED` (`_Fmt`), `F.hex("#F67")` (`_ColorFmt`),
+`F.link("url")` (`_LinkFmt`), and `F.BOLD | F.RED` (`_FmtGroup`).
+Bare formats can also appear inside tuples and nested calls:
+
+```python
+FormatCodes(
+ F.DIM("a", F.RED, "b", F.RESET_COLOR, "c"),
+).print()
```
-------------------------------------------------------------------------------------------------------------------------------------
-#### All possible Formatting Keys
-
-- RGB colors:
- Change the text color directly with an RGB color inside the square brackets. (With or without `rgb()` brackets doesn't matter.)
- Examples:
- - `[rgb(115, 117, 255)]`
- - `[(255, 0, 136)]`
- - `[255, 0, 136]`
-- HEX colors:
- Change the text color directly with a HEX color inside the square brackets. (Whether the `RGB` or `RRGGBB` HEX format is used,
- and if there's a `#` or `0x` prefix, doesn't matter.)
- Examples:
- - `[0x7788FF]`
- - `[#7788FF]`
- - `[7788FF]`
- - `[0x78F]`
- - `[#78F]`
- - `[78F]`
-- background RGB / HEX colors:
- Change the background color directly with an RGB or HEX color inside the square brackets, using the `background:` `BG:` prefix.
- (Same RGB / HEX formatting code rules as for text color.)
- Examples:
- - `[background:rgb(115, 117, 255)]`
- - `[BG:(255, 0, 136)]`
- - `[background:#7788FF]`
- - `[BG:#78F]`
-- standard console colors:
- Change the text color to one of the standard console colors by just writing the color name in the square brackets.
- - `[black]`
- - `[red]`
- - `[green]`
- - `[yellow]`
- - `[blue]`
- - `[magenta]`
- - `[cyan]`
- - `[white]`
-- bright console colors:
- Use the prefix `bright:` `BR:` to use the bright variant of the standard console color.
- Examples:
- - `[bright:black]` `[BR:black]`
- - `[bright:red]` `[BR:red]`
- - โฆ
-- Background console colors:
- Use the prefix `background:` `BG:` to set the background to a standard console color. (Not all consoles support bright
- standard colors.)
- Examples:
- - `[background:black]` `[BG:black]`
- - `[background:red]` `[BG:red]`
- - โฆ
-- Bright background console colors:
- Combine the prefixes `background:` / `BG:` and `bright:` / `BR:` to set the background to a bright console color.
- (The order of the prefixes doesn't matter.)
- Examples:
- - `[background:bright:black]` `[BG:BR:black]`
- - `[background:bright:red]` `[BG:BR:red]`
- - โฆ
-- Text styles:
- Use the built-in text formatting to change the style of the text. There are long and short forms for each formatting code.
- (Not all consoles support all text styles.)
- - `[bold]` `[b]`
- - `[dim]`
- - `[italic]` `[i]`
- - `[underline]` `[u]`
- - `[inverse]` `[invert]` `[in]`
- - `[hidden]` `[hide]` `[h]`
- - `[strikethrough]` `[s]`
- - `[double-underline]` `[du]`
-- Specific reset:
- Use these reset codes to remove a specific style, color or background. Again, there are long and
- short forms for each reset code.
- - `[_bold]` `[_b]`
- - `[_dim]`
- - `[_italic]` `[_i]`
- - `[_underline]` `[_u]`
- - `[_inverse]` `[_invert]` `[_in]`
- - `[_hidden]` `[_hide]` `[_h]`
- - `[_strikethrough]` `[_s]`
- - `[_double-underline]` `[_du]`
- - `[_color]` `[_c]`
- - `[_background]` `[_bg]`
-- Total reset:
- This will reset all previously applied formatting codes.
- - `[_]`
-- Hyperlinks:
- Create a clickable hyperlink using the `link:` prefix followed by any URL.
- Auto-reset braces are required to define the visible, clickable text.
- Examples:
- - `[link:file:///C:/path/to/file.txt](open file)`
- - `[link:https://example.com|br:blue](click here)`
-
-------------------------------------------------------------------------------------------------------------------------------------
-#### Additional Formatting Codes when a `default_color` is set
-
-1. `[*]` resets everything, just like `[_]`, but the text color will remain in `default_color`
- (if no `default_color` is set, it resets everything, exactly like `[_]`)
-2. `[default]` will just color the text in `default_color`
- (if no `default_color` is set, it's treated as an invalid formatting code)
-3. `[background:default]` `[BG:default]` will color the background in `default_color`
- (if no `default_color` is set, both are treated as invalid formatting codes)\n
-
-Unlike the standard console colors, the default color can be changed by using the following modifiers:
-
-- `[l]` will lighten the `default_color` text by `brightness_steps`%
-- `[ll]` will lighten the `default_color` text by `2 ร brightness_steps`%
-- `[lll]` will lighten the `default_color` text by `3 ร brightness_steps`%
-- โฆ etc.โSame thing for darkening:
-- `[d]` will darken the `default_color` text by `brightness_steps`%
-- `[dd]` will darken the `default_color` text by `2 ร brightness_steps`%
-- `[ddd]` will darken the `default_color` text by `3 ร brightness_steps`%
-- โฆ etc.
-Per default, you can also use `+` and `-` to get lighter and darker `default_color` versions.
-All of these lighten/darken formatting codes are treated as invalid if no `default_color` is set.
+-----------------------------------------------------------------------------------------------------------
+#### Nesting and Multi-Segment Groups
+
+A format call accepts either a single piece of text or any number of mixed segments.
+Strings, nested `_Styled` calls, bare format objects, and raw tuples can be mixed freely:
+
+* `F.X("text")` โ Apply `X` to `"text"`, auto-reset after.
+* `F.X | F.Y` โ Combine `X` and `Y` into a single group.
+* `(F.X | F.Y)("text")` โ Apply the group to `"text"`.
+* `F.X("a", F.Y("b"), "c")` โ Nested multi-segment: `Y` is applied only to `"b"`.
+* `F.X` โ Bare: emit only the opening sequence, no auto-reset.
+* `("a", F.X("b"), "c")` โ Same-line group โ passed as a single tuple to `FormatCodes(โฆ)`.
+
+Inside `FormatCodes(*segments, sep="\\n")`, every positional argument is treated as one
+logical line and joined by `sep`. An empty string argument `""` therefore produces a blank line.
+
+-----------------------------------------------------------------------------------------------------------
+#### All Possible Format Attributes
+
+* Text styles:
+ - `F.BOLD`
+ - `F.DIM`
+ - `F.ITALIC`
+ - `F.UNDERLINE`
+ - `F.INVERSE`
+ - `F.HIDDEN`
+ - `F.STRIKE`
+ - `F.DOUBLE_UNDERLINE`
+* Standard foreground colors:
+ - `F.BLACK`, `F.RED`, `F.GREEN`, `F.YELLOW`,
+ `F.BLUE`, `F.MAGENTA`, `F.CYAN`, `F.WHITE`
+* Bright foreground colors (`F.BR.*`):
+ - `F.BR.BLACK`, `F.BR.RED`, `F.BR.GREEN`, โฆ
+* Standard background colors (`F.BG.*`):
+ - `F.BG.BLACK`, `F.BG.RED`, `F.BG.GREEN`, โฆ
+* Bright background colors (`F.BG.BR.*`):
+ - `F.BG.BR.RED`, `F.BG.BR.GREEN`, โฆ
+* 24-bit true-color (foreground / background):
+ - `F.rgb(255, 96, 112)`
+ - `F.hex("#FF6070")` or `F.hex("F67")`
+ - `F.BG.rgb(0, 0, 0)`
+ - `F.BG.hex("#000")`
+* Hyperlinks (OSC 8):
+ - `F.link("https://example.com")("click here")`
+ - `(F.link("โฆ") | F.BR.BLUE)("click here")`
+* Specific resets (only needed in advanced use; auto-reset usually covers it):
+ - `F.RESET_BOLD`, `F.RESET_DIM`, `F.RESET_ITALIC`, `F.RESET_UNDERLINE`,
+ `F.RESET_INVERSE`, `F.RESET_HIDDEN`, `F.RESET_STRIKE`,
+ `F.RESET_COLOR`, `F.RESET_BG`
+* Total reset (resets every previously applied formatting):
+ - `F.RESET`
+
+-----------------------------------------------------------------------------------------------------------
+#### Terminal Control โ the `Term` class
+
+`Term` exposes commonly used non-formatting ANSI sequences for cursor- and screen-control.
+These are plain strings (or string-returning helpers), so they can be passed directly into a
+`FormatCodes(โฆ)` call or written to `sys.stdout`:
+
+* `Term.CLEAR_LINE` โ Erase the entire current line.
+* `Term.CLEAR_SCREEN` โ Erase the whole screen.
+* `Term.HIDE_CURSOR` โ Hide the cursor.
+* `Term.SHOW_CURSOR` โ Show the cursor.
+* `Term.ALT_SCREEN` โ Enter the alternate screen buffer.
+* `Term.MAIN_SCREEN` โ Leave the alternate screen buffer.
+* `Term.up(n)` โ Move the cursor up by `n` rows.
+* `Term.down(n)` โ Move the cursor down by `n` rows.
+* `Term.right(n)` โ Move the cursor right by `n` columns.
+* `Term.left(n)` โ Move the cursor left by `n` columns.
+* `Term.move(row, col)` โ Move the cursor to an absolute `(row, col)` position.
+* `Term.title(text)` โ Set the terminal window / tab title (OSC 2).
+* `Term.save()` โ Save the current cursor position.
+* `Term.restore()` โ Restore the previously saved cursor position.
"""
-from .base.types import FormattableString, Rgba, Hexa
-from .base.consts import ANSI
+from __future__ import annotations
-from .string import String
-from .regex import LazyRegex, Regex
-from .color import Color, rgba, hexa
+from .base.consts import ANSI
+from .base.decorators import mypyc_attr
-from typing import Optional, Literal, Final, overload, cast
+from typing import TypeAlias, ClassVar, Iterator, Optional, Final, Union, cast
import ctypes as _ctypes
import regex as _rx
import sys as _sys
import os as _os
-_CONSOLE_ANSI_CONFIGURED: bool = False
-"""Whether the console was already configured to be able to interpret and render ANSI formatting."""
+_terminal_ansi_configured: bool = False
+"""Whether the terminal was already configured to be able to interpret and render ANSI formatting."""
-_ANSI_SEQ_1: Final[FormattableString] = ANSI.seq(1)
-"""ANSI escape sequence with a single placeholder."""
-_DEFAULT_COLOR_MODS: Final[dict[str, str]] = {
- "lighten": "+l",
- "darken": "-d",
-}
-"""Formatting codes for lightening and darkening the `default_color`."""
-_PREFIX: Final[dict[str, set[str]]] = {
- "BG": {"background", "bg"},
- "BR": {"bright", "br"},
-}
-"""Formatting code prefixes for setting background- and bright-colors."""
-_PREFIX_RX: Final[dict[str, str]] = {
- "BG": rf"(?:{'|'.join(_PREFIX['BG'])})\s*:",
- "BR": rf"(?:{'|'.join(_PREFIX['BR'])})\s*:",
+
+def _config_terminal() -> None:
+ """Configure the terminal to be able to interpret and render ANSI formatting.\n
+ This function only does something the first time it is called. Subsequent calls are no-ops."""
+
+ global _terminal_ansi_configured
+ if _terminal_ansi_configured:
+ return
+
+ _sys.stdout.flush()
+
+ if _os.name == "nt":
+ try:
+ kernel32 = getattr(_ctypes, "windll").kernel32
+ handle = kernel32.GetStdHandle(-11)
+ mode = _ctypes.c_ulong()
+ kernel32.GetConsoleMode(handle, _ctypes.byref(mode))
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
+ except Exception:
+ pass
+
+ _terminal_ansi_configured = True
+
+
+_ANSI_SEQ_RX: Final = _rx.compile(ANSI.CHAR + r"(?:\].*?(?:\x1b\\|\x07)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])")
+"""Regex pattern matching any ANSI escape sequence (CSI, OSC, or single-character)."""
+
+_RESET_MAP: Final[dict[int, int]] = {
+ ######################### TEXT STYLES #########################
+ 1: 22, 2: 22, 3: 23, 4: 24, 7: 27, 8: 28, 9: 29, 21: 24,
+ ########################## FG COLORS ##########################
+ 30: 39, 31: 39, 32: 39, 33: 39, 34: 39, 35: 39, 36: 39, 37: 39,
+ ########################## BG COLORS ##########################
+ 40: 49, 41: 49, 42: 49, 43: 49, 44: 49, 45: 49, 46: 49, 47: 49,
+ ####################### BRIGHT FG COLORS ######################
+ 90: 39, 91: 39, 92: 39, 93: 39, 94: 39, 95: 39, 96: 39, 97: 39,
+ ####################### BRIGHT BG COLORS ######################
+ 100: 49, 101: 49, 102: 49, 103: 49, 104: 49, 105: 49, 106: 49, 107: 49
}
-"""Regex patterns for matching background- and bright-color prefixes."""
-
-_PATTERNS = LazyRegex(
- star_reset=r"\[\s*([^]_]*?)\s*\*\s*([^]_]*?)\]",
- star_reset_inside=r"([^|]*?)\s*\*\s*([^|]*)",
- ansi_seq=ANSI.CHAR + r"(?:\].*?(?:\x1b\\|\x07)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])",
- link=r"(?i)^\s*link\s*:\s*(.+?)\s*$",
- formatting=(
- Regex.brackets("[", "]", is_group=True, ignore_in_strings=False) + r"(?:([/\\]?)"
- + Regex.brackets("(", ")", is_group=True, strip_spaces=False, ignore_in_strings=False) + r")?"
- ),
- escape_char=r"(\s*)(\/|\\)",
- escape_char_cond=r"(\s*\[\s*)(\/|\\)(?!\2+)",
- bg_opt_default=r"(?i)((?:" + _PREFIX_RX["BG"] + r")?)\s*default",
- bg_default=r"(?i)" + _PREFIX_RX["BG"] + r"\s*default",
- modifier=(
- r"(?i)^((?:BG\s*:)?)\s*("
- + "|".join([f"{_rx.escape(m)}+" for m in _DEFAULT_COLOR_MODS["lighten"] + _DEFAULT_COLOR_MODS["darken"]]) + r")$"
- ),
- rgb=r"(?i)^\s*(" + _PREFIX_RX["BG"] + r")?\s*(?:rgb|rgba)?\s*\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?\s*$",
- hex=r"(?i)^\s*(" + _PREFIX_RX["BG"] + r")?\s*(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})\s*$",
-)
+"""Mapping from format code integer to its matching reset integer.\n
+Codes that fully reset everything (`0`) or have no useful specific reset are intentionally omitted."""
+_STANDARD_SEQS: Final[
+ dict[int, tuple[tuple[str, ...], tuple[str, ...]]],
+] = {cid: ((f"{ANSI.CHAR}[{cid}m", ), (f"{ANSI.CHAR}[{reset}m", ))
+ for cid, reset in _RESET_MAP.items()}
+"""Pre-computed `(opens, closes)` tuple pairs for every standard single-code SGR format.\n
+Used as a fast path in `_build_open_close` to avoid per-call list and string allocations."""
-class FormatCodes:
- """This class provides methods to print and work with strings that contain special formatting codes,
- which are then converted to ANSI codes for pretty console output."""
+################################################## CORE TYPES ##################################################
- @classmethod
- def print(
- cls,
- *values: object,
- default_color: Optional[Rgba | Hexa] = None,
- brightness_steps: int = 20,
- sep: str = " ",
- end: str = "\n",
- flush: bool = True,
- ) -> None:
- """A print function, whose print `values` can be formatted using formatting codes.\n
- --------------------------------------------------------------------------------------------------
- - `values` -โ the values to print
- - `default_color` -โ the default text color to use if no other text color was applied
- - `brightness_steps` -โ the amount to increase/decrease default-color brightness per modifier code
- - `sep` -โ the separator to use between multiple values
- - `end` -โ the string to append at the end of the printed values
- - `flush` -โ whether to flush the output buffer after printing\n
- --------------------------------------------------------------------------------------------------
- For exact information about how to use special formatting codes,
- see the `format_codes` module documentation."""
- cls._config_console()
- _sys.stdout.write(cls.to_ansi(sep.join(map(str, values)) + end, default_color, brightness_steps))
- if flush:
- _sys.stdout.flush()
+class _FmtGroup:
+ """An immutable, ordered group of format codes produced by `|`.\n
+ ------------------------------------------------------------------
+ Supports further `|` chaining and `()` application."""
- @classmethod
- 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
- --------------------------------------------------------------------------------------------------
- - `prompt` -โ the prompt to show to the user
- - `default_color` -โ the default text color to use if no other text color was applied
- - `brightness_steps` -โ the amount to increase/decrease default-color brightness per modifier code
- - `reset_ansi` -โ if true, all ANSI formatting will be reset, after the user confirmed the input
- and the program continues to run\n
- --------------------------------------------------------------------------------------------------
- For exact information about how to use special formatting codes, see the
- `format_codes` module documentation."""
- cls._config_console()
- user_input = input(cls.to_ansi(str(prompt), default_color, brightness_steps))
+ __slots__ = ("_codes", )
- if reset_ansi:
- _sys.stdout.write(f"{ANSI.CHAR}[0m")
- return user_input
+ def __init__(self, *codes: _AnyFmt) -> None:
+ self._codes: tuple[_AnyFmt, ...] = codes
- @classmethod
- 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:
- """Convert the formatting codes inside a string to ANSI formatting.\n
- --------------------------------------------------------------------------------------------------
- - `string` -โ the string that contains the formatting codes to convert
- - `default_color` -โ the default text color to use if no other text color was applied
- - `brightness_steps` -โ the amount to increase/decrease default-color brightness per modifier code
- - `_default_start` -โ whether to start the string with the `default_color` ANSI code, if set
- - `_validate_default` -โ whether to validate the `default_color` before use
- (expects valid RGBA color or None, if not validated)\n
- --------------------------------------------------------------------------------------------------
- For exact information about how to use special formatting codes,
- see the `format_codes` module documentation."""
- if not (0 < brightness_steps <= 100):
- raise ValueError("The 'brightness_steps' parameter must be between 1 and 100.")
-
- if _validate_default:
- use_default, default_color = cls._validate_default_color(default_color)
- else:
- use_default = default_color is not None
- default_color = cast(Optional[rgba], default_color)
+ def __iter__(self) -> Iterator[_AnyFmt]:
+ """Iterating a `_FmtGroup` yields its individual format codes in order."""
- if use_default:
- string = _PATTERNS.star_reset.sub(r"[\1_|default\2]", string) # REPLACE `[โฆ|*|โฆ]` WITH `[โฆ|_|default|โฆ]`
- else:
- string = _PATTERNS.star_reset.sub(r"[\1_\2]", string) # REPLACE `[โฆ|*|โฆ]` WITH `[โฆ|_|โฆ]`
-
- string = "\n".join(
- _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 (
- ((cls._get_default_ansi(default_color) or "") if _default_start else "") \
- + string
- ) if default_color is not None else string
+ return iter(self._codes)
- @classmethod
- 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
- to the console using `FormatCodes.print()`. Invalid formatting codes remain unchanged.\n
- -----------------------------------------------------------------------------------------
- - `string` -โ the string that contains the formatting codes to escape
- - `default_color` -โ the default text color to use if no other text color was applied
- - `_escape_char` -โ the character to use to escape formatting codes (`/` or `\\`)\n
- -----------------------------------------------------------------------------------------
- For exact information about how to use special formatting codes,
- see the `format_codes` module documentation."""
- use_default, default_color = cls._validate_default_color(default_color)
+ def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup:
+ """Combines this format group with another format or group via `|`."""
- return "\n".join(
- _PATTERNS.formatting.sub(
- _EscapeFormatCodeHelper(cls, use_default=use_default, default_color=default_color, escape_char=_escape_char),
- line,
- ) for line in string.split("\n")
- )
+ if isinstance(other, _FmtGroup):
+ return _FmtGroup(*self._codes, *other._codes)
- @classmethod
- 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)
+ return _FmtGroup(*self._codes, other)
- @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:
- ...
-
- @overload
- @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], ...]]:
- ...
+ def __ror__(self, other: _AnyFmt) -> _FmtGroup:
+ """Combines this format group with another format or group via `|`."""
- @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], ...]]:
- """Removes all formatting codes from the string with optional tracking of removed codes.\n
- --------------------------------------------------------------------------------------------------------
- - `string` -โ the string that contains the formatting codes to remove
- - `default_color` -โ the default text color to use if no other text color was applied
- - `get_removals` -โ if true, additionally to the cleaned string, a list of tuples will be returned,
- where each tuple contains the position of the removed formatting code and the removed formatting code
- - `_ignore_linebreaks` -โ whether to ignore line breaks for the removal positions"""
- return cls.remove_ansi(
- cls.to_ansi(string, default_color=default_color),
- get_removals=get_removals,
- _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:
- ...
-
- @overload
- @classmethod
- def remove_ansi(
- cls,
- ansi_string: str,
- /,
- *,
- get_removals: bool = False,
- _ignore_linebreaks: bool = False,
- ) -> str | tuple[str, tuple[tuple[int, str], ...]]:
- ...
+ return _FmtGroup(other, *self._codes)
- @classmethod
- def remove_ansi(
- cls,
- ansi_string: str,
- /,
- *,
- get_removals: bool = False,
- _ignore_linebreaks: bool = False,
- ) -> str | tuple[str, tuple[tuple[int, str], ...]]:
- """Removes all ANSI codes from the string with optional tracking of removed codes.\n
- ---------------------------------------------------------------------------------------------------
- - `ansi_string` -โ the string that contains the ANSI codes to remove
- - `get_removals` -โ if true, additionally to the cleaned string, a list of tuples will be returned,
- where each tuple contains the position of the removed ansi code and the removed ansi code
- - `_ignore_linebreaks` -โ whether to ignore line breaks for the removal positions"""
- if get_removals:
- removals: list[tuple[int, str]] = []
-
- clean_string = _PATTERNS.ansi_seq.sub(
- _RemAnsiSeqHelper(removals),
- ansi_string.replace("\n", "") if _ignore_linebreaks else ansi_string # REMOVE LINEBREAKS FOR POSITIONS
- )
- if _ignore_linebreaks:
- clean_string = _PATTERNS.ansi_seq.sub("", ansi_string) # BUT KEEP LINEBREAKS IN RETURNED CLEAN STRING
-
- return clean_string, tuple(removals)
+ def __call__(self, *text: _Segment) -> _Styled:
+ """Applies this format group to the given text, auto-resetting after."""
+
+ opens, closes = _build_open_close(self)
+ return _Styled(opens, closes, text[0] if len(text) == 1 else text)
+ def __matmul__(self, text: _Text) -> _Styled:
+ """Applies this format group to the given text, auto-resetting after."""
+
+ opens, closes = _build_open_close(self)
+ return _Styled(opens, closes, text)
+
+ def __repr__(self) -> str:
+ """Returns a string representation of this format group, showing its individual codes."""
+
+ return f"_FmtGroup{self._codes!r}"
+
+
+@mypyc_attr(native_class=False)
+class _Fmt(int):
+ """A single ANSI format code integer.\n
+ ----------------------------------------------------------------------------
+ Supports two operators:
+ * `|` combines two or more codes into a `_FmtGroup` โ `F.BOLD | F.RED`
+ * `()` applies the code to text, auto-resetting after โ `F.BOLD("hello")`
+ ----------------------------------------------------------------------------
+ Marked `native_class=False` because MyPyC does not support
+ subclassing the built-in `int` type in a native class."""
+
+ _oc: tuple[tuple[str, ...], tuple[str, ...]]
+
+ def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup: # type: ignore[override]
+ """Combines this format code with another code or group via `|`."""
+
+ if isinstance(other, _FmtGroup):
+ return _FmtGroup(self, *other)
+
+ return _FmtGroup(self, other)
+
+ def __ror__(self, other: _AnyFmt) -> _FmtGroup: # type: ignore[override]
+ """Combines this format code with another code or group via `|`."""
+
+ return _FmtGroup(other, self)
+
+ def __call__(self, *text: _Segment) -> _Styled:
+ """Applies this format code to the given text, auto-resetting after."""
+
+ try:
+ oc = self._oc
+
+ except AttributeError:
+ cached = _STANDARD_SEQS.get(int(self))
+ oc = _build_open_close(_FmtGroup(self)) if cached is None else cached
+ self._oc = oc
+
+ return _Styled(oc[0], oc[1], text[0] if len(text) == 1 else text)
+
+ def __matmul__(self, text: _Text) -> _Styled:
+ """Applies this format code to the given text, auto-resetting after."""
+
+ try:
+ oc = self._oc
+
+ except AttributeError:
+ cached = _STANDARD_SEQS.get(int(self))
+ oc = _build_open_close(_FmtGroup(self)) if cached is None else cached
+ self._oc = oc
+
+ return _Styled(oc[0], oc[1], text)
+
+
+class _ColorFmt:
+ """A 24-bit true-color format โ foreground or background.\n
+ ---------------------------------------------------------------------
+ >>> F.rgb(255, 96, 112)("text") # CUSTOM FG COLOR
+ >>> F.BG.rgb(0, 0, 0)("text") # CUSTOM BG COLOR
+ >>> F.hex("#FF6070")("text") # HEX FG COLOR
+ >>> (F.BOLD | F.rgb(255, 96, 112))("text") # COMBINED WITH STYLE"""
+
+ __slots__ = ("_red", "_green", "_blue", "_bg", "_open_seq", "_close_seq")
+
+ def __init__(self, red: int, green: int, blue: int, /, *, bg: bool = False) -> None:
+ self._red, self._green, self._blue, self._bg = red, green, blue, bg
+ if bg:
+ self._open_seq = ANSI.SEQ_BG_COLOR.format(red, green, blue)
+ self._close_seq = f"{ANSI.CHAR}[{F.RESET_BG}m"
else:
- return _PATTERNS.ansi_seq.sub("", ansi_string)
+ self._open_seq = ANSI.SEQ_FG_COLOR.format(red, green, blue)
+ self._close_seq = f"{ANSI.CHAR}[{F.RESET_FG}m"
@classmethod
- def _config_console(cls) -> None:
- """Internal method which configure the console to be able to interpret and render ANSI formatting.\n
- -----------------------------------------------------------------------------------------------------
- This method will only do something the first time it's called. Subsequent calls will do nothing."""
- global _CONSOLE_ANSI_CONFIGURED
- if not _CONSOLE_ANSI_CONFIGURED:
- _sys.stdout.flush()
- if _os.name == "nt":
- try:
- # ENABLE VT100 MODE ON WINDOWS TO BE ABLE TO USE ANSI CODES
- kernel32 = getattr(_ctypes, "windll").kernel32
- h = kernel32.GetStdHandle(-11)
- mode = _ctypes.c_ulong()
- kernel32.GetConsoleMode(h, _ctypes.byref(mode))
- kernel32.SetConsoleMode(h, mode.value | 0x0004)
- except Exception:
- pass
- _CONSOLE_ANSI_CONFIGURED = True # type: ignore[assignment]
+ def from_hex(cls, color: str, /, *, bg: bool = False) -> _ColorFmt:
+ """Create a `_ColorFmt` from a HEX color string (e.g. `#FF6070` or `F67`)."""
+
+ if (hex_str := color.strip().lstrip("#")).lower().startswith("0x"):
+ hex_str = hex_str[2:]
+ if len(hex_str) == 3:
+ hex_str = hex_str[0] * 2 + hex_str[1] * 2 + hex_str[2] * 2
+
+ return cls(int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16), bg=bg)
+
+ def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup:
+ """Combines this color format with another format or group via `|`."""
+
+ if isinstance(other, _FmtGroup):
+ return _FmtGroup(self, *other._codes)
+
+ return _FmtGroup(self, other)
+
+ def __ror__(self, other: _AnyFmt) -> _FmtGroup:
+ """Combines this color format with another format or group via `|`."""
+
+ return _FmtGroup(other, self)
+
+ def __call__(self, *text: _Segment) -> _Styled:
+ """Applies this color format to the given text, auto-resetting after."""
+
+ return _Styled((self._open_seq, ), (self._close_seq, ), text[0] if len(text) == 1 else text)
+
+ def __matmul__(self, text: _Text) -> _Styled:
+ """Applies this color format to the given text, auto-resetting after."""
+
+ return _Styled((self._open_seq, ), (self._close_seq, ), text)
+
+ def __repr__(self) -> str:
+ """Returns a string representation of this color format, indicating
+ whether it's foreground or background and its RGB values."""
+
+ return f"_ColorFmt({'bg' if self._bg else 'fg'} {self._red},{self._green},{self._blue})"
+
+
+class _LinkFmt:
+ """An OSC 8 hyperlink. Combine with other formats via `|` to add text styling.\n
+ ---------------------------------------------------------------------------------
+ >>> F.link("https://example.com")("click here")
+ >>> (F.link("https://example.com") | F.BR.BLUE)("click here")"""
+
+ __slots__ = ("_url", "_open_seq", "_close_seq")
+
+ def __init__(self, url: str, /) -> None:
+ self._url = url
+ self._open_seq = ANSI.SEQ_LINK_OPEN.format(url)
+ self._close_seq = ANSI.SEQ_LINK_CLOSE
+
+ def __or__(self, other: _AnyFmt | _FmtGroup) -> _FmtGroup:
+ """Combines this link format with another format or group via `|`."""
+
+ if isinstance(other, _FmtGroup):
+ return _FmtGroup(self, *other._codes)
+
+ return _FmtGroup(self, other)
+
+ def __ror__(self, other: _AnyFmt) -> _FmtGroup:
+ """Combines this link format with another format or group via `|`."""
+
+ return _FmtGroup(other, self)
+
+ def __call__(self, *text: _Segment) -> _Styled:
+ """Applies this link format to the given text, auto-resetting after."""
+
+ return _Styled((self._open_seq, ), (self._close_seq, ), text[0] if len(text) == 1 else text)
+
+ def __matmul__(self, text: _Text) -> _Styled:
+ """Applies this link format to the given text, auto-resetting after."""
+
+ return _Styled((self._open_seq, ), (self._close_seq, ), text)
+
+ def __repr__(self) -> str:
+ """Returns a string representation of this link format, showing the URL it points to."""
+
+ return f"_LinkFmt({self._url!r})"
+
+
+_AnyFmt: TypeAlias = Union["_Fmt", "_ColorFmt", "_LinkFmt"]
+"""Any single format code, color format, or link format
+that can be combined via `|` and applied to text."""
+_Segment: TypeAlias = Union[str, "_Styled", _AnyFmt, _FmtGroup]
+"""A single segment: a plain string, a nested styled segment, or a bare format object (open-only)."""
+_Text: TypeAlias = Union[str, "_Styled", _AnyFmt, _FmtGroup, "tuple[_Segment, ...]"]
+"""Anything that can be passed to a `_Fmt`/`_FmtGroup`/`_ColorFmt`/`_LinkFmt` call."""
+_Renderable: TypeAlias = Union[str, "_Styled", _AnyFmt, _FmtGroup, "tuple[_Segment, ...]"]
+"""Anything that can be passed as a positional argument to `FormatCodes(โฆ)`."""
+
+
+class _Styled:
+ """Pre-computed ANSI open/close sequences applied to text.\n
+ -------------------------------------------------------------------------------------------
+ The renderer emits the opening ANSI codes, then `text`, then the matching reset codes.
+ `text` may be a plain `str`, a nested `_Styled`, or a tuple of mixed segments."""
+
+ __slots__ = ("_opens", "_closes", "text")
+
+ def __init__(self, opens: tuple[str, ...], closes: tuple[str, ...], text: _Text) -> None:
+ self._opens = opens
+ self._closes = closes
+ self.text = text
+
+ def __repr__(self) -> str:
+ """Returns a string representation of this styled segment, showing its opens and text."""
+
+ return f"_Styled(opens={self._opens!r}, text={self.text!r})"
+
+
+################################################## NAMESPACE HELPERS ##################################################
+
+
+class _BgBrNS:
+ """Namespace for bright background colors, reachable as `F.BG.BR.*`."""
+
+ BLACK: ClassVar[_Fmt] = _Fmt(100)
+ """Bright black background."""
+ RED: ClassVar[_Fmt] = _Fmt(101)
+ """Bright red background."""
+ GREEN: ClassVar[_Fmt] = _Fmt(102)
+ """Bright green background."""
+ YELLOW: ClassVar[_Fmt] = _Fmt(103)
+ """Bright yellow background."""
+ BLUE: ClassVar[_Fmt] = _Fmt(104)
+ """Bright blue background."""
+ MAGENTA: ClassVar[_Fmt] = _Fmt(105)
+ """Bright magenta background."""
+ CYAN: ClassVar[_Fmt] = _Fmt(106)
+ """Bright cyan background."""
+ WHITE: ClassVar[_Fmt] = _Fmt(107)
+ """Bright white background."""
+
+
+class _BgNS:
+ """Namespace for background colors, reachable as `F.BG.*`."""
+
+ BLACK: ClassVar[_Fmt] = _Fmt(40)
+ """Black background."""
+ RED: ClassVar[_Fmt] = _Fmt(41)
+ """Red background."""
+ GREEN: ClassVar[_Fmt] = _Fmt(42)
+ """Green background."""
+ YELLOW: ClassVar[_Fmt] = _Fmt(43)
+ """Yellow background."""
+ BLUE: ClassVar[_Fmt] = _Fmt(44)
+ """Blue background."""
+ MAGENTA: ClassVar[_Fmt] = _Fmt(45)
+ """Magenta background."""
+ CYAN: ClassVar[_Fmt] = _Fmt(46)
+ """Cyan background."""
+ WHITE: ClassVar[_Fmt] = _Fmt(47)
+ """White background."""
+ BR: ClassVar[type[_BgBrNS]] = _BgBrNS
@staticmethod
- 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, allow_alpha=False):
- return True, hexa(cast(str | int, default_color)).to_rgba()
- elif Color.is_valid_rgba(default_color, allow_alpha=False):
- return True, Color._parse_rgba(cast(Rgba, default_color))
- raise TypeError("The 'default_color' parameter must be either a valid RGBA or HEXA color, or None.")
+ def rgb(red: int, green: int, blue: int, /) -> _ColorFmt:
+ """24-bit background color from RGB components.\n
+ `F.BG.rgb(0, 0, 0)("text")`"""
+
+ return _ColorFmt(red, green, blue, bg=True)
@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 [key.strip() for key in formats.split("|") if key.strip()]
+ def hex(color: str, /) -> _ColorFmt:
+ """24-bit background color from HEX string.\n
+ `F.BG.hex("#202020")("text")`"""
+
+ return _ColorFmt.from_hex(color, bg=True)
+
+
+class _BrNS:
+ """Namespace for bright foreground colors, reachable as `F.BR.*`."""
+
+ BLACK: ClassVar[_Fmt] = _Fmt(90)
+ """Bright black foreground."""
+ RED: ClassVar[_Fmt] = _Fmt(91)
+ """Bright red foreground."""
+ GREEN: ClassVar[_Fmt] = _Fmt(92)
+ """Bright green foreground."""
+ YELLOW: ClassVar[_Fmt] = _Fmt(93)
+ """Bright yellow foreground."""
+ BLUE: ClassVar[_Fmt] = _Fmt(94)
+ """Bright blue foreground."""
+ MAGENTA: ClassVar[_Fmt] = _Fmt(95)
+ """Bright magenta foreground."""
+ CYAN: ClassVar[_Fmt] = _Fmt(96)
+ """Bright cyan foreground."""
+ WHITE: ClassVar[_Fmt] = _Fmt(97)
+ """Bright white foreground."""
+
+
+################################################## FORMAT CODES ##################################################
+
+
+class Format:
+ """All available ANSI format codes.\n
+ -----------------------------------------------------------------------------------------
+ Every attribute supports `|` for combining and `()` for applying to text:
+
+ >>> F.BOLD("hello") # BOLD, AUTO-RESET AFTER
+ >>> (F.BOLD | F.RED)("hello") # BOLD + RED, AUTO-RESET AFTER
+ >>> F.BR.GREEN("hello") # BRIGHT GREEN
+ >>> F.BG.BLACK("hello") # BLACK BACKGROUND
+ >>> F.DIM("# ", F.ITALIC("comment")) # NESTED: DIM WRAPS ITALIC INSIDE
+
+ For a full list of available attributes, see the `format_codes` module documentation."""
+
+ ######################### TOTAL RESET #########################
+ RESET: ClassVar[_Fmt] = _Fmt(0)
+ """Reset all formatting to default."""
+
+ ####################### SPECIFIC RESETS #######################
+ RESET_BOLD: ClassVar[_Fmt] = _Fmt(22)
+ """Reset bold (also resets dim, as they share the same code)."""
+ RESET_DIM: ClassVar[_Fmt] = _Fmt(22)
+ """Reset dim (also resets bold, as they share the same code)."""
+ RESET_ITALIC: ClassVar[_Fmt] = _Fmt(23)
+ """Reset italic."""
+ RESET_UNDERLINE: ClassVar[_Fmt] = _Fmt(24)
+ """Reset underline and double underline."""
+ RESET_INVERSE: ClassVar[_Fmt] = _Fmt(27)
+ """Reset inverse."""
+ RESET_HIDDEN: ClassVar[_Fmt] = _Fmt(28)
+ """Reset hidden."""
+ RESET_STRIKETHROUGH: ClassVar[_Fmt] = _Fmt(29)
+ """Reset strikethrough."""
+ RESET_FG: ClassVar[_Fmt] = _Fmt(39)
+ """Reset foreground color."""
+ RESET_BG: ClassVar[_Fmt] = _Fmt(49)
+ """Reset background color."""
+
+ ######################### TEXT STYLES #########################
+ BOLD: ClassVar[_Fmt] = _Fmt(1)
+ """Bold text.\n
+ Note that this is also reset by `RESET_DIM`."""
+ DIM: ClassVar[_Fmt] = _Fmt(2)
+ """Dim text.\n
+ Note that this is also reset by `RESET_BOLD`."""
+ ITALIC: ClassVar[_Fmt] = _Fmt(3)
+ """Italic text."""
+ UNDERLINE: ClassVar[_Fmt] = _Fmt(4)
+ """Underline text."""
+ INVERSE: ClassVar[_Fmt] = _Fmt(7)
+ """Inverse colors (swap foreground and background colors)."""
+ HIDDEN: ClassVar[_Fmt] = _Fmt(8)
+ """Hidden (invisible) text."""
+ STRIKETHROUGH: ClassVar[_Fmt] = _Fmt(9)
+ """Strikethrough text."""
+ DOUBLE_UNDERLINE: ClassVar[_Fmt] = _Fmt(21)
+ """Double underline text."""
+
+ ###################### STANDARD FG COLORS #####################
+ BLACK: ClassVar[_Fmt] = _Fmt(30)
+ """Black foreground."""
+ RED: ClassVar[_Fmt] = _Fmt(31)
+ """Red foreground."""
+ GREEN: ClassVar[_Fmt] = _Fmt(32)
+ """Green foreground."""
+ YELLOW: ClassVar[_Fmt] = _Fmt(33)
+ """Yellow foreground."""
+ BLUE: ClassVar[_Fmt] = _Fmt(34)
+ """Blue foreground."""
+ MAGENTA: ClassVar[_Fmt] = _Fmt(35)
+ """Magenta foreground."""
+ CYAN: ClassVar[_Fmt] = _Fmt(36)
+ """Cyan foreground."""
+ WHITE: ClassVar[_Fmt] = _Fmt(37)
+ """White foreground."""
+
+ ######################### NAMESPACES ##########################
+ BR: ClassVar[type[_BrNS]] = _BrNS
+ BG: ClassVar[type[_BgNS]] = _BgNS
+
+ #################### CUSTOM COLORS & LINKS ####################
+ @staticmethod
+ def rgb(red: int, green: int, blue: int, /) -> _ColorFmt:
+ """24-bit foreground color.\n
+ `F.rgb(255, 96, 112)("text")`"""
- @classmethod
- 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)"""
- _format_key, format_key = format_key, cls._normalize_key(format_key) # NORMALIZE KEY AND SAVE ORIGINAL
- if default_color and (new_default_color := cls._get_default_ansi(default_color, format_key, brightness_steps)):
- return new_default_color
- for map_key in ANSI.CODES_MAP:
- if (isinstance(map_key, tuple) and format_key in map_key) or format_key == map_key:
- return _ANSI_SEQ_1.format(
- next((
- 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)
- hex_match = _PATTERNS.hex.match(format_key)
- try:
- if rgb_match:
- is_bg = rgb_match.group(1)
- r, g, b = map(int, rgb_match.groups()[1:])
- if Color.is_valid_rgba((r, g, b)):
- return ANSI.SEQ_BG_COLOR.format(r, g, b) if is_bg else ANSI.SEQ_COLOR.format(r, g, b)
- elif hex_match:
- is_bg = hex_match.group(1)
- rgb = Color.to_rgba(hex_match.group(2))
- return (
- ANSI.SEQ_BG_COLOR.format(rgb[0], rgb[1], rgb[2])
- if is_bg else ANSI.SEQ_COLOR.format(rgb[0], rgb[1], rgb[2])
- )
- except Exception:
- pass
- return _format_key
+ return _ColorFmt(red, green, blue)
@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."""
- _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
- )
- if format_key is None or not (match := _PATTERNS.modifier.match(format_key)):
- return None
- is_bg, modifiers = match.groups()
- adjust = 0
- for mod in _modifiers[0] + _modifiers[1]:
- adjust = String.single_char_repeats(modifiers, mod)
- if adjust and adjust > 0:
- modifiers = mod
- break
- new_rgb = _default_color
- if adjust == 0:
- return None
- elif modifiers in _modifiers[0]:
- 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]:
- 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])
+ def hex(color: str, /) -> _ColorFmt:
+ """24-bit foreground color from HEX string.\n
+ `F.hex("#FF6070")("text")` or `F.hex("F67")`"""
+
+ return _ColorFmt.from_hex(color)
@staticmethod
- 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(
- f"{prefix_key.lower()}:" for prefix_key, prefix_values in _PREFIX.items()
- if any(k_part in prefix_values for k_part in k_parts)
- )
- return prefix_str + ":".join(
- part for part in k_parts \
- if part not in {val for values in _PREFIX.values() for val in values}
- )
-
-
-class _EscapeFormatCodeHelper:
- """Internal, callable helper class to escape formatting codes."""
-
- def __init__(
- self,
- cls: type[FormatCodes],
- *,
- use_default: bool,
- default_color: Optional[rgba],
- escape_char: Literal["/", "\\"],
- ):
- self.cls = cls
- self.use_default = use_default
- self.default_color = default_color
- self.escape_char: Literal["/", "\\"] = escape_char
-
- 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
- if not formats or _PATTERNS.escape_char_cond.match(match.group(0)):
- return match.group(0)
-
- # TEMPORARILY REPLACE `*` FOR VALIDATION
- _formats = formats
- if self.use_default:
- _formats = _PATTERNS.star_reset_inside.sub(r"\1_|default\2", formats)
- else:
- _formats = _PATTERNS.star_reset_inside.sub(r"\1_\2", formats)
-
- has_link = False
- has_invalid_key = False
- for format_key in self.cls._formats_to_keys(_formats):
- if _PATTERNS.link.match(format_key):
- has_link = True
- elif self.cls._get_replacement(format_key, self.default_color) == format_key:
- has_invalid_key = True
-
- if has_link or not has_invalid_key:
- # 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, _escape_char=self.escape_char)
- escaped += f"({escaped_auto_reset})"
- return escaped
- else:
- # KEEP INVALID FORMATTING CODES AS-IS
- 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, _escape_char=self.escape_char)
- result += f"({escaped_auto_reset})"
- return result
-
-
-class _RemAnsiSeqHelper:
- """Internal, callable helper class to remove ANSI sequences and track their removal positions."""
-
- def __init__(self, removals: list[tuple[int, str]], /):
- self.removals = removals
-
- 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]
- self.removals.append((start_pos, match.group()))
- return ""
-
-
-class _ReplaceKeysHelper:
- """Internal, callable helper class to replace formatting keys with their respective ANSI codes."""
-
- def __init__(
- self,
- cls: type[FormatCodes],
- *,
- use_default: bool,
- default_color: Optional[rgba],
- brightness_steps: int,
- ):
- self.cls = cls
- self.use_default = use_default
- self.default_color = default_color
- self.brightness_steps = brightness_steps
-
- # INSTANCE VARIABLES FOR CURRENT PROCESSING STATE
- self.formats: str = ""
- self.original_formats: str = ""
- self.formats_escaped: bool = False
- self.auto_reset_escaped: bool = False
- self.auto_reset_txt: Optional[str] = None
- self.format_keys: list[str] = []
- self.ansi_formats: list[str] = []
- self.ansi_resets: list[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)
-
- # CHECK IF THERE'S ESCAPED FORMAT CODES
- self.formats_escaped = bool(_PATTERNS.escape_char_cond.match(match.group(0)))
- if self.formats_escaped:
- self.original_formats = self.formats = _PATTERNS.escape_char.sub(r"\1", self.formats)
-
- # HANDLE HYPERLINK FORMAT
- all_keys = self.cls._formats_to_keys(self.formats)
- if (result := self.handle_link(match, all_keys)) is not None:
- return result
-
- self.process_formats_and_auto_reset()
-
- # IF THERE ARE NO FORMATS OR ALL FORMATS ARE INVALID, RETURN THE ORIGINAL STRING
- if not self.formats:
- return match.group(0)
-
- self.convert_to_ansi()
- return self.build_output(match)
-
- def handle_link(self, match: _rx.Match[str], all_keys: list[str], /) -> Optional[str]:
- """Handle a hyperlink format code, returning the OSC 8 sequence or None if not a link."""
- link_key = next((k for k in all_keys if _PATTERNS.link.match(k)), None)
-
- if link_key is None:
- return None
- if self.auto_reset_txt is None:
- return match.group(0) # LINK WITHOUT DISPLAY BRACES IS INVALID
- if self.formats_escaped:
- return f"[{self.original_formats}]({self.auto_reset_txt})"
-
- link_url = _PATTERNS.link.match(link_key).group(1) # type: ignore[union-attr]
- display = self.auto_reset_txt
-
- if other_keys := [k for k in all_keys if k != link_key]:
- # APPLY REMAINING FORMAT CODES TO DISPLAY TEXT WITH AUTO-RESET
- display = "[{}]({})".format("|".join(other_keys), display)
- if other_keys or ("[" in display and "]" in display):
- display = self.cls.to_ansi(
- display,
- self.default_color,
- self.brightness_steps,
- _default_start=False,
- _validate_default=False,
- )
-
- return ANSI.SEQ_LINK_OPEN.format(link_url) + display + ANSI.SEQ_LINK_CLOSE
-
- def process_formats_and_auto_reset(self) -> None:
- """Process nested formatting in both formats and auto-reset text."""
- # PROCESS AUTO-RESET TEXT IF IT CONTAINS NESTED FORMATTING
- if self.auto_reset_txt and self.auto_reset_txt.count("[") > 0 and self.auto_reset_txt.count("]") > 0:
- self.auto_reset_txt = self.cls.to_ansi(
- self.auto_reset_txt,
- self.default_color,
- self.brightness_steps,
- _default_start=False,
- _validate_default=False,
- )
-
- # PROCESS NESTED FORMATTING IN FORMATS
- if self.formats and self.formats.count("[") > 0 and self.formats.count("]") > 0:
- self.formats = self.cls.to_ansi(
- self.formats,
- self.default_color,
- self.brightness_steps,
- _default_start=False,
- _validate_default=False,
- )
-
- 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 = [(
- ansi_code \
- if (ansi_code := self.cls._get_replacement(format_key, self.default_color, self.brightness_steps)) != format_key
- 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:
- self.gen_reset_codes()
- else:
- self.ansi_resets = []
-
- def gen_reset_codes(self) -> None:
- """Generate appropriate ANSI reset codes for each format key."""
- default_color_resets = ("_bg", "default") if self.use_default else ("_bg", "_c")
- reset_keys: list[str] = []
-
- 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(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(format_key)):
- if self.is_valid_color(format_key[i:]):
- reset_keys.append("_bg")
- break
-
- # TEXT COLOR FORMAT
- 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
+ def link(url: str, /) -> _LinkFmt:
+ """Clickable hyperlink.\n
+ `F.link("https://example.com")("click here")`"""
+
+ return _LinkFmt(url)
+
+
+F = Format # SHORT ALIAS
+
+################################################## TERMINAL CONTROL ##################################################
+
+
+class Term:
+ """Common ANSI terminal control sequences (cursor, screen, title)
+ as plain strings or string-returning static methods.\n
+ ------------------------------------------------------------------------------------------
+ Values can be passed straight into a `FormatCodes(โฆ)` call or written to `sys.stdout`."""
+
+ CLEAR_LINE: ClassVar[str] = f"{ANSI.CHAR}[2K"
+ """Erase the entire current line."""
+ CLEAR_SCREEN: ClassVar[str] = f"{ANSI.CHAR}[2J"
+ """Erase the whole screen."""
+ HIDE_CURSOR: ClassVar[str] = f"{ANSI.CHAR}[?25l"
+ """Hide the cursor."""
+ SHOW_CURSOR: ClassVar[str] = f"{ANSI.CHAR}[?25h"
+ """Show the cursor."""
+ ALT_SCREEN: ClassVar[str] = f"{ANSI.CHAR}[?1049h"
+ """Enter the alternate screen buffer."""
+ MAIN_SCREEN: ClassVar[str] = f"{ANSI.CHAR}[?1049l"
+ """Leave the alternate screen buffer."""
+
+ @staticmethod
+ def up(n: int = 1, /) -> str:
+ """Move the cursor up by `n` rows."""
+
+ return f"{ANSI.CHAR}[{n}A"
+
+ @staticmethod
+ def down(n: int = 1, /) -> str:
+ """Move the cursor down by `n` rows."""
+
+ return f"{ANSI.CHAR}[{n}B"
+
+ @staticmethod
+ def right(n: int = 1, /) -> str:
+ """Move the cursor right by `n` columns."""
+
+ return f"{ANSI.CHAR}[{n}C"
+
+ @staticmethod
+ def left(n: int = 1, /) -> str:
+ """Move the cursor left by `n` columns."""
+
+ return f"{ANSI.CHAR}[{n}D"
+
+ @staticmethod
+ def move(row: int, col: int, /) -> str:
+ """Move the cursor to absolute position `(row, col)` (1-based)."""
+
+ return f"{ANSI.CHAR}[{row};{col}H"
+
+ @staticmethod
+ def title(text: str, /) -> str:
+ """Set the terminal window / tab title (OSC 2)."""
+
+ return f"{ANSI.CHAR}]2;{text}\x07"
+
+ @staticmethod
+ def save() -> str:
+ """Save the current cursor position."""
+
+ return f"{ANSI.CHAR}[s"
+
+ @staticmethod
+ def restore() -> str:
+ """Restore the previously saved cursor position."""
+
+ return f"{ANSI.CHAR}[u"
+
+
+################################################## FORMATCODES ##################################################
+
+
+def _build_open_close(group: _FmtGroup, /) -> tuple[tuple[str, ...], tuple[str, ...]]:
+ """Build the opening and closing ANSI sequences for a `_FmtGroup`.\n
+ ------------------------------------------------------------------------------------
+ Returns a `(opens, closes)` pair of tuples. Multiple opens / closes are emitted
+ only when both an OSC 8 hyperlink and SGR codes are present (OSC wraps SGR)."""
+
+ # FAST PATH: SINGLE STANDARD _Fmt CODE (REALLY COMMON)
+ if len(codes := group._codes) == 1 and type(codes[0]) is _Fmt:
+ if (cached := _STANDARD_SEQS.get(int(codes[0]))) is not None:
+ return cached
+
+ sgr_open: list[str] = []
+ sgr_close: list[str] = []
+ link_url: Optional[str] = None
+
+ for code in group:
+ if isinstance(code, _LinkFmt):
+ link_url = code._url
+
+ elif isinstance(code, _ColorFmt):
+ if code._bg:
+ sgr_open.append(f"48;2;{code._red};{code._green};{code._blue}")
+ sgr_close.append("49")
else:
- reset_keys.append(f"_{format_key}")
-
- # CONVERT RESET KEYS TO ANSI CODES
- self.ansi_resets = [
- ansi_code for reset_key in reset_keys if ( \
- ansi_code := self.cls._get_replacement(reset_key, self.default_color, self.brightness_steps)
- ).startswith(f"{ANSI.CHAR}{ANSI.START}")
- ]
-
- 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
- all_formats_valid = all(ansi_format.startswith(f"{ANSI.CHAR}{ANSI.START}") for ansi_format in self.ansi_formats)
-
- if not has_single_valid_ansi and not all_formats_valid:
- return match.group(0)
-
- # HANDLE ESCAPED FORMATTING
- if self.formats_escaped:
- return f"[{self.original_formats}]({self.auto_reset_txt})" if self.auto_reset_txt else f"[{self.original_formats}]"
-
- # BUILD NORMAL OUTPUT WITH FORMATS AND RESETS
- output = "".join(self.ansi_formats)
-
- # ADD AUTO-RESET TEXT
- if self.auto_reset_escaped and self.auto_reset_txt:
- output += f"({self.cls.to_ansi(self.auto_reset_txt, self.default_color, self.brightness_steps, _default_start=False, _validate_default=False)})"
- elif self.auto_reset_txt:
- output += self.auto_reset_txt
-
- # ADD RESET CODES IF NOT ESCAPED
- if not self.auto_reset_escaped:
- output += "".join(self.ansi_resets)
-
- return output
-
- 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))
+ sgr_open.append(f"38;2;{code._red};{code._green};{code._blue}")
+ sgr_close.append("39")
+
+ else:
+ cid = int(code)
+ sgr_open.append(str(cid))
+ if (reset := _RESET_MAP.get(cid)) is not None:
+ sgr_close.append(str(reset))
+
+ # DE-DUPE WHILE PRESERVING ORDER FOR CLEANER OUTPUT
+ seen: set[str] = set()
+ dedup_close: list[str] = []
+
+ for close_code in sgr_close:
+ if close_code not in seen:
+ seen.add(close_code)
+ dedup_close.append(close_code)
+
+ opens: list[str] = []
+ closes: list[str] = []
+
+ if link_url is not None:
+ opens.append(ANSI.SEQ_LINK_OPEN.format(link_url))
+ if sgr_open:
+ opens.append(f"{ANSI.CHAR}[{';'.join(sgr_open)}m")
+ if dedup_close:
+ closes.append(f"{ANSI.CHAR}[{';'.join(dedup_close)}m")
+ if link_url is not None:
+ closes.append(ANSI.SEQ_LINK_CLOSE)
+
+ return tuple(opens), tuple(closes)
+
+
+class FormatCodes:
+ """Build a formatted string from a sequence of segments
+ (strings, `_Styled` calls, or raw tuples), joined by `sep`.\n
+ ------------------------------------------------------------------------------------------------------
+ * `segments` โ Any number of segments to render. Each positional argument represents one logical line.
+ * `sep` โ The separator inserted between two adjacent positional arguments (default `"\\n"`).
+ ------------------------------------------------------------------------------------------------------
+ After construction the instance exposes:
+ * `ansi` โ The fully rendered ANSI escape string, ready to be written to a terminal.
+ * `raw` โ `ansi` with every ANSI escape sequence stripped; computed on demand.
+ * `code_positions` โ A tuple of `(position, sequence)` pairs giving
+ the start offset of every ANSI escape sequence inside `ansi`; computed on demand.
+ ------------------------------------------------------------------------------------------------------
+ For exact information about how to use the operator syntax,
+ see the `format_codes` module documentation."""
+
+ __slots__ = ("_ansi_parts", "ansi")
+
+ def __init__(self, /, *segments: _Renderable, sep: str = "\n") -> None:
+ self._ansi_parts: list[str] = []
+
+ for i, segment in enumerate(segments):
+ if i > 0:
+ self._ansi_parts.append(sep)
+ self._render(segment)
+
+ self.ansi: str = "".join(self._ansi_parts)
+
+ @property
+ def raw(self) -> str:
+ """The rendered output with every ANSI escape sequence stripped (the "plain" text)."""
+
+ return _ANSI_SEQ_RX.sub("", self.ansi)
+
+ @property
+ def code_positions(self) -> tuple[tuple[int, str], ...]:
+ """A tuple of `(position, sequence)` pairs giving the
+ start offset of every ANSI escape sequence inside `ansi`."""
+
+ return tuple((match.start(), match.group()) for match in _ANSI_SEQ_RX.finditer(self.ansi))
+
+ def __str__(self) -> str:
+ """Stringifying a `FormatCodes` instance yields its rendered
+ ANSI string, ready to be written to a terminal."""
+
+ return self.ansi
+
+ def __repr__(self) -> str:
+ """Returns a string representation of this `FormatCodes` instance, showing its rendered ANSI string."""
+
+ return f"FormatCodes(ansi={self.ansi!r})"
+
+ def print(self, /, *, end: str = "\n", flush: bool = True) -> None:
+ """Write the rendered ANSI string straight to `sys.stdout` (configuring the terminal
+ for ANSI on first use). Faster than the built-in `print()` for large outputs.\n
+ -----------------------------------------------------------------------------------------
+ * `end` โ The string to append at the end of the output (default `"\\n"`).
+ * `flush` โ Whether to flush `sys.stdout` after writing (default `True`)."""
+
+ _config_terminal()
+ _sys.stdout.write(self.ansi + end)
+
+ if flush:
+ _sys.stdout.flush()
+
+ def input(self, /, *, reset_ansi: bool = False) -> str:
+ """Use the rendered ANSI string as an input prompt and return the user's input.\n
+ ----------------------------------------------------------------------------------
+ * `reset_ansi` โ If true, all ANSI formatting will be reset after
+ the user confirmed the input and the program continues to run."""
+
+ _config_terminal()
+ user_input = input(self.ansi)
+
+ if reset_ansi:
+ _sys.stdout.write(f"{ANSI.CHAR}[0m")
+
+ return user_input
+
+ @staticmethod
+ def remove_ansi(ansi_string: str, /) -> str:
+ """Remove every ANSI escape sequence from `ansi_string`, returning the plain text.\n
+ -------------------------------------------------------------------------------------
+ * `ansi_string` โ The string that contains the ANSI codes to remove."""
+
+ return _ANSI_SEQ_RX.sub("", ansi_string)
+
+ def _render(self, segment: object) -> None:
+ """Internal method to recursively render a `segment`, dispatching by runtime type.\n
+ ------------------------------------------------------------------------------------
+ Strings are emitted as raw text; `_Styled` segments are wrapped in their opening
+ and closing ANSI sequences; `tuple` segments are flattened in order.
+ Bare format objects (`_Fmt`, `_ColorFmt`, `_LinkFmt`, `_FmtGroup`) emit only
+ their opening sequence with no matching close."""
+
+ if isinstance(segment, str):
+ self._ansi_parts.append(segment)
+ return
+ if isinstance(segment, _Styled):
+ for piece in segment._opens:
+ self._ansi_parts.append(piece)
+ self._render(segment.text)
+ for piece in segment._closes:
+ self._ansi_parts.append(piece)
+ return
+ if isinstance(segment, tuple):
+ for tuple_part in cast("tuple[object, ...]", segment):
+ self._render(tuple_part)
+ return
+ if isinstance(segment, _Fmt):
+ self._ansi_parts.append(f"{ANSI.CHAR}[{int(segment)}m")
+ return
+ if isinstance(segment, _ColorFmt):
+ self._ansi_parts.append(segment._open_seq)
+ return
+ if isinstance(segment, _LinkFmt):
+ self._ansi_parts.append(segment._open_seq)
+ return
+ if isinstance(segment, _FmtGroup):
+ for piece in _build_open_close(segment)[0]:
+ self._ansi_parts.append(piece)
+ return
+
+ # FALLBACK โ COERCE UNKNOWN OBJECTS TO STR
+ self._ansi_parts.append(str(segment))
+
+
+FC = FormatCodes # SHORT ALIAS
diff --git a/src/xulbux/json.py b/src/xulbux/json.py
index e5be53d..0a1aea9 100644
--- a/src/xulbux/json.py
+++ b/src/xulbux/json.py
@@ -14,7 +14,7 @@
class Json:
- """This class provides methods to read, create and update JSON files,
+ """This class provides methods to read, create and update JSON files,
with support for comments inside the JSON data."""
@overload
@@ -54,17 +54,18 @@ def read(
return_original: bool = False,
) -> 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
- - `comment_start` -โ the string that indicates the start of a comment
- - `comment_end` -โ the string that indicates the end of a comment
- - `return_original` -โ if true, the original JSON data is returned additionally:
- ```python
- (processed_json, original_json)
- ```\n
- ------------------------------------------------------------------------------------
- For more detailed information about the comment handling,
+ --------------------------------------------------------------------------------------
+ * `json_file` โ The path (relative or absolute) to the JSON file to read.
+ * `comment_start` โ The string that indicates the start of a comment.
+ * `comment_end` โ The string that indicates the end of a comment.
+ * `return_original` โ If true, the original JSON data is returned additionally:
+ ```python
+ (processed_json, original_json)
+ ```
+ --------------------------------------------------------------------------------------
+ For more detailed information about the comment handling,
see the `Data.remove_comments()` method documentation."""
+
if (json_path := Path(json_file) if isinstance(json_file, str) else json_file).suffix != ".json":
json_path = json_path.with_suffix(".json")
file_path = FileSys.extend_or_make_path(json_path, prefer_script_dir=True)
@@ -74,12 +75,12 @@ def read(
try:
data = _json.loads(content)
- except _json.JSONDecodeError as e:
- fmt_error = "\n ".join(str(e).splitlines())
- raise ValueError(f"Error parsing JSON in {file_path!r}:\n {fmt_error}") from e
+ except _json.JSONDecodeError as exc:
+ fmt_error = "\n ".join(str(exc).splitlines())
+ raise ValueError(f"Error parsing JSON in {file_path!r}:\n {fmt_error}") from exc
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.")
+ raise ValueError(f"The JSON file {file_path!r} contains no data")
return (processed_data, data) if return_original else processed_data
@@ -95,18 +96,19 @@ def create(
force: bool = False,
) -> Path:
"""Create a nicely formatted JSON file from a dictionary.\n
- ---------------------------------------------------------------------------
- - `json_file` -โ the path (relative or absolute) to the JSON file to create
- - `data` -โ the dictionary data to write to the JSON file
- - `indent` -โ the amount of spaces to use for indentation
- - `compactness` -โ can be `0`, `1` or `2` and indicates how compact
- the data should be formatted (see `Data.render()` for more info)
- - `force` -โ if true, will overwrite existing files
- without throwing an error (errors explained below)\n
- ---------------------------------------------------------------------------
- The method will throw a `FileExistsError` if a file with the same
- name already exists and a `SameContentFileExistsError` if a file
+ ------------------------------------------------------------------------------
+ * `json_file` โ The path (relative or absolute) to the JSON file to create.
+ * `data` โ The dictionary data to write to the JSON file.
+ * `indent` โ The amount of spaces to use for indentation.
+ * `compactness` โ Can be `0`, `1` or `2` and indicates how compact
+ the data should be formatted (see `Data.render()` for more info).
+ * `force` โ If true, will overwrite existing files
+ without throwing an error (errors explained below).
+ ------------------------------------------------------------------------------
+ The method will throw a `FileExistsError` if a file with the same
+ name already exists and a `SameContentFileExistsError` if a file
with the same name and same content already exists."""
+
if (json_path := Path(json_file) if isinstance(json_file, str) else json_file).suffix != ".json":
json_path = json_path.with_suffix(".json")
@@ -130,20 +132,20 @@ def update(
comment_end: str = "<<",
path_sep: str = "->",
) -> None:
- """Update single/multiple values inside JSON files,
+ """Update single/multiple values inside JSON files,
without needing to know the rest of the data.\n
-----------------------------------------------------------------------------------
- - `json_file` -โ the path (relative or absolute) to the JSON file to update
- - `update_values` -โ a dictionary with the paths to the values to update
- and the new values to set (see explanation below โ section 2)
- - `comment_start` -โ the string that indicates the start of a comment
- - `comment_end` -โ the string that indicates the end of a comment
- - `path_sep` -โ the separator used inside the value-paths in `update_values`\n
+ * `json_file` โ The path (relative or absolute) to the JSON file to update.
+ * `update_values` โ A dictionary with the paths to the values to update
+ and the new values to set (see explanation below โ section 2).
+ * `comment_start` โ The string that indicates the start of a comment.
+ * `comment_end` โ The string that indicates the end of a comment.
+ * `path_sep` โ The separator used inside the value-paths in `update_values`.
-----------------------------------------------------------------------------------
- For more detailed information about the comment handling,
+ For more detailed information about the comment handling,
see the `Data.remove_comments()` method documentation.\n
-----------------------------------------------------------------------------------
- The `update_values` is a dictionary, where the keys are the paths
+ The `update_values` is a dictionary, where the keys are the paths
to the data to update, and the values are the new values to set.\n
For example for this JSON data:
```python
@@ -163,11 +165,12 @@ def update(
"healthy->vegetables": [1, 2, 3]
}
```
- In this example, if you want to change the value of `"apples"`,
- you can use `healthy->fruits->apples` as the value-path.
- If you don't know that the first list item is `"apples"`,
+ In this example, if you want to change the value of `"apples"`,
+ you can use `healthy->fruits->apples` as the value-path.\n
+ 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,
comment_start=comment_start,
@@ -192,19 +195,22 @@ def update(
@staticmethod
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
+ """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
for i, key in enumerate(path_keys):
if i == last_idx:
if isinstance(current, dict):
current[key] = value
+
elif isinstance(current, list) and key.isdigit():
idx = int(key)
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(cast(Any, current))}")
@@ -214,6 +220,7 @@ def _create_nested_path(data_obj: dict[str, Any], path_keys: list[str], value: A
if key not in current:
current[key] = [] if next_key.isdigit() else {}
current = cast(dict[str, Any], current)[key] # type: ignore[unnecessary-cast]
+
elif isinstance(current, list) and key.isdigit():
idx = int(key)
while len(cast(list[Any], current)) <= idx:
@@ -221,6 +228,7 @@ def _create_nested_path(data_obj: dict[str, Any], path_keys: list[str], value: A
if current[idx] is None:
current[idx] = [] if next_key.isdigit() else {}
current = cast(list[Any], current)[idx]
+
else:
raise TypeError(f"Cannot navigate through {type(cast(Any, current))}")
diff --git a/src/xulbux/regex.py b/src/xulbux/regex.py
index 16729f0..d8bf30a 100644
--- a/src/xulbux/regex.py
+++ b/src/xulbux/regex.py
@@ -18,10 +18,11 @@ def quotes(cls) -> str:
"""Matches pairs of quotes. (strings)\n
--------------------------------------------------------------------------------
Will create two named groups:
- - `quote` the quote type (single or double)
- - `string` everything inside the found quote pair\n
+ * `quote` โ The quote type (single or double).
+ * `string` โ Everything inside the found quote pair.
---------------------------------------------------------------------------------
Attention:โRequires non-standard library `regex`, not standard library `re`!"""
+
return r"""(?P["'])(?P(?:\\.|(?!\g).)*?)\g"""
@classmethod
@@ -36,16 +37,17 @@ def brackets(
ignore_in_strings: bool = True,
) -> str:
"""Matches everything inside pairs of brackets, including other nested brackets.\n
- ---------------------------------------------------------------------------------------
- - `bracket1` -โ the opening bracket (e.g. `(`, `{`, `[`, โฆ)
- - `bracket2` -โ the closing bracket (e.g. `)`, `}`, `]`, โฆ)
- - `is_group` -โ whether to create a capturing group for the content inside the brackets
- - `strip_spaces` -โ whether to strip spaces from the bracket content or not
- - `ignore_in_strings` -โ whether to ignore closing brackets that are inside
- strings/quotes (e.g. `'โฆ)โฆ'` or `"โฆ)โฆ"`)\n
- ---------------------------------------------------------------------------------------
+ ------------------------------------------------------------------------------------------
+ * `bracket1` โ The opening bracket (e.g. `(`, `{`, `[`, โฆ).
+ * `bracket2` โ The closing bracket (e.g. `)`, `}`, `]`, โฆ).
+ * `is_group` โ Whether to create a capturing group for the content inside the brackets.
+ * `strip_spaces` โ Whether to strip spaces from the bracket content or not.
+ * `ignore_in_strings` โ Whether to ignore closing brackets that are inside
+ strings/quotes (e.g. `'โฆ)โฆ'` or `"โฆ)โฆ"`).
+ ------------------------------------------------------------------------------------------
Attention:โRequires non-standard library `regex`, not standard library `re`!"""
- g = "" if is_group else "?:"
+
+ gr = "" if is_group else "?:"
b1 = _rx.escape(bracket1) if len(bracket1) == 1 else bracket1
b2 = _rx.escape(bracket2) if len(bracket2) == 1 else bracket2
s1 = r"\s*" if strip_spaces else ""
@@ -53,7 +55,7 @@ def brackets(
if ignore_in_strings:
return cls._clean( \
- rf"""{b1}{s1}({g}{s2}(?:
+ rf"""{b1}{s1}({gr}{s2}(?:
[^{b1}{b2}"']
|"(?:\\.|[^"\\])*"
|'(?:\\.|[^'\\])*'
@@ -67,7 +69,7 @@ def brackets(
)
else:
return cls._clean( \
- rf"""{b1}{s1}({g}{s2}(?:
+ rf"""{b1}{s1}({gr}{s2}(?:
[^{b1}{b2}]
|{b1}(?:
[^{b1}{b2}]
@@ -79,23 +81,25 @@ def brackets(
@classmethod
def outside_strings(cls, pattern: str = r".*", /) -> str:
"""Matches the `pattern` only when it is not found inside a string (`'โฆ'` or `"โฆ"`)."""
+
return rf"""(? str:
- """Matches everything up to the `disallowed_pattern`, unless the
+ """Matches everything up to the `disallowed_pattern`, unless the
`disallowed_pattern` is found inside a string/quotes (`'โฆ'` or `"โฆ"`).\n
- -------------------------------------------------------------------------------------
- - `disallowed_pattern` -โ the pattern that is not allowed to be matched
- - `ignore_pattern` -โ a pattern that, if found, will make the regex ignore the
- `disallowed_pattern` (even if it contains the `disallowed_pattern` inside it):
- For example if `disallowed_pattern` is `>` and `ignore_pattern` is `->`,
- the `->`-arrows will be allowed, even though they have `>` in them.
- - `is_group` -โ whether to create a capturing group for the matched content"""
- g = "" if is_group else "?:"
+ ---------------------------------------------------------------------------------------
+ * `disallowed_pattern` โ The pattern that is not allowed to be matched.
+ * `ignore_pattern` โ A pattern that, if found, will make the regex ignore the
+ `disallowed_pattern` (even if it contains the `disallowed_pattern` inside it):
+ For example if `disallowed_pattern` is `>` and `ignore_pattern` is `->`,
+ the `->`-arrows will be allowed, even though they have `>` in them.
+ * `is_group` โ Whether to create a capturing group for the matched content."""
+
+ gr = "" if is_group else "?:"
return cls._clean( \
- rf"""({g}
+ rf"""({gr}
(?:(?!{ignore_pattern}).)*
(?:(?!{cls.outside_strings(disallowed_pattern)}).)*
)"""
@@ -104,11 +108,12 @@ def all_except(cls, disallowed_pattern: str, /, ignore_pattern: str = "", *, is_
@classmethod
def func_call(cls, func_name: Optional[str] = None, /) -> str:
"""Match a function call, and get back two groups:
- 1. The function name
- 2. The function's arguments (content inside the parentheses)\n
+ 1. The function name.
+ 2. The function's arguments (content inside the parentheses).\n
If no `func_name` is given, it will match any function call.\n
---------------------------------------------------------------------------------
Attention:โRequires non-standard library `regex`, not standard library `re`!"""
+
if func_name in {"", None}:
func_name = r"[\w_]+"
@@ -117,24 +122,25 @@ def func_call(cls, func_name: Optional[str] = None, /) -> str:
@classmethod
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. `,`, `;` โฆ)
- If set to nothing or `None`, any char that is not a letter or number
- can be used to separate the RGBA values, including just a space.
- - `allow_alpha` -โ whether to include the alpha channel in the match\n
- ----------------------------------------------------------------------------------
+ -----------------------------------------------------------------------------------
+ * `fix_sep` โ The fixed separator between the RGBA values (e.g. `,`, `;` โฆ):
+ If set to nothing or `None`, any char that is not a letter or number
+ can be used to separate the RGBA values, including just a space.
+ * `allow_alpha` โ Whether to include the alpha channel in the match.
+ -----------------------------------------------------------------------------------
The RGBA color can be in the formats (for `fix_sep = ','`):
- - `rgba(r, g, b)`
- - `rgba(r, g, b, a)` (if `allow_alpha=True`)
- - `(r, g, b)`
- - `(r, g, b, a)` (if `allow_alpha=True`)
- - `r, g, b`
- - `r, g, b, a` (if `allow_alpha=True`)\n
+ * `rgba(red, green, blue)`
+ * `rgba(red, green, blue, alpha)` (if `allow_alpha=True`)
+ * `(red, green, blue)`
+ * `(red, green, blue, alpha)` (if `allow_alpha=True`)
+ * `red, green, blue`
+ * `red, green, blue, alpha` (if `allow_alpha=True`)\n
#### Valid ranges:
- - `r` 0-255 (int: red)
- - `g` 0-255 (int: green)
- - `b` 0-255 (int: blue)
- - `a` 0.0-1.0 (float: opacity)"""
+ * `red` 0-255 (int: red)
+ * `green` 0-255 (int: green)
+ * `blue` 0-255 (int: blue)
+ * `alpha` 0.0-1.0 (float: opacity)"""
+
fix_sep = _re.escape(fix_sep) if isinstance(fix_sep, str) else r"[^0-9A-Z]"
rgb_part = rf"""((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))
@@ -159,24 +165,25 @@ def rgba_str(cls, fix_sep: Optional[str] = ",", *, allow_alpha: bool = True) ->
@classmethod
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. `,`, `;` โฆ)
- If set to nothing or `None`, any char that is not a letter or number
- can be used to separate the HSLA values, including just a space.
- - `allow_alpha` -โ whether to include the alpha channel in the match\n
- ----------------------------------------------------------------------------------
+ -----------------------------------------------------------------------------------
+ * `fix_sep` โ The fixed separator between the HSLA values (e.g. `,`, `;` โฆ):
+ If set to nothing or `None`, any char that is not a letter or number
+ can be used to separate the HSLA values, including just a space.
+ * `allow_alpha` โ Whether to include the alpha channel in the match.
+ -----------------------------------------------------------------------------------
The HSLA color can be in the formats (for `fix_sep = ','`):
- - `hsla(h, s, l)`
- - `hsla(h, s, l, a)` (if `allow_alpha=True`)
- - `(h, s, l)`
- - `(h, s, l, a)` (if `allow_alpha=True`)
- - `h, s, l`
- - `h, s, l, a` (if `allow_alpha=True`)\n
+ * `hsla(hue, sat, light)`
+ * `hsla(hue, sat, light, alpha)` (if `allow_alpha=True`)
+ * `(hue, sat, light)`
+ * `(hue, sat, light, alpha)` (if `allow_alpha=True`)
+ * `hue, sat, light`
+ * `hue, sat, light, alpha` (if `allow_alpha=True`)\n
#### Valid ranges:
- - `h` 0-360 (int: hue)
- - `s` 0-100 (int: saturation)
- - `l` 0-100 (int: lightness)
- - `a` 0.0-1.0 (float: opacity)"""
+ * `hue` 0-360 (int: hue)
+ * `sat` 0-100 (int: saturation)
+ * `light` 0-100 (int: lightness)
+ * `alpha` 0.0-1.0 (float: opacity)"""
+
fix_sep = _re.escape(fix_sep) if isinstance(fix_sep, str) else r"[^0-9A-Z]"
hsl_part = rf"""((?:0*(?:360|3[0-5][0-9]|[12][0-9][0-9]|[1-9]?[0-9])))(?:\s*ยฐ)?
@@ -201,32 +208,34 @@ def hsla_str(cls, fix_sep: Optional[str] = ",", *, allow_alpha: bool = True) ->
@classmethod
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
- ----------------------------------------------------------------------
+ -----------------------------------------------------------------------
+ * `allow_alpha` โ Whether to include the alpha channel in the match.
+ -----------------------------------------------------------------------
The HEXA color can be in the formats (prefix `#`, `0x` or no prefix):
- - `RGB`
- - `RGBA` (if `allow_alpha=True`)
- - `RRGGBB`
- - `RRGGBBAA` (if `allow_alpha=True`)\n
+ * `RGB`
+ * `RGBA` (if `allow_alpha=True`)
+ * `RRGGBB`
+ * `RRGGBBAA` (if `allow_alpha=True`)\n
#### Valid ranges:
- every channel from 0-9 and A-F (case insensitive)"""
+ Every channel from 0-9 and A-F (case insensitive)"""
+
return r"(?i)(?:#|0x)?([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{4}|[0-9A-F]{3})" \
if allow_alpha else r"(?i)(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})"
@classmethod
def _clean(cls, pattern: str) -> str:
"""Internal method to make a multiline-string regex pattern into a single-line pattern."""
+
return "".join(line.strip() for line in pattern.splitlines()).strip()
@mypyc_attr(native_class=False)
class LazyRegex:
"""A class that lazily compiles and caches regex patterns on first access.\n
- --------------------------------------------------------------------------------
- - `**patterns` -โ keyword arguments where the key is the name of the pattern and
- the value is the regex pattern string to compile\n
- --------------------------------------------------------------------------------
+ ----------------------------------------------------------------------------------
+ * `**patterns` โ Keyword arguments where the key is the name of the pattern
+ and the value is the regex pattern string to compile.
+ ----------------------------------------------------------------------------------
#### Example usage:
```python
PATTERNS = LazyRegex(
diff --git a/src/xulbux/string.py b/src/xulbux/string.py
index aa16ff6..0a34d9f 100644
--- a/src/xulbux/string.py
+++ b/src/xulbux/string.py
@@ -16,7 +16,8 @@ class String:
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"""
+ * `string` โ The string to convert."""
+
try:
return _ast.literal_eval(string := string.strip())
except (ValueError, SyntaxError):
@@ -28,24 +29,40 @@ def to_type(cls, string: str, /) -> Any:
@classmethod
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"""
+ ------------------------------------------------------------------
+ * `string` โ The string to normalize.
+ * `tab_spaces` โ Number of spaces to replace tab chars with."""
+
if tab_spaces < 0:
raise ValueError(f"The 'tab_spaces' parameter must be non-negative, got {tab_spaces!r}")
- return string.replace("\t", " " * tab_spaces).replace("\u2000", " ").replace("\u2001", " ").replace("\u2002", " ") \
- .replace("\u2003", " ").replace("\u2004", " ").replace("\u2005", " ").replace("\u2006", " ") \
- .replace("\u2007", " ").replace("\u2008", " ").replace("\u2009", " ").replace("\u200A", " ")
+ table: dict[str, str | int | None] = {
+ "\t": " " * tab_spaces,
+ "\u2000": " ",
+ "\u2001": " ",
+ "\u2002": " ",
+ "\u2003": " ",
+ "\u2004": " ",
+ "\u2005": " ",
+ "\u2006": " ",
+ "\u2007": " ",
+ "\u2008": " ",
+ "\u2009": " ",
+ "\u200A": " ",
+ }
+
+ return string.translate(str.maketrans(table))
@classmethod
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
- - `str_quotes` -โ the type of quotes the string will be put inside of (or None to not escape quotes)
- Can be either `"` or `'` and should match the quotes, the string will be put inside of.
- So if your string will be `"string"`, `str_quotes` should be `"`.
- That way, if the string includes the same quotes, they will be escaped."""
+ -------------------------------------------------------------------------------------------------------------
+ * `string` โ The string to escape.
+ * `str_quotes` โ The type of quotes the string will be put inside of (or `None` to not escape quotes):
+ Can be either `"` or `'` and should match the quotes, the string will be put inside of.
+ So if your string will be `"string"`, `str_quotes` should be `"`.
+ That way, if the string includes the same quotes, they will be escaped."""
+
string = string.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") \
.replace("\b", "\\b").replace("\f", "\\f").replace("\a", "\\a")
@@ -59,9 +76,10 @@ def escape(cls, string: str, /, str_quotes: Optional[Literal["'", '"']] = None)
@classmethod
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)
- - `spaces_are_empty` -โ if true, strings consisting only of spaces are also considered empty"""
+ --------------------------------------------------------------------------------------------------
+ * `string` โ The string to check (or `None`, which is considered empty).
+ * `spaces_are_empty` โ If true, strings consisting only of spaces are also considered empty."""
+
return bool(
(string in {"", None}) or \
(spaces_are_empty and isinstance(string, str) and not string.strip())
@@ -69,11 +87,12 @@ def is_empty(cls, string: Optional[str], /, *, spaces_are_empty: bool = False) -
@classmethod
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 is empty or doesn't consist of only the same character, it returns `0`.\n
- ---------------------------------------------------------------------------------------------------
- - `string` -โ the string to check
- - `char` -โ the character to check for repetition"""
+ """* If the string consists of only the same `char`, it returns the number of times it is present.
+ * If the string is empty or doesn't consist of only the same character, it returns `0`.\n
+ ---------------------------------------------------------------------------------------------------------
+ * `string` โ The string to check.
+ * `char` โ The character to check for repetition."""
+
if len(char) != 1:
raise ValueError(f"The 'char' parameter must be a single character, got {char!r}")
@@ -86,9 +105,10 @@ def single_char_repeats(cls, string: str, char: str, /) -> int:
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
- - `seps` -โ additional separators to split the string at
- - `lower_all` -โ if true, all parts will be converted to lowercase"""
+ * `case_string` โ The string to decompose.
+ * `seps` โ Additional separators to split the string at.
+ * `lower_all` โ If true, all parts will be converted to lowercase."""
+
return [
(part.lower() if lower_all else part) \
for part in _re.split(rf"(?<=[a-z])(?=[A-Z])|[{_re.escape(seps)}]", case_string)
@@ -97,10 +117,10 @@ def decompose(cls, case_string: str, /, seps: str = "-_", *, lower_all: bool = T
@classmethod
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
- - `upper` -โ if true, it will convert to UpperCamelCase,
- otherwise to lowerCamelCase"""
+ ------------------------------------------------------------------------------------------
+ * `string` โ The string to convert.
+ * `upper` โ If true, it will convert to UpperCamelCase, otherwise to lowerCamelCase."""
+
parts = cls.decompose(string)
return (
@@ -112,9 +132,10 @@ def to_camel_case(cls, string: str, /, *, upper: bool = True) -> 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
- - `delimiter` -โ the delimiter to use between parts
- - `screaming` -โ whether to convert all parts to uppercase"""
+ * `string` โ The string to convert.
+ * `delimiter` โ The delimiter to use between parts.
+ * `screaming` โ Whether to convert all parts to uppercase."""
+
return delimiter.join(
part.upper() if screaming else part \
for part in cls.decompose(string)
@@ -123,9 +144,10 @@ def to_delimited_case(cls, string: str, /, delimiter: str = "_", *, screaming: b
@classmethod
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
- - `remove_empty_lines` -โ if true, it will remove all empty lines from the result"""
+ ---------------------------------------------------------------------------------------
+ * `string` โ The string to split.
+ * `remove_empty_lines` โ If true, it will remove all empty lines from the result."""
+
if not remove_empty_lines:
return string.splitlines()
elif not (lines := string.splitlines()):
@@ -138,12 +160,13 @@ def get_lines(cls, string: str, /, *, remove_empty_lines: bool = False) -> list[
@classmethod
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
- - `max_consecutive` -โ the maximum number of allowed consecutive empty lines.
- * If `0`, it will remove all consecutive empty lines.
- * If bigger than `0`, it will only allow `max_consecutive` consecutive empty lines
- and everything above it will be cut down to `max_consecutive` empty lines."""
+ ---------------------------------------------------------------------------------------------
+ * `string` โ The string to process.
+ * `max_consecutive` โ The maximum number of allowed consecutive empty lines:
+ - If `0`, it will remove all consecutive empty lines.
+ - If bigger than `0`, it will only allow `max_consecutive` consecutive empty lines
+ and everything above it will be cut down to `max_consecutive` empty lines."""
+
if max_consecutive < 0:
raise ValueError(f"The 'max_consecutive' parameter must be non-negative, got {max_consecutive!r}")
@@ -153,8 +176,9 @@ def remove_consecutive_empty_lines(cls, string: str, /, max_consecutive: int = 0
def split_count(cls, string: str, count: int, /) -> list[str]:
"""Will split the string every `count` characters.\n
-----------------------------------------------------
- - `string` -โ the string to split
- - `count` -โ the number of characters per part"""
+ * `string` โ The string to split.
+ * `count` โ The number of characters per part."""
+
if count <= 0:
raise ValueError(f"The 'count' parameter must be a positive integer, got {count!r}")
diff --git a/src/xulbux/system.py b/src/xulbux/system.py
index 350107e..26a93aa 100644
--- a/src/xulbux/system.py
+++ b/src/xulbux/system.py
@@ -6,7 +6,7 @@
from .base.types import MissingLibsMsgs
from .base.decorators import mypyc_attr
-from .format_codes import FormatCodes
+from .depr_format_codes import deprFormatCodes
from .console import Console
from typing import Optional
@@ -27,6 +27,7 @@ class _SystemMeta(type):
@property
def is_elevated(cls) -> bool:
"""Whether the current process has elevated privileges or not."""
+
try:
if _os.name == "nt":
return getattr(_ctypes, "windll").shell32.IsUserAnAdmin() != 0
@@ -39,26 +40,31 @@ def is_elevated(cls) -> bool:
@property
def is_win(cls) -> bool:
"""Whether the current operating system is Windows or not."""
+
return _platform.system() == "Windows"
@property
def is_linux(cls) -> bool:
"""Whether the current operating system is Linux or not."""
+
return _platform.system() == "Linux"
@property
def is_mac(cls) -> bool:
"""Whether the current operating system is macOS or not."""
+
return _platform.system() == "Darwin"
@property
def is_unix(cls) -> bool:
"""Whether the current operating system is a Unix-like OS (Linux, macOS, BSD, โฆ) or not."""
+
return _os.name == "posix"
@property
def hostname(cls) -> str:
"""The network hostname of the current machine."""
+
try:
return _socket.gethostname()
except Exception:
@@ -67,6 +73,7 @@ def hostname(cls) -> str:
@property
def username(cls) -> str:
"""The name of the current user."""
+
try:
return _getpass.getuser()
except Exception:
@@ -78,11 +85,13 @@ def username(cls) -> str:
@property
def os_name(cls) -> str:
"""The name of the operating system (e.g. `Windows`, `Linux`, โฆ)."""
+
return _platform.system()
@property
def os_version(cls) -> str:
"""The version of the operating system."""
+
try:
return _platform.version()
except Exception:
@@ -91,11 +100,13 @@ def os_version(cls) -> str:
@property
def architecture(cls) -> str:
"""The CPU architecture (e.g. `x86_64`, `ARM`, โฆ)."""
+
return _platform.machine()
@property
def cpu_count(cls) -> int:
"""The number of CPU cores available."""
+
try:
return _multiprocessing.cpu_count()
except (NotImplementedError, AttributeError):
@@ -104,6 +115,7 @@ def cpu_count(cls) -> int:
@property
def python_version(cls) -> str:
"""The version string of the currently running Python interpreter (e.g. `3.10.4`)."""
+
return _platform.python_version()
@@ -113,11 +125,12 @@ class System(metaclass=_SystemMeta):
@classmethod
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
- - `wait` -โ the time to wait until restarting in seconds
- - `continue_program` -โ whether to continue the current Python program after calling this function
- - `force` -โ whether to force a restart even if other processes are still running"""
+ -----------------------------------------------------------------------------------------------------
+ * `prompt` โ The message to be displayed in the systems restart notification.
+ * `wait` โ The time to wait until restarting in seconds.
+ * `continue_program` โ Whether to continue the current Python program after calling this function.
+ * `force` โ Whether to force a restart even if other processes are still running."""
+
if wait < 0:
raise ValueError(f"The 'wait' parameter must be non-negative, got {wait!r}")
@@ -137,15 +150,17 @@ def check_libs(
confirm_install: bool = True,
) -> Optional[list[str]]:
"""Checks if the given list of libraries are installed and optionally installs missing libraries.\n
- ------------------------------------------------------------------------------------------------------------
- - `lib_names` -โ a list of library names to check
- - `install_missing` -โ whether to directly missing libraries will be installed automatically using pip
- - `missing_libs_msgs` -โ two messages: the first one is displayed when missing libraries are found,
- the second one is the confirmation message before installing missing libraries
- - `confirm_install` -โ whether the user will be asked for confirmation before installing missing libraries\n
- ------------------------------------------------------------------------------------------------------------
- If some libraries are missing or they could not be installed, their names will be returned as a list.
+ -------------------------------------------------------------------------------------------------------------
+ * `lib_names` โ A list of library names to check.
+ * `install_missing` โ Whether to directly missing libraries will be installed automatically using pip.
+ * `missing_libs_msgs` โ Two messages:
+ - The first one is displayed when missing libraries are found.
+ - The second one is the confirmation message before installing missing libraries.
+ * `confirm_install` โ Whether the user will be asked for confirmation before installing missing libraries.
+ -------------------------------------------------------------------------------------------------------------
+ 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=install_missing,
@@ -156,16 +171,17 @@ def check_libs(
@classmethod
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)
- - `args` -โ a list of additional arguments to be passed to the elevated process\n
- ---------------------------------------------------------------------------------
- After the elevated process started, the original process will exit.
- This means, that this method has to be run at the beginning of the program or
+ -------------------------------------------------------------------------------------
+ * `win_title` โ The window title of the elevated process (only on Windows).
+ * `args` โ A list of additional arguments to be passed to the elevated process.
+ -------------------------------------------------------------------------------------
+ After the elevated process started, the original process will exit.\n
+ This means, that this method has to be run at the beginning of the program or
or else the program has to continue in a new window after elevation.\n
- ---------------------------------------------------------------------------------
- Returns `True` if the current process already has elevated privileges and raises
+ -------------------------------------------------------------------------------------
+ Returns `True` if the current process already has elevated privileges and raises
a `PermissionError` if the user denied the elevation or the elevation failed."""
+
if cls.is_elevated:
return True
@@ -173,18 +189,22 @@ def elevate(cls, win_title: Optional[str] = None, args: Optional[list[str]] = No
if _os.name == "nt": # WINDOWS
if win_title:
- args_str = f'-c "import ctypes; ctypes.windll.kernel32.SetConsoleTitleW(\\"{win_title}\\"); exec(open(\\"{_sys.argv[0]}\\").read())" {" ".join(args_list)}"'
+ args_str = (
+ '-c "import ctypes; '
+ f'ctypes.windll.kernel32.SetConsoleTitleW(\\"{win_title}\\"); '
+ f'exec(open(\\"{_sys.argv[0]}\\").read())" ' + " ".join(args_list)
+ )
else:
args_str = f'-c "exec(open(\\"{_sys.argv[0]}\\").read())" {" ".join(args_list)}'
- result = getattr(_ctypes, "windll").shell32.ShellExecuteW(None, "runas", _sys.executable, args_str, None, 1)
- if result <= 32:
+ if getattr(_ctypes, "windll").shell32.ShellExecuteW(None, "runas", _sys.executable, args_str, None, 1) <= 32:
raise PermissionError("Failed to launch elevated process.")
else:
_sys.exit(0)
else: # POSIX
cmd = ["pkexec"]
+
if win_title:
cmd.extend(["--description", win_title])
cmd.extend([_sys.executable] + _sys.argv[1:] + args_list)
@@ -215,6 +235,7 @@ def __call__(self) -> 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
@@ -229,6 +250,7 @@ def check_running_processes(self, command: str | list[str], /, skip_lines: int =
def restart_windows(self) -> None:
"""Handle Windows system restart."""
+
self.check_running_processes("tasklist", skip_lines=3)
if self.prompt:
@@ -241,6 +263,7 @@ def restart_windows(self) -> None:
def restart_posix(self) -> None:
"""Handle Linux/macOS system restart."""
+
self.check_running_processes(["ps", "-A"], skip_lines=1)
if self.prompt:
@@ -257,6 +280,7 @@ def restart_posix(self) -> None:
def wait_for_restart(self) -> None:
"""Wait and print message before restart."""
+
print(f"Restarting in {self.wait} seconds...")
_time.sleep(self.wait)
@@ -293,6 +317,7 @@ def __call__(self) -> Optional[list[str]]:
def find_missing_libs(self) -> list[str]:
"""Find which libraries are missing."""
+
missing: list[str] = []
for lib in self.lib_names:
try:
@@ -303,14 +328,16 @@ def find_missing_libs(self) -> list[str]:
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']})")
+
+ deprFormatCodes.print(f"[b]({self.missing_libs_msgs['found_missing']})")
for lib in missing:
- FormatCodes.print(f" [dim](โข) [i]{lib}[_i]")
+ deprFormatCodes.print(f" [dim](โข) [i]{lib}[_i]")
print()
return Console.confirm(self.missing_libs_msgs["should_install"], end="\n")
def install_libs(self, missing: list[str], /) -> Optional[list[str]]:
"""Install missing libraries using pip."""
+
for lib in missing[:]:
try:
_subprocess.check_call([_sys.executable, "-m", "pip", "install", lib])
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 0959413..8a36121 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,44 +1,32 @@
-from xulbux.cli.tools import render_format_codes
from xulbux.cli.help import show_help
from unittest.mock import MagicMock
from pathlib import Path
import pytest
import toml
-import sys
ROOT_DIR = Path(__file__).parent.parent
PYPROJECT_PATH = ROOT_DIR / "pyproject.toml"
-
################################################## ENTRYPOINT REGISTRATION TESTS ##################################################
-def test_xulbux_help_entrypoint_registered():
- """Verifies that the `xulbux-help` script is registered in pyproject.toml."""
- with open(PYPROJECT_PATH, "r", encoding="utf-8") as file:
- pyproject_data = toml.load(file)
- scripts = pyproject_data.get("project", {}).get("scripts", {})
- assert "xulbux-help" in scripts, "`xulbux-help` not found in [project.scripts] in pyproject.toml"
- assert scripts["xulbux-help"] == "xulbux.cli.help:show_help"
-
-
-def test_xulbux_fc_entrypoint_registered():
- """Verifies that the `xulbux-fc` script is registered in pyproject.toml."""
+def test_xulbux_lib_entrypoint_registered():
+ """Verifies that the `xulbux-lib` script is registered in pyproject.toml pointing to the CLI main()."""
with open(PYPROJECT_PATH, "r", encoding="utf-8") as file:
pyproject_data = toml.load(file)
scripts = pyproject_data.get("project", {}).get("scripts", {})
- assert "xulbux-fc" in scripts, "`xulbux-fc` not found in [project.scripts] in pyproject.toml"
- assert scripts["xulbux-fc"] == "xulbux.cli.tools:render_format_codes"
+ assert "xulbux-lib" in scripts, "`xulbux-lib` not found in [project.scripts] in pyproject.toml"
+ assert scripts["xulbux-lib"] == "xulbux.cli:main"
-################################################## xulbux-help TESTS ##################################################
+################################################## xulbux-lib TESTS ##################################################
def test_show_help_prints_output(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
"""show_help() must print the ANSI help banner to stdout."""
- monkeypatch.setattr("xulbux.console._read_single_key", MagicMock())
+ monkeypatch.setattr("xulbux.console.Console._read_single_key", MagicMock())
show_help()
@@ -50,7 +38,7 @@ def test_show_help_contains_version(monkeypatch: pytest.MonkeyPatch, capsys: pyt
"""The help banner must contain the installed package version."""
from xulbux import __version__
- monkeypatch.setattr("xulbux.console._read_single_key", MagicMock())
+ monkeypatch.setattr("xulbux.console.Console._read_single_key", MagicMock())
show_help()
@@ -67,73 +55,12 @@ def test_show_help_calls_pause_exit(monkeypatch: pytest.MonkeyPatch):
mock_pause_exit.assert_called_once()
call_kwargs = mock_pause_exit.call_args
- # pause=True must be passed so the user sees the prompt
+ # pause=True MUST BE PASSED SO THE USER SEES THE PROMPT
assert call_kwargs.kwargs.get("pause", True) is True
-def test_show_help_does_not_require_elevated_privileges(monkeypatch: pytest.MonkeyPatch):
- """show_help() must not raise when the keyboard library is unavailable or unprivileged.
- This guards against regressions where elevated privileges are accidentally required
- (the original bug on macOS and Linux)."""
- mock_read_key = MagicMock(side_effect=OSError("Error 13 - Must be run as administrator"))
- # Simulate the failure mode that was reported on macOS/Linux
- monkeypatch.setattr("xulbux.console._read_single_key", mock_read_key)
-
- with pytest.raises(OSError):
- show_help()
-
-
-def test_show_help_no_privileges_needed_when_properly_implemented(monkeypatch: pytest.MonkeyPatch):
- """With the cross-platform _read_single_key implementation, show_help() must complete
- without errors โ no elevated privileges required."""
- monkeypatch.setattr("xulbux.console._read_single_key", MagicMock())
-
- # Must not raise at all
- show_help()
-
-
-################################################## xulbux-fc TESTS ##################################################
-
-
-def test_render_format_codes_no_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
- """When no positional arguments are provided, render_format_codes() prints a usage hint."""
- monkeypatch.setattr(sys, "argv", ["xulbux-fc"])
-
- render_format_codes()
+def test_show_help_does_not_raise(monkeypatch: pytest.MonkeyPatch):
+ """show_help() must complete without errors."""
+ monkeypatch.setattr("xulbux.console.Console._read_single_key", MagicMock())
- captured = capsys.readouterr()
- assert "Provide a string" in captured.out
-
-
-def test_render_format_codes_with_plain_string(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
- """A plain string with no format codes must print the 'no valid format codes' notice."""
- monkeypatch.setattr(sys, "argv", ["xulbux-fc", "hello world"])
-
- render_format_codes()
-
- captured = capsys.readouterr()
- assert "hello world" in captured.out
- assert "doesn't contain any valid format codes" in captured.out
-
-
-def test_render_format_codes_with_format_codes(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
- """A string containing valid format codes must render the ANSI output and the escaped form."""
- monkeypatch.setattr(sys, "argv", ["xulbux-fc", "[b]bold[_]"])
-
- render_format_codes()
-
- captured = capsys.readouterr()
- # The rendered ANSI output must be non-empty
- assert len(captured.out.strip()) > 0
- # The "doesn't contain any valid format codes" notice must NOT appear
- assert "doesn't contain any valid format codes" not in captured.out
-
-
-def test_render_format_codes_multiple_tokens(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
- """Multiple positional tokens are joined and rendered as one string."""
- monkeypatch.setattr(sys, "argv", ["xulbux-fc", "hello", "world"])
-
- render_format_codes()
-
- captured = capsys.readouterr()
- assert "helloworld" in captured.out or "hello" in captured.out
+ show_help() # MUST NOT RAISE
diff --git a/tests/test_code.py b/tests/test_code.py
index efb705e..5b0b9cc 100644
--- a/tests/test_code.py
+++ b/tests/test_code.py
@@ -77,7 +77,7 @@ def test_is_js():
assert Code.is_js(js_sample) is True
js_sample = "__('translation_key')"
assert Code.is_js(js_sample) is True
- js_sample = "const func = () => { return 42; }"
+ js_sample = "const fn = () => { return 42; }"
assert Code.is_js(js_sample) is True
js_sample = "customFunc()"
assert Code.is_js(js_sample, funcs={"customFunc"}) is True
diff --git a/tests/test_color.py b/tests/test_color.py
index 0652b14..55cd41e 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -26,8 +26,8 @@ def test_is_valid_rgba():
assert Color.is_valid_rgba((255, 0, 0, 0.5)) is True
assert Color.is_valid_rgba("rgb(255, 0, 0)") is True
assert Color.is_valid_rgba("rgba(255, 0, 0, .5)") is True
- assert Color.is_valid_rgba({"r": 255, "g": 0, "b": 0}) is True
- assert Color.is_valid_rgba({"r": 255, "g": 0, "b": 0, "a": 0.5}) is True
+ assert Color.is_valid_rgba({"red": 255, "green": 0, "blue": 0}) is True
+ assert Color.is_valid_rgba({"red": 255, "green": 0, "blue": 0, "alpha": 0.5}) is True
assert Color.is_valid_rgba(rgba(255, 0, 0)) is True
assert Color.is_valid_rgba((300, 0, 0)) is False
assert Color.is_valid_rgba((255, 0)) is False
@@ -42,8 +42,8 @@ def test_is_valid_hsla():
assert Color.is_valid_hsla((0, 100, 50, 0.5)) is True
assert Color.is_valid_hsla("hsl(0, 100%, 50%)") is True
assert Color.is_valid_hsla("hsla(0, 100%, 50%, .5)") is True
- assert Color.is_valid_hsla({"h": 0, "s": 100, "l": 50}) is True
- assert Color.is_valid_hsla({"h": 0, "s": 100, "l": 50, "a": 0.5}) is True
+ assert Color.is_valid_hsla({"hue": 0, "sat": 100, "light": 50}) is True
+ assert Color.is_valid_hsla({"hue": 0, "sat": 100, "light": 50, "alpha": 0.5}) is True
assert Color.is_valid_hsla(hsla(0, 100, 50)) is True
assert Color.is_valid_hsla((370, 100, 50)) is False
assert Color.is_valid_hsla((0, 101, 50)) is False
@@ -164,7 +164,7 @@ def test_adjust_saturation():
color = rgba(128, 80, 80)
saturated = Color.adjust_saturation(color, 0.25)
assert isinstance(saturated, rgba)
- assert saturated.to_hsla().s > color.to_hsla().s
+ assert saturated.to_hsla().sat > color.to_hsla().sat
assert saturated == rgba(155, 54, 54)
desaturated = Color.adjust_saturation(hexa("#FF0000"), -1.0)
diff --git a/tests/test_console.py b/tests/test_console.py
index 9d98336..aa4ba85 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -25,10 +25,10 @@ def mock_get_terminal_size():
def mock_formatcodes_print(monkeypatch: pytest.MonkeyPatch):
mock = MagicMock()
# PATCH IN THE ORIGINAL MODULE WHERE IT IS DEFINED
- import xulbux.format_codes
- monkeypatch.setattr(xulbux.format_codes.FormatCodes, "print", mock)
+ import xulbux.depr_format_codes
+ monkeypatch.setattr(xulbux.depr_format_codes.deprFormatCodes, "print", mock)
# ALSO PATCH IN CONSOLE MODULE JUST IN CASE
- monkeypatch.setattr("xulbux.console.FormatCodes.print", mock)
+ monkeypatch.setattr("xulbux.console.deprFormatCodes.print", mock)
return mock
@@ -56,13 +56,13 @@ def test_console_user():
def test_console_width(mock_terminal_size: MagicMock):
- width_output = Console.w
+ width_output = Console.width
assert isinstance(width_output, int)
assert width_output == 80
def test_console_height(mock_terminal_size: MagicMock):
- height_output = Console.h
+ height_output = Console.height
assert isinstance(height_output, int)
assert height_output == 24
@@ -99,38 +99,38 @@ def test_console_supports_color():
["script.py", "-f=token with spaces", "-d"],
{"file": {"-f"}, "debug": {"-d"}},
{
- "file": {"exists": True, "is_pos": False, "values": ["token with spaces"], "flag": "-f"},
- "debug": {"exists": True, "is_pos": False, "values": [], "flag": "-d"},
+ "file": {"exists": True, "is_pos": False, "values": ("token with spaces", ), "flag": "-f"},
+ "debug": {"exists": True, "is_pos": False, "values": (), "flag": "-d"},
},
),
# FLAG VALUE PLUS OTHER TOKENS
(
["script.py", "--msg=hello", "world"],
{"message": {"--msg"}},
- {"message": {"exists": True, "is_pos": False, "values": ["hello"], "flag": "--msg"}},
+ {"message": {"exists": True, "is_pos": False, "values": ("hello", ), "flag": "--msg"}},
),
# VALUE SET IN SINGLE TOKEN FOLLOWED BY SECOND FLAG
(
["script.py", "--msg=this is a message", "--flag"],
{"message": {"--msg"}, "flag": {"--flag"}},
{
- "message": {"exists": True, "is_pos": False, "values": ["this is a message"], "flag": "--msg"},
- "flag": {"exists": True, "is_pos": False, "values": [], "flag": "--flag"},
+ "message": {"exists": True, "is_pos": False, "values": ("this is a message", ), "flag": "--msg"},
+ "flag": {"exists": True, "is_pos": False, "values": (), "flag": "--flag"},
},
),
# FLAG, SEPARATOR, AND VALUE SPREAD OVER MULTIPLE TOKENS
(
["script.py", "--msg", "=", "this is a message"],
{"message": {"--msg"}},
- {"message": {"exists": True, "is_pos": False, "values": ["this is a message"], "flag": "--msg"}},
+ {"message": {"exists": True, "is_pos": False, "values": ("this is a message", ), "flag": "--msg"}},
),
# CASE SENSITIVE FLAGS WITH SPACES
(
["script.py", "-t=this is some text", "-T=THIS IS A TITLE"],
{"text": {"-t"}, "title": {"-T"}},
{
- "text": {"exists": True, "is_pos": False, "values": ["this is some text"], "flag": "-t"},
- "title": {"exists": True, "is_pos": False, "values": ["THIS IS A TITLE"], "flag": "-T"},
+ "text": {"exists": True, "is_pos": False, "values": ("this is some text", ), "flag": "-t"},
+ "title": {"exists": True, "is_pos": False, "values": ("THIS IS A TITLE", ), "flag": "-T"},
},
),
@@ -140,8 +140,8 @@ def test_console_supports_color():
["script.py", "--msg=given message"],
{"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}},
{
- "msg": {"exists": True, "is_pos": False, "values": ["given message"], "flag": "--msg"},
- "other": {"exists": False, "is_pos": False, "values": [], "flag": None},
+ "msg": {"exists": True, "is_pos": False, "values": ("given message", ), "flag": "--msg"},
+ "other": {"exists": False, "is_pos": False, "values": (), "flag": None},
},
),
# DEFAULT USED WHEN FLAG PRESENT BUT NO VALUE GIVEN
@@ -149,8 +149,8 @@ def test_console_supports_color():
["script.py", "-o", "--msg"],
{"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}},
{
- "msg": {"exists": True, "is_pos": False, "values": ["no message"], "flag": "--msg"},
- "other": {"exists": True, "is_pos": False, "values": [], "flag": "-o"},
+ "msg": {"exists": True, "is_pos": False, "values": ("no message", ), "flag": "--msg"},
+ "other": {"exists": True, "is_pos": False, "values": (), "flag": "-o"},
},
),
# DEFAULT USED WHEN FLAG ABSENT
@@ -158,8 +158,8 @@ def test_console_supports_color():
["script.py", "-o"],
{"msg": {"flags": {"--msg"}, "default": "no message"}, "other": {"-o"}},
{
- "msg": {"exists": False, "is_pos": False, "values": ["no message"], "flag": None},
- "other": {"exists": True, "is_pos": False, "values": [], "flag": "-o"},
+ "msg": {"exists": False, "is_pos": False, "values": ("no message", ), "flag": None},
+ "other": {"exists": True, "is_pos": False, "values": (), "flag": "-o"},
},
),
@@ -169,24 +169,24 @@ def test_console_supports_color():
["script.py", "arg1", "arg2.1 arg2.2"],
{"before": "before", "file": {"-f"}},
{
- "before": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None},
- "file": {"exists": False, "is_pos": False, "values": [], "flag": None},
+ "before": {"exists": True, "is_pos": True, "values": ("arg1", "arg2.1 arg2.2"), "flag": None},
+ "file": {"exists": False, "is_pos": False, "values": (), "flag": None},
},
),
(
["script.py", "arg1", "arg2.1 arg2.2", "-f=file.txt", "arg3"],
{"before": "before", "file": {"-f"}},
{
- "before": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None},
- "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"},
+ "before": {"exists": True, "is_pos": True, "values": ("arg1", "arg2.1 arg2.2"), "flag": None},
+ "file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"},
},
),
(
["script.py", "-f=file.txt", "arg1"],
{"before": "before", "file": {"-f"}},
{
- "before": {"exists": False, "is_pos": True, "values": [], "flag": None},
- "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"},
+ "before": {"exists": False, "is_pos": True, "values": (), "flag": None},
+ "file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"},
},
),
# POSITIONAL "after"
@@ -194,24 +194,24 @@ def test_console_supports_color():
["script.py", "arg1", "arg2.1 arg2.2"],
{"after": "after", "file": {"-f"}},
{
- "file": {"exists": False, "is_pos": False, "values": [], "flag": None},
- "after": {"exists": True, "is_pos": True, "values": ["arg1", "arg2.1 arg2.2"], "flag": None},
+ "file": {"exists": False, "is_pos": False, "values": (), "flag": None},
+ "after": {"exists": True, "is_pos": True, "values": ("arg1", "arg2.1 arg2.2"), "flag": None},
},
),
(
["script.py", "arg1", "-f=file.txt", "arg2", "arg3.1 arg3.2"],
{"after": "after", "file": {"-f"}},
{
- "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"},
- "after": {"exists": True, "is_pos": True, "values": ["arg2", "arg3.1 arg3.2"], "flag": None},
+ "file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"},
+ "after": {"exists": True, "is_pos": True, "values": ("arg2", "arg3.1 arg3.2"), "flag": None},
},
),
(
["script.py", "arg1", "-f=file.txt"],
{"after": "after", "file": {"-f"}},
{
- "file": {"exists": True, "is_pos": False, "values": ["file.txt"], "flag": "-f"},
- "after": {"exists": False, "is_pos": True, "values": [], "flag": None},
+ "file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"},
+ "after": {"exists": False, "is_pos": True, "values": (), "flag": None},
},
),
@@ -221,8 +221,8 @@ def test_console_supports_color():
["script.py", "?help = show detailed info", "++mode=test"],
{"help": {"?help"}, "mode": {"++mode"}},
{
- "help": {"exists": True, "is_pos": False, "values": ["show detailed info"], "flag": "?help"},
- "mode": {"exists": True, "is_pos": False, "values": ["test"], "flag": "++mode"},
+ "help": {"exists": True, "is_pos": False, "values": ("show detailed info", ), "flag": "?help"},
+ "mode": {"exists": True, "is_pos": False, "values": ("test", ), "flag": "++mode"},
},
),
# AT SYMBOL PREFIX WITH POSITIONAL ARGUMENTS
@@ -230,21 +230,72 @@ def test_console_supports_color():
["script.py", "@msg = Hello, world!", "How are you?"],
{"before": "before", "message": {"@msg"}, "after": "after"},
{
- "before": {"exists": False, "is_pos": True, "values": [], "flag": None},
- "message": {"exists": True, "is_pos": False, "values": ["Hello, world!"], "flag": "@msg"},
- "after": {"exists": True, "is_pos": True, "values": ["How are you?"], "flag": None},
+ "before": {"exists": False, "is_pos": True, "values": (), "flag": None},
+ "message": {"exists": True, "is_pos": False, "values": ("Hello, world!", ), "flag": "@msg"},
+ "after": {"exists": True, "is_pos": True, "values": ("How are you?", ), "flag": None},
},
),
- # --- DON'T TREAT VALUES STARTING WITH SPECIFIED FLAG PREFIXES AS FLAGS ---
+ # --- NEGATIVE NUMBERS ARE TREATED AS VALUES; UNKNOWN FLAGS ARE COLLECTED SEPARATELY ---
(
["script.py", "-42", "-d=-256", "--file=--not-a-flag", "--also-no-flag"],
{"before": "before", "data": {"-d"}, "file": {"--file"}, "after": "after"},
{
- "before": {"exists": True, "is_pos": True, "values": ["-42"], "flag": None},
- "data": {"exists": True, "is_pos": False, "values": ["-256"], "flag": "-d"},
- "file": {"exists": True, "is_pos": False, "values": ["--not-a-flag"], "flag": "--file"},
- "after": {"exists": True, "is_pos": True, "values": ["--also-no-flag"], "flag": None},
+ "before": {"exists": True, "is_pos": True, "values": ("-42", ), "flag": None},
+ "data": {"exists": True, "is_pos": False, "values": ("-256", ), "flag": "-d"},
+ "file": {"exists": True, "is_pos": False, "values": ("--not-a-flag", ), "flag": "--file"},
+ "after": {"exists": False, "is_pos": True, "values": (), "flag": None},
+ },
+ ),
+
+ # --- SPACE-SEPARATED FLAG VALUES (allow_space_value=True default) ---
+ # SIMPLE SPACE-SEPARATED VALUE
+ (
+ ["script.py", "--flag", "myValue"],
+ {"flag": {"--flag"}},
+ {"flag": {"exists": True, "is_pos": False, "values": ("myValue", ), "flag": "--flag"}},
+ ),
+ # SHORT FLAG WITH SPACE-SEPARATED VALUE
+ (
+ ["script.py", "-f", "file.txt"],
+ {"file": {"-f", "--file"}},
+ {"file": {"exists": True, "is_pos": False, "values": ("file.txt", ), "flag": "-f"}},
+ ),
+ # KNOWN FLAG FOLLOWING A FLAG IS NOT CONSUMED AS VALUE
+ (
+ ["script.py", "--msg", "--flag"],
+ {"message": {"--msg"}, "flag": {"--flag"}},
+ {
+ "message": {"exists": True, "is_pos": False, "values": (), "flag": "--msg"},
+ "flag": {"exists": True, "is_pos": False, "values": (), "flag": "--flag"},
+ },
+ ),
+ # MULTIPLE FLAGS WITH SPACE-SEPARATED VALUES
+ (
+ ["script.py", "--msg", "hello", "-n", "42"],
+ {"message": {"--msg"}, "number": {"-n"}},
+ {
+ "message": {"exists": True, "is_pos": False, "values": ("hello", ), "flag": "--msg"},
+ "number": {"exists": True, "is_pos": False, "values": ("42", ), "flag": "-n"},
+ },
+ ),
+ # SPACE-SEPARATED VALUE CONSUMED BY FLAG, REMAINING TOKEN IS "after" POSITIONAL
+ (
+ ["script.py", "--flag", "val", "after1"],
+ {"flag": {"--flag"}, "after": "after"},
+ {
+ "flag": {"exists": True, "is_pos": False, "values": ("val", ), "flag": "--flag"},
+ "after": {"exists": True, "is_pos": True, "values": ("after1", ), "flag": None},
+ },
+ ),
+ # SPACE-SEPARATED VALUE WITH BOTH "before" AND "after" POSITIONALS
+ (
+ ["script.py", "pre", "--flag", "val", "post"],
+ {"before": "before", "flag": {"--flag"}, "after": "after"},
+ {
+ "before": {"exists": True, "is_pos": True, "values": ("pre", ), "flag": None},
+ "flag": {"exists": True, "is_pos": False, "values": ("val", ), "flag": "--flag"},
+ "after": {"exists": True, "is_pos": True, "values": ("post", ), "flag": None},
},
),
]
@@ -274,9 +325,16 @@ def test_get_args_invalid_params():
with pytest.raises(ValueError, match="The 'flags'-key set must contain at least one flag to search for."):
Console.get_args({"arg": {"flags": set(), "default": "..."}})
- with pytest.raises(ValueError, match="The 'flag_value_sep' parameter must be a non-empty string."):
+ with pytest.raises(ValueError, match=r"non-empty string or None, got"):
Console.get_args({"arg": {"-a"}}, flag_value_sep="")
+ with pytest.raises(ValueError, match="non-negative integer"):
+ Console.get_args({"arg": {"-a"}}, skip=-1)
+
+ for reserved in ParsedArgs.RESERVED_ALIASES:
+ with pytest.raises(ValueError, match=f"Invalid argument alias '{reserved}'"):
+ Console.get_args({reserved: {"-a"}})
+
def test_get_args_custom_sep(monkeypatch: pytest.MonkeyPatch):
"""Test custom flag-value separator handling"""
@@ -285,12 +343,12 @@ def test_get_args_custom_sep(monkeypatch: pytest.MonkeyPatch):
assert result.message.exists is True
assert result.message.is_pos is False
- assert result.message.values == ["This is a message"]
+ assert result.message.values == ("This is a message", )
assert result.message.flag == "--msg"
assert result.data.exists is True
assert result.data.is_pos is False
- assert result.data.values == ["42"]
+ assert result.data.values == ("42", )
assert result.data.flag == "-d"
assert result.dict() == {
@@ -299,51 +357,97 @@ def test_get_args_custom_sep(monkeypatch: pytest.MonkeyPatch):
}
+def test_get_args_no_sep(monkeypatch: pytest.MonkeyPatch):
+ """Test that flag_value_sep=None disables separator syntax and only space-separated values work."""
+ # SEPARATOR SYNTAX SHOULD NOT BE RECOGNIZED โ '--flag=val' IS TREATED AS A STANDALONE UNKNOWN TOKEN
+ monkeypatch.setattr(sys, "argv", ["script.py", "--flag", "space_val", "--other=ignored"])
+ result = Console.get_args(
+ {"flag": {"--flag"}, "other": {"--other"}, "after": "after"},
+ flag_value_sep=None,
+ )
+
+ # '--flag' CONSUMES 'space_val' VIA SPACE-SEPARATED SYNTAX
+ assert result.flag.exists is True
+ assert result.flag.values == ("space_val", )
+ assert result.flag.flag == "--flag"
+
+ # '--other=ignored' LOOKS LIKE A FLAG WITH NO SEPARATOR PROCESSING โ TREATED AS UNKNOWN FLAG
+ assert result.other.exists is False
+ assert result.other.values == ()
+
+ assert result.after.exists is False
+ assert result.after.values == ()
+ assert result.unknown_flags == frozenset({"--other=ignored"})
+
+
+def test_get_args_allow_space_value_false(monkeypatch: pytest.MonkeyPatch):
+ """Test that allow_space_value=False does not consume the next token as a flag value."""
+ monkeypatch.setattr(sys, "argv", ["script.py", "--flag", "val", "after1"])
+ result = Console.get_args(
+ {"flag": {"--flag"}, "after": "after"},
+ allow_space_value=False,
+ )
+
+ # 'val' must NOT be consumed by --flag
+ assert result.flag.exists is True
+ assert result.flag.values == ()
+ assert result.flag.flag == "--flag"
+
+ # both 'val' and 'after1' are unclaimed and collected as "after" positionals
+ assert result.after.exists is True
+ assert result.after.values == ("val", "after1")
+
+
def test_get_args_mixed_dash_scenarios(monkeypatch: pytest.MonkeyPatch):
"""Test complex scenario mixing defined flags with dash-prefixed values"""
monkeypatch.setattr(
sys, "argv", \
["script.py", "before string", "-42", "-d=256", "--file=my-file.txt", "-vv", "after string", "--also-no-flag"]
)
- result = Console.get_args({
- "before": "before",
- "data": {"-d", "--data"},
- "file": {"-f", "--file"},
- "verbose": {"-v", "-vv", "-vvv"},
- "help": {"-h", "--help"},
- "after": "after",
- })
+ result = Console.get_args(
+ {
+ "before": "before",
+ "data": {"-d", "--data"},
+ "file": {"-f", "--file"},
+ "verbose": {"-v", "-vv", "-vvv"},
+ "help": {"-h", "--help"},
+ "after": "after",
+ },
+ allow_space_value=False,
+ )
assert result.before.exists is True
assert result.before.is_pos is True
- assert result.before.values == ["before string", "-42"]
+ assert result.before.values == ("before string", "-42")
assert result.before.flag is None
assert result.data.exists is True
assert result.data.is_pos is False
- assert result.data.values == ["256"]
+ assert result.data.values == ("256", )
assert result.data.flag == "-d"
assert result.file.exists is True
assert result.file.is_pos is False
- assert result.file.values == ["my-file.txt"]
+ assert result.file.values == ("my-file.txt", )
assert result.file.flag == "--file"
assert result.verbose.exists is True
assert result.verbose.is_pos is False
- assert result.verbose.values == []
+ assert result.verbose.values == ()
assert result.verbose.flag == "-vv"
assert result.help.exists is False
assert result.help.is_pos is False
- assert result.help.values == []
+ assert result.help.values == ()
assert result.help.flag is None
assert result.after.exists is True
assert result.after.is_pos is True
- assert result.after.values == ["after string", "--also-no-flag"]
+ assert result.after.values == ("after string", )
assert result.after.flag is None
+ assert result.unknown_flags == frozenset({"--also-no-flag"})
+
assert result.dict() == {
"before": result.before.dict(),
"data": result.data.dict(),
@@ -354,6 +458,149 @@ def test_get_args_mixed_dash_scenarios(monkeypatch: pytest.MonkeyPatch):
}
+def test_get_args_unknown_flags(monkeypatch: pytest.MonkeyPatch):
+ """Unknown flags (not in config) are collected in unknown_flags and not consumed as values."""
+ # STANDALONE UNKNOWN FLAGS AND UNKNOWN FLAG WITH INLINE SEPARATOR
+ monkeypatch.setattr(sys, "argv", ["script.py", "--known=val", "--unknown", "--unknown=extra", "-u"])
+ result = Console.get_args({"known": {"--known"}, "after": "after"})
+
+ assert result.known.exists is True
+ assert result.known.values == ("val", )
+ assert result.unknown_flags == frozenset({"--unknown", "--unknown=extra", "-u"})
+ assert result.after.values == ()
+
+
+def test_get_args_unknown_flag_not_consumed_as_value(monkeypatch: pytest.MonkeyPatch):
+ """An unknown flag following a known flag is NOT consumed as that flag's space-separated value."""
+ monkeypatch.setattr(sys, "argv", ["script.py", "--known", "--unknown"])
+ result = Console.get_args({"known": {"--known"}})
+
+ assert result.known.exists is True
+ assert result.known.values == ()
+ assert result.unknown_flags == frozenset({"--unknown"})
+
+
+def test_get_args_negative_number_not_unknown_flag(monkeypatch: pytest.MonkeyPatch):
+ """Negative numbers (e.g. -42, -.5) are not treated as unknown flags."""
+ monkeypatch.setattr(sys, "argv", ["script.py", "-42", "-.5", "--known"])
+ result = Console.get_args({"before": "before", "known": {"--known"}})
+
+ assert result.before.values == ("-42", "-.5")
+ assert result.unknown_flags == frozenset()
+
+
+def test_get_args_skip(monkeypatch: pytest.MonkeyPatch):
+ """Test that skip=N drops the first N argv entries before any parsing."""
+ # WITH skip=1: argv[1] ('fc') IS SKIPPED, PARSING STARTS AT argv[2]
+ monkeypatch.setattr(sys, "argv", ["script.py", "fc", "hello", "world"])
+ result = Console.get_args({"input": "before"}, skip=1)
+ assert result.input.exists is True
+ assert result.input.values == ("hello", "world")
+
+ # WITH skip=2: argv[1] AND argv[2] ARE SKIPPED, PARSING STARTS AT argv[3]
+ monkeypatch.setattr(sys, "argv", ["script.py", "sub", "cmd", "--flag=val"])
+ result = Console.get_args({"flag": {"--flag"}}, skip=2)
+ assert result.flag.exists is True
+ assert result.flag.values == ("val", )
+
+ # WITH skip EXCEEDING ARGV LENGTH: NO ARGS ARE PARSED
+ monkeypatch.setattr(sys, "argv", ["script.py", "only"])
+ result = Console.get_args({"flag": {"--flag"}}, skip=5)
+ assert result.flag.exists is False
+
+ # WITH skip=0 (DEFAULT): BEHAVIOR IS UNCHANGED
+ monkeypatch.setattr(sys, "argv", ["script.py", "--flag=val"])
+ result = Console.get_args({"flag": {"--flag"}}, skip=0)
+ assert result.flag.exists is True
+ assert result.flag.values == ("val", )
+
+
+def test_parsed_args_is_empty():
+ # ALL MISSING, NO VALUES: is_empty IS True
+ args = ParsedArgs(
+ a=ParsedArgData(exists=False, values=[], is_pos=False),
+ b=ParsedArgData(exists=False, values=[], is_pos=True),
+ )
+ assert args.is_empty is True
+
+ # ONE HAS A DEFAULT VALUE: is_empty IS False
+ args_with_default = ParsedArgs(
+ a=ParsedArgData(exists=False, values=["default"], is_pos=False),
+ b=ParsedArgData(exists=False, values=[], is_pos=False),
+ )
+ assert args_with_default.is_empty is False
+
+ # ONE EXISTS: is_empty IS False
+ args_with_existing = ParsedArgs(
+ a=ParsedArgData(exists=True, values=["val"], is_pos=False),
+ b=ParsedArgData(exists=False, values=[], is_pos=False),
+ )
+ assert args_with_existing.is_empty is False
+
+ # EMPTY ParsedArgs: is_empty IS True
+ assert ParsedArgs().is_empty is True
+
+
+def test_parsed_args_any_exist():
+ # NONE EXIST
+ args_none = ParsedArgs(
+ a=ParsedArgData(exists=False, values=[], is_pos=False),
+ b=ParsedArgData(exists=False, values=["default"], is_pos=False),
+ )
+ assert args_none.any_exist is False
+
+ # ONE EXISTS
+ args_one = ParsedArgs(
+ a=ParsedArgData(exists=True, values=["v"], is_pos=False),
+ b=ParsedArgData(exists=False, values=[], is_pos=False),
+ )
+ assert args_one.any_exist is True
+
+ # ALL EXIST
+ args_all = ParsedArgs(
+ a=ParsedArgData(exists=True, values=["v"], is_pos=False),
+ b=ParsedArgData(exists=True, values=[], is_pos=False),
+ )
+ assert args_all.any_exist is True
+
+ # EMPTY ParsedArgs: any_exist IS False
+ assert ParsedArgs().any_exist is False
+
+
+def test_parsed_args_all_exist():
+ # ALL EXIST
+ args_all = ParsedArgs(
+ a=ParsedArgData(exists=True, values=["v"], is_pos=False),
+ b=ParsedArgData(exists=True, values=[], is_pos=False),
+ )
+ assert args_all.all_exist is True
+
+ # ONE MISSING
+ args_partial = ParsedArgs(
+ a=ParsedArgData(exists=True, values=["v"], is_pos=False),
+ b=ParsedArgData(exists=False, values=[], is_pos=False),
+ )
+ assert args_partial.all_exist is False
+
+ # NONE EXIST
+ args_none = ParsedArgs(a=ParsedArgData(exists=False, values=[], is_pos=False), )
+ assert args_none.all_exist is False
+
+ # EMPTY ParsedArgs: all_exist IS True (vacuously)
+ assert ParsedArgs().all_exist is True
+
+
+def test_parsed_args_properties_not_in_iter():
+ """Properties is_empty, any_exist, all_exist must not appear when iterating."""
+ args = ParsedArgs(flag=ParsedArgData(exists=True, values=[], is_pos=False), )
+ keys = [k for k, _ in args]
+ assert "is_empty" not in keys
+ assert "any_exist" not in keys
+ assert "all_exist" not in keys
+ assert "unknown_flags" not in keys
+ assert len(args) == 1
+
+
def test_args_dunder_methods():
args = ParsedArgs(
before=ParsedArgData(exists=True, values=["arg1", "arg2"], is_pos=True),
@@ -971,11 +1218,11 @@ def test_progressbar_create_bar():
bar = pb._create_bar(100, 100, 10)
assert len(bar) == 10
- assert all(c == pb.chars[0] for c in bar)
+ assert all(char == pb.chars[0] for char in bar)
bar = pb._create_bar(0, 100, 10)
assert len(bar) == 10
- assert all(c == pb.chars[-1] for c in bar)
+ assert all(char == pb.chars[-1] for char in bar)
def test_progressbar_intercepted_output():
diff --git a/tests/test_data.py b/tests/test_data.py
index 51bb54e..714eb90 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -157,7 +157,7 @@ def test_get_path_id():
}
-def test_get_value_by_path_id():
+def test_get_value_by_path_id() -> None:
data: dict[str, Any] = {"a": [1, {"b": "c"}], "d": ("e", "f")}
path_id_1 = str(Data.get_path_id(data, "a->1->b"))
path_id_2 = str(Data.get_path_id(data, "d->1"))
@@ -175,10 +175,11 @@ def test_get_value_by_path_id():
Data.get_value_by_path_id({"a": [1]}, "1>01")
-def test_set_value_by_path_id():
+def test_set_value_by_path_id() -> None:
data: dict[str, Any] = {"a": [1, {"b": "c"}], "d": ("e", "f")}
path_id_c = Data.get_path_id(data, "a->1->b")
path_id_f = Data.get_path_id(data, "d->1")
+ assert path_id_c is not None and path_id_f is not None
updated_data = Data.set_value_by_path_id(data, {path_id_c: "NEW_C", path_id_f: "NEW_F"}) # type: ignore[assignment]
expected_data: dict[str, Any] = {"a": [1, {"b": "NEW_C"}], "d": ("e", "NEW_F")}
diff --git a/tests/test_depr_format_codes.py b/tests/test_depr_format_codes.py
new file mode 100644
index 0000000..d164a80
--- /dev/null
+++ b/tests/test_depr_format_codes.py
@@ -0,0 +1,150 @@
+from xulbux.base.consts import ANSI
+from xulbux.depr_format_codes import deprFormatCodes
+
+
+black = ANSI.SEQ_FG_COLOR.format(0, 0, 0)
+bg_red = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['bg:red']}{ANSI.END}"
+default = ANSI.SEQ_FG_COLOR.format(255, 255, 255)
+orange = ANSI.SEQ_FG_COLOR.format(255, 136, 119)
+
+bold = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('bold', 'b')]}{ANSI.END}"
+invert = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('inverse', 'invert', 'in')]}{ANSI.END}"
+italic = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('italic', 'i')]}{ANSI.END}"
+underline = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('underline', 'u')]}{ANSI.END}"
+
+reset = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['_']}{ANSI.END}"
+reset_bg = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_background', '_bg')]}{ANSI.END}"
+reset_bold = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_bold', '_b')]}{ANSI.END}"
+reset_color = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_color', '_c')]}{ANSI.END}"
+reset_italic = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_italic', '_i')]}{ANSI.END}"
+reset_invert = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_inverse', '_invert', '_in')]}{ANSI.END}"
+reset_underline = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_underline', '_u')]}{ANSI.END}"
+
+#
+################################################## deprFormatCodes TESTS ##################################################
+
+
+def test_to_ansi():
+ assert (
+ deprFormatCodes.to_ansi("[b|#000|bg:red](He[in](l)lo) [[i|u|#F87](world)][default]![_]",
+ default_color="#FFF") == f"{default}{bold}{black}{bg_red}" + "He" + invert + "l" + reset_invert
+ + "lo" + f"{reset_bold}{default}{reset_bg}" + " [" + f"{italic}{underline}{orange}" + "world"
+ + f"{reset_italic}{reset_underline}{default}" + "]" + default + "!" + reset
+ )
+
+
+def test_escape_ansi():
+ ansi_string = f"{bold}Hello {orange}World!{reset}"
+ escaped_string = ansi_string.replace(ANSI.CHAR, ANSI.CHAR_ESCAPED)
+ assert deprFormatCodes.escape_ansi(ansi_string) == escaped_string
+
+
+def test_escape():
+ # TEST BASIC FORMATTING CODES
+ assert deprFormatCodes.escape("[b]Hello[_]") == "[/b]Hello[/_]"
+ assert deprFormatCodes.escape("[bold|italic]Text[_]") == "[/bold|italic]Text[/_]"
+
+ # TEST WITH COLORS
+ assert deprFormatCodes.escape("[#F87]Hello[_]") == "[/#F87]Hello[/_]"
+ assert deprFormatCodes.escape("[rgb(255, 136, 119)]Hello[_]") == "[/rgb(255, 136, 119)]Hello[/_]"
+
+ # TEST WITH DEFAULT COLOR
+ assert deprFormatCodes.escape("[default]Hello", default_color="#FFF") == "[/default]Hello"
+ assert deprFormatCodes.escape("[bg:default]Hello", default_color="#FFF") == "[/bg:default]Hello"
+
+ # TEST WITH * FORMATTING CODE
+ assert deprFormatCodes.escape("[*]Hello", default_color="#FFF") == "[/*]Hello"
+ assert deprFormatCodes.escape("[b|*]Hello", default_color="#FFF") == "[/b|*]Hello"
+
+ # TEST WITH AUTO-RESET
+ assert deprFormatCodes.escape("[b](Hello)") == "[/b](Hello)"
+ assert deprFormatCodes.escape("[*](Hello)", default_color="#FFF") == "[/*](Hello)"
+
+ # TEST INVALID FORMATTING CODES (SHOULD REMAIN UNCHANGED)
+ assert deprFormatCodes.escape("[invalid]Hello") == "[invalid]Hello"
+ assert deprFormatCodes.escape("[default]Hello") == "[default]Hello" # NO 'default_color'
+ assert deprFormatCodes.escape("[*]Hello") == "[/*]Hello" # NO 'default_color'
+
+ # TEST ALREADY ESCAPED CODES
+ assert deprFormatCodes.escape("[/b]Hello") == "[/b]Hello"
+ assert deprFormatCodes.escape("[/*]Hello", default_color="#FFF") == "[/*]Hello"
+
+ # TEST WITH BRIGHTNESS MODIFIERS
+ assert deprFormatCodes.escape("[l]Hello", default_color="#FFF") == "[/l]Hello"
+ assert deprFormatCodes.escape("[ll]Hello", default_color="#FFF") == "[/ll]Hello"
+ assert deprFormatCodes.escape("[+]Hello", default_color="#FFF") == "[/+]Hello"
+ assert deprFormatCodes.escape("[++]Hello", default_color="#FFF") == "[/++]Hello"
+ assert deprFormatCodes.escape("[d]Hello", default_color="#FFF") == "[/d]Hello"
+ assert deprFormatCodes.escape("[dd]Hello", default_color="#FFF") == "[/dd]Hello"
+ assert deprFormatCodes.escape("[-]Hello", default_color="#FFF") == "[/-]Hello"
+ assert deprFormatCodes.escape("[--]Hello", default_color="#FFF") == "[/--]Hello"
+
+
+def test_hyperlinks():
+ url = "https://example.com"
+ file_url = "file:///C:/path/to/file.txt"
+ link_open = ANSI.SEQ_LINK_OPEN.format(url)
+ link_open_file = ANSI.SEQ_LINK_OPEN.format(file_url)
+ link_close = ANSI.SEQ_LINK_CLOSE
+
+ # BASIC LINK
+ assert deprFormatCodes.to_ansi(f"[link:{url}](click here)") == f"{link_open}click here{link_close}"
+
+ # FILE URL
+ assert deprFormatCodes.to_ansi(f"[link:{file_url}](open file)") == f"{link_open_file}open file{link_close}"
+
+ # LINK WITH NESTED FORMATTING IN DISPLAY TEXT
+ assert deprFormatCodes.to_ansi(f"[link:{url}]([b](bold link))") == f"{link_open}{bold}bold link{reset_bold}{link_close}"
+
+ # LINK COMBINED WITH OTHER FORMAT KEYS
+ assert deprFormatCodes.to_ansi(f"[link:{url}|b](click here)") == f"{link_open}{bold}click here{reset_bold}{link_close}"
+ bright_blue = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['br:blue']}{ANSI.END}"
+ assert deprFormatCodes.to_ansi(f"[link:{url}|br:blue](click here)"
+ ) == (f"{link_open}{bright_blue}click here{reset_color}{link_close}")
+
+ # LINK WITHOUT DISPLAY BRACES IS INVALID (LEFT AS-IS)
+ assert deprFormatCodes.to_ansi(f"[link:{url}]") == f"[link:{url}]"
+
+ # ESCAPE: LINK SHOULD BE ESCAPED
+ assert deprFormatCodes.escape(f"[link:{url}](click here)") == f"[/link:{url}](click here)"
+ assert deprFormatCodes.escape(f"[link:{url}|b](click here)") == f"[/link:{url}|b](click here)"
+
+ # REMOVE: OSC SEQUENCES FROM LINK SHOULD BE STRIPPED, LEAVING ONLY DISPLAY TEXT
+ assert deprFormatCodes.remove(f"[link:{url}](click here)") == "click here"
+ assert deprFormatCodes.remove_ansi(f"{link_open}click here{link_close}") == "click here"
+
+
+def test_remove_ansi():
+ ansi_string = f"{bold}Hello {orange}World!{reset}"
+ clean_string = "Hello World!"
+ assert deprFormatCodes.remove_ansi(ansi_string) == clean_string
+
+
+def test_remove_ansi_with_removals():
+ ansi_string = f"{bold}Hello\n{orange}World!{reset}"
+ clean_string = "Hello\nWorld!"
+ removals = ((0, bold), (6, orange), (12, reset))
+ assert deprFormatCodes.remove_ansi(ansi_string, get_removals=True) == (clean_string, removals)
+ removals = ((0, bold), (5, orange), (11, reset))
+ assert deprFormatCodes.remove_ansi(ansi_string, get_removals=True, _ignore_linebreaks=True) == (clean_string, removals)
+
+
+def test_remove_formatting():
+ format_string = "[b](Hello [#F87](World!))"
+ clean_string = "Hello World!"
+ assert deprFormatCodes.remove(format_string) == clean_string
+
+
+def test_remove_formatting_with_removals():
+ format_string = "[b](Hello [#F87](World!))"
+ clean_string = "Hello World!"
+ removals = ((0, default), (0, bold), (6, orange), (12, default), (12, reset_bold))
+ assert deprFormatCodes.remove(format_string, default_color="#FFF", get_removals=True) == (clean_string, removals)
+ format_string = "[b](Hello)\n[#F87](World!)"
+ clean_string = "Hello\nWorld!"
+ removals = ((0, default), (0, bold), (5, reset_bold), (6, orange), (12, default))
+ assert deprFormatCodes.remove(format_string, default_color="#FFF", get_removals=True) == (clean_string, removals)
+ removals = ((0, default), (0, bold), (5, reset_bold), (5, orange), (11, default))
+ assert deprFormatCodes.remove(
+ format_string, default_color="#FFF", get_removals=True, _ignore_linebreaks=True
+ ) == (clean_string, removals)
diff --git a/tests/test_format_codes.py b/tests/test_format_codes.py
index ec5acd6..f2ea38b 100644
--- a/tests/test_format_codes.py
+++ b/tests/test_format_codes.py
@@ -1,150 +1,256 @@
+from xulbux.format_codes import FormatCodes, FC, Format, F, Term, _FmtGroup, _build_open_close
from xulbux.base.consts import ANSI
-from xulbux.format_codes import FormatCodes
+import pytest
+import sys
+import io
-black = ANSI.SEQ_COLOR.format(0, 0, 0)
-bg_red = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['bg:red']}{ANSI.END}"
-default = ANSI.SEQ_COLOR.format(255, 255, 255)
-orange = ANSI.SEQ_COLOR.format(255, 136, 119)
-bold = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('bold', 'b')]}{ANSI.END}"
-invert = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('inverse', 'invert', 'in')]}{ANSI.END}"
-italic = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('italic', 'i')]}{ANSI.END}"
-underline = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('underline', 'u')]}{ANSI.END}"
-
-reset = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['_']}{ANSI.END}"
-reset_bg = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_background', '_bg')]}{ANSI.END}"
-reset_bold = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_bold', '_b')]}{ANSI.END}"
-reset_color = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_color', '_c')]}{ANSI.END}"
-reset_italic = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_italic', '_i')]}{ANSI.END}"
-reset_invert = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_inverse', '_invert', '_in')]}{ANSI.END}"
-reset_underline = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP[('_underline', '_u')]}{ANSI.END}"
+ESC = ANSI.CHAR
#
################################################## FormatCodes TESTS ##################################################
-def test_to_ansi():
- assert (
- FormatCodes.to_ansi("[b|#000|bg:red](He[in](l)lo) [[i|u|#F87](world)][default]![_]",
- default_color="#FFF") == f"{default}{bold}{black}{bg_red}" + "He" + invert + "l" + reset_invert
- + "lo" + f"{reset_bold}{default}{reset_bg}" + " [" + f"{italic}{underline}{orange}" + "world"
- + f"{reset_italic}{reset_underline}{default}" + "]" + default + "!" + reset
- )
+def test_bare_fmt_emits_only_open_sequence():
+ result = FC(F.RED)
+ assert result.ansi == f"{ESC}[31m"
+ assert result.raw == ""
+
+
+def test_bare_reset_fmt():
+ result = FC(F.RESET)
+ assert result.ansi == f"{ESC}[0m"
+ assert result.raw == ""
+
+
+def test_bare_fmt_sequence_with_explicit_reset():
+ result = FC(F.RED, "hello", F.RESET)
+ assert result.ansi == f"{ESC}[31m\nhello\n{ESC}[0m"
+ assert result.raw == "\nhello\n"
+
+
+def test_bare_fmt_inside_tuple():
+ result = FC((F.RED, "hello", F.RESET))
+ assert result.ansi == f"{ESC}[31mhello{ESC}[0m"
+ assert result.raw == "hello"
+
+
+def test_bare_colorfmt_emits_open_sequence():
+ result = FC(F.hex("#ff6070"))
+ assert result.ansi == f"{ESC}[38;2;255;96;112m"
+ assert result.raw == ""
+
+
+def test_bare_linkfmt_emits_open_sequence():
+ result = FC(F.link("https://example.com"))
+ assert result.ansi == f"{ESC}]8;;https://example.com{ESC}\\"
+ assert result.raw == ""
+
+
+def test_bare_fmtgroup_emits_open_sequence():
+ result = FC(F.BOLD | F.RED)
+ assert result.ansi == f"{ESC}[1;31m"
+ assert result.raw == ""
+
+
+def test_bare_fmt_inside_nested_styled_call():
+ result = FC(F.DIM("a", F.RED, "b", F.RESET_FG, "c"))
+ expected = f"{ESC}[2ma{ESC}[31mb{ESC}[39mc{ESC}[22m"
+ assert result.ansi == expected
+ assert result.raw == "abc"
+
+
+def test_plain_string_passes_through():
+ result = FC("hello world")
+ assert result.ansi == "hello world"
+ assert result.raw == "hello world"
+ assert result.code_positions == ()
+
+
+def test_single_style_wraps_text_with_open_and_reset():
+ result = FC(F.BOLD("hi"))
+ assert result.ansi == f"{ESC}[1mhi{ESC}[22m"
+ assert result.raw == "hi"
+
+
+def test_combined_group_emits_single_sgr():
+ result = FC((F.BOLD | F.RED)("hi"))
+ assert result.ansi == f"{ESC}[1;31mhi{ESC}[22;39m"
+ assert result.raw == "hi"
+
+
+def test_default_separator_is_newline():
+ result = FC("a", "b", "c")
+ assert result.ansi == "a\nb\nc"
+ assert result.raw == "a\nb\nc"
+
+
+def test_custom_separator():
+ result = FC("a", "b", sep=" | ")
+ assert result.ansi == "a | b"
+
+
+def test_nested_styled_keeps_outer_style_after_inner_reset():
+ result = FC(F.CYAN("outer ", F.DIM("inner"), " outer"))
+ expected = f"{ESC}[36mouter {ESC}[2minner{ESC}[22m outer{ESC}[39m"
+ assert result.ansi == expected
+ assert result.raw == "outer inner outer"
+
+
+def test_tuple_as_multi_segment_group():
+ result = FC(("a", F.BOLD("b"), "c"))
+ assert result.ansi == f"a{ESC}[1mb{ESC}[22mc"
+ assert result.raw == "abc"
+
+
+def test_multi_text_args_in_call():
+ result = FC(F.BOLD("a", F.RED("b"), "c"))
+ expected = f"{ESC}[1ma{ESC}[31mb{ESC}[39mc{ESC}[22m"
+ assert result.ansi == expected
+ assert result.raw == "abc"
+
+
+def test_bright_fg_color():
+ result = FC(F.BR.BLUE("x"))
+ assert result.ansi == f"{ESC}[94mx{ESC}[39m"
+
+
+def test_bg_color():
+ result = FC(F.BG.RED("x"))
+ assert result.ansi == f"{ESC}[41mx{ESC}[49m"
+
+
+def test_bright_bg_color():
+ result = FC(F.BG.BR.GREEN("x"))
+ assert result.ansi == f"{ESC}[102mx{ESC}[49m"
+
+
+def test_rgb_fg():
+ result = FC(F.rgb(10, 20, 30)("x"))
+ assert result.ansi == f"{ESC}[38;2;10;20;30mx{ESC}[39m"
+
+
+def test_rgb_bg():
+ result = FC(F.BG.rgb(10, 20, 30)("x"))
+ assert result.ansi == f"{ESC}[48;2;10;20;30mx{ESC}[49m"
+
+
+def test_hex_fg_short_and_long():
+ short_form = FC(F.hex("#abc")("x")).ansi
+ long_form = FC(F.hex("aabbcc")("x")).ansi
+ assert short_form == long_form == f"{ESC}[38;2;170;187;204mx{ESC}[39m"
+
+
+def test_hex_bg():
+ result = FC(F.BG.hex("#102030")("x"))
+ assert result.ansi == f"{ESC}[48;2;16;32;48mx{ESC}[49m"
+
+
+def test_link_alone_wraps_text():
+ result = FC(F.link("https://example.com")("click"))
+ assert result.ansi == f"{ESC}]8;;https://example.com{ESC}\\click{ESC}]8;;{ESC}\\"
+ assert result.raw == "click"
+
+
+def test_link_combined_with_style():
+ result = FC((F.link("https://x") | F.BOLD)("click"))
+ expected = (f"{ESC}]8;;https://x{ESC}\\"
+ f"{ESC}[1m"
+ f"click"
+ f"{ESC}[22m"
+ f"{ESC}]8;;{ESC}\\")
+ assert result.ansi == expected
+
+
+def test_code_positions_match_offsets_in_ansi():
+ result = FC(F.BOLD("hi"))
+ # ESC[1m THEN "hi" THEN ESC[22m
+ assert result.code_positions == ((0, f"{ESC}[1m"), (len(f"{ESC}[1m") + 2, f"{ESC}[22m"))
+ # OFFSETS MUST BE VALID SLICE POINTS INTO THE ANSI STRING
+ for position, sequence in result.code_positions:
+ assert result.ansi[position:position + len(sequence)] == sequence
+
+
+def test_raw_equals_ansi_minus_sequences():
+ result = FC(F.CYAN("a"), (F.BOLD | F.RED)("b"), "plain")
+ stripped = result.ansi
+ for _, sequence in result.code_positions:
+ stripped = stripped.replace(sequence, "", 1)
+ assert stripped == result.raw
+
+
+def test_remove_ansi_strips_csi_and_osc():
+ mixed = f"{ESC}[1mhi{ESC}[0m and {ESC}]8;;u{ESC}\\link{ESC}]8;;{ESC}\\"
+ assert FC.remove_ansi(mixed) == "hi and link"
+
+
+def test_print_writes_ansi_plus_end_to_stdout(monkeypatch: pytest.MonkeyPatch):
+ buffer = io.StringIO()
+ monkeypatch.setattr(sys, "stdout", buffer)
+ FC(F.BOLD("hi")).print(end="!")
+ assert buffer.getvalue() == f"{ESC}[1mhi{ESC}[22m!"
+
+
+def test_input_uses_ansi_as_prompt(monkeypatch: pytest.MonkeyPatch):
+ captured: dict[str, str] = {}
+
+ def fake_input(prompt: str = "") -> str:
+ captured["prompt"] = prompt
+ return "answer"
+
+ monkeypatch.setattr("builtins.input", fake_input)
+ result = FC(F.BOLD("Name: ")).input()
+ assert result == "answer"
+ assert captured["prompt"] == f"{ESC}[1mName: {ESC}[22m"
+
+
+def test_or_chains_produce_fmtgroup():
+ group = F.BOLD | F.RED | F.UNDERLINE
+ assert isinstance(group, _FmtGroup)
+ # `_Fmt` IS AN `int` SUBCLASS, SO DIRECT INT COMPARISON WORKS
+ assert list(group) == [1, 31, 4]
+
+
+def test_pipe_with_fmtgroup_left_and_right():
+ left_assoc = (F.BOLD | F.RED) | F.UNDERLINE
+ right_assoc = F.BOLD | (F.RED | F.UNDERLINE)
+ assert list(left_assoc) == list(right_assoc) == [1, 31, 4]
+
+
+def test_build_open_close_dedupes_close_codes():
+ # BOLD + DIM BOTH RESET TO 22 -> ONLY ONE 22 IN CLOSE
+ opens, closes = _build_open_close(F.BOLD | F.DIM)
+ assert opens == (f"{ESC}[1;2m", )
+ assert closes == (f"{ESC}[22m", )
+
+
+def test_term_constants():
+ assert Term.CLEAR_LINE == f"{ESC}[2K"
+ assert Term.CLEAR_SCREEN == f"{ESC}[2J"
+ assert Term.HIDE_CURSOR == f"{ESC}[?25l"
+ assert Term.SHOW_CURSOR == f"{ESC}[?25h"
+ assert Term.ALT_SCREEN == f"{ESC}[?1049h"
+ assert Term.MAIN_SCREEN == f"{ESC}[?1049l"
+
+
+def test_term_cursor_movement():
+ assert Term.up(3) == f"{ESC}[3A"
+ assert Term.down() == f"{ESC}[1B"
+ assert Term.right(5) == f"{ESC}[5C"
+ assert Term.left(2) == f"{ESC}[2D"
+ assert Term.move(4, 7) == f"{ESC}[4;7H"
+
+def test_term_save_restore_and_title():
+ assert Term.save() == f"{ESC}[s"
+ assert Term.restore() == f"{ESC}[u"
+ assert Term.title("hi") == f"{ESC}]2;hi\x07"
-def test_escape_ansi():
- ansi_string = f"{bold}Hello {orange}World!{reset}"
- escaped_string = ansi_string.replace(ANSI.CHAR, ANSI.CHAR_ESCAPED)
- assert FormatCodes.escape_ansi(ansi_string) == escaped_string
+def test_FormatCodes_and_FC_are_same_object():
+ assert FC is FormatCodes
-def test_escape():
- # TEST BASIC FORMATTING CODES
- assert FormatCodes.escape("[b]Hello[_]") == "[/b]Hello[/_]"
- assert FormatCodes.escape("[bold|italic]Text[_]") == "[/bold|italic]Text[/_]"
- # TEST WITH COLORS
- assert FormatCodes.escape("[#F87]Hello[_]") == "[/#F87]Hello[/_]"
- assert FormatCodes.escape("[rgb(255, 136, 119)]Hello[_]") == "[/rgb(255, 136, 119)]Hello[/_]"
-
- # TEST WITH DEFAULT COLOR
- assert FormatCodes.escape("[default]Hello", default_color="#FFF") == "[/default]Hello"
- assert FormatCodes.escape("[bg:default]Hello", default_color="#FFF") == "[/bg:default]Hello"
-
- # TEST WITH * FORMATTING CODE
- assert FormatCodes.escape("[*]Hello", default_color="#FFF") == "[/*]Hello"
- assert FormatCodes.escape("[b|*]Hello", default_color="#FFF") == "[/b|*]Hello"
-
- # TEST WITH AUTO-RESET
- assert FormatCodes.escape("[b](Hello)") == "[/b](Hello)"
- assert FormatCodes.escape("[*](Hello)", default_color="#FFF") == "[/*](Hello)"
-
- # TEST INVALID FORMATTING CODES (SHOULD REMAIN UNCHANGED)
- assert FormatCodes.escape("[invalid]Hello") == "[invalid]Hello"
- assert FormatCodes.escape("[default]Hello") == "[default]Hello" # NO 'default_color'
- assert FormatCodes.escape("[*]Hello") == "[/*]Hello" # NO 'default_color'
-
- # TEST ALREADY ESCAPED CODES
- assert FormatCodes.escape("[/b]Hello") == "[/b]Hello"
- assert FormatCodes.escape("[/*]Hello", default_color="#FFF") == "[/*]Hello"
-
- # TEST WITH BRIGHTNESS MODIFIERS
- assert FormatCodes.escape("[l]Hello", default_color="#FFF") == "[/l]Hello"
- assert FormatCodes.escape("[ll]Hello", default_color="#FFF") == "[/ll]Hello"
- assert FormatCodes.escape("[+]Hello", default_color="#FFF") == "[/+]Hello"
- assert FormatCodes.escape("[++]Hello", default_color="#FFF") == "[/++]Hello"
- assert FormatCodes.escape("[d]Hello", default_color="#FFF") == "[/d]Hello"
- assert FormatCodes.escape("[dd]Hello", default_color="#FFF") == "[/dd]Hello"
- assert FormatCodes.escape("[-]Hello", default_color="#FFF") == "[/-]Hello"
- assert FormatCodes.escape("[--]Hello", default_color="#FFF") == "[/--]Hello"
-
-
-def test_hyperlinks():
- url = "https://example.com"
- file_url = "file:///C:/path/to/file.txt"
- link_open = ANSI.SEQ_LINK_OPEN.format(url)
- link_open_file = ANSI.SEQ_LINK_OPEN.format(file_url)
- link_close = ANSI.SEQ_LINK_CLOSE
-
- # BASIC LINK
- assert FormatCodes.to_ansi(f"[link:{url}](click here)") == f"{link_open}click here{link_close}"
-
- # FILE URL
- assert FormatCodes.to_ansi(f"[link:{file_url}](open file)") == f"{link_open_file}open file{link_close}"
-
- # LINK WITH NESTED FORMATTING IN DISPLAY TEXT
- assert FormatCodes.to_ansi(f"[link:{url}]([b](bold link))") == f"{link_open}{bold}bold link{reset_bold}{link_close}"
-
- # LINK COMBINED WITH OTHER FORMAT KEYS
- assert FormatCodes.to_ansi(f"[link:{url}|b](click here)") == f"{link_open}{bold}click here{reset_bold}{link_close}"
- bright_blue = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['br:blue']}{ANSI.END}"
- assert FormatCodes.to_ansi(f"[link:{url}|br:blue](click here)"
- ) == (f"{link_open}{bright_blue}click here{reset_color}{link_close}")
-
- # LINK WITHOUT DISPLAY BRACES IS INVALID (LEFT AS-IS)
- assert FormatCodes.to_ansi(f"[link:{url}]") == f"[link:{url}]"
-
- # ESCAPE: LINK SHOULD BE ESCAPED
- assert FormatCodes.escape(f"[link:{url}](click here)") == f"[/link:{url}](click here)"
- assert FormatCodes.escape(f"[link:{url}|b](click here)") == f"[/link:{url}|b](click here)"
-
- # REMOVE: OSC SEQUENCES FROM LINK SHOULD BE STRIPPED, LEAVING ONLY DISPLAY TEXT
- assert FormatCodes.remove(f"[link:{url}](click here)") == "click here"
- assert FormatCodes.remove_ansi(f"{link_open}click here{link_close}") == "click here"
-
-
-def test_remove_ansi():
- ansi_string = f"{bold}Hello {orange}World!{reset}"
- clean_string = "Hello World!"
- assert FormatCodes.remove_ansi(ansi_string) == clean_string
-
-
-def test_remove_ansi_with_removals():
- ansi_string = f"{bold}Hello\n{orange}World!{reset}"
- clean_string = "Hello\nWorld!"
- removals = ((0, bold), (6, orange), (12, reset))
- assert FormatCodes.remove_ansi(ansi_string, get_removals=True) == (clean_string, removals)
- removals = ((0, bold), (5, orange), (11, reset))
- assert FormatCodes.remove_ansi(ansi_string, get_removals=True, _ignore_linebreaks=True) == (clean_string, removals)
-
-
-def test_remove_formatting():
- format_string = "[b](Hello [#F87](World!))"
- clean_string = "Hello World!"
- assert FormatCodes.remove(format_string) == clean_string
-
-
-def test_remove_formatting_with_removals():
- format_string = "[b](Hello [#F87](World!))"
- clean_string = "Hello World!"
- removals = ((0, default), (0, bold), (6, orange), (12, default), (12, reset_bold))
- assert FormatCodes.remove(format_string, default_color="#FFF", get_removals=True) == (clean_string, removals)
- format_string = "[b](Hello)\n[#F87](World!)"
- clean_string = "Hello\nWorld!"
- removals = ((0, default), (0, bold), (5, reset_bold), (6, orange), (12, default))
- assert FormatCodes.remove(format_string, default_color="#FFF", get_removals=True) == (clean_string, removals)
- removals = ((0, default), (0, bold), (5, reset_bold), (5, orange), (11, default))
- assert FormatCodes.remove(
- format_string, default_color="#FFF", get_removals=True, _ignore_linebreaks=True
- ) == (clean_string, removals)
+def test_Format_and_F_are_same_object():
+ assert F is Format
diff --git a/tests/test_json.py b/tests/test_json.py
index 03614c8..63050f1 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -122,12 +122,12 @@ def test_read_empty_json(tmp_path: Path):
data = Json.read(str(file_path))
assert data == {}
except ValueError as e:
- assert "empty or contains only comments" in str(e)
+ assert "contains no data" in str(e)
def test_read_comment_only_json(tmp_path: Path):
file_path = create_test_json_string(tmp_path, "comment_only.json", '{\n">>": "comment"\n}')
- with pytest.raises(ValueError, match="empty or contains only comments"):
+ with pytest.raises(ValueError, match="contains no data"):
Json.read(str(file_path))
diff --git a/tests/test_metadata_consistency.py b/tests/test_metadata_consistency.py
index 8c0cde9..2e30339 100644
--- a/tests/test_metadata_consistency.py
+++ b/tests/test_metadata_consistency.py
@@ -23,7 +23,13 @@ def get_current_branch() -> Optional[str]:
# FALLBACK TO GIT COMMAND FOR LOCAL DEV
try:
- result = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True, check=True)
+ result = subprocess.run(
+ ["git", "branch", "--show-current"],
+ stdin=subprocess.DEVNULL,
+ capture_output=True,
+ text=True,
+ check=True,
+ )
return result.stdout.strip() or None
except (subprocess.CalledProcessError, FileNotFoundError):
return None
diff --git a/tests/test_regex.py b/tests/test_regex.py
index f47d276..0b28fbc 100644
--- a/tests/test_regex.py
+++ b/tests/test_regex.py
@@ -169,7 +169,7 @@ def test_regex_brackets_as_group():
def test_regex_brackets_ignore_in_strings():
"""Test brackets pattern with ignore_in_strings option"""
- text = 'func(param = "f(x)")'
+ text = 'fn(param = "f(x)")'
pattern = Regex.brackets(ignore_in_strings=True)
matches = rx.findall(pattern, text)
assert len(matches) == 1