Source code for excelbird.core.series

"""
.. _header:

Headers
------------------------

A Series' :attr:`header <excelbird._Series.header>` attribute is a string which will
*later* be inserted as a :class:`Cell <excelbird.Cell>`. It has several purposes:

* Ignored by formulas and cell references, and is instead treated as a stylistic attribute
  of the parent container.
* Declared manually, or automatically if a :class:`pd.Series <pandas.Series>` is given
* Its styling is independent of the other child cells, and must be styled separately with :attr:`header_style <excelbird._Series.header_style>`.
* It can be referenced globally by other :class:`Exprs <excelbird.Expr>` in the layout, just like an ``id``. This is to avoid
  redundancy, and also provide convenience when headers are set automatically from :mod:`pandas` objects.

"""
# External
from __future__ import annotations
from pandas import Series
from numpy import ndarray
from typing import Iterable, Any, overload
from copy import copy, deepcopy

from excelbird._base.container import ListIndexableById
from excelbird._base.identifier import HasId
from excelbird._base.identifier import HasHeader
from excelbird._base.styling import HasBorder
from excelbird._base.dotdict import Style
from excelbird._base.loc import Loc
from excelbird._base.math import CanDoMath, elem_math

from excelbird._utils.util import (
    get_dimensions,
    get_idx,
    init_from_same_dimension_type,
)
from excelbird._utils.validation import (
    require_each_element_to_be_cls_type,
    ensure_value_is_not_number,
)
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.expression import Expr
from excelbird.core.function import Func
from excelbird.core.gap import Gap
from excelbird.core.item import Item



