diff --git a/LICENSE b/LICENSE index 50e6dbe..408a11f 100644 --- a/LICENSE +++ b/LICENSE @@ -26,4 +26,4 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 1aba38f..f26f6e4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,8 @@ include LICENSE +include README.md +include requirements.txt +include MANIFEST.in +recursive-include cli_ui *.py +recursive-include examples *.py +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b67438 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# python-cli-ui + +[![Python](https://img.shields.io/pypi/pyversions/cli-ui.svg)](https://pypi.org/project/cli-ui) +[![PyPI](https://img.shields.io/pypi/v/cli-ui.svg)](https://pypi.org/project/cli-ui/) +[![License](https://img.shields.io/github/license/your-tools/python-cli-ui.svg)](https://github.com/your-tools/python-cli-ui/blob/main/LICENSE) + +Tools for nice user interfaces in the terminal. + +## Note + +This project was originally hosted on the [TankerHQ](https://github.com/TankerHQ) organization, which was my employer from 2016 to 2021. They kindly agreed to give back ownership of this project to me. Thanks! + +## Features + +- **Colored output**: Easy to use color constants and formatting +- **User input**: Ask questions, get choices, passwords +- **Progress indication**: Show progress with dots, counters, and progress bars +- **Tables**: Display data in nicely formatted tables +- **Timers**: Time operations with context managers or decorators +- **Cross-platform**: Works on Linux, macOS, and Windows + +## Installation + +```bash +pip install cli-ui +``` + +## Quick Start + +```python +import cli_ui + +# Coloring +cli_ui.info( + "This is", cli_ui.red, "red", cli_ui.reset, + "and this is", cli_ui.bold, "bold" +) + +# User input +with_sugar = cli_ui.ask_yes_no("With sugar?", default=False) + +fruits = ["apple", "orange", "banana"] +selected_fruit = cli_ui.ask_choice("Choose a fruit", choices=fruits) + +# Progress +list_of_things = ["foo", "bar", "baz"] +for i, thing in enumerate(list_of_things): + cli_ui.info_count(i, len(list_of_things), thing) + +# Tables +headers = ["name", "score"] +data = [ + [("John", cli_ui.bold), (10, cli_ui.green)], + [("Jane", cli_ui.bold), (5, cli_ui.green)], +] +cli_ui.info_table(data, headers=headers) +``` + +## API Overview + +### Colors and Formatting + +Available colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white` +Available formatting: `bold`, `faint`, `standout`, `underline`, `blink`, `overline`, `reset` + +### Information Functions + +- `info(*tokens, **kwargs)` - Print informative message +- `info_1(*tokens, **kwargs)` - Important info with "::" prefix +- `info_2(*tokens, **kwargs)` - Secondary info with "=>" prefix +- `info_3(*tokens, **kwargs)` - Detailed info with "*" prefix +- `info_section(*tokens, **kwargs)` - Section header with underline +- `debug(*tokens, **kwargs)` - Debug message (only shown if verbose=True) + +### Error Functions + +- `warning(*tokens, **kwargs)` - Print warning message +- `error(*tokens, **kwargs)` - Print error message +- `fatal(*tokens, exit_code=1, **kwargs)` - Print error and exit + +### Progress Functions + +- `dot(last=False, fileobj=sys.stdout)` - Print progress dot +- `info_count(i, n, *rest, **kwargs)` - Show counter with message +- `info_progress(prefix, value, max_value)` - Show percentage progress + +### User Input Functions + +- `ask_string(*question, default=None)` - Ask for string input +- `ask_password(*question)` - Ask for password (hidden input) +- `ask_yes_no(*question, default=False)` - Ask yes/no question +- `ask_choice(*prompt, choices, func_desc=None, sort=True)` - Choose from list +- `select_choices(*prompt, choices, func_desc=None, sort=True)` - Select multiple + +### Table Functions + +- `info_table(data, headers=(), fileobj=sys.stdout)` - Display formatted table + +### Utility Functions + +- `indent(text, num=2)` - Indent text by number of spaces +- `tabs(num)` - Generate tab spacing +- `did_you_mean(message, user_input, choices)` - Suggest corrections + +### Configuration + +```python +cli_ui.setup( + verbose=False, # Show debug messages + quiet=False, # Hide info messages + color="auto", # Color mode: "auto", "always", "never" + title="auto", # Terminal title updates + timestamp=False # Add timestamps to messages +) +``` + +### Timer Context Manager + +```python +# As context manager +with cli_ui.Timer("doing something"): + do_something() + +# As decorator +@cli_ui.Timer("processing") +def process_data(): + # ... processing code + pass +``` + +## Testing Support + +For testing code that uses cli-ui: + +```python +from cli_ui.tests import MessageRecorder + +def test_my_function(message_recorder): + message_recorder.start() + my_function() # calls cli_ui.info("something") + assert message_recorder.find("something") + message_recorder.stop() +``` + +## Examples + +See the `examples/` directory for: +- Basic usage examples +- Threading examples +- Async/await examples + +## Requirements + +- Python 3.7+ +- colorama>=0.4.1 +- tabulate>=0.8.3 +- unidecode>=1.0.23 + +## License + +BSD 3-Clause License. See LICENSE file for details. + +## Contributing + +Contributions welcome! Please see the contributing guidelines in the repository. \ No newline at end of file diff --git a/cli_ui/__init__.py b/cli_ui/__init__.py index ccd20bd..317f35c 100644 --- a/cli_ui/__init__.py +++ b/cli_ui/__init__.py @@ -7,7 +7,6 @@ import io import os import re -import shutil import sys import time import traceback @@ -23,14 +22,14 @@ # Global variable to store configuration -CONFIG: Dict[str, ConfigValue] = { +CONFIG = { "verbose": os.environ.get("VERBOSE"), "quiet": False, "color": "auto", "title": "auto", "timestamp": False, "record": False, # used for testing -} +} # type: Dict[str, ConfigValue] # used for testing @@ -189,7 +188,7 @@ def process_tokens( """ # Flatten the list of tokens in case some of them are of # class UnicodeSequence: - flat_tokens: List[Token] = [] + flat_tokens = [] # type: List[Token] for token in tokens: if isinstance(token, UnicodeSequence): flat_tokens.extend(token.tuple()) @@ -331,14 +330,7 @@ def dot(*, last: bool = False, fileobj: FileObj = sys.stdout) -> None: info(".", end=end, fileobj=fileobj) -def erase_last_line() -> None: - terminal_size = shutil.get_terminal_size() - info(" " * terminal_size.columns, end="\r") - - -def info_count( - i: int, n: int, *rest: Token, one_line: bool = False, **kwargs: Any -) -> None: +def info_count(i: int, n: int, *rest: Token, **kwargs: Any) -> None: """Display a counter before the rest of the message. ``rest`` and ``kwargs`` are passed to :func:`info` @@ -347,14 +339,10 @@ def info_count( :param i: current index :param n: total number of items - :param one_line: force all messages to be printed on one line """ num_digits = len(str(n)) counter_format = "(%{}d/%d)".format(num_digits) counter_str = counter_format % (i + 1, n) - if one_line: - kwargs["end"] = "\r" - erase_last_line() info(green, "*", reset, counter_str, reset, *rest, **kwargs) @@ -603,11 +591,7 @@ def select_choices( continue try: - res = ( - list(itemgetter(*index)(choices)) - if len(index) > 1 - else [itemgetter(*index)(choices)] - ) + res = list(itemgetter(*index)(choices)) except Exception: info("Please enter valid selection number(s)") continue @@ -723,21 +707,11 @@ def main_demo() -> None: info() info_section(bold, "progress info") - info_2("3 things") list_of_things = ["foo", "bar", "baz"] for i, thing in enumerate(list_of_things): info_count(i, len(list_of_things), thing) info() - info_2("3 other things on one line") - list_of_things = ["spam", "eggs", "butter"] - for i, thing in enumerate(list_of_things): - time.sleep(0.5) - info_count(i, len(list_of_things), thing, one_line=True) - info() - - info() - info_2("Doing someting that takes some time") time.sleep(0.5) info_progress("Doing something", 5, 20) time.sleep(0.5) @@ -761,4 +735,4 @@ def main() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/cli_ui/__main__.py b/cli_ui/__main__.py new file mode 100644 index 0000000..36a3093 --- /dev/null +++ b/cli_ui/__main__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +""" +Main entry point for cli_ui module when called with python -m cli_ui +""" + +from . import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/cli_ui/tests/__init__.py b/cli_ui/tests/__init__.py index ccf7674..4cb8eac 100644 --- a/cli_ui/tests/__init__.py +++ b/cli_ui/tests/__init__.py @@ -1 +1 @@ -from .conftest import MessageRecorder # noqa +from .conftest import MessageRecorder # noqa \ No newline at end of file diff --git a/cli_ui/tests/conftest.py b/cli_ui/tests/conftest.py index b9d407a..1ad816a 100644 --- a/cli_ui/tests/conftest.py +++ b/cli_ui/tests/conftest.py @@ -43,4 +43,4 @@ def message_recorder(request: Any) -> Iterator[MessageRecorder]: recorder = MessageRecorder() recorder.start() yield recorder - recorder.stop() + recorder.stop() \ No newline at end of file diff --git a/cli_ui/tests/test_cli_ui.py b/cli_ui/tests/test_cli_ui.py index ba83ba3..38bcf59 100644 --- a/cli_ui/tests/test_cli_ui.py +++ b/cli_ui/tests/test_cli_ui.py @@ -345,13 +345,6 @@ def func_desc(fruit: Fruit) -> str: assert m.call_count == 3 -def test_select_choices_single_select() -> None: - with mock.patch("builtins.input") as m: - m.side_effect = ["1"] - res = cli_ui.select_choices("Select a animal", choices=["cat", "dog", "cow"]) - assert res == ["cat"] - - def test_select_choices_empty_input() -> None: with mock.patch("builtins.input") as m: m.side_effect = [""] @@ -425,4 +418,4 @@ def foo() -> None: cli_ui.red, "error when fooing\n", "ZeroDivisionError", - ) + ) \ No newline at end of file diff --git a/examples/asynchronous.py b/examples/asynchronous.py index 2f114a8..93e00b3 100644 --- a/examples/asynchronous.py +++ b/examples/asynchronous.py @@ -43,4 +43,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main()) \ No newline at end of file diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..b9020a8 --- /dev/null +++ b/examples/basic_usage.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Basic usage examples for cli-ui library +""" + +import cli_ui +import time + + +def basic_colors_demo(): + """Demonstrate basic color and formatting features""" + cli_ui.info_section("Color and Formatting Demo") + + # Basic colors + cli_ui.info("This is", cli_ui.red, "red", cli_ui.reset, "text") + cli_ui.info("This is", cli_ui.green, "green", cli_ui.reset, "text") + cli_ui.info("This is", cli_ui.blue, "blue", cli_ui.reset, "text") + cli_ui.info("This is", cli_ui.yellow, "yellow", cli_ui.reset, "text") + + # Formatting + cli_ui.info("This is", cli_ui.bold, "bold", cli_ui.reset, "text") + cli_ui.info("This is", cli_ui.underline, "underlined", cli_ui.reset, "text") + + # Special characters + cli_ui.info("Success", cli_ui.check) + cli_ui.info("Failed", cli_ui.cross) + cli_ui.info("Loading", cli_ui.ellipsis) + + +def message_levels_demo(): + """Demonstrate different message levels""" + cli_ui.info_section("Message Levels Demo") + + cli_ui.info_1("Important information") + cli_ui.info_2("Secondary information") + cli_ui.info_3("Detailed information") + cli_ui.warning("This is a warning") + cli_ui.error("This is an error") + + # Debug messages (only shown if verbose=True) + cli_ui.setup(verbose=True) + cli_ui.debug("This is a debug message") + cli_ui.setup(verbose=False) + + +def progress_demo(): + """Demonstrate progress indicators""" + cli_ui.info_section("Progress Demo") + + # Dot progress + cli_ui.info("Processing", end="") + for i in range(5): + time.sleep(0.2) + cli_ui.dot(last=(i == 4)) + + # Counter progress + items = ["item1", "item2", "item3", "item4", "item5"] + for i, item in enumerate(items): + cli_ui.info_count(i, len(items), "Processing", item) + time.sleep(0.1) + + # Percentage progress + for i in range(0, 21, 5): + cli_ui.info_progress("Downloading", i, 20) + time.sleep(0.1) + cli_ui.info() # New line after progress + + +def table_demo(): + """Demonstrate table formatting""" + cli_ui.info_section("Table Demo") + + # Simple table with headers + headers = ["Name", "Age", "Score"] + data = [ + [(cli_ui.bold, "Alice"), (25,), (cli_ui.green, 95)], + [(cli_ui.bold, "Bob"), (30,), (cli_ui.yellow, 78)], + [(cli_ui.bold, "Charlie"), (28,), (cli_ui.red, 65)], + ] + cli_ui.info_table(data, headers=headers) + + # Dictionary-based table + cli_ui.info() + dict_data = { + (cli_ui.bold, "Product"): [(cli_ui.blue, "Widget A"), (cli_ui.blue, "Widget B")], + (cli_ui.bold, "Price"): [(cli_ui.green, "$19.99"), (cli_ui.green, "$29.99")], + (cli_ui.bold, "Stock"): [(50,), (23,)], + } + cli_ui.info_table(dict_data, headers="keys") + + +def user_input_demo(): + """Demonstrate user input functions (interactive)""" + cli_ui.info_section("User Input Demo") + + # Ask for string + name = cli_ui.ask_string("What's your name?", default="Anonymous") + cli_ui.info("Hello,", cli_ui.green, name) + + # Ask yes/no + likes_pizza = cli_ui.ask_yes_no("Do you like pizza?", default=True) + if likes_pizza: + cli_ui.info("Great! Pizza is awesome", cli_ui.check) + else: + cli_ui.info("That's okay, more for us!", cli_ui.cross) + + # Ask choice + colors = ["red", "green", "blue", "yellow"] + favorite_color = cli_ui.ask_choice("Pick your favorite color", choices=colors) + if favorite_color: + cli_ui.info("Your favorite color is", getattr(cli_ui, favorite_color), favorite_color) + + # Multiple selection + fruits = ["apple", "banana", "orange", "grape", "kiwi"] + selected_fruits = cli_ui.select_choices("Select fruits you like", choices=fruits) + if selected_fruits: + cli_ui.info("You selected:", ", ".join(selected_fruits)) + + +def timer_demo(): + """Demonstrate timer functionality""" + cli_ui.info_section("Timer Demo") + + # Timer as context manager + with cli_ui.Timer("Context manager timer"): + time.sleep(1) + + # Timer as decorator + @cli_ui.Timer("Decorated function") + def slow_function(): + time.sleep(0.5) + return "result" + + result = slow_function() + cli_ui.info("Function returned:", result) + + +def error_handling_demo(): + """Demonstrate error handling and suggestions""" + cli_ui.info_section("Error Handling Demo") + + # Did you mean functionality + valid_commands = ["start", "stop", "restart", "status"] + user_input = "stat" + + suggestion = cli_ui.did_you_mean("Invalid command", user_input, valid_commands) + cli_ui.info(suggestion) + + # Exception formatting + try: + x = 1 / 0 + except Exception as e: + tokens = cli_ui.message_for_exception(e, "Mathematical error occurred") + cli_ui.error(*tokens) + + +def configuration_demo(): + """Demonstrate configuration options""" + cli_ui.info_section("Configuration Demo") + + # Timestamp + cli_ui.setup(timestamp=True) + cli_ui.info("This message has a timestamp") + cli_ui.setup(timestamp=False) + + # Quiet mode + cli_ui.info("This will be shown") + cli_ui.setup(quiet=True) + cli_ui.info("This will be hidden") + cli_ui.error("But errors are still shown") + cli_ui.setup(quiet=False) + + # Color mode + cli_ui.setup(color="always") + cli_ui.info("Colors are", cli_ui.red, "always", cli_ui.reset, "on") + cli_ui.setup(color="auto") # Reset to auto + + +def main(): + """Run all demos""" + cli_ui.info_section(cli_ui.bold, "CLI-UI Library Demo") + + demos = [ + ("Basic Colors and Formatting", basic_colors_demo), + ("Message Levels", message_levels_demo), + ("Progress Indicators", progress_demo), + ("Table Formatting", table_demo), + ("Timer Functionality", timer_demo), + ("Error Handling", error_handling_demo), + ("Configuration Options", configuration_demo), + ] + + for name, demo_func in demos: + cli_ui.info() + cli_ui.info_2("Running:", name) + demo_func() + time.sleep(0.5) + + # Interactive demo (optional) + cli_ui.info() + if cli_ui.ask_yes_no("Run interactive user input demo?", default=False): + user_input_demo() + + cli_ui.info() + cli_ui.info_1("Demo complete!", cli_ui.check) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/threads.py b/examples/threads.py index 7f1979f..618c212 100644 --- a/examples/threads.py +++ b/examples/threads.py @@ -52,4 +52,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 442c682..bd7c1e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,45 +1,56 @@ -[project] -name = "cli-ui" -version = "0.19.0" -description = "Build Nice User Interfaces In The Terminal" -readme = "README.rst" -authors = [{ name = "Dimitri Merejkowsky", email = "dimitri@dmerej.info" }] -license = "BSD-3-Clause" -repository = "https://git.sr.ht/~your-tools/python-cli-ui" -documentation = "https://your-tools.github.io/python-cli-ui/" -dynamic = ["requires-python", "dependencies"] - [tool.isort] profile = "black" -[tool.poetry.dependencies] -python = "^3.9" - -colorama = "^0.4.1" -tabulate = "^0.9.0" -unidecode = "^1.3.6" - -[tool.poetry.group.dev.dependencies] -# Tests -pytest = "^8.3" -pytest-cov = "^6.0.0" - -# Linters -black = "^25" -flake8 = "^7.1" -flake8-bugbear = "^24.12" -flake8-comprehensions = "^3.10.0" -pep8-naming = "^0.14.1" -isort = "^6.0" -types-tabulate = "^0.9.0" -mypy = "0.991" - -# Documentation -sphinx = "^7.4" -sphinx-autobuild = "2024.10.3" -ghp-import = "^2.1.0" - - [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "cli-ui" +version = "0.17.2" +description = "Build Nice User Interfaces In The Terminal" +readme = "README.md" +authors = [{name = "Dimitri Merejkowsky", email = "dimitri@dmerej.info"}] +license = {text = "BSD-3-Clause"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +requires-python = ">=3.7" +dependencies = [ + "colorama>=0.4.1", + "tabulate>=0.8.3", + "unidecode>=1.0.23", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov>=3.0.0", + "black>=22.3", + "flake8>=5.0", + "flake8-bugbear>=22.7", + "flake8-comprehensions>=3.10.0", + "pep8-naming>=0.13.1", + "isort>=5.7.0", + "mypy>=0.960", +] + +[project.urls] +Homepage = "https://github.com/your-tools/python-cli-ui" +Documentation = "https://your-tools.github.io/python-cli-ui/" +Repository = "https://github.com/your-tools/python-cli-ui.git" +Changelog = "https://your-tools.github.io/python-cli-ui/changelog.html" +Issues = "https://github.com/your-tools/python-cli-ui/issues" + +[project.scripts] +cli-ui = "cli_ui:main" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0da0b7f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +colorama>=0.4.1 +tabulate>=0.8.3 +unidecode>=1.0.23 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..806d1a7 --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-ui", + version="0.17.2", + author="Dimitri Merejkowsky", + author_email="dimitri@dmerej.info", + description="Build Nice User Interfaces In The Terminal", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/your-tools/python-cli-ui", + packages=find_packages(), + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.7", + install_requires=[ + "colorama>=0.4.1", + "tabulate>=0.8.3", + "unidecode>=1.0.23", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=3.0.0", + "black>=22.3", + "flake8>=5.0", + "flake8-bugbear>=22.7", + "flake8-comprehensions>=3.10.0", + "pep8-naming>=0.13.1", + "isort>=5.7.0", + "mypy>=0.960", + ] + }, + entry_points={ + "console_scripts": [ + "cli-ui=cli_ui:main", + ], + }, +) \ No newline at end of file