Source code for excelbird.core.stack

"""
Detailed documentation and code examples coming soon.
"""
from __future__ import annotations
# External
from pandas import Series, DataFrame
from typing import Any
from copy import deepcopy

# Internal main
from excelbird.styles import default_table_style

from excelbird._base.container import ListIndexableById
from excelbird._base.identifier import HasId
from excelbird._base.dotdict import Style
from excelbird._base.loc import Loc
from excelbird._base.styling import (
    HasMargin, 
    HasPadding,
)
from excelbird._utils.util import (
    init_from_same_dimension_type,
)
from excelbird._utils.pass_attributes import (
    pass_attr_to_children,
    pass_dict_to_children,
)
from excelbird._utils.argument_parsing import (
    combine_args_and_children_to_list,
    convert_all_to_type,
    move_remaining_kwargs_to_dict,
)

from excelbird.core.cell import Cell
from excelbird.core.series import (
    _Series,
    Col,
)
from excelbird.core.gap import Gap
from excelbird.core.frame import _Frame, Frame, VFrame
from excelbird.core.expression import Expr
from excelbird.core.function import Func


class _Stack(ListIndexableById, HasId, HasMargin, HasPadding):
    _doc_primary_summary = """
    A general container that can hold any element, *including itself*. Offers unique spatial styling
    features, like margin and padding, described below.
    """
    _doc_params = """
    .. note:: Stacks *cannot* be used in a python expression, or included in a :class:`Func`. However, you can still call :meth:`self.ref()` to make an exact reference to its cells.

    Parameters
    ----------
    *args : Union[Stack, VStack, Frame, VFrame, Col, Row, Cell, list, tuple, str, int, float, pd.Series, pd.DataFrame, np.ndarray, Gap, Expr, Func, set, None]
        Can take any layout element (besides Book or Sheet), or any value that
        can be used to construct a layout element. Stack is the only layout element
        that can store other instances of itself as children
    children : list, optional
        Will be combined with args
    id : str, optional
        Unique identifier to store globally so that this element can be referenced
        elsewhere in the layout without being assigned to a variable
    sep : Gap or bool or int or dict, optional
        A sep in any excelbird layout element inserts a Gap between each of its children.
        If True, a default of Gap(1) is used. If int, Gap(sep) will be used. If a dict,
        ``Gap(1, **sep)`` will be used.
    background_color : str, optional
        Hex code for background_color. Will be applied to fill_color of padding, any Gap
        child who hasn't specified its own fill_color, and to any child Stack/VStack's margins.
        Will also be passed down to any child (Cell excluded) who hasn't specified its own
        background_color.
    schema : Schema, optional
        Applied to each child who takes schema
    cell_style : dict, optional
        Applied to each child who has cell_style
    header_style : dict, optional
        Applied to each child who has header_style
    table_style : dict or bool, optional
        Applied to each child who has table_style
    margin : int or list[int], optional
        Margin, like padding, will apply space around the element. Unlike padding, margin space
        will NOT inherit any of the element's styling. It will, however, be filled with the
        parent container's background_color, if present. Syntax inspired by CSS. An int,
        if passed, will be applied to all 4 sides. If list, length can be 2, 3, or 4 elements.
        Order is [top, right, bottom, left]. If length 2, apply the first element to top and
        bottom margin, and second to right and left.
    margin_top : int, optional
        Top margin, measured in number of cells
    margin_right : int, optional
        Right margin, measured in number of cells
    margin_bottom : int, optional
        Bottom marign, measured in number of cells
    margin_left : int, optional
        Left margin, measured in number of cells
    padding : int or list[int], optional
        Padding, like margin, will apply space around the element. Unlike margin, padding space
        WILL inherit the element's styling, like background_color. Syntax inspired by CSS. An int,
        if passed, will be applied to all 4 sides. If list, length can be 2, 3, or 4 elements.
        Order is [top, right, bottom, left]. If length 2, apply the first element to top and
        bottom margin, and second to right and left.
    padding_top : int, optional
        Top padding, measured in number of cells
    padding_right : int, optional
        Right padding, measured in number of cells
    padding_bottom : int, optional
        Bottom padding, measured in number of cells
    padding_left : int, optional
        Left padding, measured in number of cells
    **kwargs : Any
        Remaining kwargs will be applied to cell_style

    """

    sibling_type: type = None
    elem_type: type = None
    _dimensions = -1

    def __init__(
        self,
        *args: Any,
        children: list | None = None,
        id: str | int | None = None,
        sep: Any | None = None,
        background_color: str | None = None,
        margin: int | list[int] | None = None,
        margin_top: int | None = None,
        margin_right: int | None = None,
        margin_bottom: int | None = None,
        margin_left: int | None = None,
        padding: int | list[int] | None = None,
        padding_top: int | None = None,
        padding_right: int | None = None,
        padding_bottom: int | None = None,
        padding_left: int | None = None,
        schema: None = None,
        cell_style: Style | dict | None = None,
        header_style: Style | dict | None = None,
        table_style: Style | dict | bool | None = None,
        **kwargs,
    ) -> None:
        children = combine_args_and_children_to_list(args, children)

        children = [i for i in children if i is not None]

        children = init_from_same_dimension_type(self, children)
        if getattr(self, "_id", None) is not None and id is None:
            id = self.id

        if cell_style is None:
            cell_style = dict()
        if header_style is None:
            header_style = dict()
        if table_style is None or table_style is False:
            table_style = dict()
        elif table_style is True:
            table_style = default_table_style

        self._format_args(children)

        move_remaining_kwargs_to_dict(kwargs, cell_style)

        self._loc = None
        self.id = id
        self.background_color = background_color
        # Attrs that must be passed to children
        self.schema = schema
        # Dicts that must be passed to children
        self.cell_style = Style(**cell_style)
        self.header_style = Style(**header_style)
        self.table_style = Style(**table_style)

        self._init(children)

        self._init_margin(
            margin,
            margin_top,
            margin_right,
            margin_bottom,
            margin_left,
        )
        self._init_padding(
            padding,
            padding_top,
            padding_right,
            padding_bottom,
            padding_left,
        )

        if sep is not None:
            self._insert_separator(sep)


    def ref(self, inherit_style: bool = False, **kwargs):
        """
        Get a new object with cell references to those in the caller.
        This assumes that **both** the calling object
        and the returned object will be placed in the workbook.

        Parameters
        ----------
        inherit_style : bool, default False
            Copy the caller's style to the returned object.

        Returns
        -------
        :class:`Self`

        Notes
        -----

        .. note::

            Children's ``header`` attributes are stylistic attributes, and therefore will **not** be
            passed to the returned object's children unless ``inherit_style=True``. And, if style
            is inherited, headers will be copied over to the children, instead of cell references to them.

        """
        new_elements = [
            i.ref(inherit_style=inherit_style, **kwargs)
            if not isinstance(i, Gap)
            else deepcopy(i)
            for i in self
        ]
        new_dict = kwargs
        if inherit_style is True:
            self_dict = deepcopy(self.__dict__)
            for key, val in self_dict.items():
                if key == "_header":
                    key = "header"
                if key not in new_dict and key not in ["_id", "_loc"]:
                    new_dict[key] = val
        return type(self)(*new_elements, **new_dict)

    def transpose(self, **kwargs):
        """
        Convert to sibling type. Places current children into the returned object,
        without copying or making cell references to them.

        Parameters
        ----------
        **kwargs : Any
            Keyword arguments to apply as attributes to the new object.

        Returns
        -------
        :class:`Stack <excelbird.Stack>` or :class:`VStack <excelbird.VStack>`
            The opposite to self's type. Try ``type(my_obj).sibling_type``

        Notes
        -----
        **Assumes that the caller won't be placed in the layout**. Do not
        place both the calling object and returned object in the layout, since
        they both contain the same children.

        .. code-block::

            # 'current' must not be placed in the workbook.
            new = current.transpose()

        To include the caller and make cell references to it, get a reference
        first:

        .. code-block::

            new = current.ref().transpose()

        """
        elements = list(self)
        new = type(self).sibling_type(*elements)
        for key, val in self.__dict__.items():
            if key == "_id":
                key = "id"
            setattr(new, key, val)
        for key, val in kwargs.items():
            if hasattr(new, key):
                setattr(new, key, val)
            elif hasattr(new, 'cell_style'):
                new.cell_style[key] = val
        return new

    def _format_args(self, args: list) -> None:
        convert_all_to_type(args, (str, int, float), Cell, strict=True)
        convert_all_to_type(args, Series, Col)
        convert_all_to_type(args, DataFrame, Frame)
        convert_all_to_type(args, set, Expr)

    @property
    def _elem_widths(self) -> list:
        return [i.width for i in self if hasattr(i, "width")]

    @property
    def _elem_heights(self) -> list:
        return [i.height for i in self if hasattr(i, "height")]

    def _resolve_background_color(self) -> None:

        for elem in self:
            if hasattr(elem, "_resolve_background_color"):
                if (
                    self.background_color not in [None, False]
                    and elem.background_color is None
                ):
                    elem.background_color = self.background_color
                elem._resolve_background_color()

        if self.background_color not in [None, False]:
            for elem in self:
                if isinstance(elem, Gap):
                    if "fill_color" not in elem.kwargs and elem.is_margin is False:
                        elem.fill = True
                        elem.kwargs["fill_color"] = self.background_color

                # Child's margins should be filled with self's background color
                elif hasattr(elem, "margin"):
                    if elem.margin != HasMargin.empty:
                        for item in elem:
                            if isinstance(item, Gap):
                                if (
                                    "fill_color" not in item.kwargs
                                    and item.is_margin is True
                                ):
                                    item.fill = True
                                    item.kwargs["fill_color"] = self.background_color
                            elif hasattr(item, "margin"):
                                for x in item:
                                    if isinstance(x, Gap):
                                        if (
                                            "fill_color" not in x.kwargs
                                            and x.is_margin is True
                                        ):
                                            x.fill = True
                                            x.kwargs[
                                                "fill_color"
                                            ] = self.background_color

    def _resolve_padding(self) -> None:
        for elem in self:
            if hasattr(elem, "padding"):
                elem._resolve_padding()

        def get_gap(amount, elem) -> Gap:
            if getattr(elem, "background_color", None) not in [None, False]:
                return Gap(amount, fill_color=elem.background_color)
            return Gap(amount, fill_color=None)

        for i, elem in enumerate(self):
            if hasattr(elem, "padding"):
                if elem.padding != HasPadding.empty:
                    top, right, bottom, left = elem.padding
                    elem_type = type(elem)
                    new_elements = []
                    for i, item in reversed(list(enumerate(elem))):
                        new_elements.insert(0, elem.pop(i))

                    if issubclass(elem_type, Stack):
                        if left is not None:
                            elem.append(get_gap(left, elem))

                        elem.append(
                            elem_type.sibling_type(
                                get_gap(top, elem) if top is not None else None,
                                elem_type(*new_elements),
                                get_gap(bottom, elem) if bottom is not None else None,
                            )
                        )
                        if right is not None:
                            elem.append(get_gap(right, elem))

                    elif issubclass(elem_type, VStack):
                        if top is not None:
                            elem.append(get_gap(top, elem))

                        elem.append(
                            elem_type.sibling_type(
                                get_gap(left, elem) if left is not None else None,
                                elem_type(*new_elements),
                                get_gap(right, elem) if right is not None else None,
                            ),
                        )
                        if bottom is not None:
                            elem.append(get_gap(bottom, elem))

    def _resolve_margin(self) -> None:
        for elem in self:
            if hasattr(elem, "margin"):
                elem._resolve_margin()

        for i, elem in enumerate(self):
            if hasattr(elem, "margin"):
                if elem.margin != HasMargin.empty:
                    top, right, bottom, left = elem.margin
                    elem_type = type(elem)
                    new_elements = []
                    for i, item in reversed(list(enumerate(elem))):
                        new_elements.insert(0, elem.pop(i))

                    if issubclass(elem_type, Stack):
                        if left is not None:
                            elem.append(Gap(left, is_margin=True))

                        elem.append(
                            elem_type.sibling_type(
                                Gap(top, is_margin=True) if top is not None else None,
                                elem_type(*new_elements),
                                Gap(bottom, is_margin=True)
                                if bottom is not None
                                else None,
                            ),
                        )
                        if right is not None:
                            elem.append(Gap(right, is_margin=True))

                    elif issubclass(elem_type, VStack):
                        if top is not None:
                            elem.append(Gap(top, is_margin=True))

                        elem.append(
                            elem_type.sibling_type(
                                Gap(left, is_margin=True) if left is not None else None,
                                elem_type(*new_elements),
                                Gap(right, is_margin=True)
                                if right is not None
                                else None,
                            ),
                        )
                        if bottom is not None:
                            elem.append(Gap(bottom, is_margin=True))

    def _resolve_gaps(self) -> None:
        Gap._convert_all_to_frames(self, type(self).elem_type, self._gap_size)
        for elem in self:
            if hasattr(elem, "_resolve_gaps"):
                elem._resolve_gaps()

    def _set_loc(self, loc: Loc) -> None:
        self._loc = loc

        offset = self._starting_offset()
        for elem in self:
            elem._set_loc(
                Loc((self._loc.y + offset.y, self._loc.x + offset.x), self._loc.ws)
            )
            offset = self._inc_offset(offset, elem)

    def __getitem__(self, key):
        if not isinstance(key, list):
            # return super().__getitem__(key)
            return ListIndexableById.__getitem__(self, key)

        new_elements = [self[self._key_to_idx(k)] for k in key]
        new_dict = {k: v for k, v in self.__dict__.items() if k not in ["_id", "_loc"]}
        if "_header" in new_dict:
            new_dict["header"] = new_dict.pop("_header")

        return type(self)(*new_elements, **new_dict)

    def _validate_child_types(self) -> None:
        valid_types = (
            _Stack,
            _Frame,
            _Series,
            Cell,
            Gap,
        )
        for elem in self:
            if not isinstance(elem, valid_types):
                raise TypeError(
                    f"At write time, a {type(self).__name__} can only hold "
                    "the following types:\n{valid_types}"
                )
            if hasattr(elem, "_validate_child_types"):
                elem._validate_child_types()

    def _write(self) -> None:
        pass_attr_to_children(self, "schema")
        pass_dict_to_children(self, "cell_style")
        pass_dict_to_children(self, "header_style")
        pass_dict_to_children(self, "table_style")

        if len(self.cell_style) > 0:
            for elem in self:
                if isinstance(elem, Cell):
                    elem._inherit_style_without_override(self.cell_style)

        for elem in self:
            elem._write()

    def _starting_offset(self) -> Loc:
        return Loc((0, 0), self._loc.ws)