class _Series(CanDoMath, ListIndexableById, HasId, HasHeader, HasBorder):
    _doc_primary_summary = """
    A 1-dimensional vector that can be used in a python expression
    and can be prefixed with a :ref:`header <header>`.
    """
    _doc_params = """
    Parameters
    ----------
    *args : Union[Cell, Col, Row, str, int, float, list, tuple, pd.Series, np.ndarray, Gap, Item, Expr, Func, set, None]
        Children must be, or resolve to, `Cell`. Iterables will be *exploded* inplace. So, if given
        ``1, pandas.Series([2, 3]), 4``, it will be read as ``1, 2, 3, 4`` before converting the
        ints to `Cell`. If a :class:`pandas.Series` is passed as the *only* argument, its name
        (if present) will be used as :ref:`header <header>`.
    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
    header : str, optional
        Unique identifier to be inserted as a Cell at position 0 at write time. Can be referenced
        by an `Expr` elsewhere in the layout. Set automatically if a named ``pandas.Series`` is given.
        :ref:`Read more <header>`
    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 any Gap child who hasn't specified its own
        fill_color.
    cell_style : dict, optional
        Each key/value will be used to set an attribute on each child Cell (header excluded)
        only if the respective attribute has not already been set on the child Cell (its value is None).
        This mimics HTML/CSS behavior, where styling declared at the parent level is passed down
        to children, but each child can override the parent.
    header_style : dict, optional
        Just like cell_style, but for the header only. Ignored if header is None.
    border : list[tuple or str or bool] or tuple[str or bool, str or bool] or str or bool, optional
        Syntax inspired by CSS. A non-list value 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 border, and apply the second element to right and left.
        To apply border to children instead, use cell_style.
    border_top : tuple[str or bool, str or bool] or str or bool, optional
        Top border. If True, a thin black border is used. If string (6 char hex code),
        use the default weight and apply the specified color. If string (valid weight name),
        use the default color and apply the specified weight. If tuple, apply the first
        element as weight, and second element as color.
    border_right : tuple[str or bool, str or bool] or str or bool, optional
        Right border. See border_top
    border_bottom : tuple[str or bool, str or bool] or str or bool, optional
        Bottom border. See border_top
    border_left : tuple[str or bool, str or bool] or str or bool, optional
        Left border. See border_top
    **kwargs : Any
        Remaining kwargs will be applied to cell_style
    """

    _dimensions = 1
    elem_type = Cell

    @overload
    def __new__(cls, fn: str | Func, **kwargs) -> Func:
        ...

    # @overload
    # def __new__(cls, func: str | Func, **kwargs) -> Func:
    #     ...

    @overload
    def __new__(cls, ex: str | set | Expr, **kwargs) -> Expr:
        ...

    # @overload
    # def __new__(cls, expr: str | set | Expr, **kwargs) -> Expr:
    #     ...

    @overload
    def __new__(
        cls, 
        *args: Any,
        children: list | None = None,
        id: str | None = None,
        header: str | None = None,
        sep: Any | None = None,
        border_left: bool | str | None = None,
        border_right: bool | str | None = None,
        border_top: bool | str | None = None,
        border_bottom: bool | str | None = None,
        border: bool | str | Iterable | None = None,
        background_color: str | None = None,
        cell_style: Style | dict | None = None,
        header_style: Style | dict | None = None,
        **kwargs,
    ) -> _Series:
        ...

    def __new__(cls, *args, fn=None, func=None, ex=None, expr=None, **kwargs):
        fn = fn if fn is not None else func
        ex = ex if ex is not None else expr
        if isinstance(fn, Func):
            fn = fn.inner
        if isinstance(ex, Expr):
            ex = ex.expr_str

        if fn is not None:
            new_func = Func.__new__(Func)
            new_func.__init__(fn, res_type=cls, **kwargs)
            return new_func

        if ex is not None:
            new_expr = Expr.__new__(Expr)
            new_expr.__init__(ex, res_type=cls, **kwargs)
            return new_expr

        return super().__new__(cls)

    def __init__(
        self,
        *args: Any,
        children: list | None = None,
        id: str | None = None,
        header: str | None = None,
        sep: Any | None = None,
        border_left: bool | str | None = None,
        border_right: bool | str | None = None,
        border_top: bool | str | None = None,
        border_bottom: bool | str | None = None,
        border: bool | str | Iterable | None = None,
        background_color: str | None = None,
        cell_style: Style | dict | None = None,
        header_style: Style | dict | None = None,
        **kwargs,
    ) -> None:
        children = combine_args_and_children_to_list(args, children)

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

        if cell_style is None:
            cell_style = dict()
        if header_style is None:
            header_style = dict()

        if len(children) == 1 and isinstance(get_idx(children, 0), Series):
            if children[0].name is not None and header is None:
                header = children[0].name

        self._format_args(children)

        move_remaining_kwargs_to_dict(kwargs, cell_style)

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

        self._init(children)

        self._init_border(
            border,
            border_top,
            border_right,
            border_bottom,
            border_left,
        )
        if sep is not None:
            self._insert_separator(sep)

        self.header_written = False

    def from_valid_children(
        self,
        children: list,
        id: str | None = None,
        header: str | None = None,
        sep: Any | None = None,
        border_left: bool | str | None = None,
        border_right: bool | str | None = None,
        border_top: bool | str | None = None,
        border_bottom: bool | str | None = None,
        border: bool | str | Iterable | None = None,
        background_color: str | None = None,
        cell_style: Style | dict | None = None,
        header_style: Style | dict | None = None,
        **kwargs,
    ):
        if cell_style is None:
            cell_style = dict()
        if header_style is None:
            header_style = dict()

        move_remaining_kwargs_to_dict(kwargs, cell_style)

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

        self._init(children)

        self._init_border(
            border,
            border_top,
            border_right,
            border_bottom,
            border_left,
        )
        if sep is not None:
            self._insert_separator(sep)

        self.header_written = False

    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.

        .. note::

            Calling ``.ref()`` is **not** necessary  when an object is used in
            a python expression (i.e. ``some_cell + some_row``) and should `only`
            be used to duplicate data across a workbook.

        Parameters
        ----------
        inherit_style : bool, default False
            Copy the caller's style to the returned object.
        **kwargs : Any
            Extra keyword arguments are set as attributes on the returned
            object.

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

        Notes
        -----

        .. note::

            ``self.header`` is a stylistic attribute, and therefore will **not** be
            passed to the returned object unless ``inherit_style=True``. And, if style
            is inherited, the header will be copied to its new cell, instead of having
            a cell reference made to it.

        """
        new_elements = [
            i.ref(inherit_style=inherit_style, **kwargs)
            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:`Col <excelbird.Col>` or :class:`Row <excelbird.Row>`
            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 == "_header":
                key = "header"
            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 range(self, include_headers: bool = False) -> Cell:
        """
        Get a reference to the range of the series, instead of a vector of
        cell references.

        Parameters
        ----------
        include_headers : bool, default False
            If True, the header cell will be included in the range reference.

        Returns
        -------
        :class:`Cell <excelbird.Cell>`
        """
        if self.header_written is True and include_headers is False:
            first = self[1]
        else:
            first = self[0]
        last = self[-1]
        return first >> last

    @property
    def shape(self) -> tuple[int]:
        length = sum([1 if not isinstance(i, Gap) else i for i in self])
        if self.header is not None:
            length += 1
        return (length,)

    def _format_args(self, args: list) -> None:
        self._explode_all_1d_iterables(args)
        convert_all_to_type(args, set, Expr)
        convert_all_to_type(args, int, Cell, strict=True)
        convert_all_to_type(args, (str, float), Cell)
        Item._resolve_all_in_container(args, type(self).elem_type)
        for i, elem in enumerate(args):
            if not isinstance(elem, (Cell, Iterable, Gap, Expr, Func)):
                args[i] = Cell(elem)

    def _explode_all_1d_iterables(self, args: list) -> None:

        for i, elem in enumerate(args):
            if isinstance(elem, Series):
                sr = args.pop(i)
                for cell in reversed(sr.reset_index(drop=True)):
                    args.insert(i, cell)

            elif isinstance(elem, (list, tuple, ndarray)):
                sr = args.pop(i)
                for value in reversed(sr):
                    args.insert(i, value)

    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:
                        elem.fill = True
                        elem.kwargs["fill_color"] = self.background_color

    def _resolve_gaps(self):
        Gap._explode_all_to_values(self, Cell)

    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 __rshift__(self, other):
    #     if get_dimensions(other) < get_dimensions(self):
    #         return elem_math(self[0], other, lambda a, b: a >> b, " >> ")
    #     return self[0] >> other[-1]
    #
    # def __rrshift__(self, other):
    #     if get_dimensions(other) < get_dimensions(self):
    #         return elem_math(other, self[-1], lambda a, b: a >> b, " >> ")
    #     return other[0] >> self[-1]

    def _validate_child_types(self) -> None:
        cls_name = type(self).__name__
        elem_type_name = type(self).elem_type.__name__
        valid_types = (
            type(self).elem_type,
            Gap,
        )
        for elem in self:
            if not isinstance(elem, valid_types):
                raise TypeError(
                    f"At write time, a {cls_name} can only hold {elem_type_name}s or Gaps. "
                    "To arrange mixed types, place them in a Stack or VStack"
                )
            if hasattr(elem, "_validate_child_types"):
                elem._validate_child_types()

    def _write(self) -> None:
        require_each_element_to_be_cls_type(self)

        self._apply_border()

        for cell in self:
            cell._inherit_style_without_override(self.cell_style)

        if self.header is not None:
            ensure_value_is_not_number(self.header)
            new_header = Cell(self.header)

            new_header._set_loc(self._loc)

            new_header._inherit_style_without_override(self.header_style)

            if (
                self.cell_style.get("autofit") is True
                and self.header_style.get("autofit") is not False
            ):
                new_header.autofit = True

            self.insert(0, new_header)
            self.header_written = True

        for cell in self:
            cell._write()

    def _starting_offset(self) -> Loc:
        ...


[docs]class Col(_Series): _doc_custom_summary = """ * Direction: **vertical** * Child Type: :class:`Cell` """ sibling_type: type = None # these are set after class declaration elem_type = Cell def transpose(self, **kwargs) -> Row: return super().transpose(**kwargs) def ref(self, inherit_style: bool = False, **kwargs) -> Col: return super().ref(inherit_style, **kwargs) @property def width(self): return 1 @property def height(self): return self.shape[0] def _repr_html_(self): from pandas import DataFrame header = "" if self.header is None else self.header elems_to_show = list(self) if len(self) <= 10 else list(self)[:10] if len(self) > 10: elems_to_show.append(f"(+{len(self)-10})") df = DataFrame(elems_to_show + [""], columns=[header]) return df.style.hide(axis="index")._repr_html_() def _border_mask(self, top, right, bottom, left) -> Style: return Style( first=[top, right, False, left], last=[False, right, bottom, left], middle=[False, right, False, left], ) @staticmethod def _inc_offset(offset: Loc, elem: Any) -> Loc: offset.y += elem.height return offset def _starting_offset(self) -> Loc: offset = Loc((0, 0), self._loc.ws) if getattr(self, "_header", None) is not None: offset.y += 1 return offset
[docs]class Row(_Series): _doc_custom_summary = """ * Direction: **horizontal** * Child Type: :class:`Cell` """ sibling_type = Col # these are set after class declaration elem_type = Cell def transpose(self, **kwargs) -> Col: return super().transpose(**kwargs) def ref(self, inherit_style: bool = False, **kwargs) -> Row: return super().ref(inherit_style, **kwargs) @property def width(self): return self.shape[0] @property def height(self): return 1 def _repr_html_(self): from pandas import DataFrame header = "" if self.header is None else self.header elems_to_show = list(self) if len(self) <= 10 else list(self)[:10] if len(self) > 10: elems_to_show.append(f"(+{len(self)-10})") df = DataFrame( [elems_to_show, ["" for _ in elems_to_show]], index=[header, ""], columns=["" for _ in elems_to_show], ) if self.header is None: return df.style.hide(axis="index")._repr_html_() return df._repr_html_() def _border_mask(self, top, right, bottom, left) -> Style: return Style( first=[top, False, bottom, left], last=[top, right, bottom, False], middle=[top, False, bottom, False], ) @staticmethod def _inc_offset(offset: Loc, elem: Any) -> Loc: offset.x += elem.width return offset def _starting_offset(self) -> Loc: offset = Loc((0, 0), self._loc.ws) if getattr(self, "_header", None) is not None: offset.x += 1 return offset
Col.sibling_type = Row Col.__doc__ = f""" {_Series._doc_primary_summary} {Col._doc_custom_summary} {_Series._doc_params} """ Row.__doc__ = f""" {_Series._doc_primary_summary} {Row._doc_custom_summary} {_Series._doc_params} """