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