[docs]class Stack(_Stack): _doc_custom_summary = """ * Direction: **horizontal** * Child Type: :class:`Stack`, :class:`VStack`, :class:`Frame`, :class:`VFrame`, :class:`Col`, :class:`Row`, :class:`Cell` """ sibling_type: type = None # these are set after class declaration elem_type: type = Frame def transpose(self, **kwargs) -> VStack: return super().transpose(**kwargs) def ref(self, inherit_style: bool = False, **kwargs) -> Stack: return super().ref(inherit_style, **kwargs) @property def width(self) -> int: return sum(self._elem_widths + [0]) @property def height(self) -> int: heights = [i.height for i in self if hasattr(i, 'height') and not isinstance(i, Gap)] return max(heights + [0]) @staticmethod def _inc_offset(offset: Loc, elem: Any) -> Loc: offset.x += elem.width return offset @property def _gap_size(self) -> int: return self.height
[docs]class VStack(_Stack): _doc_custom_summary = """ * Direction: **vertical** * Child Type: :class:`Stack`, :class:`VStack`, :class:`Frame`, :class:`VFrame`, :class:`Col`, :class:`Row`, :class:`Cell` """ sibling_type: type = Stack # these are set after class declaration elem_type: type = VFrame def transpose(self, **kwargs) -> Stack: return super().transpose(**kwargs) def ref(self, inherit_style: bool = False, **kwargs) -> VStack: return super().ref(inherit_style, **kwargs) @property def width(self) -> int: widths = [i.width for i in self if hasattr(i, 'width') and not isinstance(i, Gap)] return max(widths + [0]) @property def height(self) -> int: return sum(self._elem_heights + [0]) @staticmethod def _inc_offset(offset: Loc, elem: Any) -> Loc: offset.y += elem.height return offset @property def _gap_size(self) -> int: return self.width
Stack.sibling_type = VStack Stack.__doc__ = f""" {_Stack._doc_primary_summary} {Stack._doc_custom_summary} {_Stack._doc_params} """ VStack.__doc__ = f""" {_Stack._doc_primary_summary} {VStack._doc_custom_summary} {_Stack._doc_params} """