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