From 59a450f43a8ce2004bc2c7640f38a3cccdec893c Mon Sep 17 00:00:00 2001 From: Karel Vaculik Date: Wed, 6 Sep 2023 09:07:43 +0200 Subject: [PATCH] Redesigning tables --- docs/configuration.md | 41 +-- docs/tables.md | 9 + pyreball/__main__.py | 76 ++++-- pyreball/cfg/config.ini | 7 +- pyreball/cfg/styles.template | 81 ------ pyreball/html.py | 263 +++++++++++++++----- pyreball/utils/utils.py | 91 ++++++- tests/test_html.py | 465 ++++++++++++++++++++++++++--------- tests/test_main.py | 75 ++++-- tests/utils/test_utils.py | 88 ++++++- 10 files changed, 846 insertions(+), 350 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 810fa48..e7e1a9c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -105,25 +105,32 @@ parameters. The following table shows the parameter alternatives between these t !!! note - When the table mentions allowed values `yes` and `no`, the allowed values of functions parameters are actually + Acceptable values mentioned in the table below are primarily intended for `config.ini` and CLI arguments. + The function arguments might be slightly different, so it's recommended to see appropriate API documentation + of given function. For example, instead of values `yes` and `no`, the functions usually use Boolean values `True` and `False`. -| `config.ini` key | CLI argument | Function argument | Description | -|---------------------------|-----------------------------|------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------| -| `toc` | `--toc ` | _N/A_ | Include table of contents. It is included only when there are any headings. Allowed values: `yes`, `no`. | -| `align-tables` | `--align-tables` | `align` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Horizontal alignment of tables. Allowed values: `left`, `center`, `right`. | -| `table-captions-position` | `--table-captions-position` | `caption_position` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Caption position. Allowed values: `top`, `bottom`. | -| `numbered-tables` | `--numbered-tables` | `numbered` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Whether to number tables. Allowed values: `yes`, `no`. | -| `sortable-tables` | `--sortable-tables` | `sortable` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Whether to make columns in tables sortable. Allowed values: `yes`, `no`. | -| `full-tables` | `--full-tables` | `full_table` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Whether to show tables expanded. Allowed values: `yes`, `no`. | -| `align-plots` | `--align-plots` | `align` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Horizontal alignment of plots. Allowed values: `left`, `center`, `right`. | -| `plot-captions-position` | `--plot-captions-position` | `caption_position` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Caption position. Allowed values: `top`, `bottom`. | -| `numbered-plots` | `--numbered-plots` | `numbered` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Whether to number plots. Allowed values: `yes`, `no`. | -| `matplotlib-format` | `--matplotlib-format` | `matplotlib_format` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Format of matplotlib (and thus also seaborn) plots. Allowed values: `png`, `svg`. | -| `matplotlib-embedded` | `--matplotlib-embedded` | `embedded` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Whether to embedded matplotlib (and thus also seaborn) plots directly into HTML. Only for svg format. Allowed values: `yes`, `no`. | -| `numbered-headings` | `--numbered-headings` | _N/A_ | Whether to number headings. Allowed values: `yes`, `no`. | -| `page-width` | `--page-width` | _N/A_ | Width of the page container in percentage. Allowed values: An integer in the range 40..100. | -| `keep-stdout` | `--keep-stdout` | _N/A_ | Whether to print the output to stdout too: `yes`, `no`. | +| `config.ini` key | CLI argument | Function argument | Description | +|---------------------------|-----------------------------|------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `toc` | `--toc ` | _N/A_ | Include table of contents. It is included only when there are any headings. Allowed values: `yes`, `no`. | +| `align-tables` | `--align-tables` | `align` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Horizontal alignment of tables. Allowed values: `left`, `center`, `right`. | +| `table-captions-position` | `--table-captions-position` | `caption_position` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Caption position. Allowed values: `top`, `bottom`. | +| `numbered-tables` | `--numbered-tables` | `numbered` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Whether to number tables. Allowed values: `yes`, `no`. | +| `tables-display-option` | `--tables-display-option` | `display_option` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | How to display table. This option is useful for long tables, which should not be displayed fully. Allowed values are: `full` (show the full table), `scrolling` (show the table in scrolling mode on y-axis), `paging` (show the table in paging mode). | +| `tables-paging-sizes` | `--tables-paging-sizes` | `paging_sizes` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | The paging sizes that can be selected. Ignored when `tables-display-option` is not `paging`. Allowed values are integers and string `all` (no matter the case of letters), written as a non-empty comma-separated list. | +| `tables-scroll-y-height` | `--tables-scroll-y-height` | `scroll_y_height` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Height of the tables when `tables-display-option` is set to `scrolling`. Allowed values TODO | +| `tables-scroll-x` | `--tables-scroll-x` | `scroll_x` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Whether to allow scrolling on the x-axis. If turned off, a wide table is allowed to overflow the main container. It is recommended to turn this on, especially with `tables-display-option` set to `scrolling`, because otherwise the table header won't interact properly when scrolling horizontally. Allowed values: `yes`, `no`. | +| `sortable-tables` | `--sortable-tables` | `sortable` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Whether to make columns in tables sortable. Allowed values: `yes`, `no`. | +| `tables-search-box` | `--tables-search-box` | `show_search_box` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Whether to show the search box for the tables. Allowed values: `yes`, `no`. | +| `tables-datatables-style` | `--tables-datatables-style` | `datatables_style` in [`print_table()`](../api/pyreball_html/#pyreball.html.print_table) | Datatables class(es) that affect the tables styling. If multiple classes are provided, separate them either with commas or spaces. See [DataTables documentation](https://datatables.net/manual/styling/classes) with the possible values. | +| `align-plots` | `--align-plots` | `align` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Horizontal alignment of plots. Allowed values: `left`, `center`, `right`. | +| `plot-captions-position` | `--plot-captions-position` | `caption_position` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Caption position. Allowed values: `top`, `bottom`. | +| `numbered-plots` | `--numbered-plots` | `numbered` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Whether to number plots. Allowed values: `yes`, `no`. | +| `matplotlib-format` | `--matplotlib-format` | `matplotlib_format` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Format of matplotlib (and thus also seaborn) plots. Allowed values: `png`, `svg`. | +| `matplotlib-embedded` | `--matplotlib-embedded` | `embedded` in [`plot_graph()`](../api/pyreball_html/#pyreball.html.plot_graph) | Whether to embedded matplotlib (and thus also seaborn) plots directly into HTML. Only for svg format. Allowed values: `yes`, `no`. | +| `numbered-headings` | `--numbered-headings` | _N/A_ | Whether to number headings. Allowed values: `yes`, `no`. | +| `page-width` | `--page-width` | _N/A_ | Width of the page container in percentage. Allowed values: An integer in the range 40..100. | +| `keep-stdout` | `--keep-stdout` | _N/A_ | Whether to print the output to stdout too: `yes`, `no`. | The reason for having multiple options for setting these values is to allow the user to set some properties globally, while others locally as needed for particular scripts. diff --git a/docs/tables.md b/docs/tables.md index 4c0df57..71b3111 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -42,6 +42,8 @@ Tables can be horizontally aligned by `align` parameter, as shown in the followi ## Sorting +TODO: update this section. + It is possible to make the table sortable on all columns by setting `sortable` parameter to `True`, or by setting `sorting_definition` parameter, which also sorts the table initially on the specified column. @@ -52,3 +54,10 @@ or by setting `sorting_definition` parameter, which also sorts the table initial ## Dealing with Large Tables TBD + +## Styling + +TBD + +For more options, see the styling reference +in [Datatables documentation](https://datatables.net/manual/styling/classes). \ No newline at end of file diff --git a/pyreball/__main__.py b/pyreball/__main__.py index 12dfbe6..a0c4101 100644 --- a/pyreball/__main__.py +++ b/pyreball/__main__.py @@ -22,10 +22,12 @@ from pyreball.utils.utils import ( carefully_remove_directory_if_exists, check_and_fix_parameters, + check_paging_sizes_string_parameter, ChoiceParameter, get_file_config, IntegerParameter, merge_parameter_dictionaries, + StringParameter, Substitutor, ) @@ -100,18 +102,6 @@ """ -JAVASCRIPT_SORTABLE_TABLE = """ - - $(document).ready(function () { - $('.sortable_table').DataTable({ - "paging": false, - "searching": false, - "info": false, - }); - }); - -""" - def _replace_ids(html_path: Path) -> None: """ @@ -313,17 +303,56 @@ def insert_heading_title_and_toc(filename: Path, include_toc: bool = True): default="no", help="Number the tables.", ), + ChoiceParameter( + "--tables-display-option", + choices=["full", "paging", "scrolling"], + default="full", + help="How to display tables. Either full, with scrollbar, or with paging.", + ), + StringParameter( + "--tables-paging-sizes", + default="10,25,100,All", + help=( + "The paging sizes that can be selected. " + "Allowed values are integers and string 'all' (no matter the case of letters), " + "written as a non-empty comma-separated list. " + "Ignored when tables-display-option is not 'paging'." + ), + validation_function=check_paging_sizes_string_parameter, + ), + # TODO add more details what values can be passed here: + StringParameter( + "--tables-scroll-y-height", + default="300px", + help=( + "Height of the tables when 'scrolling' display option is set. Ignored with other display options." + ), + ), + ChoiceParameter( + "--tables-scroll-x", + choices=["yes", "no"], + default="yes", + help="Whether to allow horizontal scrolling on tables.", + ), ChoiceParameter( "--sortable-tables", choices=["yes", "no"], default="no", - help="Make the tables sortable.", + help="Whether to make the tables sortable.", ), ChoiceParameter( - "--full-tables", + "--tables-search-box", choices=["yes", "no"], default="no", - help="Force all tables to be expanded.", + help="Whether to show search box for tables.", + ), + StringParameter( + "--tables-datatables-style", + default="display", + help=( + "Datatables class(es) that affect the tables styling. If multiple classes are provided, " + "separate them either with commas or spaces." + ), ), ChoiceParameter( "--align-plots", @@ -537,13 +566,19 @@ def parse_arguments(args) -> Dict[str, Optional[Union[str, int]]]: nargs=argparse.REMAINDER, help="Remaining arguments that are passed to the Python script.", ) - return vars(parser.parse_args(args)) + variables = vars(parser.parse_args(args)) + # positional arguments must be renamed manually + variables['input_path'] = variables['input-path'] + variables['script_args'] = variables['script-args'] + del variables['input-path'] + del variables['script-args'] + return variables def main() -> None: args_dict = parse_arguments(sys.argv[1:]) - script_args_string = " ".join(cast(List[str], args_dict.pop("script-args"))) - input_path = cast(Path, args_dict.pop("input-path")) + script_args_string = " ".join(cast(List[str], args_dict.pop("script_args"))) + input_path = cast(Path, args_dict.pop("input_path")) input_path = input_path.expanduser().resolve() output_path = cast(Optional[Path], args_dict.pop("output_path")) config_path = cast(Optional[Path], args_dict.pop("config_path")) @@ -584,10 +619,7 @@ def main() -> None: carefully_remove_directory_if_exists(directory=Path(html_dir_path_str)) script_definitions = ( - JAVASCRIPT_CHANGE_EXPAND - + JAVASCRIPT_ON_LOAD - + JAVASCRIPT_SORTABLE_TABLE - + JAVASCRIPT_ROLLING_PLOTS + JAVASCRIPT_CHANGE_EXPAND + JAVASCRIPT_ON_LOAD + JAVASCRIPT_ROLLING_PLOTS ) css_definitions = get_css( diff --git a/pyreball/cfg/config.ini b/pyreball/cfg/config.ini index 5f56484..31433df 100644 --- a/pyreball/cfg/config.ini +++ b/pyreball/cfg/config.ini @@ -3,8 +3,13 @@ toc = yes align-tables = center table-captions-position = top numbered-tables = yes +tables-display-option = full +tables-paging-sizes = 10,25,100,All +tables-scroll-y-height = 300px +tables-scroll-x = yes sortable-tables = no -full-tables = yes +tables-search-box = no +tables-datatables-style = display align-plots = center plot-captions-position = bottom numbered-plots = yes diff --git a/pyreball/cfg/styles.template b/pyreball/cfg/styles.template index 0ccf0da..55c6ca4 100644 --- a/pyreball/cfg/styles.template +++ b/pyreball/cfg/styles.template @@ -21,14 +21,6 @@ img, svg, canvas.marks, div.vega-embed { display: block; } -table.dataframe { - border-style: none; - border-width: 0px; - border-collapse: separate; - border-spacing: 0; - font-size: 80%; -} - .text-centered { text-align: center; } @@ -40,41 +32,11 @@ table.dataframe { margin-bottom: 20px; } - .table-wrapper-inner { width: fit-content; max-width: 100%; } -.table-scroller -{ - overflow: auto; /* scrollable */ - margin-top: 10px; - margin-bottom: 10px; - max-height: none; /* don't show all cells - the last one should be hidden a bit */ - width: fit-content; - margin-left: auto; - margin-right: auto; - max-width: 100%; -} - -.table-scroller-collapsed -{ - overflow: auto; /* scrollable */ - margin-top: 10px; - margin-bottom: 10px; - max-height: 390px; /* don't show all cells - the last one should be hidden a bit */ - width: fit-content; - margin-left: auto; - margin-right: auto; - max-width: 100%; -} - -.table-expander { - background: rgb(250, 250, 250); - cursor: pointer; -} - .centered { margin-left:auto; margin-right:auto; @@ -94,49 +56,6 @@ table.dataframe { font-weight: bold; } -.dataframe tbody tr th:only-of-type { - vertical-align: middle; - border-width: 0px; - padding: 6px; -} - -.dataframe tbody tr th { - border-width: 0px; -} - -.dataframe tbody > tr:nth-child(odd) { - background: rgb(245, 245, 245); -} - -.dataframe tbody > tr:hover { - background: #c4e3f3; -} - -.dataframe td { - vertical-align: top; - border-width: 0px; - padding: 6px; -} - -.dataframe thead th { - border-bottom-width: 1px; - border-top-width: 0px; - border-left-width: 0px; - border-right-width: 0px; - /*top: 0; */ - position: sticky; - background: white; -} - -.dataframe thead tr:nth-child(1) th { position: sticky; top: 0; } /* first row of table header */ -.dataframe thead tr:nth-child(2) th { position: sticky; top: 28px; } /* second row of table header */ - -.dataframe tr, th { - text-align: right; - vertical-align: middle; - padding: 6px; -} - a.anchor-link:link { text-decoration: none; padding: 0px 20px; diff --git a/pyreball/html.py b/pyreball/html.py index 8d8bb1a..58fdb7c 100644 --- a/pyreball/html.py +++ b/pyreball/html.py @@ -1,6 +1,7 @@ """Main functions that serve as building blocks of the final html file.""" import builtins import io +import json import os import random import re @@ -443,32 +444,117 @@ def _prepare_caption_element( ) +def _compute_length_menu_for_datatables( + paging_sizes: List[Union[int, str]] +) -> Tuple[List[int], List[Union[int, str]]]: + arr_1 = [] + arr_2 = [] + for size in paging_sizes: + if isinstance(size, int): + arr_1.append(size) + arr_2.append(size) + elif isinstance(size, str) and size.lower() == "all": + arr_1.append(-1) + arr_2.append(size) + else: + raise ValueError(f"Unsupported value in paging_sizes: {size}") + return arr_1, arr_2 + + +def _gather_datatables_setup( + display_option: str = "full", + scroll_y_height: str = "300px", + scroll_x: bool = True, + sortable: bool = False, + sorting_definition: Optional[List[Tuple[int, str]]] = None, + paging_sizes: Optional[List[Union[int, str]]] = None, + show_search_box: bool = False, + datatables_definition: Optional[Dict[str, Any]] = None, +) -> Optional[Dict[str, Any]]: + if datatables_definition is not None: + return datatables_definition + + datatables_setup = {} + if display_option == "scrolling": + datatables_setup["paging"] = False + datatables_setup["scrollCollapse"] = True + datatables_setup["scrollY"] = scroll_y_height + elif display_option == "paging": + datatables_setup["paging"] = True + if paging_sizes is None: + paging_sizes = [10, 25, 100, "All"] + datatables_setup["lengthMenu"] = _compute_length_menu_for_datatables( + paging_sizes + ) + elif display_option == "full": + datatables_setup["paging"] = False + if scroll_x: + datatables_setup["scrollX"] = True + + # if show_search_box: + datatables_setup["searching"] = show_search_box + + if sortable or sorting_definition is not None: + if sorting_definition is None: + datatables_setup["order"] = [] + else: + datatables_setup["order"] = sorting_definition + else: + datatables_setup["ordering"] = False + + return datatables_setup + + def _prepare_table_html( df: "pandas.DataFrame", + tab_index: int = 0, caption: Optional[str] = None, + reference: Optional[Reference] = None, align: str = "center", caption_position: str = "top", - full_table: bool = True, numbered: bool = True, - reference: Optional[Reference] = None, + display_option: str = "full", + scroll_y_height: str = "300px", + scroll_x: bool = True, sortable: bool = False, - tab_index: int = 0, - sorting_definition: Optional[Tuple[str, str]] = None, + sorting_definition: Optional[List[Tuple[int, str]]] = None, + paging_sizes: Optional[List[Union[int, str]]] = None, + show_search_box: bool = False, + datatables_style: Union[str, List[str]] = "display", + datatables_definition: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> str: + # TODO: add a small margin between table caption and the table itself. + + # TODO: when creating an example with sorting_definition, use and recommend get_loc method + # https://saturncloud.io/blog/how-to-get-column-index-from-column-name-in-python-pandas/#method-1-using-the-get_loc-method + # Show that also for complex headers align_mapping = { "center": "centered", "left": "left-aligned", "right": "right-aligned", } - - if sorting_definition: - # if we have sorting definition, we turn on sortable - sortable = True table_classes = ["centered"] - if sortable and not sorting_definition: - # add this class only to sortable tables that don't have sorting definition - table_classes.append("sortable_table") + if isinstance(datatables_style, list): + table_classes += datatables_style + else: + table_classes.append(datatables_style) + + datatables_setup = _gather_datatables_setup( + display_option=display_option, + scroll_y_height=scroll_y_height, + scroll_x=scroll_x, + sortable=sortable, + sorting_definition=sorting_definition, + paging_sizes=paging_sizes, + show_search_box=show_search_box, + datatables_definition=datatables_definition, + ) + + if "border" not in kwargs: + # If border is not set explicitly, + # turn it off because it would clash with datatables styling + kwargs["border"] = 0 df_html = df.to_html(classes=table_classes, **kwargs) if reference: _check_and_mark_reference(reference) @@ -483,61 +569,41 @@ def _prepare_table_html( index=tab_index, anchor_link=anchor_link, ) - # div that is scrollable - scroller_id = "table-scroller-" + str(tab_index) - # div button that expands the table - expander_id = "table-expander-" + str(tab_index) - if full_table: - enclosing_div_class = "table-scroller" - style_attr = 'style="display: none;" ' - else: - enclosing_div_class = "table-scroller-collapsed" - style_attr = " " if caption_position == "top": table_html = caption_element else: table_html = "" - table_html += ( - f'
\n{df_html}\n
\n' - ) - table_html += ( - f'
" - ) + + table_html += df_html + if caption_position == "bottom": table_html += caption_element else: table_html += "" + table_wrapper_inner_id = "table-wrapper-inner-" + str(tab_index) table_html = ( - f'
' - + table_html - + "
" + f'
' + f"{table_html}\n" + f"
" ) - table_html = '
' + table_html + "
" - if sorting_definition: - if sorting_definition[0] not in df.columns: - raise ValueError( - f"{sorting_definition[0]} is not a column in provided data frame." - ) - if sorting_definition[1] not in ["asc", "desc"]: - raise ValueError( - "sorting_definition must be either None " - "or a pair (, ), " - "where is either 'asc' or 'desc'." - ) - column_index = df.columns.get_loc(sorting_definition[0]) + 1 # (+ index) - table_init = ( - '{"retrieve": true, "paging": false, "searching": false, "info": false}' - ) - js = ( - f"var table = $('#{scroller_id} > table').DataTable({table_init});" - f"table.order( [ {column_index}, '{sorting_definition[1]}' ] ).draw();" - ) + table_html = f'
\n{table_html}\n
' + + if datatables_setup is not None: + # table_init = '{"retrieve": true, "paging": false, "searching": false, "info": false}' + table_init = json.dumps(datatables_setup) + js = f"var table = $('#{table_wrapper_inner_id} > table').DataTable({table_init});" table_html += f"\n" + return table_html +def _parse_tables_paging_sizes(sizes: str) -> List[Union[int, str]]: + return [ + value if value.lower() == "all" else int(value) for value in sizes.split(",") + ] + + def print_table( df: "pandas.DataFrame", caption: Optional[str] = None, @@ -545,9 +611,15 @@ def print_table( align: Optional[str] = None, caption_position: Optional[str] = None, numbered: Optional[bool] = None, - full_table: Optional[bool] = None, + display_option: Optional[str] = None, + paging_sizes: Optional[List[Union[int, str]]] = None, + scroll_y_height: Optional[str] = None, + scroll_x: Optional[bool] = None, sortable: Optional[bool] = None, - sorting_definition: Optional[Tuple[str, str]] = None, + sorting_definition: Optional[List[Tuple[int, str]]] = None, + show_search_box: Optional[bool] = None, + datatables_style: Optional[Union[str, List[str]]] = None, + datatables_definition: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> None: """Print pandas DataFrame into HTML. @@ -567,14 +639,41 @@ def print_table( Defaults to settings from config or CLI arguments if None. numbered: Whether the caption should be numbered. Defaults to settings from config or CLI arguments if None. - full_table: Whether to show the table expanded. + + display_option: How to display table. This option is useful for long tables, + which should not be displayed fully. Acceptable values are: 'full' (show the full table), + 'scrolling' (show the table in scrolling mode on y-axis), 'paging' (show the table in paging mode). + Defaults to settings from config or CLI arguments if None. + paging_sizes: A list of page sizes to display in paging mode. + Allowed values in the list are integer values and string "All" (the case is not important). + When `display_option` is not `"paging"`, the value is ignored. Defaults to settings from config or CLI arguments if None. + If it still remains None, values `[10, 25, 100, "All"]` are used. + scroll_y_height: Height of the tables when `display_option` is set to `"scrolling"`. + Ignored with other display options. + Defaults to settings from config or CLI arguments if None. + # TODO add more details what values can be passed in scroll_y_height + scroll_x: Whether to allow scrolling on the x-axis. If set to False, a wide table + is allowed to overflow the main container. It is recommended to set this to True, + especially with display_option="scrolling", because otherwise the table header + won't interact properly when scrolling horizontally. sortable: Whether to allow sortable columns. Defaults to settings from config or CLI arguments if None. - sorting_definition: How to sort the table initially, - in the form (, ), + sorting_definition: How to sort the table columns initially, + in the form of a list of tuples (, ), where is either 'asc' or 'desc'. - **kwargs: Other parameters to pandas to_html method. + When None, the columns are not pre-sorted. + show_search_box: Whether to show the search box for the table. + Defaults to settings from config or CLI arguments if None. + datatables_style: One or more class names for styling tables using Datatables styling. + See https://datatables.net/manual/styling/classes for possible values. + Can be either a string with the class name, or a list of class name strings. + datatables_definition: Custom setup for datatables in the form of a dictionary. + This dictionary is serialized to json and passed to `DataTable` JavaScript object as it is. + If set (i.e. not None), values of parameters `display_option`, `scroll_y_height`, `scroll_x`, + `sortable`, `sorting_definition`, `paging_sizes`, and `show_search_box` are ignored. + Note that `datatables_style` is independent of this parameter. + **kwargs: Other parameters to pandas `to_html()` method. """ if not get_parameter_value("html_file_path") or get_parameter_value("keep_stdout"): builtins.print(df) @@ -602,29 +701,67 @@ def print_table( secondary_value=get_parameter_value("numbered_tables"), ) ) + display_option = str( + merge_values( + primary_value=display_option, + secondary_value=get_parameter_value("tables_display_option"), + ) + ) + scroll_y_height = str( + merge_values( + primary_value=scroll_y_height, + secondary_value=get_parameter_value("tables_scroll_y_height"), + ) + ) + scroll_x = bool( + merge_values( + primary_value=scroll_x, + secondary_value=get_parameter_value("tables_scroll_x"), + ) + ) sortable = bool( merge_values( primary_value=sortable, secondary_value=get_parameter_value("sortable_tables"), ) ) - full_table = bool( + paging_sizes = merge_values( + primary_value=paging_sizes, + secondary_value=_parse_tables_paging_sizes( + get_parameter_value("tables_paging_sizes") + ), + ) + show_search_box = bool( merge_values( - primary_value=full_table, - secondary_value=get_parameter_value("full_tables"), + primary_value=show_search_box, + secondary_value=get_parameter_value("tables_search_box"), ) ) + conf_datatables_style = get_parameter_value("tables_datatables_style") + if conf_datatables_style: + conf_datatables_style = re.split(r"[, ]", conf_datatables_style) + datatables_style = merge_values( + primary_value=datatables_style, + secondary_value=conf_datatables_style, + ) + table_html = _prepare_table_html( df=df, + tab_index=table_index, caption=caption, + reference=reference, align=align, caption_position=caption_position, - full_table=full_table, numbered=numbered, - reference=reference, + display_option=display_option, + scroll_y_height=scroll_y_height, + scroll_x=scroll_x, sortable=sortable, - tab_index=table_index, sorting_definition=sorting_definition, + paging_sizes=paging_sizes, + show_search_box=show_search_box, + datatables_style=datatables_style, + datatables_definition=datatables_definition, **kwargs, ) _write_to_html(table_html) diff --git a/pyreball/utils/utils.py b/pyreball/utils/utils.py index 942d4b5..14b10d0 100644 --- a/pyreball/utils/utils.py +++ b/pyreball/utils/utils.py @@ -7,7 +7,7 @@ import sys from abc import ABC, abstractmethod from pathlib import Path -from typing import Any, cast, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union from pyreball.utils.logger import get_logger @@ -19,6 +19,12 @@ class Parameter(ABC): + def __init__(self, option_string: str, help: str): + self.option_string = option_string + self._config_param_key = option_string.replace("--", "") + self._param_key = self._config_param_key.replace("-", "_") + self.help = help + @property @abstractmethod def param_key(self): @@ -46,12 +52,9 @@ def check_and_fix_value( class ChoiceParameter(Parameter): def __init__(self, option_string: str, choices: List[str], default: str, help: str): - self.option_string = option_string - self._config_param_key = option_string.replace("--", "") - self._param_key = self._config_param_key.replace("-", "_") - self.choices = choices + super().__init__(option_string=option_string, help=help) self.default = default - self.help = help + self.choices = choices @property def param_key(self): @@ -90,12 +93,9 @@ def __init__( default: int, help: str, ): - self.option_string = option_string - self._config_param_key = option_string.replace("--", "") - self._param_key = self._config_param_key.replace("-", "_") + super().__init__(option_string=option_string, help=help) self.boundaries = boundaries self.default = default - self.help = help @property def param_key(self): @@ -126,6 +126,77 @@ def check_and_fix_value( ) +class StringParameter(Parameter): + def __init__( + self, + option_string: str, + default: str, + help: str, + validation_function: Optional[ + Callable[[str, Any, str, bool, List[str], List[str]], str] + ] = None, + ): + super().__init__(option_string=option_string, help=help) + self.default = default + self.validation_function = validation_function + + @property + def param_key(self): + return self._param_key + + @property + def config_param_key(self): + return self._config_param_key + + def add_argument_to_parser(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument(self.option_string, type=str, help=self.help) + + def check_and_fix_value( + self, + value: Any, + none_allowed: bool, + warning_messages: List[str], + error_messages: List[str], + ) -> Any: + if self.validation_function is not None: + return self.validation_function( + self.param_key, + value, + self.default, + none_allowed, + warning_messages, + error_messages, + ) + else: + return value + + +def _matches_paging_sizes_string(value: str) -> bool: + return bool(re.match(r"^(all|[\d]+)(,(all|[\d]+))*$", value, re.IGNORECASE)) + + +def check_paging_sizes_string_parameter( + key: str, + value: Optional[str], + default_value: str, + none_allowed: bool, + warning_messages: List[str], + error_messages: List[str], +) -> Optional[str]: + if value is None and not none_allowed: + warning_messages.append( + f'Parameter {key} was not set, setting its value to "{default_value}".' + ) + return default_value + elif value is not None and not _matches_paging_sizes_string(value): + error_messages.append( + f"Parameter {key} is set to an unsupported value {value}. " + "Allowed values are integers and string 'all' (no matter the case of letters), " + "written as a non-empty comma-separated list." + ) + return value + + def check_choice_string_parameter( key: str, value: Optional[str], diff --git a/tests/test_html.py b/tests/test_html.py index e1ff514..5020faa 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -14,11 +14,14 @@ from pyreball.html import ( _check_and_mark_reference, + _compute_length_menu_for_datatables, _construct_plot_anchor_link, + _gather_datatables_setup, _get_heading_number, _graph_memory, _heading_memory, _multi_graph_memory, + _parse_tables_paging_sizes, _plot_graph, _prepare_altair_plot_element, _prepare_bokeh_plot_element, @@ -663,16 +666,233 @@ def test__prepare_caption_element( ) -def test__prepare_table_html__wrong_sorting_definitions( - pre_test_check_and_mark_reference_cleanup, simple_dataframe +@pytest.mark.parametrize( + "paging_sizes,expected_result", + [ + ([1], ([1], [1])), + ([10, "all", 100], ([10, -1, 100], [10, "all", 100])), + (["ALL", 100], ([-1, 100], ["ALL", 100])), + (["All", 100], ([-1, 100], ["All", 100])), + ], +) +def test__compute_length_menu_for_datatables__valid_values( + paging_sizes, expected_result ): - with pytest.raises(ValueError) as excinfo: - _prepare_table_html(simple_dataframe, sorting_definition=("unknown", "asc")) - assert "is not a column in provided data frame" in str(excinfo.value) + assert _compute_length_menu_for_datatables(paging_sizes) == expected_result - with pytest.raises(ValueError) as excinfo2: - _prepare_table_html(simple_dataframe, sorting_definition=("x1", "unknown")) - assert "sorting_definition must be either None or a pair" in str(excinfo2.value) + +@pytest.mark.parametrize( + "paging_sizes", + [ + [1, "UNK"], + [{"x": "y"}], + ], +) +def test__compute_length_menu_for_datatables__invalid_values(paging_sizes): + with pytest.raises(ValueError): + assert _compute_length_menu_for_datatables(paging_sizes) + + +@pytest.mark.parametrize( + "display_option," + "scroll_y_height," + "scroll_x," + "sortable," + "sorting_definition," + "paging_sizes," + "show_search_box," + "datatables_definition," + "expected_result", + [ + # display_option = scrolling + ( + "scrolling", + "300px", + True, + False, + None, + None, + False, + None, + { + "paging": False, + "scrollCollapse": True, + "scrollY": "300px", + "scrollX": True, + "ordering": False, + "searching": False, + }, + ), + # display_option = scrolling; different height + ( + "scrolling", + "500px", + True, + False, + None, + None, + False, + None, + { + "paging": False, + "scrollCollapse": True, + "scrollY": "500px", + "scrollX": True, + "ordering": False, + "searching": False, + }, + ), + # display_option = paging + ( + "paging", + "300px", + True, + False, + None, + None, + False, + None, + { + "paging": True, + "lengthMenu": ([10, 25, 100, -1], [10, 25, 100, "All"]), + "scrollX": True, + "ordering": False, + "searching": False, + }, + ), + # display_option = paging + custom page sizes + ( + "paging", + "300px", + True, + False, + None, + [20, "All"], + False, + None, + { + "paging": True, + "lengthMenu": ([20, -1], [20, "All"]), + "scrollX": True, + "ordering": False, + "searching": False, + }, + ), + # display_option = full + ( + "full", + "300px", + True, + False, + None, + None, + False, + None, + { + "paging": False, + "scrollX": True, + "ordering": False, + "searching": False, + }, + ), + # display_option = full; scroll_x = False, show_search_box = True + ( + "full", + "300px", + False, + False, + None, + None, + True, + None, + { + "paging": False, + "ordering": False, + "searching": True, + }, + ), + # display_option = full; sortable + ( + "full", + "300px", + True, + True, + None, + None, + False, + None, + { + "paging": False, + "scrollX": True, + "order": [], + "searching": False, + }, + ), + # display_option = full; sorting_definition + ( + "full", + "300px", + True, + False, + [[1, "asc"]], + None, + False, + None, + { + "paging": False, + "scrollX": True, + "order": [[1, "asc"]], + "searching": False, + }, + ), + # display_option = full; datatables_definition => overrides everything + ( + "full", + "300px", + True, + False, + None, + None, + False, + {"a": "b"}, + {"a": "b"}, + ), + # display_option = full; datatables_definition => overrides everything (even as empty dict) + ( + "full", + "300px", + True, + False, + None, + None, + False, + {}, + {}, + ), + ], +) +def test__gather_datatables_setup( + display_option, + scroll_y_height, + scroll_x, + sortable, + sorting_definition, + paging_sizes, + show_search_box, + datatables_definition, + expected_result, +): + result = _gather_datatables_setup( + display_option, + scroll_y_height, + scroll_x, + sortable, + sorting_definition, + paging_sizes, + show_search_box, + datatables_definition, + ) + assert result == expected_result def test__prepare_table_html__reused_reference_error( @@ -694,13 +914,6 @@ def test__prepare_table_html__reused_reference_error( "right", ], ) -@pytest.mark.parametrize( - "full_table", - [ - False, - True, - ], -) @pytest.mark.parametrize( "use_reference", [ @@ -716,19 +929,25 @@ def test__prepare_table_html__reused_reference_error( ], ) @pytest.mark.parametrize( - "sorting_definition", + "caption_position", [ - None, - ("x2", "desc"), - ("x1", "asc"), + "top", + "bottom", + ], +) +@pytest.mark.parametrize( + "datatables_style", + [ + "display", + ["display", "compact"], ], ) def test__prepare_table_html( align, - full_table, use_reference, sortable, - sorting_definition, + caption_position, + datatables_style, pre_test_check_and_mark_reference_cleanup, simple_dataframe, ): @@ -742,32 +961,32 @@ def test__prepare_table_html( df=simple_dataframe, caption="mycap", align=align, - full_table=full_table, + caption_position=caption_position, numbered=True, reference=reference, sortable=sortable, tab_index=5, - sorting_definition=sorting_definition, + datatables_style=datatables_style, ) - if sorting_definition is not None: - # ET cannot parse the the