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..17d0b42 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",
@@ -7,14 +6,13 @@ requires = [
"mypy-extensions>=1.1.0",
# TYPES FOR MyPy
"types-regex",
- "types-keyboard",
"prompt_toolkit>=3.0.41",
]
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" }]
@@ -22,11 +20,7 @@ maintainers = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }]
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10.0"
-dependencies = [
- "keyboard>=0.13.5",
- "prompt_toolkit>=3.0.41",
- "regex>=2023.10.3",
-]
+dependencies = ["prompt_toolkit>=3.0.41", "regex>=2023.10.3"]
optional-dependencies = { dev = [
"flake8-pyproject>=1.2.3",
"flake8>=6.1.0",
@@ -117,6 +111,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..ed48776 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"
@@ -12,7 +12,6 @@
__requires_python__ = ">=3.10.0"
__dependencies__ = [
- "keyboard>=0.13.5",
"prompt_toolkit>=3.0.41",
"regex>=2023.10.3",
]
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..eea439a 100644
--- a/src/xulbux/console.py
+++ b/src/xulbux/console.py
@@ -23,7 +23,6 @@
import prompt_toolkit as _pt
import subprocess as _subprocess
import threading as _threading
-import keyboard as _keyboard
import getpass as _getpass
import ctypes as _ctypes
import shutil as _shutil
@@ -98,6 +97,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:
@@ -347,7 +357,7 @@ def pause_exit(
if reset_ansi:
FormatCodes.print("[_]", end="")
if pause:
- _keyboard.read_key(suppress=True)
+ cls._read_single_key()
if exit:
_sys.exit(exit_code)
@@ -971,6 +981,27 @@ def input(
return default_val
raise
+ @staticmethod
+ 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
+ _msvcrt.getch()
+ 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]
+
@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."""
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_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..0959413
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,139 @@
+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."""
+ 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"
+
+
+################################################## xulbux-help 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())
+
+ show_help()
+
+ captured = capsys.readouterr()
+ assert len(captured.out) > 0, "show_help() produced no output"
+
+
+def test_show_help_contains_version(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
+ """The help banner must contain the installed package version."""
+ from xulbux import __version__
+
+ monkeypatch.setattr("xulbux.console._read_single_key", MagicMock())
+
+ show_help()
+
+ captured = capsys.readouterr()
+ assert __version__ in captured.out
+
+
+def test_show_help_calls_pause_exit(monkeypatch: pytest.MonkeyPatch):
+ """show_help() must call Console.pause_exit to wait for a key press before exiting."""
+ mock_pause_exit = MagicMock()
+ monkeypatch.setattr("xulbux.cli.help.Console.pause_exit", mock_pause_exit)
+
+ show_help()
+
+ mock_pause_exit.assert_called_once()
+ call_kwargs = mock_pause_exit.call_args
+ # 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()
+
+ 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
diff --git a/tests/test_console.py b/tests/test_console.py
index 6eb03e7..71a796e 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")
@@ -400,33 +419,33 @@ def test_multiline_input_no_bindings(mock_prompt_toolkit: MagicMock, capsys: pyt
def test_pause_exit_pause_only(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
- mock_keyboard = MagicMock()
- monkeypatch.setattr("xulbux.console._keyboard.read_key", mock_keyboard)
+ mock_read_key = MagicMock()
+ monkeypatch.setattr("xulbux.console._read_single_key", mock_read_key)
Console.pause_exit("Press any key...", pause=True, exit=False)
captured = capsys.readouterr()
assert "Press any key..." in captured.out
- mock_keyboard.assert_called_once_with(suppress=True)
+ mock_read_key.assert_called_once_with()
def test_pause_exit_with_exit(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
- mock_keyboard = MagicMock()
+ mock_read_key = MagicMock()
mock_sys_exit = MagicMock()
- monkeypatch.setattr("xulbux.console._keyboard.read_key", mock_keyboard)
+ monkeypatch.setattr("xulbux.console._read_single_key", mock_read_key)
monkeypatch.setattr("xulbux.console._sys.exit", mock_sys_exit)
Console.pause_exit("Exiting...", pause=True, exit=True, exit_code=1)
captured = capsys.readouterr()
assert "Exiting..." in captured.out
- mock_keyboard.assert_called_once_with(suppress=True)
+ mock_read_key.assert_called_once_with()
mock_sys_exit.assert_called_once_with(1)
def test_pause_exit_reset_ansi(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]):
- mock_keyboard = MagicMock()
- monkeypatch.setattr("xulbux.console._keyboard.read_key", mock_keyboard)
+ mock_read_key = MagicMock()
+ monkeypatch.setattr("xulbux.console._read_single_key", mock_read_key)
Console.pause_exit(pause=True, exit=False, reset_ansi=True)
@@ -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