diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml
index 8a13209..67d23fd 100644
--- a/.github/workflows/build-and-publish.yml
+++ b/.github/workflows/build-and-publish.yml
@@ -1,7 +1,7 @@
# THIS WORKFLOW WILL BUILD WHEELS FOR ALL MAJOR PLATFORMS AND UPLOAD THEM TO PYPI
# TO BUILD AND INSTALL LOCALLY FOR TESTING, RUN THE FOLLOWING COMMAND:
-# pip install "/path/to/python-lib-xulbux" --no-deps --no-cache-dir --force-reinstall --no-build-isolation
+# py -m pip install "/path/to/python-lib-xulbux" --no-deps --no-cache-dir --force-reinstall -vv
# TO CREATE A NEW RELEASE, TAG A COMMIT WITH THE FOLLOWING FORMAT:
# git tag v1.X.Y
@@ -36,8 +36,7 @@ jobs:
env:
CIBW_BUILD: cp310-* cp311-* cp312-* cp313-* cp314-*
CIBW_SKIP: "*-musllinux_*"
- CIBW_BEFORE_BUILD: pip install setuptools>=80.0.0 wheel>=0.45.0 mypy>=1.19.0 mypy-extensions>=1.1.0 types-regex types-keyboard prompt_toolkit>=3.0.41
- CIBW_BUILD_FRONTEND: "pip; args: --no-build-isolation"
+ CIBW_BUILD_FRONTEND: pip
CIBW_ENVIRONMENT: XULBUX_USE_MYPYC=1
- name: Verify wheels were built
diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml
index de90d54..b4e7a91 100644
--- a/.github/workflows/test-and-lint.yml
+++ b/.github/workflows/test-and-lint.yml
@@ -39,7 +39,7 @@ jobs:
- name: Install project and dependencies
run: |
python -m pip install --upgrade pip
- pip install -e .[dev]
+ pip install .[dev]
pip install flake8 flake8-pyproject pyright pytest
- name: Lint with flake8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5716f42..96039aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,17 @@
#
Changelog
+
+
+## ... `v1.9.7`
+
+* Created a new CLI command `xulbux-fc`, which allows you to parse and render a given 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.
+
+
## 13.04.2026 `v1.9.6`
diff --git a/README.md b/README.md
index d234d3e..6e5125f 100644
--- a/README.md
+++ b/README.md
@@ -34,9 +34,11 @@ pip install --upgrade xulbux
## CLI Commands
When the library is installed, the following commands are available in the console:
-| Command | Description |
-| :------------ | :--------------------------------------- |
-| `xulbux-help` | shows some information about the library |
+
+| Command | Description |
+| :------------ | :--------------------------------------------------------------- |
+| `xulbux-help` | Show some information about the library. |
+| `xulbux-fc` | Parse and render a string's format codes as ANSI console output. |
diff --git a/pyproject.toml b/pyproject.toml
index 037b4e0..5ada6e2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,4 @@
[build-system]
-# SAME BUILD-DEPS ALSO NEED TO BE SPECIFIED IN CIBW_BEFORE_BUILD IN .github/workflows/build-and-publish.yml
requires = [
"setuptools>=80.0.0",
"wheel>=0.45.0",
@@ -14,7 +13,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "xulbux"
-version = "1.9.6"
+version = "1.9.7"
description = "A Python library to simplify common programming tasks."
readme = "README.md"
authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }]
@@ -117,6 +116,7 @@ keywords = [
[project.scripts]
xulbux-help = "xulbux.cli.help:show_help"
+xulbux-fc = "xulbux.cli.tools:render_format_codes"
[tool.flake8]
max-complexity = 12
diff --git a/setup.py b/setup.py
index deb0e04..3a84219 100644
--- a/setup.py
+++ b/setup.py
@@ -9,6 +9,8 @@
def find_python_files(directory: str) -> list[str]:
python_files: list[str] = []
for file in Path(directory).rglob("*.py"):
+ if file.name == "__init__.py":
+ continue
python_files.append(str(file))
return python_files
@@ -41,13 +43,9 @@ def generate_stubs_for_package():
or str(Path(sys.executable).parent / ("stubgen.exe" if sys.platform == "win32" else "stubgen"))
)
result = subprocess.run(
- [stubgen_exe,
- str(py_file),
- "-o", "src",
- "--include-private",
- "--export-less"],
+ [stubgen_exe, str(py_file), "-o", "src", "--include-private", "--export-less"],
capture_output=True,
- text=True
+ text=True,
)
if result.returncode == 0:
diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py
index 16e5bfd..6c19cbe 100644
--- a/src/xulbux/__init__.py
+++ b/src/xulbux/__init__.py
@@ -1,5 +1,5 @@
__package_name__ = "xulbux"
-__version__ = "1.9.6"
+__version__ = "1.9.7"
__description__ = "A Python library to simplify common programming tasks."
__status__ = "Production/Stable"
diff --git a/src/xulbux/base/__init__.py b/src/xulbux/base/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/xulbux/base/exceptions.py b/src/xulbux/base/exceptions.py
index 4b0d5d6..0a3ed3e 100644
--- a/src/xulbux/base/exceptions.py
+++ b/src/xulbux/base/exceptions.py
@@ -4,6 +4,8 @@
from .decorators import mypyc_attr
+# yapf: disable
+
################################################## FILE ##################################################
diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py
index b61c42e..af8cc2d 100644
--- a/src/xulbux/base/types.py
+++ b/src/xulbux/base/types.py
@@ -5,6 +5,8 @@
from typing import Annotated, TypeAlias, TypedDict, Optional, Protocol, Literal, Union, Any
from pathlib import Path
+# yapf: disable
+
################################################## Annotated ##################################################
diff --git a/src/xulbux/cli/__init__.py b/src/xulbux/cli/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/xulbux/cli/tools.py b/src/xulbux/cli/tools.py
new file mode 100644
index 0000000..b991717
--- /dev/null
+++ b/src/xulbux/cli/tools.py
@@ -0,0 +1,22 @@
+from ..format_codes import FormatCodes
+from ..console import Console
+
+
+def render_format_codes():
+ args = Console.get_args({"input": "before"})
+
+ if not args.input.values:
+ 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(args.input.values))
+ 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/console.py b/src/xulbux/console.py
index d63f1ce..682d31b 100644
--- a/src/xulbux/console.py
+++ b/src/xulbux/console.py
@@ -98,6 +98,17 @@ 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)
+ 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
+ -------------------------------------------------------------------
+ Returns the value at `index` if it exists, otherwise `default`."""
+ if 0 <= index < len(self.values):
+ return self.values[index]
+ return default
+
@mypyc_attr(native_class=False)
class ParsedArgs:
diff --git a/src/xulbux/data.py b/src/xulbux/data.py
index f7e6109..fea05c9 100644
--- a/src/xulbux/data.py
+++ b/src/xulbux/data.py
@@ -232,12 +232,15 @@ def remove_comments(
if len(comment_start) == 0:
raise ValueError("The 'comment_start' parameter string must not be empty.")
- return cast(DataObj, _DataRemoveCommentsHelper(
- data,
- comment_start=comment_start,
- comment_end=comment_end,
- comment_sep=comment_sep,
- )())
+ return cast(
+ DataObj,
+ _DataRemoveCommentsHelper(
+ data,
+ comment_start=comment_start,
+ comment_end=comment_end,
+ comment_sep=comment_sep,
+ )()
+ )
@classmethod
def is_equal(
diff --git a/tests/test_console.py b/tests/test_console.py
index 6eb03e7..8042fd8 100644
--- a/tests/test_console.py
+++ b/tests/test_console.py
@@ -249,7 +249,12 @@ def test_console_supports_color():
),
]
)
-def test_get_args(monkeypatch: pytest.MonkeyPatch, argv: list[str], arg_parse_configs: dict[str, Any], expected_parsed_args: dict[str, dict[str, Any]]):
+def test_get_args(
+ monkeypatch: pytest.MonkeyPatch,
+ argv: list[str],
+ arg_parse_configs: dict[str, Any],
+ expected_parsed_args: dict[str, dict[str, Any]],
+):
monkeypatch.setattr(sys, "argv", argv)
args_result = Console.get_args(arg_parse_configs)
assert isinstance(args_result, ParsedArgs)
@@ -369,6 +374,20 @@ def test_args_dunder_methods():
assert (args != ParsedArgs()) is True
+def test_parsed_arg_data_get():
+ data = ParsedArgData(exists=True, values=["first", "second", "third"], is_pos=False)
+ assert data.get(0) == "first"
+ assert data.get(1) == "second"
+ assert data.get(2) == "third"
+ assert data.get(3) is None
+ assert data.get(3, "fallback") == "fallback"
+ assert data.get(-1) is None
+
+ empty = ParsedArgData(exists=True, values=[], is_pos=False)
+ assert empty.get(0) is None
+ assert empty.get(0, "default") == "default"
+
+
def test_multiline_input(mock_prompt_toolkit: MagicMock, capsys: pytest.CaptureFixture[str]):
expected_input = "mocked multiline input"
result = Console.multiline_input("Enter text:", show_keybindings=True, default_color="#BCA")
@@ -737,7 +756,10 @@ def test_input_style_configuration(mock_prompt_session: tuple[MagicMock, MagicMo
assert call_kwargs["style"] is not None
-def test_input_validate_while_typing_enabled(mock_prompt_session: tuple[MagicMock, MagicMock], mock_formatcodes_print: MagicMock):
+def test_input_validate_while_typing_enabled(
+ mock_prompt_session: tuple[MagicMock, MagicMock],
+ mock_formatcodes_print: MagicMock,
+):
"""Test that validate_while_typing is enabled."""
mock_session_class, _ = mock_prompt_session
diff --git a/tests/test_data.py b/tests/test_data.py
index 85feda6..51bb54e 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -95,9 +95,9 @@ def test_strip(input_data: DataObj, expected_output: DataObj):
@pytest.mark.parametrize(
- "input_data, spaces_are_empty, expected_output", cast(
- list[tuple[DataObj, bool, DataObj]],
- [
+ "input_data, spaces_are_empty, expected_output",
+ cast(
+ list[tuple[DataObj, bool, DataObj]], [
(["a", "", "b", None, " "], False, ["a", "b", " "]),
(["a", "", "b", None, " "], True, ["a", "b"]),
(("a", "", "b", None, " "), False, ("a", "b", " ")),
@@ -218,7 +218,7 @@ def test_render(
max_width: int,
sep: str,
as_json: bool,
- expected_str: str
+ expected_str: str,
):
result = Data.render(
data,
@@ -227,7 +227,7 @@ def test_render(
max_width=max_width,
sep=sep,
as_json=as_json,
- syntax_highlighting=False
+ syntax_highlighting=False,
)
normalized_result = "\n".join(line.rstrip() for line in result.splitlines())
normalized_expected = "\n".join(line.rstrip() for line in expected_str.splitlines())
diff --git a/tests/test_file.py b/tests/test_file.py
index 50ecc27..87c1149 100644
--- a/tests/test_file.py
+++ b/tests/test_file.py
@@ -38,7 +38,13 @@
("no_dot_file", ".txt", True, True, "NoDotFile.txt"),
]
)
-def test_rename_extension(input_file: str | Path, new_extension: str, full_extension: bool, camel_case: bool, expected_output: str):
+def test_rename_extension(
+ input_file: str | Path,
+ new_extension: str,
+ full_extension: bool,
+ camel_case: bool,
+ expected_output: str,
+):
result = File.rename_extension(input_file, new_extension, full_extension=full_extension, camel_case_filename=camel_case)
assert isinstance(result, Path)
assert str(result) == expected_output