diff --git a/MANIFEST.in b/MANIFEST.in index 8832b34c5..0694b399f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include COPYING -recursive-include larray/tests/data *.csv *.xlsx *.h5 \ No newline at end of file +recursive-include larray/tests/data *.csv *.xlsx *.h5 +recursive-include larray/tests/excel_template *.crtx \ No newline at end of file diff --git a/condarecipe/larray/meta.yaml b/condarecipe/larray/meta.yaml index 159ed36c1..45a29ae36 100644 --- a/condarecipe/larray/meta.yaml +++ b/condarecipe/larray/meta.yaml @@ -1,9 +1,9 @@ package: name: larray - version: 0.30 + version: 0.31-dev source: - git_tag: 0.30 + git_tag: 0.31-dev git_url: https://github.com/larray-project/larray.git # git_tag: master # git_url: file://c:/Users/gdm/devel/larray/.git diff --git a/doc/source/api.rst b/doc/source/api.rst index eb830a760..9f4d8363b 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -675,6 +675,37 @@ Excel Workbook.close Workbook.app +ExcelReport +=========== + +.. autosummary:: + :toctree: _generated/ + + ExcelReport + ExcelReport.template_dir + ExcelReport.template + ExcelReport.set_item_default_size + ExcelReport.graphs_per_row + ExcelReport.new_sheet + ExcelReport.sheet_names + ExcelReport.to_excel + +ReportSheet +=========== + +.. autosummary:: + :toctree: _generated/ + + ReportSheet + ReportSheet.template_dir + ReportSheet.template + ReportSheet.set_item_default_size + ReportSheet.graphs_per_row + ReportSheet.add_title + ReportSheet.add_graph + ReportSheet.add_graphs + ReportSheet.newline + .. _api-misc: Miscellaneous diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 65762e33e..53d972b8f 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -1,6 +1,14 @@ Change log ########## +Version 0.31 +============ + +In development. + +.. include:: ./changes/version_0_31.rst.inc + + Version 0.30 ============ diff --git a/doc/source/changes/version_0_31.rst.inc b/doc/source/changes/version_0_31.rst.inc new file mode 100644 index 000000000..6d6f1fde9 --- /dev/null +++ b/doc/source/changes/version_0_31.rst.inc @@ -0,0 +1,36 @@ +.. py:currentmodule:: larray + + +Syntax changes +^^^^^^^^^^^^^^ + +* renamed ``LArray.old_method_name()`` to :py:obj:`LArray.new_method_name()` (closes :issue:`1`). + +* renamed ``old_argument_name`` argument of :py:obj:`LArray.method_name()` to ``new_argument_name``. + + +Backward incompatible changes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* other backward incompatible changes + + +New features +^^^^^^^^^^^^ + +* added the :py:obj:`ExcelReport` class allowing to generate multiple graphs in an + Excel file at once (closes :issue:`676`). + + +.. _misc: + +Miscellaneous improvements +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +* improved something. + + +Fixes +^^^^^ + +* fixed something (closes :issue:`1`). diff --git a/larray/__init__.py b/larray/__init__.py index a8e04ac6a..9f675ecea 100644 --- a/larray/__init__.py +++ b/larray/__init__.py @@ -1,6 +1,6 @@ from __future__ import absolute_import, division, print_function -__version__ = '0.30' +__version__ = '0.31-dev' from larray.core.axis import Axis, AxisCollection, X @@ -30,6 +30,7 @@ from larray.inout.sas import read_sas from larray.inout.stata import read_stata from larray.inout.xw_excel import open_excel, Workbook +from larray.inout.xw_reporting import ExcelReport # just make sure handlers for .pkl and .pickle are initialized import larray.inout.pickle as _pkl @@ -41,7 +42,7 @@ from larray.extra.ipfp import ipfp -from larray.example import get_example_filepath, load_example_data +from larray.example import get_example_filepath, load_example_data, EXAMPLE_EXCEL_TEMPLATES_DIR import larray.random @@ -75,7 +76,8 @@ 'real_if_close', 'interp', 'isnan', 'isinf', 'inverse', # inout 'from_lists', 'from_string', 'from_frame', 'from_series', 'read_csv', 'read_tsv', - 'read_eurostat', 'read_excel', 'read_hdf', 'read_sas', 'read_stata', 'open_excel', 'Workbook', + 'read_eurostat', 'read_excel', 'read_hdf', 'read_sas', 'read_stata', + 'open_excel', 'Workbook', 'ExcelReport', # utils 'get_options', 'set_options', # viewer @@ -83,7 +85,7 @@ # ipfp 'ipfp', # example - 'get_example_filepath', 'load_example_data', + 'get_example_filepath', 'load_example_data', 'EXAMPLE_EXCEL_TEMPLATES_DIR', ] diff --git a/larray/example.py b/larray/example.py index 302b74be8..a116998f0 100644 --- a/larray/example.py +++ b/larray/example.py @@ -2,12 +2,18 @@ import larray as la -EXAMPLE_FILES_DIR = os.path.dirname(__file__) + '/tests/data/' +_TEST_DIR = os.path.join(os.path.dirname(__file__), 'tests') + +EXAMPLE_FILES_DIR = os.path.join(_TEST_DIR, 'data') +# TODO : replace 'demography.h5' by 'population_session.h5' and remove 'demo' ? AVAILABLE_EXAMPLE_DATA = { + 'demo': os.path.join(EXAMPLE_FILES_DIR, 'population_session.h5'), 'demography': os.path.join(EXAMPLE_FILES_DIR, 'demography.h5') } AVAILABLE_EXAMPLE_FILES = os.listdir(EXAMPLE_FILES_DIR) +EXAMPLE_EXCEL_TEMPLATES_DIR = os.path.join(_TEST_DIR, 'excel_template') + def get_example_filepath(fname): """Return absolute path to an example file if exist. diff --git a/larray/inout/xw_reporting.py b/larray/inout/xw_reporting.py new file mode 100644 index 000000000..8c4470ef7 --- /dev/null +++ b/larray/inout/xw_reporting.py @@ -0,0 +1,765 @@ +import os +import warnings +from collections import OrderedDict + +from larray.util.misc import PY2, _positive_integer, _validate_dir +from larray.core.group import _translate_sheet_name +from larray.core.array import aslarray, zip_array_items +from larray.example import load_example_data, EXAMPLE_EXCEL_TEMPLATES_DIR + +try: + import xlwings as xw +except ImportError: + xw = None + + +_default_items_size = {} + + +def _validate_template_filename(filename): + filename, ext = os.path.splitext(filename) + if not ext: + ext = '.crtx' + if ext != '.crtx': + raise ValueError("Extension for the excel template file must be '.crtx' " + "instead of {}".format(ext)) + return filename + ext + + +class AbstractReportItem(object): + def __init__(self, template_dir=None, template=None, graphs_per_row=1): + self.template_dir = template_dir + self.template = template + self.default_items_size = _default_items_size.copy() + self.graphs_per_row = graphs_per_row + + @property + def template_dir(self): + r""" + Set the path to the directory containing the Excel template files (with '.crtx' extension). + This method is mainly useful if your template files are located in several directories, + otherwise pass the template directory directly the ExcelReport constructor. + + Parameters + ---------- + template_dir : str + Path to the directory containing the Excel template files. + + See Also + -------- + set_graph_template + + Examples + -------- + >>> report = ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + >>> # ... add some graphs using template files from 'C:\excel_templates_dir' + >>> report.template_dir = r'C:\other_templates_dir' # doctest: +SKIP + >>> # ... add some graphs using template files from 'C:\other_templates_dir' + """ + return self._template_dir + + @template_dir.setter + def template_dir(self, template_dir): + if template_dir is not None: + _validate_dir(template_dir) + template_dir = template_dir + self._template_dir = template_dir + + @property + def template(self): + r""" + Set a default Excel template file. + + Parameters + ---------- + template_file : str + Name of the template to be used as default template. + The extension '.crtx' will be added if not given. + The full path to the template file must be given if no template directory has been set. + + Examples + -------- + >>> demo = load_example_data('demo') + + Passing the name of the template (only if a template directory has been set) + + >>> report = ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + >>> report.template = 'Line' + + >>> sheet_pop = report.new_sheet('Population') + >>> sheet_pop.add_graph(demo.pop['Belgium'],'Belgium') + + Passing the full path of the template file + + >>> # if no default template directory has been set + >>> # or if the new template is located in another directory, + >>> # you must provide the full path + >>> sheet_pop.template = r'C:\other_templates_dir\Line_Marker.crtx' # doctest: +SKIP + >>> sheet_pop.add_graph(demo.pop['Germany'],'Germany') # doctest: +SKIP + """ + return self._template + + @template.setter + def template(self, template): + if template is not None: + if self.template_dir is None: + raise RuntimeError("Please set template_dir first") + filename = _validate_template_filename(template) + template = os.path.join(self.template_dir, filename) + self._template = template + + def set_item_default_size(self, kind, width=None, height=None): + r""" + Override the default 'width' and 'height' values for the given kind of item. + A new value must be provided at least for 'width' or 'height'. + + Parameters + ---------- + kind : str + kind of item for which default values of 'width' and/or 'height' are modified. + Currently available kinds are 'title' and 'graph'. + width : int, optional + new default width value. + height : int, optional + new default height value. + + Examples + -------- + >>> report = ExcelReport() + >>> report.set_item_default_size('graph', width=450, height=250) + """ + if width is None and height is None: + raise ValueError("No value provided for both 'width' and 'heigth'. " + "Please provide one for at least 'width' or 'heigth'") + if kind not in self.default_items_size: + raise ValueError("Type item {} is not registered. Please choose in " + "list {}".format(kind, self.default_items_size.keys())) + if width is None: + width = self.default_items_size[kind].width + if height is None: + height = self.default_items_size[kind].height + self.default_items_size[kind] = ItemSize(width, height) + + @property + def graphs_per_row(self): + r""" + Default number of graphs per row. + + Parameters + ---------- + graphs_per_row: int + + See Also + -------- + :py:obj:``~AbstractSheetReport.newline` + """ + return self._graphs_per_row + + @graphs_per_row.setter + def graphs_per_row(self, graphs_per_row): + _positive_integer(graphs_per_row) + self._graphs_per_row = graphs_per_row + + +class AbstractReportSheet(AbstractReportItem): + r""" + Represents a sheet dedicated to contains only graphical items (title banners, graphs). + See :py:obj:`ExcelReport` for use cases. + + Parameters + ---------- + template_dir : str, optional + Path to the directory containing the Excel template files (with a '.crtx' extension). + Defaults to None. + template : str, optional + Name of the template to be used as default template. + The extension '.crtx' will be added if not given. + The full path to the template file must be given if no template directory has been set. + Defaults to None. + graphs_per_row: int, optional + Default number of graphs per row. + Defaults to 1. + + See Also + -------- + ExcelReport + """ + def add_title(self, title, width=None, height=None, fontsize=11): + r""" + Add a title item to the current sheet. + Note that the current method only add a new item to the list of items to be generated. + The report Excel file is generated only when the :py:obj:`~ExcelReport.to_excel` is called. + + Parameters + ---------- + title : str + Text to write in the title item. + width : int, optional + width of the title item. The current default value is used if None + (see :py:obj:`~ExcelReport.set_item_default_size`). Defaults to None. + height : int, optional + height of the title item. The current default value is used if None + (see :py:obj:`~ExcelReport.set_item_default_size`). Defaults to None. + fontsize : int, optional + fontsize of the displayed text. Defaults to 11. + + Examples + -------- + >>> report = ExcelReport() + + >>> first_sheet = report.new_sheet('First_sheet') + >>> first_sheet.add_title('Title banner with default width, height and fontsize') + >>> first_sheet.add_title('Larger title banner', width=1200, height=100) + >>> first_sheet.add_title('Bigger fontsize', fontsize=13) + + >>> # do not forget to call 'to_excel' to create the report file + >>> report.to_excel('Report.xlsx') + """ + pass + + def add_graph(self, data, title=None, template=None, width=None, height=None): + r""" + Add a graph item to the current sheet. + Note that the current method only add a new item to the list of items to be generated. + The report Excel file is generated only when the :py:obj:`~ExcelReport.to_excel` is called. + + Parameters + ---------- + data : 1D or 2D array-like + 1D or 2D array representing the data associated with the graph. + The first row represents the abscissa labels. + Each additional row represents a new series and must start with the name of the current series. + title : str, optional + title of the graph. Defaults to None. + template : str, optional + name of the template to be used to generate the graph. + The full path to the template file must be provided if no template directory has not been set + or if the template file belongs to another directory. + Defaults to the defined template (see :py:obj:`~ExcelReport.set_graph_template`). + width : int, optional + width of the title item. The current default value is used if None + (see :py:obj:`~ExcelReport.set_item_default_size`). Defaults to None. + height : int, optional + height of the title item. The current default value is used if None + (see :py:obj:`~ExcelReport.set_item_default_size`). Defaults to None. + + Examples + -------- + >>> demo = load_example_data('demo') + >>> report = ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + + >>> sheet_be = report.new_sheet('Belgium') + + Specifying the 'template' + + >>> sheet_be.add_graph(demo.pop['Belgium'], 'Population', template='Line') + + Specifying the 'template', 'width' and 'height' values + + >>> sheet_be.add_graph(demo.births['Belgium'], 'Births', template='Line', width=450, height=250) + + Setting a default template + + >>> sheet_be.template = 'Line_Marker' + >>> sheet_be.add_graph(demo.deaths['Belgium'], 'Deaths') + + Dumping the report Excel file + + >>> # do not forget to call 'to_excel' to create the report file + >>> report.to_excel('Demography_Report.xlsx') + """ + pass + + def add_graphs(self, array_per_title, axis_per_loop_variable, template=None, width=None, height=None, + graphs_per_row=1): + r""" + + Parameters + ---------- + array_per_title: dict + dictionary containing pairs (title template, array). + axis_per_loop_variable: dict + dictionary containing pairs (variable used in the title template, axis). + template : str, optional + name of the template to be used to generate the graph. + The full path to the template file must be provided if no template directory has not been set + or if the template file belongs to another directory. + Defaults to the defined template (see :py:obj:`~ExcelReport.set_graph_template`). + width : int, optional + width of the title item. The current default value is used if None + (see :py:obj:`~ExcelReport.set_item_default_size`). Defaults to None. + height : int, optional + height of the title item. The current default value is used if None + (see :py:obj:`~ExcelReport.set_item_default_size`). Defaults to None. + graphs_per_row: int, optional + Number of graphs per row. Defaults to 1. + + + Examples + -------- + >>> demo = load_example_data('demo') + >>> report = ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + + >>> sheet_pop = report.new_sheet('Population') + >>> pop = demo.pop + + >>> sheet_pop.add_graphs({'Population of {gender} by country for the year {year}': pop}, + ... {'gender': pop.gender, 'year': pop.time}, + ... template='line', width=450, height=250, graphs_per_row=2) + + >>> # do not forget to call 'to_excel' to create the report file + >>> report.to_excel('Demography_Report.xlsx') + """ + pass + + def newline(self): + r""" + Force a new row of graphs. + """ + pass + + +class AbstractExcelReport(AbstractReportItem): + r""" + Automate the generation of multiple graphs in an Excel file. + + The ExcelReport instance is initially populated with information + (data, title, destination sheet, template, size) required to create the graphs. + Once all information has been provided, the :py:obj:`~ExcelReport.to_excel` method + is called to generate an Excel file with all graphs in one step. + + Parameters + ---------- + template_dir : str, optional + Path to the directory containing the Excel template files (with a '.crtx' extension). + Defaults to None. + template : str, optional + Name of the template to be used as default template. + The extension '.crtx' will be added if not given. + The full path to the template file must be given if no template directory has been set. + Defaults to None. + graphs_per_row: int, optional + Default number of graphs per row. + Defaults to 1. + + Notes + ----- + The data associated with all graphical items is dumped in the same sheet named '__data__'. + + Examples + -------- + >>> demo = load_example_data('demo') + >>> report = ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + + Set a new destination sheet + + >>> sheet_be = report.new_sheet('Belgium') + + Add a new title item + + >>> sheet_be.add_title('Population, births and deaths') + + Add a new graph item (each new graph is placed right to previous one unless you use newline() or add_title()) + + >>> # using default 'width' and 'height' values + >>> sheet_be.add_graph(demo.pop['Belgium'], 'Population', template='Line') + >>> # specifying the 'width' and 'height' values + >>> sheet_be.add_graph(demo.births['Belgium'], 'Births', template='Line', width=450, height=250) + + Override the default 'width' and 'height' values for graphs + + >>> sheet_be.set_item_default_size('graph', width=450, height=250) + >>> # add a new graph with the new default 'width' and 'height' values + >>> sheet_be.add_graph(demo.deaths['Belgium'], 'Deaths') + + Set a default template for all next graphs + + >>> # if a default template directory has been set, just pass the name + >>> sheet_be.template = 'Line' + >>> # otherwise, give the full path to the template file + >>> sheet_be.template = r'C:\other_template_dir\Line_Marker.crtx' # doctest: +SKIP + >>> # add a new graph with the default template + >>> sheet_be.add_graph(demo.pop['Belgium', 'Female'], 'Population - Female') + >>> sheet_be.add_graph(demo.pop['Belgium', 'Male'], 'Population - Male') + + Specify the number of graphs per row + + >>> sheet_countries = report.new_sheet('All countries') + + >>> sheet_countries.graphs_per_row = 2 + >>> for combined_labels, subset in demo.pop.items(('time', 'gender')): + ... title = ' - '.join([str(label) for label in combined_labels]) + ... sheet_countries.add_graph(subset, title) + + Force a new row of graphs + + >>> sheet_countries.newline() + + Add multiple graphs at once + + >>> sheet_countries.add_graphs({'Population of {gender} by country for the year {year}': pop}, + ... {'gender': pop.gender, 'year': pop.time}, + ... template='line', width=450, height=250, graphs_per_row=2) + + Generate the report Excel file + + >>> report.to_excel('Demography_Report.xlsx') + """ + + def new_sheet(self, sheet_name): + r""" + Add a new empty output sheet. + This sheet will contain only graphical elements, all data are exported + to a dedicated separate sheet. + + Parameters + ---------- + sheet_name : str + name of the current sheet. + + Returns + ------- + sheet: SheetReport + + Examples + -------- + >>> demo = load_example_data('demo') + >>> report = ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + + >>> # prepare new output sheet named 'Belgium' + >>> sheet_be = report.new_sheet('Belgium') + + >>> # add graph to the output sheet 'Belgium' + >>> sheet_be.add_graph(demo.pop['Belgium'], 'Population', template='Line') + """ + pass + + def sheet_names(self): + r""" + Returns the names of the output sheets. + + Examples + -------- + >>> report = ExcelReport() + >>> sheet_pop = report.new_sheet('Pop') + >>> sheet_births = report.new_sheet('Births') + >>> sheet_deaths = report.new_sheet('Deaths') + >>> report.sheet_names() + ['Pop', 'Births', 'Deaths'] + """ + pass + + def to_excel(self, filepath, data_sheet_name='__data__', overwrite=True): + r""" + Generate the report Excel file. + + Parameters + ---------- + filepath : str + Path of the report file for the dump. + data_sheet_name : str, optional + name of the Excel sheet where all data associated with items is dumped. + Defaults to '__data__'. + overwrite : bool, optional + whether or not to overwrite an existing report file. + Defaults to True. + + Examples + -------- + >>> demo = load_example_data('demo') + >>> report = ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + >>> report.template = 'Line_Marker' + + >>> for c in demo.country: + ... sheet_country = report.new_sheet(c) + ... sheet_country.add_graph(demo.pop[c], 'Population') + ... sheet_country.add_graph(demo.births[c], 'Births') + ... sheet_country.add_graph(demo.deaths[c], 'Deaths') + + Basic usage + + >>> report.to_excel('Demography_Report.xlsx') + + Alternative data sheet name + + >>> report.to_excel('Demography_Report.xlsx', data_sheet_name='Data Tables') # doctest: +SKIP + + Check if ouput file already exists + + >>> report.to_excel('Demography_Report.xlsx', overwrite=False) # doctest: +SKIP + Traceback (most recent call last): + ... + ValueError: Sheet named 'Belgium' already present in workbook + """ + pass + + +if xw is not None: + from xlwings.constants import LegendPosition, HAlign, VAlign, ChartType, RowCol + from larray.inout.xw_excel import open_excel + + class ItemSize(object): + def __init__(self, width, height): + self.width = width + self.height = height + + @property + def width(self): + return self._width + + @width.setter + def width(self, width): + _positive_integer(width) + self._width = width + + @property + def height(self): + return self._height + + @height.setter + def height(self, height): + _positive_integer(height) + self._height = height + + + class ExcelTitleItem(ItemSize): + + _default_size = ItemSize(1000, 50) + + def __init__(self, text, fontsize, top, left, width, height): + ItemSize.__init__(self, width, height) + self.top = top + self.left = left + self.text = str(text) + _positive_integer(fontsize) + self.fontsize = fontsize + + def dump(self, sheet, data_sheet, row): + data_cells = data_sheet.Cells + # add title in data sheet + data_cells(row, 1).Value = self.text + # generate title banner in destination sheet + msoShapeRectangle = 1 + msoThemeColorBackground1 = 14 + sheet_shapes = sheet.Shapes + shp = sheet_shapes.AddShape(Type=msoShapeRectangle, Left=self.left, Top=self.top, + Width=self.width, Height=self.height) + fill = shp.Fill + fill.ForeColor.ObjectThemeColor = msoThemeColorBackground1 + fill.Solid() + shp.Line.Visible = False + frame = shp.TextFrame + chars = frame.Characters() + chars.Text = self.text + font = chars.Font + font.Color = 1 + font.Bold = True + font.Size = self.fontsize + frame.HorizontalAlignment = HAlign.xlHAlignLeft + frame.VerticalAlignment = VAlign.xlVAlignCenter + shp.SetShapesDefaultProperties() + # update and return current row position + return row + 2 + + _default_items_size['title'] = ExcelTitleItem._default_size + + class ExcelGraphItem(ItemSize): + + _default_size = ItemSize(427, 230) + + def __init__(self, data, title, template, top, left, width, height): + ItemSize.__init__(self, width, height) + self.top = top + self.left = left + self.title = str(title) if title is not None else None + data = aslarray(data) + if not (1 <= data.ndim <= 2): + raise ValueError("Expected 1D or 2D array for data argument. " + "Got array of dimensions {}".format(data.ndim)) + self.data = data + if template is not None and not os.path.isfile(template): + raise ValueError("Could not find template file {}".format(template)) + self.template = template + + def dump(self, sheet, data_sheet, row): + data_range = data_sheet.Range + data_cells = data_sheet.Cells + # write graph title in data sheet + data_cells(row, 1).Value = self.title + row += 1 + # dump data to make the graph in data sheet + data = self.data + nb_series = 1 if data.ndim == 1 else data.shape[0] + nb_xticks = data.size if data.ndim == 1 else data.shape[1] + last_row, last_col = row + nb_series, nb_xticks + 1 + data_range(data_cells(row, 1), data_cells(last_row, last_col)).Value = data.dump(na_repr=None) + data_cells(row, 1).Value = '' + # generate graph in destination sheet + sheet_charts = sheet.ChartObjects() + obj = sheet_charts.Add(self.left, self.top, self.width, self.height) + obj_chart = obj.Chart + source = data_range(data_cells(row, 1), data_cells(last_row, last_col)) + obj_chart.SetSourceData(source) + obj_chart.ChartType = ChartType.xlLine + if self.title is not None: + obj_chart.HasTitle = True + obj_chart.ChartTitle.Caption = self.title + obj_chart.Legend.Position = LegendPosition.xlLegendPositionBottom + if self.template is not None: + obj_chart.ApplyChartTemplate(self.template) + # flagflip + if nb_series > 1 and nb_xticks == 1: + obj_chart.PlotBy = RowCol.xlRows + # update and return current row position + return row + nb_series + 2 + + _default_items_size['graph'] = ExcelGraphItem._default_size + + class ReportSheet(AbstractReportSheet): + def __init__(self, excel_report, name, template_dir=None, template=None, graphs_per_row=1): + name = _translate_sheet_name(name) + self.excel_report = excel_report + self.name = name + self.items = [] + self.top = 0 + self.left = 0 + self.position_in_row = 1 + self.curline_height = 0 + if template_dir is None: + template_dir = excel_report.template_dir + if template is None: + template = excel_report.template + AbstractReportSheet.__init__(self, template_dir, template, graphs_per_row) + + def add_title(self, title, width=None, height=None, fontsize=11): + if width is None: + width = self.default_items_size['title'].width + if height is None: + height = self.default_items_size['title'].height + self.newline() + self.items.append(ExcelTitleItem(title, fontsize, self.top, 0, width, height)) + self.top += height + + def add_graph(self, data, title=None, template=None, width=None, height=None): + if width is None: + width = self.default_items_size['graph'].width + if height is None: + height = self.default_items_size['graph'].height + if template is not None: + self.template = template + template = self.template + if self.graphs_per_row is not None and self.position_in_row > self.graphs_per_row: + self.newline() + self.items.append(ExcelGraphItem(data, title, template, self.top, self.left, width, height)) + self.left += width + self.curline_height = max(self.curline_height, height) + self.position_in_row += 1 + + def add_graphs(self, array_per_title, axis_per_loop_variable, template=None, width=None, height=None, + graphs_per_row=1): + loop_variable_names = axis_per_loop_variable.keys() + axes = tuple(axis_per_loop_variable.values()) + titles = array_per_title.keys() + arrays = array_per_title.values() + if graphs_per_row is not None: + previous_graphs_per_row = self.graphs_per_row + self.graphs_per_row = graphs_per_row + if self.position_in_row > 1: + self.newline() + for loop_variable_values, arrays_chunk in zip_array_items(arrays, axes=axes): + loop_variables_dict = dict(zip(loop_variable_names, loop_variable_values)) + for title_template, array_chunk in zip(titles, arrays_chunk): + title = title_template.format(**loop_variables_dict) + self.add_graph(array_chunk, title, template, width, height) + if graphs_per_row is not None: + self.graphs_per_row = previous_graphs_per_row + + def newline(self): + self.top += self.curline_height + self.curline_height = 0 + self.left = 0 + self.position_in_row = 1 + + def _to_excel(self, workbook, data_row): + # use first sheet as data sheet + data_sheet = workbook.Worksheets(1) + data_cells = data_sheet.Cells + # write destination sheet name in data sheet + data_cells(data_row, 1).Value = self.name + data_row += 2 + # create new empty sheet in workbook (will contain output graphical items) + # Hack, since just specifying "After" is broken in certain environments + # see: https://stackoverflow.com/questions/40179804/adding-excel-sheets-to-end-of-workbook + dest_sheet = workbook.Worksheets.Add(Before=None, After=workbook.Sheets(workbook.Sheets.Count)) + dest_sheet.Name = self.name + # for each item, dump data + generate associated graphical items + for item in self.items: + data_row = item.dump(dest_sheet, data_sheet, data_row) + # reset + self.top = 0 + self.left = 0 + self.curline_height = 0 + # return current row in data sheet + return data_row + + # TODO : add a new section about this class in the tutorial + class ExcelReport(AbstractExcelReport): + def __init__(self, template_dir=None, template=None, graphs_per_row=1): + AbstractExcelReport.__init__(self, template_dir, template, graphs_per_row) + self.sheets = OrderedDict() + + def sheet_names(self): + return [sheet_name for sheet_name in self.sheets.keys()] + + def __getitem__(self, key): + return self.sheets[key] + + # TODO : Do not implement __setitem__ and move code below to new_sheet()? + def __setitem__(self, key, value): + if not isinstance(value, ReportSheet): + raise ValueError('Expected SheetReport object. ' + 'Got {} object instead.'.format(type(value).__name__)) + if key in self.sheet_names(): + warnings.warn("Sheet '{}' already exists in the report and will be reset".format(key)) + self.sheets[key] = value + + def __delitem__(self, key): + del self.sheets[key] + + def __repr__(self): + return 'sheets: {}'.format(self.sheet_names()) + + def new_sheet(self, sheet_name): + sheet = ReportSheet(self, sheet_name, self.template_dir, self.template, self.graphs_per_row) + self[sheet_name] = sheet + return sheet + + def to_excel(self, filepath, data_sheet_name='__data__', overwrite=True): + with open_excel(filepath, overwrite_file=overwrite) as wb: + # from here on, we use pure win32com objects instead of + # larray.excel or xlwings objects as this is faster + xl_wb = wb.api + + # rename first sheet + xl_wb.Worksheets(1).Name = data_sheet_name + + # dump items for each output sheet + data_sheet_row = 1 + for sheet in self.sheets.values(): + data_sheet_row = sheet._to_excel(xl_wb, data_sheet_row) + wb.save() + # reset + self.sheets.clear() +else: + class ReportSheet(AbstractReportSheet): + def __init__(self): + raise Exception("SheetReport class cannot be instantiated because xlwings is not installed") + + class ExcelReport(AbstractExcelReport): + def __init__(self): + raise Exception("ExcelReport class cannot be instantiated because xlwings is not installed") + + +if not PY2: + ExcelReport.__doc__ = AbstractExcelReport.__doc__ + ReportSheet.__doc__ = AbstractReportSheet.__doc__ diff --git a/larray/tests/excel_template/Line.crtx b/larray/tests/excel_template/Line.crtx new file mode 100644 index 000000000..58c4beaf5 Binary files /dev/null and b/larray/tests/excel_template/Line.crtx differ diff --git a/larray/tests/excel_template/Line_Marker.crtx b/larray/tests/excel_template/Line_Marker.crtx new file mode 100644 index 000000000..a05fff630 Binary files /dev/null and b/larray/tests/excel_template/Line_Marker.crtx differ diff --git a/larray/tests/test_excel.py b/larray/tests/test_excel.py index ebb50ef4d..0a24ec79e 100644 --- a/larray/tests/test_excel.py +++ b/larray/tests/test_excel.py @@ -1,13 +1,15 @@ from __future__ import absolute_import, division, print_function import re +import os import pytest import numpy as np -from larray.tests.common import needs_xlwings -from larray import ndtest, open_excel, aslarray, Axis +from larray.tests.common import needs_xlwings, TESTDATADIR +from larray import ndtest, open_excel, aslarray, Axis, nan, ExcelReport from larray.inout import xw_excel +from larray.example import load_example_data, EXAMPLE_EXCEL_TEMPLATES_DIR @needs_xlwings @@ -254,5 +256,144 @@ def test_repr(self): 1 3 4 5""" +# ================ # +# Test ExcelReport # +# ================ # + + +@needs_xlwings +def test_excel_report_init(): + # No argument + ExcelReport() + # with template dir + ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + # with graphs_per_row + ExcelReport(graphs_per_row=2) + + +@needs_xlwings +def test_excel_report_setting_template(): + excel_report = ExcelReport() + + # test setting template dir + # 1) wrong template dir + wrong_template_dir = r"C:\Wrong\Directory\Path" + msg = "Template directory {} could not been found".format(wrong_template_dir) + with pytest.raises(ValueError, message=msg): + excel_report.template_dir = wrong_template_dir + # 2) correct path + excel_report.template_dir = EXAMPLE_EXCEL_TEMPLATES_DIR + assert excel_report.template_dir == EXAMPLE_EXCEL_TEMPLATES_DIR + + # test setting template file + # 1) wrong extension + template_file = 'wrong_extension.txt' + msg = "Extension for the template file must be '.crtx' instead of .txt" + with pytest.raises(ValueError, message=msg): + excel_report.template = template_file + # 2) add .crtx extension if no extension + template_name = 'Line' + excel_report.template = template_name + assert excel_report.template == os.path.join(EXAMPLE_EXCEL_TEMPLATES_DIR, template_name + '.crtx') + + +@needs_xlwings +def test_excel_report_sheets(): + report = ExcelReport() + # test adding new sheets + sheet_pop = report.new_sheet('Pop') + sheet_births = report.new_sheet('Births') + sheet_deaths = report.new_sheet('Deaths') + # test warning if sheet already exists + with pytest.warns(UserWarning) as caught_warnings: + sheet_pop2 = report.new_sheet('Pop') + assert len(caught_warnings) == 1 + warn_msg = "Sheet 'Pop' already exists in the report and will be reset" + assert caught_warnings[0].message.args[0] == warn_msg + # test sheet_names() + assert report.sheet_names() == ['Pop', 'Births', 'Deaths'] + + +@needs_xlwings +def test_excel_report_titles(): + excel_report = ExcelReport() + + # test dumping titles + sheet_titles = excel_report.new_sheet('Titles') + # 1) default + sheet_titles.add_title('Default title') + # 2) specify width and height + width, height = 1100, 100 + sheet_titles.add_title('Width = {} and Height = {}'.format(width, height), + width=width, height=height) + # 3) specify fontsize + fontsize = 13 + sheet_titles.add_title('Fontsize = {}'.format(fontsize), fontsize=fontsize) + + # generate Excel file + fpath = 'test_excel_report_titles.xlsx' + excel_report.to_excel(fpath) + + +@needs_xlwings +def test_excel_report_arrays(): + excel_report = ExcelReport(EXAMPLE_EXCEL_TEMPLATES_DIR) + demo = load_example_data('demo') + pop = demo.pop + pop_be = pop['Belgium'] + pop_be_nan = pop_be.astype(float) + pop_be_nan[2013] = nan + + # test dumping arrays + sheet_graphs = excel_report.new_sheet('Graphs') + # 1) default + sheet_graphs.add_title('No template') + sheet_graphs.add_graph(pop_be['Female'], 'Pop Belgium Female') + sheet_graphs.add_graph(pop_be, 'Pop Belgium') + sheet_graphs.add_graph(pop_be_nan, 'Pop Belgium with nans') + # 2) no title + sheet_graphs.add_title('No title graph') + sheet_graphs.add_graph(pop_be) + # 3) specify width and height + sheet_graphs.add_title('Alternative Width and Height') + width, height = 500, 300 + sheet_graphs.add_graph(pop_be, 'width = {} and Height = {}'.format(width, height), width=width, height=height) + # 4) specify template + template_name = 'Line_Marker' + sheet_graphs.add_title('Template = {}'.format(template_name)) + sheet_graphs.add_graph(pop_be, 'Template = {}'.format(template_name), template_name) + + # test setting default size + # 1) pass a not registered kind of item + type_item = 'unknown_item' + msg = "Type item {} is not registered. Please choose in " \ + "list ['title', 'graph']".format(type_item) + with pytest.raises(ValueError, message=msg): + sheet_graphs.set_item_default_size(type_item, width, height) + # 2) update default size for graphs + sheet_graphs.set_item_default_size('graph', width, height) + sheet_graphs.add_title('Using Defined Sizes For All Graphs') + sheet_graphs.add_graph(pop_be, 'Pop Belgium') + + # test setting default number of graphs per row + sheet_graphs = excel_report.new_sheet('Graphs2') + sheet_graphs.graphs_per_row = 2 + sheet_graphs.add_title('graphs_per_row = 2') + for combined_labels, subset in pop.items(('time', 'gender')): + title = ' - '.join([str(label) for label in combined_labels]) + sheet_graphs.add_graph(subset, title) + + # testing add_graphs + sheet_graphs = excel_report.new_sheet('Graphs3') + sheet_graphs.add_title('add_graphs') + sheet_graphs.add_graphs({'Population of {gender} by country for the year {year}': pop}, + {'gender': pop.gender, 'year': pop.time}, + template='line', width=450, height=250, graphs_per_row=2) + + # generate Excel file + fpath = 'test_excel_report_arrays.xlsx' + excel_report.to_excel(fpath) + + if __name__ == "__main__": pytest.main() diff --git a/larray/util/misc.py b/larray/util/misc.py index 186aaf3d5..b08a89e7e 100644 --- a/larray/util/misc.py +++ b/larray/util/misc.py @@ -6,6 +6,7 @@ import __main__ import math import itertools +import os import sys import operator import warnings @@ -1014,3 +1015,17 @@ def ensure_no_numpy_type(array): if array.dtype.kind == 'O': array = _kill_np_types(array) return array.tolist() + + +# ################# # +# validator funcs # +# ################# # + +def _positive_integer(value): + if not (isinstance(value, int) and value > 0): + raise ValueError("Expected positive integer") + + +def _validate_dir(directory): + if not os.path.isdir(directory): + raise ValueError("The directory {} could not been found".format(directory)) diff --git a/larray/util/options.py b/larray/util/options.py index 5e0a52e64..03029bb85 100644 --- a/larray/util/options.py +++ b/larray/util/options.py @@ -1,5 +1,5 @@ from __future__ import absolute_import, division, print_function - +from larray.util.misc import _positive_integer DISPLAY_PRECISION = 'display_precision' DISPLAY_WIDTH = 'display_width' @@ -15,11 +15,6 @@ } -def _positive_integer(value): - if not (isinstance(value, int) and value > 0): - raise ValueError("Expected positive integer") - - def _integer_maxlines(value): if not isinstance(value, int) and value >= -1: raise ValueError("Expected integer") diff --git a/setup.cfg b/setup.cfg index 2dfadb1a2..fb74a09ad 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,7 +5,10 @@ test=pytest testpaths = larray # exclude (doc)tests from ufuncs (because docstrings are copied from numpy # and many of those doctests are failing -addopts = -v --doctest-modules --ignore=larray/core/npufuncs.py --ignore=larray/ipfp +addopts = -v --doctest-modules + --ignore=larray/core/npufuncs.py + --ignore=larray/ipfp + --ignore=larray/inout/xw_reporting.py --pep8 #--cov # E122: continuation line missing indentation or outdented diff --git a/setup.py b/setup.py index e741ff50c..ced0e58ef 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ def readlocal(fname): DISTNAME = 'larray' -VERSION = '0.30' +VERSION = '0.31-dev' AUTHOR = 'Gaetan de Menten, Geert Bryon, Johan Duyck, Alix Damman' AUTHOR_EMAIL = 'gdementen@gmail.com' DESCRIPTION = "N-D labeled arrays in Python"