Repository: pyjanitor-devs/pyjanitor

Issue:
Implement tidyxl for Excel files
# Brief Description

<!-- Please provide a brief description of what you'd like to propose. -->

I would like to propose an implementation of [tidyxl](https://cran.r-project.org/web/packages/tidyxl/vignettes/tidyxl.html) within Pyjanitor for non tabular data in Excel files.

The idea is to read in the Excel file, if it cannot be handled easily with Pandas because of the non tabular nature of the data; [tidyxl](https://cran.r-project.org/web/packages/tidyxl/vignettes/tidyxl.html) in R represents each cell in its own row, where it describes the cell’s address, value and other properties. The data can then be wrangled with Pandas/Pyjanitor in a vectorized fashion.

There is a [cookbook](https://nacnudus.github.io/spreadsheet-munging-strategies/case-studies.html) that shows the advantages of this approach. I feel it might be a way to ease wrangling Excel data in python, especially for non tabular data.




Discussion:
This looks like a neat idea, @samukweku! 

Resolving patch:
diff --git a/janitor/io.py b/janitor/io.py
--- a/janitor/io.py
+++ b/janitor/io.py
@@ -1,13 +1,18 @@
+from __future__ import annotations
 import os
 import subprocess
 from glob import glob
 from io import StringIO
-from typing import Iterable, Union
+from typing import Iterable, Union, TYPE_CHECKING, NamedTuple
+
 
 import pandas as pd
+import inspect
 
 from .errors import JanitorError
 from .utils import deprecated_alias, check, import_message
+from collections import defaultdict
+from itertools import chain
 
 
 @deprecated_alias(seperate_df="separate_df", filespath="files_path")
@@ -36,7 +41,7 @@ def read_csvs(
     # Sanitize input
     if files_path is None:
         raise JanitorError("`None` provided for `files_path`")
-    if len(files_path) == 0:
+    if not files_path:
         raise JanitorError("0 length `files_path` provided")
 
     # Read the csv files
@@ -52,7 +57,7 @@ def read_csvs(
             os.path.basename(f): pd.read_csv(f, **kwargs) for f in files_path
         }
     # Check if dataframes have been read
-    if len(dfs_dict) == 0:
+    if not dfs_dict:
         raise ValueError("No CSV files to read with the given `files_path`")
     # Concatenate the dataframes if requested (default)
     col_names = list(dfs_dict.values())[0].columns  # noqa: PD011
@@ -62,15 +67,15 @@ def read_csvs(
             if not all(df.columns == col_names):
                 raise ValueError(
                     "Columns in input CSV files do not match."
-                    "Files cannot be concatenated"
+                    "Files cannot be concatenated."
                 )
         return pd.concat(
             list(dfs_dict.values()),
             ignore_index=True,
             sort=False,  # noqa: PD011
+            copy=False,
         )
-    else:
-        return dfs_dict
+    return dfs_dict
 
 
 def read_commandline(cmd: str, **kwargs) -> pd.DataFrame:
@@ -99,22 +104,25 @@ def read_commandline(cmd: str, **kwargs) -> pd.DataFrame:
     :param cmd: Shell command to preprocess a file on disk.
     :param kwargs: Keyword arguments that are passed through to
         `pd.read_csv()`.
-    :raises JanitorError: If commandline command is malformed or invalid.
     :returns: A pandas DataFrame parsed from the stdout of the underlying
         shell.
     """
 
     check("cmd", cmd, [str])
-    outcome = subprocess.run(cmd, shell=True, capture_output=True, text=True)
-    if outcome.returncode != 0:
-        raise JanitorError(outcome.stderr)
-    else:
-        outcome = outcome.stdout
-    return pd.read_csv(StringIO(outcome), **kwargs)
+    # adding check=True ensures that an explicit, clear error
+    # is raised, so that the user can see the reason for the failure
+    outcome = subprocess.run(
+        cmd, shell=True, capture_output=True, text=True, check=True
+    )
+    return pd.read_csv(StringIO(outcome.stdout), **kwargs)
+
+
+if TYPE_CHECKING:
+    from openpyxl import Workbook
 
 
 def xlsx_table(
-    path: str,
+    path: Union[str, Workbook],
     sheetname: str,
     table: Union[str, list, tuple] = None,
 ) -> Union[pd.DataFrame, dict]:
@@ -132,49 +140,51 @@ def xlsx_table(
 
     Example:
 
-    ```python
-
-    filename = "excel_table.xlsx"
-
-    # single table
-    jn.xlsx_table(filename, sheetname='Tables', table = 'dCategory')
-
-        CategoryID      Category
-    0           1       Beginner
-    1           2       Advanced
-    2           3      Freestyle
-    3           4    Competition
-    4           5  Long Distance
-
-    # multiple tables:
-    jn.xlsx_table(filename, sheetname = 'Tables', table = ['dCategory', 'dSupplier'])
-
-    {'dCategory':    CategoryID       Category
-                    0           1       Beginner
-                    1           2       Advanced
-                    2           3      Freestyle
-                    3           4    Competition
-                    4           5  Long Distance,
-    'dSupplier':   SupplierID             Supplier        City State                         E-mail
-                    0         GB       Gel Boomerangs     Oakland    CA          gel@gel-boomerang.com
-                    1         CO  Colorado Boomerangs    Gunnison    CO  Pollock@coloradoboomerang.com
-                    2         CC        Channel Craft    Richland    WA                    Dino@CC.com
-                    3         DB        Darnell Booms  Burlington    VT            Darnell@Darnell.com}
-    ```
-
-    :param path: Path to the Excel File.
+        >>> import pandas as pd
+        >>> from janitor import xlsx_table
+        >>> filename="../pyjanitor/tests/test_data/016-MSPTDA-Excel.xlsx"
+
+        # single table
+        >>> xlsx_table(filename, sheetname='Tables', table='dCategory')
+           CategoryID       Category
+        0           1       Beginner
+        1           2       Advanced
+        2           3      Freestyle
+        3           4    Competition
+        4           5  Long Distance
+
+        # multiple tables:
+        >>> out=xlsx_table(filename, sheetname="Tables", table=["dCategory", "dSalesReps"])
+        >>> out["dCategory"]
+           CategoryID       Category
+        0           1       Beginner
+        1           2       Advanced
+        2           3      Freestyle
+        3           4    Competition
+        4           5  Long Distance
+        >>> out["dSalesReps"].head(3)
+           SalesRepID             SalesRep Region
+        0           1  Sioux Radcoolinator     NW
+        1           2        Tyrone Smithe     NE
+        2           3         Chantel Zoya     SW
+
+    :param path: Path to the Excel File. It can also be an openpyxl Workbook.
     :param sheetname: Name of the sheet from which the tables
         are to be extracted.
     :param table: Name of a table, or list of tables in the sheet.
     :returns: A pandas DataFrame, or a dictionary of DataFrames,
         if there are multiple arguments for the `table` parameter,
         or the argument to `table` is `None`.
+    :raises AttributeError: If a workbook is provided, and is a ReadOnlyWorksheet.
     :raises ValueError: If there are no tables in the sheet.
+    :raises KeyError: If the provided table does not exist in the sheet.
+
 
     """  # noqa : E501
 
     try:
         from openpyxl import load_workbook
+        from openpyxl.workbook.workbook import Workbook
     except ImportError:
         import_message(
             submodule="io",
@@ -182,41 +192,340 @@ def xlsx_table(
             conda_channel="conda-forge",
             pip_install=True,
         )
-    wb = load_workbook(filename=path, read_only=False, keep_links=False)
-    check("sheetname", sheetname, [str])
-    ws = wb[sheetname]
+    if isinstance(path, Workbook):
+        ws = path[sheetname]
+    else:
+        ws = load_workbook(
+            filename=path, read_only=False, keep_links=False, data_only=True
+        )
+        ws = ws[sheetname]
+
+    try:
+        contents = ws.tables
+    except AttributeError as error:
+        raise AttributeError(
+            "Accessing the tables is not supported for ReadOnlyWorksheet"
+        ) from error
 
-    contents = ws.tables
     if not contents:
-        raise ValueError(f"There is no table in `{sheetname}` sheet.")
+        raise ValueError(f"There is no table in '{sheetname}' sheet.")
+
+    class TableArgs(NamedTuple):
+        """
+        Named Tuple to easily index values
+        from the tables in the sheet.
+        """
+
+        table_name: str
+        ref: str
+        headerRowCount: int
 
     if isinstance(table, str):
         table = [table]
     if table is not None:
-        check("table", table, [list, tuple])
-        for entry in table:
-            if entry not in contents:
-                raise ValueError(
-                    f"{entry} is not a table in the {sheetname} sheet."
+        check("table", table, [str, list, tuple])
+        try:
+            data = []
+            for key in table:
+                outcome = TableArgs(
+                    key, contents[key].ref, contents[key].headerRowCount
                 )
+                data.append(outcome)
+        except KeyError as error:
+            raise KeyError(
+                f"Table {error} is not in the '{sheetname}' sheet."
+            ) from error
+    else:
         data = (
-            (key, value) for key, value in contents.items() if key in table
+            TableArgs(key, contents[key].ref, contents[key].headerRowCount)
+            for key in contents
         )
-    else:
-        data = contents.items()
 
     frame = {}
-    for key, value in data:
-        content = ((cell.value for cell in row) for row in ws[value])
-        if contents[key].headerRowCount == 1:
-            column_names = next(content)
-            content = zip(*content)
-            frame[key] = dict(zip(column_names, content))
+    for table_arg in data:
+        content = [[cell.value for cell in row] for row in ws[table_arg.ref]]
+
+        if table_arg.headerRowCount:
+            header, *content = content
         else:
-            content = zip(*content)
-            frame[key] = {f"C{num}": val for num, val in enumerate(content)}
+            header = [f"C{num}" for num in range(len(content[0]))]
+        frame[table_arg.table_name] = pd.DataFrame(
+            content, columns=header, copy=False
+        )
 
     if len(frame) == 1:
         _, frame = frame.popitem()
-        return pd.DataFrame(frame)
-    return {key: pd.DataFrame(value) for key, value in frame.items()}
+    return frame
+
+
+def xlsx_cells(
+    path: Union[str, Workbook],
+    sheetnames: Union[str, list, tuple] = None,
+    start_point: Union[str, int] = None,
+    end_point: Union[str, int] = None,
+    read_only: bool = True,
+    include_blank_cells: bool = True,
+    fill: bool = False,
+    font: bool = False,
+    alignment: bool = False,
+    border: bool = False,
+    protection: bool = False,
+    comment: bool = False,
+    **kwargs,
+) -> Union[dict, pd.DataFrame]:
+    """
+    Imports data from spreadsheet without coercing it into a rectangle.
+    Each cell is represented by a row in a dataframe, and includes the
+    cell's coordinates, the value, row and column position.
+    The cell formatting (fill, font, border, etc) can also be accessed;
+    usually this is returned as a dictionary in the cell, and the specific
+    cell format attribute can be accessed using `pd.Series.str.get`.
+
+    Inspiration for this comes from R's [tidyxl][link] package.
+    [link]: https://nacnudus.github.io/tidyxl/reference/tidyxl.html
+
+    Example:
+
+        >>> import pandas as pd
+        >>> from janitor import xlsx_cells
+        >>> pd.set_option("display.max_columns", None)
+        >>> pd.set_option("display.expand_frame_repr", False)
+        >>> pd.set_option("max_colwidth", None)
+        >>> filename = "../pyjanitor/tests/test_data/worked-examples.xlsx"
+
+        # Each cell is returned as a row:
+        >>> xlsx_cells(filename, sheetnames="highlights")
+            value internal_value coordinate  row  column data_type  is_date number_format
+        0     Age            Age         A1    1       1         s    False       General
+        1  Height         Height         B1    1       2         s    False       General
+        2       1              1         A2    2       1         n    False       General
+        3       2              2         B2    2       2         n    False       General
+        4       3              3         A3    3       1         n    False       General
+        5       4              4         B3    3       2         n    False       General
+        6       5              5         A4    4       1         n    False       General
+        7       6              6         B4    4       2         n    False       General
+
+        # Access cell formatting such as fill :
+        >>> out=xlsx_cells(filename, sheetnames="highlights", fill=True).select_columns("value", "fill")
+        >>> out
+            value                                                                                                                                              fill
+        0     Age     {'patternType': None, 'fgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}, 'bgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}}
+        1  Height     {'patternType': None, 'fgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}, 'bgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}}
+        2       1     {'patternType': None, 'fgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}, 'bgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}}
+        3       2     {'patternType': None, 'fgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}, 'bgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}}
+        4       3  {'patternType': 'solid', 'fgColor': {'rgb': 'FFFFFF00', 'type': 'rgb', 'tint': 0.0}, 'bgColor': {'rgb': 'FFFFFF00', 'type': 'rgb', 'tint': 0.0}}
+        5       4  {'patternType': 'solid', 'fgColor': {'rgb': 'FFFFFF00', 'type': 'rgb', 'tint': 0.0}, 'bgColor': {'rgb': 'FFFFFF00', 'type': 'rgb', 'tint': 0.0}}
+        6       5     {'patternType': None, 'fgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}, 'bgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}}
+        7       6     {'patternType': None, 'fgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}, 'bgColor': {'rgb': '00000000', 'type': 'rgb', 'tint': 0.0}}
+
+        # specific cell attributes can be accessed by using Pandas' series.str.get :
+        >>> out.fill.str.get("fgColor").str.get("rgb")
+        0    00000000
+        1    00000000
+        2    00000000
+        3    00000000
+        4    FFFFFF00
+        5    FFFFFF00
+        6    00000000
+        7    00000000
+        Name: fill, dtype: object
+
+    :param path: Path to the Excel File. It can also be an openpyxl Workbook.
+    :param sheetnames: Names of the sheets from which the cells are to be extracted.
+        If `None`, all the sheets in the file are extracted;
+        if it is a string, or list or tuple, only the specified sheets are extracted.
+    :param start_point: start coordinates of the Excel sheet. This is useful
+        if the user is only interested in a subsection of the sheet.
+        If start_point is provided, end_point must be provided as well.
+    :param end_point: end coordinates of the Excel sheet. This is useful
+        if the user is only interested in a subsection of the sheet.
+        If end_point is provided, start_point must be provided as well.
+    :param read_only: Determines if the entire file is loaded in memory,
+        or streamed. For memory efficiency, read_only should be set to `True`.
+        Some cell properties like `comment`, can only be accessed by
+        setting `read_only` to `False`.
+    :param include_blank_cells: Determines if cells without a value should be included.
+    :param fill: If `True`, return fill properties of the cell.
+        It is usually returned as a dictionary.
+    :param font: If `True`, return font properties of the cell.
+        It is usually returned as a dictionary.
+    :param alignment: If `True`, return alignment properties of the cell.
+        It is usually returned as a dictionary.
+    :param border: If `True`, return border properties of the cell.
+        It is usually returned as a dictionary.
+    :param protection: If `True`, return protection properties of the cell.
+        It is usually returned as a dictionary.
+    :param comment: If `True`, return comment properties of the cell.
+        It is usually returned as a dictionary.
+    :param kwargs: Any other attributes of the cell, that can be accessed from openpyxl.
+    :raises ValueError: If kwargs is provided, and one of the keys is a default column.
+    :raises AttributeError: If kwargs is provided and any of the keys
+        is not a openpyxl cell attribute.
+    :returns: A pandas DataFrame, or a dictionary of DataFrames.
+    """  # noqa : E501
+
+    try:
+        from openpyxl import load_workbook
+        from openpyxl.cell.read_only import ReadOnlyCell
+        from openpyxl.cell.cell import Cell
+        from openpyxl.workbook.workbook import Workbook
+    except ImportError:
+        import_message(
+            submodule="io",
+            package="openpyxl",
+            conda_channel="conda-forge",
+            pip_install=True,
+        )
+
+    path_is_workbook = isinstance(path, Workbook)
+    if not path_is_workbook:
+        # for memory efficiency, read_only is set to True
+        # if comments is True, read_only has to be False,
+        # as lazy loading is not enabled for comments
+        if comment and read_only:
+            raise ValueError(
+                "To access comments, kindly set 'read_only' to False."
+            )
+        path = load_workbook(
+            filename=path, read_only=read_only, keep_links=False
+        )
+    # start_point and end_point applies if the user is interested in
+    # only a subset of the Excel File and knows the coordinates
+    if start_point or end_point:
+        check("start_point", start_point, [str, int])
+        check("end_point", end_point, [str, int])
+
+    defaults = (
+        "value",
+        "internal_value",
+        "coordinate",
+        "row",
+        "column",
+        "data_type",
+        "is_date",
+        "number_format",
+    )
+
+    parameters = {
+        "fill": fill,
+        "font": font,
+        "alignment": alignment,
+        "border": border,
+        "protection": protection,
+        "comment": comment,
+    }
+
+    if kwargs:
+        if path_is_workbook:
+            if path.read_only:
+                _cell = ReadOnlyCell
+            else:
+                _cell = Cell
+        else:
+            if read_only:
+                _cell = ReadOnlyCell
+            else:
+                _cell = Cell
+
+        attrs = {
+            attr
+            for attr, _ in inspect.getmembers(_cell, not (inspect.isroutine))
+            if not attr.startswith("_")
+        }
+
+        for key in kwargs:
+            if key in defaults:
+                raise ValueError(
+                    f"{key} is part of the default attributes "
+                    "returned as a column."
+                )
+            elif key not in attrs:
+                raise AttributeError(
+                    f"{key} is not a recognized attribute of {_cell}."
+                )
+        parameters.update(kwargs)
+
+    if not sheetnames:
+        sheetnames = path.sheetnames
+    elif isinstance(sheetnames, str):
+        sheetnames = [sheetnames]
+    else:
+        check("sheetnames", sheetnames, [str, list, tuple])
+
+    out = {
+        sheetname: _xlsx_cells(
+            path[sheetname],
+            defaults,
+            parameters,
+            start_point,
+            end_point,
+            include_blank_cells,
+        )
+        for sheetname in sheetnames
+    }
+    if len(out) == 1:
+        _, out = out.popitem()
+
+    if (not path_is_workbook) and path.read_only:
+        path.close()
+
+    return out
+
+
+def _xlsx_cells(
+    wb: Workbook,
+    defaults: tuple,
+    parameters: dict,
+    start_point: Union[str, int],
+    end_point: Union[str, int],
+    include_blank_cells: bool,
+):
+    """
+    Function to process a single sheet. Returns a DataFrame.
+
+    :param wb: Openpyxl Workbook.
+    :param defaults: Sequence of default cell attributes.
+    :param parameters: Dictionary of cell attributes to be retrieved.
+        that will always be returned as columns.
+    :param start_point: start coordinates of the Excel sheet.
+    :param end_point: end coordinates of the Excel sheet.
+    :param include_blank_cells: Determines if empty cells should be included.
+    :param path_is_workbook: True/False.
+    :returns : A pandas DataFrame.
+    """
+
+    if start_point:
+        wb = wb[start_point:end_point]
+    wb = chain.from_iterable(wb)
+    frame = defaultdict(list)
+
+    for cell in wb:
+        if (cell.value is None) and (not include_blank_cells):
+            continue
+        for value in defaults:
+            frame[value].append(getattr(cell, value, None))
+        for parent, boolean_value in parameters.items():
+            check(f"The value for {parent}", boolean_value, [bool])
+            if not boolean_value:
+                continue
+            boolean_value = _object_to_dict(getattr(cell, parent, None))
+            frame[parent].append(boolean_value)
+
+    return pd.DataFrame(frame, copy=False)
+
+
+def _object_to_dict(obj):
+    """
+    Recursively get the attributes
+    of an object as a dictionary.
+
+    :param obj: Object whose attributes are to be extracted.
+    :returns: A dictionary or the object.
+    """
+    # https://stackoverflow.com/a/71366813/7175713
+    data = {}
+    if getattr(obj, "__dict__", None):
+        for key, value in obj.__dict__.items():
+            data[key] = _object_to_dict(value)
+        return data
+    return obj


Test patch:
diff --git a/tests/io/test_read_commandline.py b/tests/io/test_read_commandline.py
--- a/tests/io/test_read_commandline.py
+++ b/tests/io/test_read_commandline.py
@@ -1,10 +1,10 @@
 import os
+from subprocess import CalledProcessError
 
 import pandas as pd
 import pytest
 
 import janitor.io
-from janitor.errors import JanitorError
 
 
 def test_read_commandline(dataframe):
@@ -35,12 +35,12 @@ def test_read_commandline_bad_cmd(dataframe):
     Test 1 raises a TypeError if read_commandline
         is given an input that is not a string.
 
-    Test 2 raises a JanitorError if
+    Test 2 raises a CalledProcessError if
         read_commandline is given a string
         which is not a valid bash command.
 
     Test 3 raises an EmptyDataError if
-        read_commandlind is given a string which
+        read_commandline is given a string which
         is a valid bash command, however results
         in the shell not creating a dataframe.
     """
@@ -52,7 +52,7 @@ def test_read_commandline_bad_cmd(dataframe):
         janitor.io.read_commandline(6)
 
     # Test 2
-    with pytest.raises(JanitorError):
+    with pytest.raises(CalledProcessError):
         janitor.io.read_commandline("bad command")
 
     # Test 3
diff --git a/tests/io/test_tidyxl.py b/tests/io/test_tidyxl.py
new file mode 100644
--- /dev/null
+++ b/tests/io/test_tidyxl.py
@@ -0,0 +1,179 @@
+import pytest
+
+from janitor import io
+from pathlib import Path
+from openpyxl import load_workbook
+from pandas.testing import assert_series_equal
+
+
+TEST_DATA_DIR = "tests/test_data"
+filename = Path(TEST_DATA_DIR).joinpath("worked-examples.xlsx").resolve()
+
+
+wb = load_workbook(filename)
+wb_c = load_workbook(filename, read_only=True)
+
+
+def test_sheetname_type():
+    """Raise error if sheet name is the wrong type."""
+    with pytest.raises(TypeError, match="sheetnames should be one of .+"):
+        io.xlsx_cells(filename, 5)
+
+
+def test_sheetname_presence():
+    """Raise error if sheet name does not exist."""
+    with pytest.raises(KeyError, match="Worksheet 5 .+"):
+        io.xlsx_cells(filename, [5])
+
+
+def test_comment_read_only():
+    """Raise error if comment and read_only is True."""
+    with pytest.raises(ValueError, match="To access comments.+"):
+        io.xlsx_cells(filename, "clean", comment=True, read_only=True)
+
+
+def test_check_filename():
+    """Raise error if file does not exist."""
+    with pytest.raises(
+        FileNotFoundError, match=r"\[Errno 2\] No such file or directory.+"
+    ):
+        io.xlsx_cells("excel.xlsx", "clean")
+
+
+def test_check_start_none():
+    """
+    Raise error if start_point is None,
+    and end_point is not None.
+    """
+    with pytest.raises(TypeError, match="start_point should be one.+"):
+        io.xlsx_cells(filename, "clean", start_point=None, end_point="B5")
+
+
+def test_check_end_none():
+    """
+    Raise error if start_point is not None,
+    and end_point is None.
+    """
+    with pytest.raises(TypeError, match="end_point should be one.+"):
+        io.xlsx_cells(wb, "clean", start_point="A1", end_point=None)
+
+
+def test_output_kwargs_defaults():
+    """
+    Raise AttributeError if kwargs is provided
+    and the key is already part of the default attributes
+    that are returned as columns.
+    """
+    with pytest.raises(
+        ValueError, match="internal_value is part of the default attributes.+"
+    ):
+        io.xlsx_cells(wb, "clean", internal_value=True)
+
+
+def test_output_kwargs_defaults_read_only_true():
+    """
+    Raise AttributeError if kwargs is provided
+    and the key is already part of the default attributes
+    that are returned as columns.
+    """
+    with pytest.raises(
+        ValueError, match="internal_value is part of the default attributes.+"
+    ):
+        io.xlsx_cells(wb_c, "clean", internal_value=True)
+
+
+def test_output_wrong_kwargs():
+    """
+    Raise AttributeError if kwargs is provided
+    and the key is not a valid openpyxl cell attribute.
+    """
+    with pytest.raises(
+        AttributeError, match="font_color is not a recognized attribute of.+"
+    ):
+        io.xlsx_cells(filename, "clean", font_color=True)
+
+
+def test_output_wrong_kwargs_read_only_false():
+    """
+    Raise AttributeError if kwargs is provided
+    and the key is not a valid openpyxl cell attribute.
+    """
+    with pytest.raises(
+        AttributeError, match="font_color is not a recognized attribute of.+"
+    ):
+        io.xlsx_cells(filename, "clean", font_color=True, read_only=False)
+
+
+def test_default_values():
+    """
+    Test output for default values.
+    """
+    assert "Matilda" in io.xlsx_cells(wb, "clean")["value"].tolist()
+
+
+def test_default_values_blank_cells_true():
+    """
+    Test output for default values if include_blank_cells.
+    """
+    assert None in io.xlsx_cells(filename, "pivot-notes")["value"].tolist()
+
+
+def test_default_values_blank_cells_false():
+    """
+    Test output for default values if include_blank_cells is False.
+    """
+    assert (
+        None
+        not in io.xlsx_cells(
+            wb,
+            "pivot-notes",
+            include_blank_cells=False,
+            start_point="A1",
+            end_point="G7",
+        )["value"].tolist()
+    )
+
+
+def test_output_parameters():
+    """Test output for existing parameters."""
+    result = (
+        io.xlsx_cells(filename, "pivot-notes", font=True)["font"]
+        .str.get("name")
+        .tolist()
+    )
+    assert "Calibri" in result
+
+
+def test_output_kwargs():
+    """Test output for extra attributes via kwargs."""
+    result = io.xlsx_cells(wb, "pivot-notes", col_idx=True)
+    assert_series_equal(result["column"], result["col_idx"].rename("column"))
+
+
+def test_output_kwargs_type():
+    """Test output for parameters if value is not boolean."""
+    with pytest.raises(TypeError):
+        io.xlsx_cells(wb, "pivot-notes", col_idx="True")
+
+
+def test_output_sheetnames_sequence():
+    """Test output if sheetnames is a list."""
+    result = (
+        io.xlsx_cells(filename, sheetnames=["pivot-notes"], font=True)["font"]
+        .str.get("name")
+        .tolist()
+    )
+    assert "Calibri" in result
+
+
+def test_output_sheetnames_none():
+    """Test output if sheetnames is None."""
+    result = (
+        io.xlsx_cells(wb, sheetnames=None, font=True)["pivot-notes"]["font"]
+        .str.get("name")
+        .tolist()
+    )
+    assert "Calibri" in result
+
+
+wb_c.close()
diff --git a/tests/io/test_xlsx_table.py b/tests/io/test_xlsx_table.py
--- a/tests/io/test_xlsx_table.py
+++ b/tests/io/test_xlsx_table.py
@@ -4,6 +4,7 @@
 from janitor import io
 from pandas.testing import assert_frame_equal
 from pathlib import Path
+from openpyxl import load_workbook
 
 
 TEST_DATA_DIR = "tests/test_data"
@@ -18,8 +19,8 @@
 
 
 def test_check_sheetname():
-    """Test sheetname is a string."""
-    with pytest.raises(TypeError):
+    """Raise KeyError if sheetname does not exist."""
+    with pytest.raises(KeyError):
         io.xlsx_table(filename, 1, None)
 
 
@@ -31,7 +32,9 @@ def test_check_filename():
 
 def test_table_exists():
     """Raise error if there is no table in the sheet."""
-    with pytest.raises(ValueError):
+    with pytest.raises(
+        ValueError, match="There is no table in 'Cover' sheet."
+    ):
         io.xlsx_table(filename, "Cover")
 
 
@@ -40,10 +43,25 @@ def test_table_name():
     Raise error if `table` is not None,
     and the table name cannot be found.
     """
-    with pytest.raises(ValueError):
+    with pytest.raises(
+        KeyError, match="Table 'fake' is not in the 'Tables' sheet."
+    ):
         io.xlsx_table(filename, "Tables", table="fake")
 
 
+def test_wb_read_only():
+    """
+    Raise error if Workbook is provided, and read_only is True.
+    """
+    wb = load_workbook(filename, read_only=True)
+    with pytest.raises(
+        AttributeError,
+        match="Accessing the tables.+",
+    ):
+        io.xlsx_table(wb, "Tables")
+    wb.close()
+
+
 def test_table_str():
     """Test output for single table."""
     expected = io.xlsx_table(filename, "Tables", "dSupplier")
@@ -72,7 +90,8 @@ def test_table_no_header():
 
 def test_tables():
     """Test output for multiple tables."""
-    expected = io.xlsx_table(filename, "Tables", ("dSalesReps", "dSupplier"))
+    wb = load_workbook(filename, read_only=False)
+    expected = io.xlsx_table(wb, "Tables", ("dSalesReps", "dSupplier"))
     actual = {
         "dSalesReps": pd.read_excel(
             filename, engine="openpyxl", sheet_name="Tables", usecols="A:C"
@@ -87,7 +106,7 @@ def test_tables():
         assert_frame_equal(value, actual[key])
 
 
-def test_tables_None():
+def test_tables_none():
     """Test output for multiple tables."""
     expected = io.xlsx_table(filename, "Tables")
     actual = {
diff --git a/tests/test_data/worked-examples.xlsx b/tests/test_data/worked-examples.xlsx
new file mode 100644
Binary files /dev/null and b/tests/test_data/worked-examples.xlsx differ