diff --git a/.vscode/settings.json b/.vscode/settings.json index 99ee539..4e70236 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,7 +24,7 @@ "commandCenter.border": "#15202b99" }, "peacock.color": "#61dafb", - "python.testing.pytestArgs": ["-rP -vv --color=yes tests"], + // "python.testing.pytestArgs": ["-rP -vv --color=yes tests"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python.testing.cwd": "${workspaceFolder}", diff --git a/README.md b/README.md index 31588f5..4b7f761 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Tinta is a magical console output tool with support for printing in beautiful colors and with rich formatting, like bold and underline, using static, chain-able methods. It's so pretty, it's almost like a unicorn! -![version](https://img.shields.io/badge/version-0.1.7b1-green.svg) [_![GitHub Actions Badge](https://img.shields.io/github/actions/workflow/status/brandonscript/tinta/run-tests.yml)_](https://github.com/brandonscript/tinta/actions) [_![Codacy Badge](https://app.codacy.com/project/badge/Grade/32bf3e3172cf434b914647f06569a836)_](https://www.codacy.com/gh/brandonscript/tinta/dashboard?utm_source=github.com&utm_medium=referral&utm_content=brandonscript/tinta&utm_campaign=Badge_Grade) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tinta) ![MIT License](https://img.shields.io/github/license/brandonscript/tinta) [_![](https://img.shields.io/badge/ethical-source-%23bb8c3c?labelColor=393162)_](https://img.shields.io/badge/ethical-source-%23bb8c3c?labelColor=393162) [_![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)_](code_of_conduct.md) +![version](https://img.shields.io/badge/version-0.1.7b2-green.svg) [_![GitHub Actions Badge](https://img.shields.io/github/actions/workflow/status/brandonscript/tinta/run-tests.yml)_](https://github.com/brandonscript/tinta/actions) [_![Codacy Badge](https://app.codacy.com/project/badge/Grade/32bf3e3172cf434b914647f06569a836)_](https://www.codacy.com/gh/brandonscript/tinta/dashboard?utm_source=github.com&utm_medium=referral&utm_content=brandonscript/tinta&utm_campaign=Badge_Grade) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/tinta) ![MIT License](https://img.shields.io/github/license/brandonscript/tinta) [_![](https://img.shields.io/badge/ethical-source-%23bb8c3c?labelColor=393162)_](https://img.shields.io/badge/ethical-source-%23bb8c3c?labelColor=393162) [_![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.0-4baaaa.svg)_](code_of_conduct.md) ## Features and Tinta Basics @@ -193,7 +193,7 @@ All "add" methods (each color and style method, `Tinta()`, `push()`, and `tint`) - `s (str)` – A sequence of one or more text strings, to be joined together. - `sep (str)` – Used to join segment strings. Defaults to `' '`. - > _Note: `sep` behavior has been changed in v0.1.7b1 - if passing a `sep` argument in `print()`, it will overwrite any segment's individual `sep` argument._ + > _Note: `sep` behavior has been changed in v0.1.7b2 - if passing a `sep` argument in `print()`, it will overwrite any segment's individual `sep` argument._ For example: @@ -228,7 +228,7 @@ All `Tinta` and dynamic color methods will make available the following attribut _See below for detailed usage and arguments._ -> (Note: breaking changes in v0.1.7b1 - several methods have been renamed for better semantics). +> (Note: breaking changes in v0.1.7b2 - several methods have been renamed for better semantics). - `print()` – Prints to the console. - `to_str() -> str` – Returns a joined text string. diff --git a/dist/tinta-0.1.6-py2.py3-none-any.whl b/dist/tinta-0.1.6-py2.py3-none-any.whl deleted file mode 100644 index 7a347b6..0000000 Binary files a/dist/tinta-0.1.6-py2.py3-none-any.whl and /dev/null differ diff --git a/dist/tinta-0.1.7b1-py2.py3-none-any.whl b/dist/tinta-0.1.7b1-py2.py3-none-any.whl new file mode 100644 index 0000000..802a667 Binary files /dev/null and b/dist/tinta-0.1.7b1-py2.py3-none-any.whl differ diff --git a/examples/complete_example.py b/examples/complete_example.py index 03e65ff..1a9b38f 100644 --- a/examples/complete_example.py +++ b/examples/complete_example.py @@ -98,20 +98,20 @@ # complex formatting t = Tinta() t.vanilla("vanilla").bold("bold", sep="\n") -t.reset() +t.clear() t.mint("mint").underline("underline", sep="\n") -t.reset() +t.clear() t.olive("olive").dim("dim", sep="\n") t.print(end=GAP) -# reset +# clear t = Tinta() t.vanilla("vanilla").bold("bold", sep="\n") -t.reset("plain text", sep="\n") +t.clear("plain text", sep="\n") t.mint("mint").underline("underline", sep="\n") t.olive("olive inherits underline", sep="\n").dim("dim inherits both", sep="\n") -t.reset("reset clears all", sep="\n") +t.clear("clear clears all", sep="\n") t.amber("so we can start fresh") t.print(end=GAP) diff --git a/pyproject.toml b/pyproject.toml index b7f96aa..1d1c526 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ [project] name = "tinta" -version = "0.1.7b1" +version = "0.1.7b2" description = "Tinta, a magical console output tool." authors = [{ name = "Brandon Shelley", email = "brandon@pacificaviator.co" }] license = "MIT" diff --git a/setup.py b/setup.py index 44e2982..66d1c02 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setup( name="tinta", - version="0.1.7b1", + version="0.1.7b2", description="Tinta, a magical console output tool.", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/test_tinta.py b/tests/test_tinta.py index 43455df..a924d1a 100644 --- a/tests/test_tinta.py +++ b/tests/test_tinta.py @@ -20,7 +20,7 @@ # shall take precedence. -from typing import List, Tuple +from typing import Any, Callable, List, Tuple import pytest @@ -36,52 +36,118 @@ def test_init(self): assert len(Tinta("initialized").parts) == 1 -class TestColors: +class TestBasicColorizing: - def test_green(self): - Tinta().green("green").print() - - def test_red(self): - Tinta().red("red").print() - - def test_blue(self): - Tinta().blue("Green").print() + @pytest.mark.parametrize( + "color,Testa,expected", + [ + # fmt: off + ("green", lambda: Tinta().green("green"), "\x1b[38;5;35mgreen\x1b[0m"), + ("green", lambda: Tinta().tint(35, "green"), "\x1b[38;5;35mgreen\x1b[0m"), + ("green", lambda: Tinta().tint("green", "green"), "\x1b[38;5;35mgreen\x1b[0m"), + ("red", lambda: Tinta().red("red"), "\x1b[38;5;1mred\x1b[0m"), + ("blue", lambda: Tinta().blue("blue"), "\x1b[38;5;32mblue\x1b[0m"), + ("blue", lambda: Tinta().blue("Green"), "\x1b[38;5;32mGreen\x1b[0m"), + ("light_blue", lambda: Tinta().light_blue("light_blue"), "\x1b[38;5;37mlight_blue\x1b[0m"), + ("yellow", lambda: Tinta().yellow("yellow"), "\x1b[38;5;214myellow\x1b[0m"), + ("amber", lambda: Tinta().amber("amber"), "\x1b[38;5;208mamber\x1b[0m"), + ("olive", lambda: Tinta().olive("olive"), "\x1b[38;5;106molive\x1b[0m"), + ("orange", lambda: Tinta().orange("orange"), "\x1b[38;5;166morange\x1b[0m"), + ("purple", lambda: Tinta().purple("purple"), "\x1b[38;5;18mpurple\x1b[0m"), + ("pink", lambda: Tinta().pink("pink"), "\x1b[38;5;197mpink\x1b[0m"), + ("gray", lambda: Tinta().gray("gray"), "\x1b[38;5;243mgray\x1b[0m"), + ("dark_gray", lambda: Tinta().dark_gray("dark_gray"), "\x1b[38;5;235mdark_gray\x1b[0m"), + ("light_gray", lambda: Tinta().light_gray("light_gray"), "\x1b[38;5;248mlight_gray\x1b[0m"), + ("white", lambda: Tinta().white("white"), "\x1b[38;5;255mwhite\x1b[0m"), + ("mint", lambda: Tinta().mint("Mint"), "\x1b[38;5;84mMint\x1b[0m") + # fmt: on + ], + ) + def test_colors(self, color, Testa, expected): + Testa().print() + assert Testa().to_str() == expected - def test_light_blue(self): - Tinta().light_blue("light_blue").print() - def test_yellow(self): - Tinta().yellow("yellow").print() +class TestChaining: - def test_amber(self): - Tinta().amber("amber").print() + @pytest.mark.parametrize( + "test_case, kwargs, expected", + [ + # fmt: off + (Tinta().green("green").red("red").blue("blue"), {"sep":"", "plaintext":True}, "greenredblue"), + (Tinta().green("green").red("red").blue("blue"), {"plaintext": True}, "green red blue"), + (Tinta().green("green").red("red").blue("blue"), {"sep": ""}, "\x1b[38;5;35mgreen\x1b[38;5;1mred\x1b[38;5;32mblue\x1b[0m"), + (Tinta().green("green").red("red").blue("blue"), {}, "\x1b[38;5;35mgreen \x1b[38;5;1mred \x1b[38;5;32mblue\x1b[0m"), + # fmt: on + ], + ) + def test_chaining_resets_correctly( + self, test_case: Tinta, kwargs: dict[str, Any], expected: str + ): + s = test_case.to_str(**kwargs) + test_case.print(**kwargs) + assert s == expected - def test_olive(self): - Tinta().olive("olive").print() + @pytest.mark.parametrize( + "Testa,expected", + [ + ( + lambda: Tinta().mint("Mint\nice cream"), + "\x1b[38;5;84mMint\nice cream\x1b[0m", + ), + ( + lambda: Tinta().mint("Mint").white("\nice cream\nis the")._("\nbest"), + "\x1b[38;5;84mMint\x1b[38;5;255m\nice cream\nis the\x1b[38;5;255;4m\nbest\x1b[0m", + ), + ( + lambda: Tinta("\n").mint("Mint\n").white("ice cream is the")._("best"), + "\n\x1b[38;5;84mMint\n\x1b[38;5;255mice cream is the \x1b[38;5;255;4mbest\x1b[0m", + ), + ], + ) + def test_newlines_in_chaining(self, Testa: Callable[[], Tinta], expected: str): + s = Testa().to_str() + Testa().print() + assert s == expected - def test_orange(self): - Tinta().orange("orange").print() - def test_purple(self): - Tinta().purple("purple").print() +class TestUnicode: - def test_pink(self): - Tinta().pink("pink").print() + def test_unicode(self): + Tinta().pink("I ♡ Unicorns").print() + assert ( + Tinta().pink("I ♡ Unicorns").to_str() == "\x1b[38;5;197mI ♡ Unicorns\x1b[0m" + ) - def test_gray(self): - Tinta().gray("gray").print() + def test_box_drawing(self): + s = f"┌{'─'*14}┐\n│ I ♡ Unicorns │\n└{'─'*14}┘" + Tinta("\n").mint(s).print() + assert ( + Tinta().mint(s).to_str() + == f"\x1b[38;5;84m┌──────────────┐\n│ I ♡ Unicorns │\n└──────────────┘\x1b[0m" + ) - def test_dark_gray(self): - Tinta().dark_gray("dark_gray").print() + def test_emoji(self): + Tinta("🦄").print() + assert Tinta("🦄").to_str() == "🦄" - def test_light_gray(self): - Tinta().light_gray("light_gray").print() + def test_emoji_with_color(self): + Tinta().purple("🦄").print() + assert Tinta().purple("🦄").to_str() == "\x1b[38;5;18m🦄\x1b[0m" - def test_black(self): - Tinta().black("black").print() + def test_emoji_with_color_and_clear(self): + Tinta().pink("🦄").clear("🦄").print() + assert ( + Tinta().pink("🦄").clear("🦄").to_str(sep="") + == "\x1b[38;5;197m🦄\x1b[0m🦄\x1b[0m" + ) - def test_white(self): - Tinta().white("white").print() + def test_emoji_with_color_and_clear_and_emoji(self): + Tinta().purple("🦄").clear("🦄").pink("🦄").print() + assert ( + Tinta().purple("🦄").clear("🦄").pink("🦄").to_str() + == "\x1b[38;5;18m🦄 \x1b[0m🦄 \x1b[38;5;197m🦄\x1b[0m" + ) class TestLowerLevel: @@ -111,13 +177,13 @@ def test_tint_takes_str_color_kwarg(self): class TestEdgeCases: - @pytest.mark.xfail() def test_missing_color(self): - Tinta().sparkle().print() + with pytest.raises(Exception): + Tinta().sparkle().print() - @pytest.mark.xfail() def test_color_same_as_builtin_method(self): - Tinta().load_colors("tests/test_colors_invalid.ini") + with pytest.raises(Exception): + Tinta().load_colors("tests/test_colors_invalid.ini") def test_print_empty(self): Tinta().print() @@ -141,6 +207,41 @@ def test_sep_inherits_prev_part_color(self): ) +class TestMultipleInstances: + + def test_same_instance_chains_to_str(self): + t = Tinta() + red = "\x1b[38;5;1mRed" + green = "\x1b[38;5;35mGreen" + blue = "\x1b[38;5;32mBlue" + assert t.red("Red").to_str() == f"{red}\x1b[0m" + assert t.green("Green").to_str() == f"{red} {green}\x1b[0m" + assert t.blue("Blue").to_str() == f"{red} {green} {blue}\x1b[0m" + + def test_same_instance_resets_after_print(self): + t = Tinta() + red = "\x1b[38;5;1mRed" + green = "\x1b[38;5;35mGreen" + blue = "\x1b[38;5;32mBlue" + assert t.red("Red").to_str() == f"{red}\x1b[0m" + t.print() + assert t.green("Green").to_str() == f"{green}\x1b[0m" + t.print() + assert t.blue("Blue").to_str() == f"{blue}\x1b[0m" + + def test_multiple_instances_dont_interfere(self): + t1 = Tinta() + t2 = Tinta() + t3 = Tinta() + red = "\x1b[38;5;1mRed" + green = "\x1b[38;5;35mGreen" + blue = "\x1b[38;5;32mBlue" + assert t1.red("Red").to_str() == f"{red}\x1b[0m" + assert t2.green("Green").to_str() == f"{green}\x1b[0m" + assert t3.blue("Blue").to_str() == f"{blue}\x1b[0m" + + +# @pytest.mark.skip class TestFeatures: def test_inspect(self): @@ -151,58 +252,37 @@ def test_inspect(self): assert Tinta().inspect(name="green") == 35 @pytest.mark.parametrize( - "test,expected", + "Testa,expected", [ - ("Hello world", "Hello world"), - (Tinta("Hello")("world").to_str(), "Hello world"), - ("Hello world", "Hello world"), - (Tinta("Hello ")("world").to_str(), "Hello world"), - ("Hello , world", "Hello , world"), - (Tinta("Hello")(", world").to_str(), "Hello, world"), - ("Hello world !", "Hello world !"), - (Tinta("Hello world")("!").to_str(), "Hello world!"), - ("Nice .", "Nice ."), - (Tinta("Nice")(".").to_str(), "Nice."), - ("Nice .gif file", "Nice .gif file"), - (Tinta("Nice")(".gif file").to_str(), "Nice .gif file"), - (" *** Important", " *** Important"), - (", world", ", world"), - (" , world", " , world"), - (Tinta(",")("world").to_str(), ", world"), + (lambda: Tinta("Hello")("world"), "Hello world"), + (lambda: Tinta("Hello ")("world"), "Hello world"), + (lambda: Tinta("Hello")(", world"), "Hello, world"), + (lambda: Tinta("Hello,")("world"), "Hello, world"), + (lambda: Tinta("Hello")(",")("world"), "Hello, world"), + (lambda: Tinta("Hello world")("!"), "Hello world!"), + (lambda: Tinta("Nice")("."), "Nice."), + (lambda: Tinta("Nice")(".gif file"), "Nice .gif file"), + (lambda: Tinta(" *** Important"), " *** Important"), + (lambda: Tinta(", world"), ", world"), + (lambda: Tinta(" , world"), " , world"), + (lambda: Tinta(",")("world"), ", world"), + (lambda: Tinta(" ,")("world"), " , world"), + (lambda: Tinta(" ,")(" world"), " , world"), ( - Tinta() + lambda: Tinta() .red(" *** Important : Smart fix punctuation should work with") .green("ANSI") - .red(", as well as plaintext.") - .to_str(), + .red(", as well as plaintext."), "\x1b[38;5;1m *** Important : Smart fix punctuation should work with \x1b[38;5;35mANSI\x1b[38;5;1m, as well as plaintext.\x1b[0m", ), ], ) - def test_smart_fix_punctuation(self, test: str, expected: str): - assert Tinta(test).to_str() == expected + def test_smart_fix_punctuation(self, Testa: Callable[[], Tinta], expected: str): + assert Testa().to_str() == expected class TestComplexStructure: - @pytest.mark.parametrize( - "inputs,expected", - [ - ( - ("How long", "can two people talk about nothing?"), - "How long can two people talk about nothing?", - ), - ( - (["How long", "can two people talk about nothing?"]), - "How long can two people talk about nothing?", - ), - ], - ) - def test_push(self, inputs: Tuple[str], expected): - t = Tinta().push(*inputs) - assert t.get_plaintext() == expected - assert t.to_str(plaintext=True) == expected - @pytest.mark.parametrize( "inputs,expected", [ @@ -248,7 +328,6 @@ def test_sep(self, inputs: List[Tuple[str, str]], expected: str) -> None: t = Tinta() for s, sep in inputs: t.push(s, sep=sep) - assert t.get_plaintext() == expected assert t.to_str(plaintext=True) == expected def testparts(self): @@ -273,30 +352,48 @@ def testparts(self): def test_f_strings(self): dog = "cat" assert ( - Tinta(f"A {dog} is a human's best friend").get_plaintext() + Tinta(f"A {dog} is a human's best friend").to_str(plaintext=True) == "A cat is a human's best friend" ) assert Tinta(f"A {Tinta().red('hologram').to_str()} is a human's best friend") def test_push(self): t = Tinta().push("How long").push("can two people talk about nothing?") - assert t.get_plaintext() == "How long can two people talk about nothing?" + assert t.to_str(plaintext=True) == "How long can two people talk about nothing?" assert len(t.parts) == 2 assert len(t.parts_fmt) == 2 assert len(t.parts_pln) == 2 t = Tinta().push("How long", "can two people talk about nothing?") - assert t.get_plaintext() == "How long can two people talk about nothing?" + assert t.to_str(plaintext=True) == "How long can two people talk about nothing?" assert len(t.parts) == 1 assert len(t.parts_fmt) == 1 assert len(t.parts_pln) == 1 + @pytest.mark.parametrize( + "inputs,expected", + [ + ( + ("How long", "can two people talk about nothing?"), + "How long can two people talk about nothing?", + ), + ( + (["How long", "can two people talk about nothing?"]), + "How long can two people talk about nothing?", + ), + ], + ) + def test_to_str(self, inputs: Tuple[str], expected): + t = Tinta().push(*inputs) + assert t.to_str(plaintext=True) == expected + assert t.to_str(plaintext=True) == expected + def test_pop(self): t = Tinta().push("How long").push("can two people talk about nothing?") t.pop() - assert t.get_plaintext() == "How long" + assert t.to_str(plaintext=True) == "How long" assert len(t.parts) == 1 assert len(t.parts_fmt) == 1 assert len(t.parts_pln) == 1 @@ -310,21 +407,52 @@ def test_pop(self): assert len(t.parts) == 0 - t.remove(2) # Shouldn't error if we remove more than we have + t.pop(2) # Shouldn't error if we remove more than we have assert len(t.parts) == 0 - def test_zero_handles_reset(self): + def test_zero_handles_clear(self): s = ( Tinta("White") .red("Red") .green("Green") .blue("Blue") - .tint(0, "Reset") + .tint(0, "Clear") .to_str(sep=" ") ) print(s) assert ( s - == "White \x1b[38;5;1mRed \x1b[38;5;35mGreen \x1b[38;5;32mBlue \x1b[0mReset\x1b[0m" + == "White \x1b[38;5;1mRed \x1b[38;5;35mGreen \x1b[38;5;32mBlue \x1b[0mClear\x1b[0m" ) + + +class TestUtils: + + @pytest.mark.parametrize( + "test_str,expected", + [ + (Tinta().blue("blue").to_str(), "blue"), + (Tinta().red("red").to_str(), "red"), + (Tinta().green("green").to_str(), "green"), + (Tinta().yellow("yellow").to_str(), "yellow"), + (Tinta().pink("🦄").to_str(), "🦄"), + ( + Tinta() + .pink("Pink unicorns 🦄") + .purple("are as good as purple 🦄 ones") + .to_str(), + "Pink unicorns 🦄 are as good as purple 🦄 ones", + ), + ( + Tinta() + .purple("Hello purple world!") + .clear("I've got a song to sing about") + ._("unicorns 🦄") + .to_str(), + "Hello purple world! I've got a song to sing about unicorns 🦄", + ), + ], + ) + def test_strip_ansi(self, test_str: str, expected: str): + assert Tinta.strip_ansi(test_str) == expected diff --git a/tinta/__init__.py b/tinta/__init__.py index 0d45dfa..e60376d 100644 --- a/tinta/__init__.py +++ b/tinta/__init__.py @@ -21,7 +21,7 @@ from logging import getLogger -__version__ = "0.1.7b1" +__version__ = "0.1.7b2" logger = getLogger(__name__) diff --git a/tinta/colorize.py b/tinta/colorize.py index 059d1b8..d4c1208 100644 --- a/tinta/colorize.py +++ b/tinta/colorize.py @@ -19,7 +19,13 @@ # MIT License. import re -from typing import List, Optional, Tuple, Union +from typing import Any, List, Optional, overload, Tuple, TYPE_CHECKING, Union + +from .constants import SEP +from .typ import MissingColorError + +if TYPE_CHECKING: + from .tinta import Tinta ANSI_RESET_HEX = "\x1b[0m" ANSI_RESET_OCT = "\033[0m" @@ -146,3 +152,66 @@ def colorize( return template.format(_join(*codes), s) else: return s + + +@overload +def tint( + instance: "Tinta", *s: Any, color: Union[str, int], sep: str = SEP +) -> "Tinta": ... + + +@overload +def tint( + instance: "Tinta", color: Union[str, int], *s: Any, sep: str = SEP +) -> "Tinta": ... + + +# pylint: disable=redefined-outer-name +def tint(instance: "Tinta", *args, **kwargs) -> "Tinta": + """Adds segments of text colored with the specified color. + Can be used in place of calling named color methods. + + Args: + instance (Tinta): The Tinta instance. + color (str | int, optional): A color name or ANSI color index. Defaults to first argument. + *s: Any: Segments of text to add. + sep (str, optional): Used to join strings. Defaults to ' '. + + Returns: + self + """ + + # color: Optional[Union[str, int]] = None, *s: Any, sep: str = SEP + + # check if the first argument is a known color or valid ANSI code, or comes from kwargs + self = instance + s = args + color = kwargs.get("color", None) + sep = kwargs.get("sep", SEP) + if color is None: + if not len(s) > 1: + raise AttributeError( + "If no color is specified, tint() requires at least two arguments." + ) + if args and isinstance(args[0], (str, int)): + color = s[0] + s = s[1:] + else: + raise AttributeError( + "Could not determine color from arguments. Either pass a color as the first argument, or use the color keyword argument." + ) + + # if color is numeric integer string, assume it's an ANSI color code + if isinstance(color, int) or (isinstance(color, str) and color.isdigit()): + self.color = int(color) + + # Check if color_name is a valid color if color is a string + if isinstance(color, str): + if not hasattr(self.colors, color): # type: ignore + raise MissingColorError( + f"Invalid color name: {color}. Is it in colors.ini?" + ) + self.color = self.colors.get(color) # type: ignore + + self.push(*s, sep=sep) + return self diff --git a/tinta/constants.py b/tinta/constants.py new file mode 100644 index 0000000..8c6b28a --- /dev/null +++ b/tinta/constants.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python + +# Tinta +# Copyright 2024 github.com/brandoncript + +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# If you use this software, you must also agree under the terms of the Hippocratic License 3.0 to not use this software in a way that directly or indirectly causes harm. You can find the full text of the license at https://firstdonoharm.dev. + +"""Tinta is a magical console output tool with support for printing in beautiful +colors and with rich formatting, like bold and underline. It's so pretty, +it's almost like a unicorn. +""" + +import os + +from .typ import parse_bool + +CURSOR_UP_ONE = "\x1b[1A" +ERASE_LINE = "\x1b[2K" +SEP = os.getenv("TINTA_SEPARATOR", " ") +STEALTH = parse_bool(os.getenv("TINTA_STEALTH", False)) +PREFER_PLAINTEXT = parse_bool(os.getenv("TINTA_PLAINTEXT", False)) +SMART_FIX_PUNCTUATION = parse_bool(os.getenv("TINTA_SMART_FIX_PUNCTUATION", True)) diff --git a/tinta/tinta.py b/tinta/tinta.py index 6ee24ae..9ad27da 100644 --- a/tinta/tinta.py +++ b/tinta/tinta.py @@ -28,19 +28,13 @@ from deprecated import deprecated from .ansi import AnsiColors -from .colorize import ANSI_RESET_HEX, colorize +from .colorize import ANSI_RESET_HEX, colorize, tint +from .constants import PREFER_PLAINTEXT, SEP, SMART_FIX_PUNCTUATION, STEALTH from .discover import discover as _discover -from .typ import copy_kwargs, MissingColorError, parse_bool, StringType +from .typ import copy_kwargs, MissingColorError, StringType config = configparser.ConfigParser() -CURSOR_UP_ONE = "\x1b[1A" -ERASE_LINE = "\x1b[2K" -SEP = os.getenv("TINTA_SEPARATOR", " ") -STEALTH = parse_bool(os.getenv("TINTA_STEALTH", False)) -PREFER_PLAINTEXT = parse_bool(os.getenv("TINTA_PLAINTEXT", False)) -SMART_FIX_PUNCTUATION = parse_bool(os.getenv("TINTA_SMART_FIX_PUNCTUATION", True)) - class _MetaTinta(type): @@ -102,11 +96,13 @@ class Tinta(metaclass=_MetaTinta): underline() -> self: Sets segments to underline. dim() -> self: Sets segments to a darker, dimmed color. normal() -> self: Removes all styles. - reset() -> self: Removes all styles and colors. + clear() -> self: Removes all styles and colors. line() -> self: Adds segments on a new line. - print(): Prints the output of a Tinta instance, then resets. + print(): Prints the output of a Tinta instance, then clears. """ + _initialized = False + _known_colors: dict[str, Any] = {} color: Union[int, str] colors: AnsiColors @@ -126,8 +122,11 @@ def __init__( self._prefixes: List[str] = [] # Inject ANSI helper functions - for c in vars(self.colors): - self._colorizer(c) + if not Tinta._initialized: + Tinta._known_colors = vars(self.colors) + Tinta._initialized = True + for c in Tinta._known_colors: + self.__setattr__(c, functools.partial(self.tint, c)) if s: self.push(*s, sep=sep) @@ -144,15 +143,6 @@ def __repr__(self) -> str: """ return str(self.to_str(plaintext=True)) - def _colorizer(self, c: str): - """Generates statically typed color methods - based on colors.ini. - - Args: - c (str): Method name of color, e.g. 'pink', 'blue'. - """ - self.__setattr__(c, functools.partial(self.tint, c)) - def _get_sep( self, p: "Tinta.Part", @@ -174,7 +164,7 @@ def _get_str( s: str = getattr(p, attr) - next_is_punc = ( + next_char_is_punc = ( next_p.pln[0] in [ ".", @@ -187,8 +177,9 @@ def _get_str( if fix_punc and next_p and next_p.pln else False ) - should_ignore_sep = ( - next_is_punc + + punc_affects_sep = ( + next_char_is_punc and any( [ (len(next_p.pln) == 1), @@ -199,6 +190,12 @@ def _get_str( else False ) + next_char_is_newline = next_p and next_p.pln.startswith(os.linesep) + last_char_is_newline = p.pln.endswith(os.linesep) + newline_affects_punc = last_char_is_newline or next_char_is_newline + + should_ignore_sep = punc_affects_sep or newline_affects_punc + if not should_ignore_sep: s = f"{s}{self._get_sep(p, next_p, sep)}" @@ -432,39 +429,7 @@ def tint(self, *args, **kwargs) -> "Tinta": self """ - # color: Optional[Union[str, int]] = None, *s: Any, sep: str = SEP - - # check if the first argument is a known color or valid ANSI code, or comes from kwargs - s = args - color = kwargs.get("color", None) - sep = kwargs.get("sep", SEP) - if color is None: - if not len(s) > 1: - raise AttributeError( - "If no color is specified, tint() requires at least two arguments." - ) - if args and isinstance(args[0], (str, int)): - color = s[0] - s = s[1:] - else: - raise AttributeError( - "Could not determine color from arguments. Either pass a color as the first argument, or use the color keyword argument." - ) - - # if color is numeric integer string, assume it's an ANSI color code - if isinstance(color, int) or (isinstance(color, str) and color.isdigit()): - self.color = int(color) - - # Check if color_name is a valid color if color is a string - if isinstance(color, str): - if not hasattr(self.colors, color): # type: ignore - raise MissingColorError( - f"Invalid color name: {color}. Is it in colors.ini?" - ) - self.color = self.colors.get(color) # type: ignore - - self.push(*s, sep=sep) - return self + return tint(self, *args, **kwargs) @overload def inspect(self, code: int, name: None = None, throw: bool = False) -> str: ... @@ -692,7 +657,8 @@ def __getattr__(self, name: str) -> "Tinta": try: return self.__getattribute__(name) # type: ignore except AttributeError as e: - known_colors = f"- {'- '.join(self.colors.list_colors())}" + known_colors = "\n - ".join(self.colors.list_colors()) + known_colors = f" - {known_colors}" raise AttributeError( f"Attribute '{name}' not found. Did you try and access a color that doesn't exist? Available colors:\n{known_colors}" ) from e @@ -759,7 +725,21 @@ def has_formatting(self) -> bool: @staticmethod def strip_ansi(s: str) -> str: """A utility method that strips ANSI escape codes from a string, converting a styled string into plaintext.""" - return re.sub(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]", "", s, re.I, re.M) + return re.sub( + r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", "", s, re.I | re.M | re.U + ) + + @classmethod + def ljust(cls, s: str, width: int, fillchar: str = " ") -> str: + """Returns a string left justified in a field of a specified width, accounting for ansi formatting.""" + p = cls.strip_ansi(s) + return s.replace(p, p.ljust(width, fillchar)) + + @classmethod + def rjust(cls, s: str, width: int, fillchar: str = " ") -> str: + """Returns a string right justified in a field of a specified width, accounting for ansi formatting.""" + p = cls.strip_ansi(s) + return s.replace(p, p.rjust(width, fillchar)) def esc(string: str, replace: bool = False) -> str: