Repository: AnonymouX47/term-image (language: python)

Issue:
The Color API
Introduces the Color API

- Adds `term_image.color` submodule.
  - `Color`


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34ae6317..0df6fd59 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   - `Padding`, `AlignedPadding`, `ExactPadding`, etc.
 - Static Typing & Type Checking Support ([#100]).
 - `TermImageUserWarning` warning sub-category ([d710a9e]).
+- `term_image.color` submodule ([#106]).
+  - `Color`.
 
 ### Changed
 - `term_image.utils.get_cell_size()` now returns `term_image.geometry.Size` instances in place of tuples ([#96]).
@@ -30,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 [#96]: https://github.com/AnonymouX47/term-image/pull/96
 [#97]: https://github.com/AnonymouX47/term-image/pull/97
 [#100]: https://github.com/AnonymouX47/term-image/pull/100
+[#106]: https://github.com/AnonymouX47/term-image/pull/106
 [497d9b7]: https://github.com/AnonymouX47/term-image/commit/497d9b70dd74605e6589b81bea2fcac22efc684b
 [d296a31]: https://github.com/AnonymouX47/term-image/commit/d296a3110882449f6717959400abbc5fa1bd0891
 [5537037]: https://github.com/AnonymouX47/term-image/commit/5537037a10b1da7ae8467cefaf99dc7959ceb7bc
diff --git a/Makefile b/Makefile
index d2655078..a5205c6e 100644
--- a/Makefile
+++ b/Makefile
@@ -65,6 +65,7 @@ pytest := pytest -v -r a
 ## Filepath variables
 
 test-top := tests/test_top_level.py
+test-color := tests/test_color.py
 test-geometry := tests/test_geometry.py
 test-padding := tests/test_padding.py
 test-renderable-renderable := tests/renderable/test_renderable.py
@@ -87,12 +88,13 @@ test-graphics := $(test-kitty) $(test-iterm2)
 test-image := $(test-base) $(test-text) $(test-graphics) $(test-others)
 test-widget-urwid := $(test-widget-urwid-main) $(test-widget-urwid-screen)
 test-widget := $(test-widget-urwid)
-test := $(test-top) $(test-geometry) $(test-padding) $(test-renderable) $(test-render) $(test-image) $(test-iterator) $(test-widget)
+test := $(test-top) $(test-color) $(test-geometry) $(test-padding) $(test-renderable) $(test-render) $(test-image) $(test-iterator) $(test-widget)
 test-all := $(test) $(test-url)
 
 ## Targets
 
 test-top \
+test-color \
 test-geometry \
 test-padding \
 test-renderable test-renderable-renderable test-renderable-types \
diff --git a/docs/source/api/color.rst b/docs/source/api/color.rst
new file mode 100644
index 00000000..84508351
--- /dev/null
+++ b/docs/source/api/color.rst
@@ -0,0 +1,4 @@
+``color`` Module
+===================
+
+.. automodule:: term_image.color
diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst
index 008550da..bddecf01 100644
--- a/docs/source/api/index.rst
+++ b/docs/source/api/index.rst
@@ -20,6 +20,7 @@ API Reference
    :caption: Sub-sections:
 
    toplevel
+   color
    exceptions
    geometry
    image
diff --git a/src/term_image/color.py b/src/term_image/color.py
new file mode 100644
index 00000000..237b9b54
--- /dev/null
+++ b/src/term_image/color.py
@@ -0,0 +1,155 @@
+"""
+.. The Color API
+"""
+
+from __future__ import annotations
+
+__all__ = ("Color",)
+
+import re
+
+from typing_extensions import NamedTuple, Self
+
+from .utils import arg_value_error_range
+
+# Unfortunately, `re` doesn't support repeated capturing groups; only the last match
+# is captured. So, we'd have to repeat manually.
+# Allows mixture of letter case to simplify parsing.
+XX = "[0-9a-f]{2}"
+_RGBA_HEX_RE = re.compile(rf"#?({XX})({XX})({XX})({XX})?", re.A | re.I)
+del XX
+
+
+# To bypass `NamedTuple`'s `__new__()` override limitation
+class _DummyColor(NamedTuple):
+    r: int
+    g: int
+    b: int
+    a: int = 255
+
+
+class Color(_DummyColor):
+    """A color.
+
+    Args:
+        r: The red channel.
+        g: The green channel.
+        b: The blue channel.
+        a: The alpha channel (opacity).
+
+    Raises:
+        ValueError: The value of a channel is not within the valid range.
+
+    NOTE:
+        The valid value range for all channels is 0 to 255, both inclusive.
+
+    TIP:
+        This class is a :py:class:`~typing.NamedTuple` of four fields.
+
+    WARNING:
+        In the case of multiple inheritance i.e if subclassing this class along with
+        other classes, this class should appear last (i.e to the far right) in the
+        base class list.
+    """
+
+    __slots__ = ()
+
+    # Overrides these descriptors in order to speed up attribute resolution and to
+    # simplify auto documentation.
+    r: int = _DummyColor.r
+    r.__doc__ = """The red channel"""
+
+    g: int = _DummyColor.g
+    g.__doc__ = """The green channel"""
+
+    b: int = _DummyColor.b
+    b.__doc__ = """The blue channel"""
+
+    a: int = _DummyColor.a
+    a.__doc__ = """The alpha channel (opacity)"""
+
+    def __new__(cls, r: int, g: int, b: int, a: int = 255) -> Self:
+        # Only the 8 LSb may be set for any value within the range [0, 255].
+        # `x & ~255` unsets the 8 LSb. Hence, if the result is non-zero (i.e any
+        # of the bits above the lowest 8 is set), it implies `x` is out of range.
+        #
+        # Actually benchmarked *this* against the "simpler" logically-negated chained
+        # comparison (i.e `0 <= x <= 255`) and *this* was significantly faster for all
+        # cases i.e within, on the boundaries, and outside (on both sides).
+        if (r | g | b | a) & ~255:  # First test to see if *any* is out of range
+            if r & ~255:
+                raise arg_value_error_range("r", r)
+            if g & ~255:
+                raise arg_value_error_range("g", g)
+            if b & ~255:
+                raise arg_value_error_range("b", b)
+            if a & ~255:
+                raise arg_value_error_range("a", a)
+
+        # Using `tuple` directly instead of `super()` for performance
+        return tuple.__new__(cls, (r, g, b, a))
+
+    @property
+    def hex(self) -> str:
+        """Converts the color to its RGBA hexadecimal representation.
+
+        Returns:
+            The RGBA hex color string, starting with the ``#`` character i.e
+            ``#rrggbbaa``.
+
+            Each channel is represented by two **lowercase** hex digits ranging from
+            ``00`` to ``ff``.
+        """
+        return "#%02x%02x%02x%02x" % self
+
+    @property
+    def rgb(self) -> tuple[int, int, int]:
+        """Extracts the R, G and B channels of the color.
+
+        Returns:
+            A 3-tuple containing the red, green and blue channel values.
+        """
+        return self[:3]
+
+    @property
+    def rgb_hex(self) -> str:
+        """Converts the color to its RGB hexadecimal representation.
+
+        Returns:
+            The RGB hex color string, starting with the ``#`` character i.e ``#rrggbb``.
+
+            Each channel is represented by two **lowercase** hex digits ranging from
+            ``00`` to ``ff``.
+        """
+        return "#%02x%02x%02x" % self[:3]
+
+    @classmethod
+    def from_hex(cls, color: str) -> Self:
+        """Creates a new instance from a hexadecimal color string.
+
+        Args:
+            color: A **case-insensitive** RGB or RGBA hex color string, **optionally**
+              starting with the ``#`` (pound) character i.e ``[#]rrggbb[aa]``.
+
+        Returns:
+            A new instance representing the given hex color string.
+
+        Raises:
+            ValueError: Invalid hex color string.
+
+        NOTE:
+            For an RGB hex color string, the value of A (the alpha channel) is
+            taken to be 255.
+        """
+        if not (match := _RGBA_HEX_RE.fullmatch(color)):
+            raise ValueError(f"Invalid hex color string (got: {color!r})")
+
+        return tuple.__new__(cls, [int(x, 16) for x in match.groups("ff")])
+
+    @classmethod
+    def _new(cls, r: int, g: int, b: int, a: int = 255) -> Self:
+        """Alternate constructor for internal use only."""
+        return tuple.__new__(cls, (r, g, b, a))
+
+
+_Color = Color._new


Test patch:
diff --git a/tests/test_color.py b/tests/test_color.py
new file mode 100644
index 00000000..cae84cef
--- /dev/null
+++ b/tests/test_color.py
@@ -0,0 +1,162 @@
+import pytest
+
+from term_image.color import Color
+
+
+class TestColor:
+    @pytest.mark.parametrize("value", [-1, 256])
+    def test_out_of_range(self, value):
+        with pytest.raises(ValueError, match="'r'"):
+            Color(value, 0, 0)
+        with pytest.raises(ValueError, match="'g'"):
+            Color(0, value, 0)
+        with pytest.raises(ValueError, match="'b'"):
+            Color(0, 0, value)
+        with pytest.raises(ValueError, match="'a'"):
+            Color(0, 0, 0, value)
+
+    @pytest.mark.parametrize("value", [0, 127, 255])
+    class TestInRange:
+        def test_r(self, value):
+            color = Color(value, 0, 0)
+            assert color.r == value
+
+        def test_g(self, value):
+            color = Color(0, value, 0)
+            assert color.g == value
+
+        def test_b(self, value):
+            color = Color(0, 0, value)
+            assert color.b == value
+
+        def test_a(self, value):
+            color = Color(0, 0, 0, value)
+            assert color.a == value
+
+    def test_a_default(self):
+        color = Color(0, 0, 0)
+        assert color.a == 255
+
+    def test_is_tuple(self):
+        color = Color(255, 127, 1, 0)
+        assert isinstance(color, tuple)
+        assert len(color) == 4
+        assert color == (255, 127, 1, 0)
+        assert color[0] == 255
+        assert color[1] == 127
+        assert color[2] == 1
+        assert color[3] == 0
+
+    @pytest.mark.parametrize(
+        "rgba,hex",
+        [
+            ((0, 0, 0, 0), "#00000000"),
+            ((0, 10, 127, 255), "#000a7fff"),
+            ((255, 255, 255, 255), "#ffffffff"),
+        ],
+    )
+    def test_hex(self, rgba, hex):
+        color = Color(*rgba)
+        assert color.hex == hex
+
+    @pytest.mark.parametrize(
+        "rgba,rgb",
+        [
+            ((0, 0, 0, 0), (0, 0, 0)),
+            ((0, 10, 127, 255), (0, 10, 127)),
+            ((255, 255, 255, 255), (255, 255, 255)),
+        ],
+    )
+    def test_rgb(self, rgba, rgb):
+        color = Color(*rgba)
+        assert color.rgb == rgb
+
+    @pytest.mark.parametrize(
+        "rgb,hex",
+        [
+            ((0, 0, 0), "#000000"),
+            ((0, 10, 127), "#000a7f"),
+            ((255, 255, 255), "#ffffff"),
+        ],
+    )
+    def test_rgb_hex(self, rgb, hex):
+        color = Color(*rgb)
+        assert color.rgb_hex == hex
+
+    class TestFromHex:
+        @pytest.mark.parametrize("hex", ["", "#", "#000", "#ffff", "#12345", "abcdefg"])
+        def test_invalid(self, hex):
+            with pytest.raises(ValueError):
+                Color.from_hex(hex)
+
+        class TestRGBA:
+            @pytest.mark.parametrize(
+                "hex,rgba",
+                [
+                    ("#00000000", (0, 0, 0, 0)),
+                    ("#000a7fff", (0, 10, 127, 255)),
+                    ("#ffffffff", (255, 255, 255, 255)),
+                ],
+            )
+            def test_with_pound(self, hex, rgba):
+                color = Color.from_hex(hex)
+                assert color == Color(*rgba)
+
+            @pytest.mark.parametrize(
+                "hex,rgba",
+                [
+                    ("00000000", (0, 0, 0, 0)),
+                    ("000a7fff", (0, 10, 127, 255)),
+                    ("ffffffff", (255, 255, 255, 255)),
+                ],
+            )
+            def test_without_pound(self, hex, rgba):
+                color = Color.from_hex(hex)
+                assert color == Color(*rgba)
+
+            @pytest.mark.parametrize("hex", ["#0abc7def", "#0aBc7DeF", "#0ABC7DEF"])
+            def test_case_insensitive(self, hex):
+                assert Color.from_hex(hex) == Color(0x0A, 0xBC, 0x7D, 0xEF)
+
+        class TestRGB:
+            @pytest.mark.parametrize(
+                "hex,rgba",
+                [
+                    ("#000000", (0, 0, 0, 255)),
+                    ("#000a7f", (0, 10, 127, 255)),
+                    ("#ffffff", (255, 255, 255, 255)),
+                ],
+            )
+            def test_with_pound(self, hex, rgba):
+                color = Color.from_hex(hex)
+                assert color == Color(*rgba)
+
+            @pytest.mark.parametrize(
+                "hex,rgba",
+                [
+                    ("000000", (0, 0, 0, 255)),
+                    ("000a7f", (0, 10, 127, 255)),
+                    ("ffffff", (255, 255, 255, 255)),
+                ],
+            )
+            def test_without_pound(self, hex, rgba):
+                color = Color.from_hex(hex)
+                assert color == Color(*rgba)
+
+            @pytest.mark.parametrize("hex", ["#abcdef", "#aBcDeF", "#ABCDEF"])
+            def test_case_insensitive(self, hex):
+                assert Color.from_hex(hex) == Color(0xAB, 0xCD, 0xEF)
+
+    class TestNew:
+        def test_instance_type(self):
+            class SubColor(Color):
+                pass
+
+            assert type(Color._new(0, 0, 0)) is Color
+            assert type(SubColor._new(0, 0, 0)) is SubColor
+
+        @pytest.mark.parametrize(
+            "rgba", [(0, 0, 0), (0, 1, 127), (0, 0, 0, 0), (255, 255, 255, 127)]
+        )
+        def test_equal_to_normally_constructed(self, rgba):
+            assert Color._new(*rgba) == Color(*rgba)