From 13caed2d604c205d83eb2f105716958c592da9cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karel=20Vacul=C3=ADk?= Date: Tue, 12 Sep 2023 09:12:22 +0200 Subject: [PATCH] Allow adding javascript and css links dynamically as needed --- CHANGELOG.md | 1 + docs/configuration.md | 88 +- examples/custom_arguments.html | 122 +- examples/longer_report.html | 1397 +++++++++++++++-- examples/report.html | 188 +-- examples/sample_plots.html | 720 +++++++-- examples/simple.html | 124 +- pyreball/__main__.py | 254 ++- .../cfg/{styles.template => css.template} | 35 +- pyreball/cfg/external_links.ini | 23 + .../cfg/{html_end.template => html.template} | 14 +- pyreball/cfg/html_begin.template | 30 - pyreball/config_generator.py | 10 +- pyreball/constants.py | 9 +- pyreball/html.py | 100 +- pyreball/text.py | 4 +- pyreball/utils/template_utils.py | 25 +- pyreball/utils/utils.py | 37 + tests/test_html.py | 98 +- tests/test_main.py | 243 +-- tests/test_text.py | 4 +- tests/utils/test_template_utils.py | 24 +- tests/utils/test_utils.py | 77 +- 23 files changed, 2628 insertions(+), 999 deletions(-) rename pyreball/cfg/{styles.template => css.template} (66%) create mode 100644 pyreball/cfg/external_links.ini rename pyreball/cfg/{html_end.template => html.template} (51%) delete mode 100644 pyreball/cfg/html_begin.template diff --git a/CHANGELOG.md b/CHANGELOG.md index a60a427..0da7263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Added `--config-path` CLI option, changed how `pyreball-generate-config` command works and how the config paths are handled. - Updated CLI arguments and config parameters for tables and figures. +- Updated template files. - Updated to newer versions of 3rd party dependencies for example. - Created documentation at readthedocs. diff --git a/docs/configuration.md b/docs/configuration.md index 8a65365..25b8850 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -21,6 +21,8 @@ pyreball The Python script path is the only required argument at the moment. This argument can be preceded by pyreball options described below. There can be also arguments for the script itself, in which case they should be just passed after the script path. +Internally, Pyreball uses current executable `python` and executes the script with it, passing the script +arguments to the script itself. For better readability, the script arguments can be delimited from script path by `--`. An example of a call: @@ -30,11 +32,11 @@ pyreball --page-width=90 my_script.py -- --sum 23 25 24 In this example, script `my_script.py` uses option `--sum` followed by one or more numbers. -By default, Pyreball re-uses the input script path to get the output HTML file, only changing the `.py` suffix -to `.html`. The output path can be modified by option `--output-path`. +By default, Pyreball re-uses the input script path to construct the output HTML file path, only changing the `.py` +suffix to `.html`. The output path can be modified by option `--output-path`. If the output path ends with `.html`, it is interpreted as HTML file path. If not, it is interpreted as a directory, where the HTML file should be created. In such a case, the HTML file will have the same filename stem as the input -script (e.g., `my_report.py` would produce `my_report.html`). +script (e.g., `my_report.py` will produce `my_report.html`). Another optional argument is `--config-path`, which can be used to override the directory path with configuration files. More information about configuration files and how `--config-path` is used can be found in the remained of this @@ -47,21 +49,35 @@ section [config.ini vs. CLI arguments vs. function arguments](#configini-vs-cli- ## Config Files When Pyreball is installed, it also creates a config directory with a few files in the installation directory. -This directory contains HTML and CSS templates as well as `config.ini` file with various settings. +This directory contains HTML and CSS templates as well as configuration files `config.ini` and `external_links.ini`. ### Template files -There are currently three template files: - -* `styles.template` - Contains CSS definitions that are inserted into the final HTML file. -* `html_begin.template` - Contains the beginning of the HTML file. Everything generated by Pyreball from your Python - script goes right after this text. It contains three placeholders: - * `{{title}}` - Here goes the title set by [`set_title()`](../api/pyreball_html/#pyreball.html.set_title) function - or Table-of-Contents title. - * `{{script_definitions}}` - Here go JavaScript definitions. There is currently no file for these definitions, - they are created by Pyreball code. - * `{{css_definitions}}` - The CSS definitions from `styles.template` are inserted here. -* `html_end.template` - Contains the end of the HTML file, which goes after content generated from your script. +There are currently two template files: + +* `css.template` - Contains CSS definitions that are inserted into the final HTML file. +* `html.template` - Contains the template of the HTML file. It contains three placeholders: + * `` - Here goes the title set + by [`set_title()`](../api/pyreball_html/#pyreball.html.set_title) + function + or Table-of-Contents title. If the title is not set, nor Table-of-Contents is created, the file stem is used as + the value of `` element. + * `<!--PYREBALL_HEAD_LINKS-->` - Placeholder for necessary `<link>` and `<script>` elements with 3rd party library + links. Pyreball decides dynamically which links need to be added. Links from `external_links.ini` are used here. + * `<!--PYREBALL_CSS_DEFINITIONS-->` - The CSS definitions from `styles.template` are inserted here. + * `<!--PYREBALL_REPORT_CONTENTS-->` - Everything that Pyreball generates from the source Python script goes here. + Specifically, Pyreball initializes the HTML file with the text placed before this placeholder, then adds contents + from the Python script, and finishes the HTML by adding the text placed after this placeholder. + +### external_links.ini File + +When using 3rd party libraries to display various elements, necessary JavaScript and CSS links need to be added to the +HTML page. Such links are selected from `external_links.ini` file. +Each key-value pair represents a library with the relevant links. For example, if your Pyreball report creates +a [Bokeh](https://bokeh.org/) figure in the HTML, relevant JavaScript references for [Bokeh](https://bokeh.org/) are +added to the HTML `<head>` element. +Some libraries, e.g. [DataTables](https://datatables.net/), require also [jQuery](https://jquery.com/), which is listed +separately in `external_links.ini`. ### config.ini File @@ -110,27 +126,27 @@ parameters. The following table shows the parameter alternatives between these t 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`. | -| `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`. Any string compatible with CSS sizing can be used, e.g. `300px`, `20em`, etc. | -| `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. 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-figures` | `--align-figures` | `align` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Horizontal alignment of figures. Allowed values: `left`, `center`, `right`. | -| `figure-captions-position` | `--figure-captions-position` | `caption_position` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Caption position. Allowed values: `top`, `bottom`. | -| `numbered-figures` | `--numbered-figures` | `numbered` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Whether to number figures. Allowed values: `yes`, `no`. | -| `matplotlib-format` | `--matplotlib-format` | `matplotlib_format` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Format of matplotlib (and thus also seaborn) figures. Allowed values: `png`, `svg`. | -| `matplotlib-embedded` | `--matplotlib-embedded` | `embedded` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Whether to embedded matplotlib (and thus also seaborn) figures 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`. Any string compatible with CSS sizing can be used, e.g. `300px`, `20em`, etc. | +| `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. 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-figures` | `--align-figures` | `align` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Horizontal alignment of figures. Allowed values: `left`, `center`, `right`. | +| `figure-captions-position` | `--figure-captions-position` | `caption_position` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Caption position. Allowed values: `top`, `bottom`. | +| `numbered-figures` | `--numbered-figures` | `numbered` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Whether to number figures. Allowed values: `yes`, `no`. | +| `matplotlib-format` | `--matplotlib-format` | `matplotlib_format` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Format of matplotlib (and thus also seaborn) figures. Allowed values: `png`, `svg`. | +| `matplotlib-embedded` | `--matplotlib-embedded` | `embedded` in [`print_figure()`](../api/pyreball_html/#pyreball.html.print_figure) | Whether to embedded matplotlib (and thus also seaborn) figures 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/examples/custom_arguments.html b/examples/custom_arguments.html index ffb6514..d81c52c 100644 --- a/examples/custom_arguments.html +++ b/examples/custom_arguments.html @@ -1,102 +1,21 @@ <!DOCTYPE html> <html> <head> +<meta charset="UTF-8"> <title class="custom_pyreball_title">Custom Arguments - - - - - - - - - - - - - - - - - - -
-

Custom Arguments

+
+

Custom Arguments

- - - - - - - - - - + + + - - -
-

Sample Report

+
+

Sample Report

1  Displaying Texts
  • 1.1  Basic String-wrapping Formatting Functions
  • @@ -181,39 +101,717 @@

    Sample Report2  Inspecting Data
    -

    1  Displaying Texts

    +

    1  Displaying Texts

    We can always start inserting custom raw HTML code.

    However, we can use special function to write text into a <br> element.


    It is possible to pass several values and optionally a separator.
    The values will be joined and automatically converted to strings, as with the following number and list.
    42
    [11, 13, 19]
    -

    1.1  Basic String-wrapping Formatting Functions

    +

    1.1  Basic String-wrapping Formatting Functions

    This is a text with bold word and with emphasised word.You can also use inline code formatting.
    In the previous section, we pasted string values on separate lines. Let's use lists instead:
    • Each argument is one element in the list
    • We can even make nested lists as with the following ordered list:
      1. First
      2. Second
    • And we can of course mix the lists:
      • Nested list again, but now an unordered one.
    -

    1.2  Other Special Functions

    +

    1.2  Other Special Functions

    We can also add a link to Python documentation if necessary. Talking about links, note that each heading has a clickable anchor.

    We already used code function for inline formatting. There is also print_code function that creates a text block formatted as code, which can be useful when we want to print various data structures, such as matrices:
    -
    [[4.17022005e-01 7.20324493e-01 1.14374817e-04]
    +
    [[4.17022005e-01 7.20324493e-01 1.14374817e-04]
      [3.02332573e-01 1.46755891e-01 9.23385948e-02]
      [1.86260211e-01 3.45560727e-01 3.96767474e-01]]
    ... or even a piece of code:
    -
    def factorial(n):
    +
    def factorial(n):
         result = 1
         for i in range(1, n + 1):
             result *= i
         return result
     
    Before going further, let's return to the very first function we used: set_title. As the name suggests, it sets the title of the HTML page. It is optional and can be actually placed in any part of the report.
    -

    2  Inspecting Data

    -

    2.1  Tables and Plots

    -
    -
    -
    +

    2  Inspecting Data

    +

    2.1  Tables and Plots

    +
    +
    + -
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    xy
    15.155267
    24.676778
    35.740878
    43.817809
    56.512470
    63.109550
    75.681870
    84.669219
    95.234759
    103.561548
    +
    +
    + +
    + + + + + + + 2023-09-12T08:59:35.237417 + image/svg+xml + + + Matplotlib v3.7.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +

    2.2  References to Plots and Tables

    +
    It is also possible to create references to tables and figures. For example Table 2 shows sortable columns and Fig. 2 displays a scatterplot.
    +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    xy
    15.155267
    24.676778
    35.740878
    43.817809
    56.512470
    63.109550
    75.681870
    84.669219
    95.234759
    103.561548
    +
    +
    + +
    +
    + +
    @@ -265,7 +863,580 @@

    2.1  Tables and Plots - + + +
    Note that you can use the references in your text multiple times, see again the reference to Table 2 and Fig. 2. Of course, we cannot use a single reference for multiple tables or figures.
    - - - - - - - - - - + + + - - -
    -

    Pyreball Illustration

    +
    +

    Pyreball Illustration

    1  Introduction
    2  Tables and Figures
    -

    1  Introduction

    +

    1  Introduction

    Pyreball has many features, among others:
    • Charts in altair, plotly, bokeh, and matplotlib (and thus also seaborn etc.).
    • Sortable and scrollable tables from pandas DataFrame.
    • Basic text formatting such as headings, emphasis, and lists.
    • hyperlinks, references and table of contents.
    -

    2  Tables and Figures

    -

    x
    +
    @@ -211,8 +131,8 @@

    2  Tables and Figures - -
    + + diff --git a/examples/sample_plots.html b/examples/sample_plots.html index 0a17820..8a59b67 100644 --- a/examples/sample_plots.html +++ b/examples/sample_plots.html @@ -1,102 +1,32 @@ + Sample Plots - + - - - - - - - - - + + + -
    -

    Sample Plots

    +
    +

    Sample Plots

    All supported plots are embedded directly into the final HTML file, except matplotllib plots with png format. For such plots, a folder with png images is created.
    -

    +
    @@ -207,7 +132,598 @@

    Sample Plots + + +
    + + + + + + + 2023-09-12T08:59:33.591841 + image/svg+xml + + + Matplotlib v3.7.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + +
    +
    + +
    +
    +
    + +
    - - - - - - - - - - - - - - - - -
    -

    Simple report

    +
    +

    Simple report

    1  Heading
    -

    1  Heading

    +

    1  Heading

    This is my div with important text.
    diff --git a/pyreball/__main__.py b/pyreball/__main__.py index 18daac1..1c4af25 100644 --- a/pyreball/__main__.py +++ b/pyreball/__main__.py @@ -13,17 +13,18 @@ from pyreball.constants import ( CONFIG_INI_FILENAME, DEFAULT_PATH_TO_CONFIG, - HTML_BEGIN_TEMPLATE_FILENAME, - HTML_END_TEMPLATE_FILENAME, + HTML_TEMPLATE_FILENAME, + LINKS_INI_FILENAME, STYLES_TEMPLATE_FILENAME, ) from pyreball.utils.logger import get_logger -from pyreball.utils.template_utils import get_css, get_html_begin, get_html_end +from pyreball.utils.template_utils import get_css, get_html_begin_and_end from pyreball.utils.utils import ( carefully_remove_directory_if_exists, check_and_fix_parameters, check_paging_sizes_string_parameter, ChoiceParameter, + get_external_links_from_config, get_file_config, IntegerParameter, merge_parameter_dictionaries, @@ -33,108 +34,40 @@ logger = get_logger() -# keep the indentation in the following snippets!!! -JAVASCRIPT_CHANGE_EXPAND = """ - function change_expand(button, table_id){ - var table = document.getElementById(table_id); - if (table.classList.contains("expanded")) { - // collapse the table - table.style.maxHeight = "390px"; - button.innerHTML = "⟱"; - } else { - // expand the table - table.style.maxHeight = "none"; - button.innerHTML = "⟰"; - } - table.classList.toggle("expanded"); - } - -""" -JAVASCRIPT_ON_LOAD = """ - - window.onload = function() { - //dom not only ready, but everything is loaded - scrollers = document.getElementsByClassName("table-scroller"); - - for (i = 0; i < scrollers.length; i++) { - if (scrollers[i].scrollHeight == scrollers[i].clientHeight) { - // hide the expand button - expander_id = scrollers[i].id.replace('scroller', 'expander'); - expander = document.getElementById(expander_id); - expander.style.display = "none"; - } - } - - }; -""" - -JAVASCRIPT_ROLLING_PLOTS = """ - - function next(div_id, button_next_id, button_prev_id) { - var qElems = document.querySelectorAll(div_id + '>div'); - for (var i = 0; i < qElems.length; i++) { - if (qElems[i].style.display != 'none') { - qElems[i].style.display = 'none'; - qElems[i + 1].style.display = 'block'; - if (i == qElems.length - 2) { - document.getElementById(button_next_id).disabled = true; - } - document.getElementById(button_prev_id).disabled = false; - break; - } - } - } - - function previous(div_id, button_next_id, button_prev_id) { - var qElems = document.querySelectorAll(div_id + '>div'); - for (var i = 0; i < qElems.length; i++) { - if (qElems[i].style.display != 'none') { - qElems[i].style.display = 'none'; - qElems[i - 1].style.display = 'block'; - if (i == 1) { - document.getElementById(button_prev_id).disabled = true; - } - document.getElementById(button_next_id).disabled = false; - break; - } - } - } - -""" - - -def _replace_ids(html_path: Path) -> None: + +def _replace_ids(lines: List[str]) -> List[str]: """ Replace IDs of HTML elements to create working anchors based on references. Args: - html_path: Path to the HTML file. + lines: Lines of the html file. + + Returns: + Updated lines of the html file. """ # collect all ids in form of "table-N-M", "img-N-M" all_table_and_img_ids = set() chapter_text_replacemenets = [] - with open(html_path, "r") as f: - for line in f: - # note that we don't need to replace only "table" ids by also "img" etc. - results = re.findall(r"table-id[\d]+-[\d]+", line) - if results: - all_table_and_img_ids.update(results) - results = re.findall(r"img-id[\d]+-[\d]+", line) - if results: - all_table_and_img_ids.update(results) - # now collect heading references: - results = re.findall(r"ch_id[\d]+_[^\"]+", line) - if results: - all_table_and_img_ids.update(results) - # obtain also the heading text - search_result_text = re.search(results[0] + r"\">([^<]+)<", line) - link_text = search_result_text.group(1) if search_result_text else "" - search_result_id = re.search(r"_(id[\d]+)_", results[0]) - link_id = search_result_id.group(1) if search_result_id else "" - if link_id and link_text: - chapter_text_replacemenets.append( - (f">{link_id}<", f">{link_text}<") - ) + # with open(html_path, "r") as f: + for line in lines: + # note that we don't need to replace only "table" ids by also "img" etc. + results = re.findall(r"table-id[\d]+-[\d]+", line) + if results: + all_table_and_img_ids.update(results) + results = re.findall(r"img-id[\d]+-[\d]+", line) + if results: + all_table_and_img_ids.update(results) + # now collect heading references: + results = re.findall(r"ch_id[\d]+_[^\"]+", line) + if results: + all_table_and_img_ids.update(results) + # obtain also the heading text + search_result_text = re.search(results[0] + r"\">([^<]+)<", line) + link_text = search_result_text.group(1) if search_result_text else "" + search_result_id = re.search(r"_(id[\d]+)_", results[0]) + link_id = search_result_id.group(1) if search_result_id else "" + if link_id and link_text: + chapter_text_replacemenets.append((f">{link_id}<", f">{link_text}<")) # Prepare all replacement definitions for a substitutor below replacements = [] for element_id in all_table_and_img_ids: @@ -173,13 +106,7 @@ def _replace_ids(html_path: Path) -> None: # replace all table-N-M with table-M and Table N with Table M substitutor = Substitutor(replacements=replacements) - modified_lines = [] - with open(html_path, "r") as f: - for line in f: - modified_lines.append(substitutor.sub(line)) - - with open(html_path, "w") as f: - f.writelines(modified_lines) + return [substitutor.sub(line) for line in lines] def _get_node_text(node: xml.dom.minidom.Element) -> str: @@ -207,11 +134,9 @@ def _parse_heading_info(line: str) -> Optional[Tuple[int, str, str]]: return None -def insert_heading_title_and_toc(filename: Path, include_toc: bool = True): - # fetch all lines - with open(filename, "r") as f: - lines = f.readlines() - +def _insert_heading_title_and_toc( + lines: List[str], include_toc: bool = True +) -> List[str]: # try to extract the title from element: report_title = None for line in lines: @@ -224,7 +149,7 @@ def insert_heading_title_and_toc(filename: Path, include_toc: bool = True): container_start_index = 0 headings = [] for i, line in enumerate(lines): - if '<div class="main_container">' in line: + if '<div class="pyreball-main-container">' in line: container_start_index = i if include_toc: @@ -245,7 +170,7 @@ def insert_heading_title_and_toc(filename: Path, include_toc: bool = True): lines_index, ( f'<h1 id="toc_generated_0">{report_title}' - f'<a class="anchor-link" href="#toc_generated_0">¶</a></h1>\n' + f'<a class="pyreball-anchor-link" href="#toc_generated_0">¶</a></h1>\n' ), ) lines_index += 1 @@ -277,8 +202,82 @@ def insert_heading_title_and_toc(filename: Path, include_toc: bool = True): lines_index += 1 current_level -= 1 - with open(filename, "w") as f: - f.writelines(lines) + return lines + + +def _contains_class(html_text: str, class_name: str) -> bool: + """ + Check whether the given HTML text contains the given class name in any element. + + Args: + html_text: HTML text. + class_name: Class to be found. + + Returns: + True if the HTML text contains the given class name. + """ + pattern = ( + r'class\s*=\s*["\']\s*(?:\S+\s+)*' + + re.escape(class_name) + + r'(?:\s+\S+)*\s*["\']' + ) + return re.search(pattern, html_text) is not None + + +def _insert_js_and_css_links( + html_content: str, external_links: Dict[str, List[str]] +) -> str: + groups_of_links_to_add = set() + add_jquery = False + if _contains_class( + html_text=html_content, class_name="inline-highlight" + ) or _contains_class(html_text=html_content, class_name="pyreball-code-block"): + add_jquery = True + groups_of_links_to_add.add("highlight_js") + if _contains_class(html_text=html_content, class_name="pyreball-table-wrapper"): + add_jquery = True + groups_of_links_to_add.add("datatables") + if _contains_class(html_text=html_content, class_name="pyreball-altair-fig"): + groups_of_links_to_add.add("altair") + if _contains_class(html_text=html_content, class_name="pyreball-plotly-fig"): + groups_of_links_to_add.add("plotly") + if _contains_class(html_text=html_content, class_name="pyreball-bokeh-fig"): + groups_of_links_to_add.add("bokeh") + + # gather all links; jquery must be first + links_to_add = "\n".join( + (external_links["jquery"] if add_jquery else []) + + [ + el + for group in sorted(list(groups_of_links_to_add)) + for el in external_links[group] + ] + ) + html_content = re.sub("<!--PYREBALL_HEAD_LINKS-->", links_to_add, html_content) + return html_content + + +def _finish_html_file( + html_path: Path, include_toc: bool, external_links: Dict[str, List[str]] +) -> None: + """ + Load the printed HTML and finish substitutions to make it complete. + + Args: + html_path: Path to the HTML file. + include_toc: Whether to include the table of contents. + """ + with open(html_path, "r") as f: + lines = f.readlines() + + lines = _replace_ids(lines) + lines = _insert_heading_title_and_toc(lines=lines, include_toc=include_toc) + + html_content = "".join(lines) + html_content = _insert_js_and_css_links(html_content, external_links) + + with open(html_path, "w") as f: + f.write(html_content) parameter_specifications = [ @@ -421,9 +420,9 @@ def _check_existence_of_config_files( ) -> None: required_filename = [ CONFIG_INI_FILENAME, + LINKS_INI_FILENAME, STYLES_TEMPLATE_FILENAME, - HTML_BEGIN_TEMPLATE_FILENAME, - HTML_END_TEMPLATE_FILENAME, + HTML_TEMPLATE_FILENAME, ] for filename in required_filename: if not (config_dir_path / filename).exists(): @@ -608,6 +607,10 @@ def main() -> None: directory=config_directory, parameter_specifications=parameter_specifications, ) + external_links = get_external_links_from_config( + filename=LINKS_INI_FILENAME, + directory=config_directory, + ) parameters = merge_parameter_dictionaries( primary_parameters=cli_parameters, @@ -622,29 +625,19 @@ def main() -> None: # remove the directory with images if it exists: carefully_remove_directory_if_exists(directory=Path(html_dir_path_str)) - script_definitions = ( - JAVASCRIPT_CHANGE_EXPAND + JAVASCRIPT_ON_LOAD + JAVASCRIPT_ROLLING_PLOTS - ) - css_definitions = get_css( filename=STYLES_TEMPLATE_FILENAME, directory=config_directory, page_width=cast(int, parameters["page_width"]), ) - html_begin = get_html_begin( + html_begin, html_end = get_html_begin_and_end( template_path=Path( - pkg_resources.resource_filename("pyreball", "cfg/html_begin.template") + pkg_resources.resource_filename("pyreball", f"cfg/{HTML_TEMPLATE_FILENAME}") ), title=filename_stem, - script_definitions=script_definitions, css_definitions=css_definitions, ) - html_end = get_html_end( - template_path=Path( - pkg_resources.resource_filename("pyreball", "cfg/html_end.template") - ) - ) with open(html_path, "w") as f: f.write(html_begin) @@ -656,9 +649,10 @@ def main() -> None: with open(html_path, "a") as f: f.write(html_end) - _replace_ids(html_path) - insert_heading_title_and_toc( - filename=html_path, include_toc=parameters["toc"] == "yes" + _finish_html_file( + html_path=html_path, + include_toc=parameters["toc"] == "yes", + external_links=external_links, ) diff --git a/pyreball/cfg/styles.template b/pyreball/cfg/css.template similarity index 66% rename from pyreball/cfg/styles.template rename to pyreball/cfg/css.template index 3f5fb37..0e5bf63 100644 --- a/pyreball/cfg/styles.template +++ b/pyreball/cfg/css.template @@ -2,13 +2,13 @@ body { font-family: Arial, Helvetica, sans-serif; } -.main_container { +.pyreball-main-container { width: {{page_width}}%; margin-left: auto; margin-right: auto; } -.image-wrapper { +.pyreball-image-wrapper { margin-left: auto; margin-right: auto; margin-top: 20px; @@ -21,44 +21,40 @@ img, svg, canvas.marks, div.vega-embed { display: block; } -.text-centered { +.pyreball-text-centered { text-align: center; } -.table-wrapper +.pyreball-table-wrapper { width: 100%; margin-top: 30px; margin-bottom: 30px; } -.table-fit-content { +.pyreball-table-fit-content { width: fit-content; max-width: 100%; margin-top: 10px; margin-bottom: 10px; } -.centered { +.pyreball-centered { margin-left:auto; margin-right:auto; } -.left-aligned { +.pyreball-left-aligned { margin-left:0px; margin-right:auto; } -.right-aligned { +.pyreball-right-aligned { margin-left:auto; margin-right:0px; } -.dataframe caption { - font-weight: bold; -} - -a.anchor-link:link { +a.pyreball-anchor-link:link { text-decoration: none; padding: 0px 20px; opacity: 0; @@ -71,12 +67,11 @@ a[href]:hover { color: #001f3f } - -h1:hover .anchor-link, -h2:hover .anchor-link, -h3:hover .anchor-link, -h4:hover .anchor-link, -h5:hover .anchor-link, -h6:hover .anchor-link { +h1:hover .pyreball-anchor-link, +h2:hover .pyreball-anchor-link, +h3:hover .pyreball-anchor-link, +h4:hover .pyreball-anchor-link, +h5:hover .pyreball-anchor-link, +h6:hover .pyreball-anchor-link { opacity: 1; } \ No newline at end of file diff --git a/pyreball/cfg/external_links.ini b/pyreball/cfg/external_links.ini new file mode 100644 index 0000000..e1831d3 --- /dev/null +++ b/pyreball/cfg/external_links.ini @@ -0,0 +1,23 @@ +[Links] +altair = + <script src="https://cdn.jsdelivr.net/npm/vega@5"></script> + <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script> + <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script> +bokeh = + <script src="https://cdn.bokeh.org/bokeh/release/bokeh-3.2.2.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.2.2.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.2.2.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.2.2.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.bokeh.org/bokeh/release/bokeh-mathjax-3.2.2.min.js" crossorigin="anonymous"></script> +datatables = + <link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css" /> + <script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></script> +highlight_js = + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/default.min.css"> + <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/languages/python.min.js"></script> + <script>hljs.highlightAll();</script> +jquery = + <script src="https://code.jquery.com/jquery-3.7.0.js"></script> +plotly = + <script src="https://cdn.plot.ly/plotly-latest.min.js"></script> \ No newline at end of file diff --git a/pyreball/cfg/html_end.template b/pyreball/cfg/html.template similarity index 51% rename from pyreball/cfg/html_end.template rename to pyreball/cfg/html.template index 174440a..4464dc3 100644 --- a/pyreball/cfg/html_end.template +++ b/pyreball/cfg/html.template @@ -1,4 +1,16 @@ -</div> +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<title><!--PYREBALL_PAGE_TITLE--> + + + + +
    +
    - - - - - - - - - - - - - - - - - - - -
    diff --git a/pyreball/config_generator.py b/pyreball/config_generator.py index dea1815..15549e9 100644 --- a/pyreball/config_generator.py +++ b/pyreball/config_generator.py @@ -5,8 +5,8 @@ from pyreball.constants import ( CONFIG_INI_FILENAME, DEFAULT_PATH_TO_CONFIG, - HTML_BEGIN_TEMPLATE_FILENAME, - HTML_END_TEMPLATE_FILENAME, + HTML_TEMPLATE_FILENAME, + LINKS_INI_FILENAME, STYLES_TEMPLATE_FILENAME, ) from pyreball.utils.logger import get_logger @@ -21,11 +21,9 @@ def copy_config_files(output_directory: Path) -> None: ) output_directory.mkdir(parents=True, exist_ok=True) shutil.copy2(DEFAULT_PATH_TO_CONFIG / CONFIG_INI_FILENAME, output_directory) + shutil.copy2(DEFAULT_PATH_TO_CONFIG / LINKS_INI_FILENAME, output_directory) shutil.copy2(DEFAULT_PATH_TO_CONFIG / STYLES_TEMPLATE_FILENAME, output_directory) - shutil.copy2( - DEFAULT_PATH_TO_CONFIG / HTML_BEGIN_TEMPLATE_FILENAME, output_directory - ) - shutil.copy2(DEFAULT_PATH_TO_CONFIG / HTML_END_TEMPLATE_FILENAME, output_directory) + shutil.copy2(DEFAULT_PATH_TO_CONFIG / HTML_TEMPLATE_FILENAME, output_directory) def main() -> None: diff --git a/pyreball/constants.py b/pyreball/constants.py index 485b43b..4870c2c 100644 --- a/pyreball/constants.py +++ b/pyreball/constants.py @@ -3,7 +3,10 @@ import pkg_resources CONFIG_INI_FILENAME = "config.ini" -STYLES_TEMPLATE_FILENAME = "styles.template" -HTML_BEGIN_TEMPLATE_FILENAME = "html_begin.template" -HTML_END_TEMPLATE_FILENAME = "html_end.template" +LINKS_INI_FILENAME = "external_links.ini" +STYLES_TEMPLATE_FILENAME = "css.template" +HTML_TEMPLATE_FILENAME = "html.template" DEFAULT_PATH_TO_CONFIG = Path(pkg_resources.resource_filename("pyreball", "cfg")) + +PILCROW_SIGN = "¶" +NON_BREAKABLE_SPACE = "\u00A0" diff --git a/pyreball/html.py b/pyreball/html.py index fe505a3..a85e3ac 100644 --- a/pyreball/html.py +++ b/pyreball/html.py @@ -19,6 +19,7 @@ ) from pyreball._common import AttrsParameter, ClParameter +from pyreball.constants import NON_BREAKABLE_SPACE, PILCROW_SIGN from pyreball.text import code_block, div from pyreball.utils.utils import get_parameter_value, make_sure_dir_exists, merge_values @@ -230,10 +231,10 @@ def _print_heading( # reset all sub-levels _heading_memory["heading_counting"][level:] = [0] * (6 - level) # get the string of the numbered section and append non-breakable space - non_breakable_spaces = "\u00A0\u00A0" heading_number_str = ( _get_heading_number(level, _heading_memory["heading_counting"]) - + non_breakable_spaces + + NON_BREAKABLE_SPACE + + NON_BREAKABLE_SPACE ) else: heading_number_str = "" @@ -248,12 +249,12 @@ def _print_heading( tidy_string = f"ch_{_tidy_title(string)}_{heading_index}" if not get_parameter_value("html_file_path") or get_parameter_value("keep_stdout"): - builtins.print(string.replace("\u00A0\u00A0", " ")) + builtins.print(string.replace(NON_BREAKABLE_SPACE * 2, " ")) if get_parameter_value("html_file_path"): - pilcrow_sign = "\u00B6" header_contents = ( - string + f'{pilcrow_sign}' + f"{string}" + f'{PILCROW_SIGN}' ) # For correct functioning of references, # it is expected that single line contains at most one heading, @@ -438,7 +439,7 @@ def _prepare_caption_element( else: caption_text = "" return ( - f'\n
    ' + f'\n\n" ) @@ -527,9 +528,9 @@ def _prepare_table_html( **kwargs: Any, ) -> str: align_mapping = { - "center": "centered", - "left": "left-aligned", - "right": "right-aligned", + "center": "pyreball-centered", + "left": "pyreball-left-aligned", + "right": "pyreball-right-aligned", } table_classes = [] if isinstance(datatables_style, list): @@ -567,9 +568,10 @@ def _prepare_table_html( anchor_link=anchor_link, ) - table_wrapper_inner_id = "table-wrapper-inner-" + str(tab_index) + table_wrapper_inner_id = "pyreball-table-wrapper-inner-" + str(tab_index) table_html = ( - f'
    ' + f'
    ' f"{df_html}\n" f"
    " ) @@ -580,17 +582,15 @@ def _prepare_table_html( table_html = table_html + caption_element table_html = ( - f'
    {table_html}
    ' + f'
    ' + f"{table_html}
    " ) - table_html = f'
    \n{table_html}\n
    ' + table_html = f'
    \n{table_html}\n
    ' if datatables_setup is not None: table_init = json.dumps(datatables_setup) - js = ( - f"var table = $('#{table_wrapper_inner_id} > table')" - f".DataTable({table_init});" - ) + js = f"new DataTable('#{table_wrapper_inner_id} > table', {table_init});" table_html += f"\n" return table_html @@ -782,17 +782,22 @@ def _construct_image_anchor_link(reference: Optional[Reference], fig_index: int) def _wrap_image_element_by_outer_divs( - img_element: str, align: str, hidden: bool + img_element: str, align: str, hidden: bool, img_type: str ) -> str: img_element = ( f'
    ' f"{img_element}" f"
    " ) + wrapper_classes = f"pyreball-image-wrapper pyreball-{img_type}-fig" if hidden: - return f'' + return ( + f'" + ) else: - return f'
    {img_element}
    ' + return f'
    {img_element}
    ' def _prepare_matplotlib_image_element( @@ -869,7 +874,7 @@ def _prepare_image_element( fig_index: int, matplotlib_format: Optional[str] = None, embedded: Optional[bool] = None, -): +) -> Tuple[str, str]: # Create the html string according to the figure type. # (if we checked type of fig, we would have to add the libraries to requirements) if ( @@ -883,6 +888,7 @@ def _prepare_image_element( image_format=matplotlib_format, embedded=embedded, ) + img_type = "matplotlib" elif type(fig).__name__ in [ "Chart", "ConcatChart", @@ -893,20 +899,23 @@ def _prepare_image_element( "VConcatChart", ]: img_element = _prepare_altair_image_element(fig=fig, fig_index=fig_index) + img_type = "altair" elif ( type(fig).__name__ == "Figure" and type(fig).__module__ == "plotly.graph_objs._figure" ): img_element = _prepare_plotly_image_element(fig=fig) + img_type = "plotly" elif type(fig).__name__.lower() == "figure" and type(fig).__module__ in [ "bokeh.plotting.figure", "bokeh.plotting._figure", ]: img_element = _prepare_bokeh_image_element(fig=fig) + img_type = "bokeh" else: raise ValueError(f"Unknown figure type {type(fig)}.") - return img_element + return img_element, img_type def _print_figure( @@ -939,40 +948,33 @@ def _print_figure( anchor_link = _construct_image_anchor_link( reference=reference, fig_index=fig_index ) + img_element, img_type = _prepare_image_element( + fig=fig, + fig_index=fig_index, + matplotlib_format=matplotlib_format, + embedded=embedded, + ) + caption_element = _prepare_caption_element( + prefix="Figure", + caption=caption, + numbered=numbered, + index=fig_index, + anchor_link=anchor_link, + ) + if caption_position == "bottom": - img_element = _prepare_image_element( - fig=fig, - fig_index=fig_index, - matplotlib_format=matplotlib_format, - embedded=embedded, - ) - img_element += _prepare_caption_element( - prefix="Figure", - caption=caption, - numbered=numbered, - index=fig_index, - anchor_link=anchor_link, - ) + img_with_caption = img_element + caption_element elif caption_position == "top": - img_element = _prepare_caption_element( - prefix="Figure", - caption=caption, - numbered=numbered, - index=fig_index, - anchor_link=anchor_link, - ) - img_element += _prepare_image_element( - fig=fig, - fig_index=fig_index, - matplotlib_format=matplotlib_format, - embedded=embedded, - ) + img_with_caption = caption_element + img_element else: raise ValueError( f"caption_position must be 'top' or 'bottom', not {caption_position}." ) img_html = _wrap_image_element_by_outer_divs( - img_element=img_element, align=align, hidden=hidden + img_element=img_with_caption, + align=align, + hidden=hidden, + img_type=img_type, ) _write_to_html(img_html) diff --git a/pyreball/text.py b/pyreball/text.py index 4052d55..46f6b59 100644 --- a/pyreball/text.py +++ b/pyreball/text.py @@ -174,7 +174,9 @@ def code_block( Returns: HTML string representing the tag with given values. """ - cl = _collect_classes_for_code_strings([], cl, syntax_highlight) + cl = _collect_classes_for_code_strings( + ["pyreball-code-block"], cl, syntax_highlight + ) code_text = tag(*values, name="code", cl=cl, attrs=attrs, sep=sep) return tag(code_text, name="pre") diff --git a/pyreball/utils/template_utils.py b/pyreball/utils/template_utils.py index 2332210..96e619c 100644 --- a/pyreball/utils/template_utils.py +++ b/pyreball/utils/template_utils.py @@ -1,27 +1,24 @@ import re import sys from pathlib import Path +from typing import Tuple from pyreball.utils.logger import get_logger logger = get_logger() -def get_html_begin( - template_path: Path, title: str, script_definitions: str, css_definitions: str -) -> str: +def get_html_begin_and_end( + template_path: Path, title: str, css_definitions: str +) -> Tuple[str, str]: with open(template_path, "r") as f: - html_start = f.read() - html_start = re.sub(r"{{title}}", title, html_start) - html_start = re.sub(r"{{script_definitions}}", script_definitions, html_start) - html_start = re.sub(r"{{css_definitions}}", css_definitions, html_start) - return html_start - - -def get_html_end(template_path: Path) -> str: - with open(template_path, "r") as f: - html_end = f.read() - return html_end + html_text = f.read() + html_start, html_end = html_text.split("") + html_start = re.sub("", title, html_start) + html_start = re.sub( + r"", css_definitions, html_start + ) + return html_start, html_end def get_css(filename: str, directory: Path, page_width: int = 60) -> str: diff --git a/pyreball/utils/utils.py b/pyreball/utils/utils.py index 6e600fe..5ed5c00 100644 --- a/pyreball/utils/utils.py +++ b/pyreball/utils/utils.py @@ -320,6 +320,43 @@ def get_file_config( ) +def _parse_links_from_config(links_str: str) -> List[str]: + return [x for x in links_str.split("\n") if x] + + +def get_external_links_from_config( + filename: str, directory: Path +) -> Dict[str, List[str]]: + config = read_file_config(filename, directory) + section_name = "Links" + if section_name not in config: + logger.error( + f"{section_name} section not found in {directory / filename} " + f"configuration file. Fix the file or try re-installing pyreball." + ) + sys.exit(1) + + links = { + key: _parse_links_from_config(value) + for key, value in config[section_name].items() + } + required_keys = { + "altair", + "bokeh", + "datatables", + "highlight_js", + "jquery", + "plotly", + } + if links.keys() != required_keys: + logger.error( + "Configuration with items must contain links for exactly these keys: " + f"{', '.join(sorted(list(required_keys)))}." + ) + sys.exit(1) + return links + + def merge_values(primary_value: Any, secondary_value: Any) -> Any: return primary_value if primary_value is not None else secondary_value diff --git a/tests/test_html.py b/tests/test_html.py index b3b7cd0..a41237b 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -323,12 +323,12 @@ def fake_get_parameter_value(key): expected_result = ( "\n" - f'

    heading 1\u00B6

    \n' - '

    heading 3\u00B6

    \n' - '
    heading 6\u00B6
    \n' - '

    heading 4\u00B6

    \n' - '

    heading 2\u00B6

    \n' - '
    heading 5\u00B6
    \n' + f'

    heading 1\u00B6

    \n' + '

    heading 3\u00B6

    \n' + '
    heading 6\u00B6
    \n' + '

    heading 4\u00B6

    \n' + '

    heading 2\u00B6

    \n' + '
    heading 5\u00B6
    \n' ) with open(simple_html_file, "r") as f: @@ -385,15 +385,15 @@ def fake_get_parameter_value(key): expected_result = ( "\n" - f'

    1\u00A0\u00A0he 1\u00B6

    \n' - '

    1.1\u00A0\u00A0he 2\u00B6

    \n' - '

    1.1.1\u00A0\u00A0he 3\u00B6

    \n' - '

    1.1.2\u00A0\u00A0he 3\u00B6

    \n' - '

    1.2\u00A0\u00A0he 2\u00B6

    \n' - '

    2\u00A0\u00A0he 1\u00B6

    \n' - '

    2.1\u00A0\u00A0he 2\u00B6

    \n' - '

    2.2\u00A0\u00A0he 2\u00B6

    \n' - '

    2.2.1\u00A0\u00A0he 3\u00B6

    \n' + f'

    1\u00A0\u00A0he 1\u00B6

    \n' + '

    1.1\u00A0\u00A0he 2\u00B6

    \n' + '

    1.1.1\u00A0\u00A0he 3\u00B6

    \n' + '

    1.1.2\u00A0\u00A0he 3\u00B6

    \n' + '

    1.2\u00A0\u00A0he 2\u00B6

    \n' + '

    2\u00A0\u00A0he 1\u00B6

    \n' + '

    2.1\u00A0\u00A0he 2\u00B6

    \n' + '

    2.2\u00A0\u00A0he 2\u00B6

    \n' + '

    2.2.1\u00A0\u00A0he 3\u00B6

    \n' ) with open(simple_html_file, "r") as f: @@ -503,8 +503,14 @@ def fake_get_parameter_value_different(key): @pytest.mark.parametrize( "syntax_highlight,expected_result", [ - ("python", '
    [1, 2, 3]
    '), - (None, "
    [1, 2, 3]
    "), + ( + "python", + '
    [1, 2, 3]
    ', + ), + ( + None, + "
    [1, 2, 3]
    ", + ), ], ) def test_print_code_block__file_output( @@ -628,7 +634,7 @@ def fake_get_parameter_value(key): False, 3, "myanchor", - '\n\n', + '\n\n', ), ( "tab", @@ -636,7 +642,7 @@ def fake_get_parameter_value(key): False, 3, "myanchor", - '\n\n', + '\n\n', ), ( "tab", @@ -644,7 +650,7 @@ def fake_get_parameter_value(key): True, 3, "myanchor2", - '\n\n', + '\n\n', ), ( "img", @@ -652,7 +658,7 @@ def fake_get_parameter_value(key): True, 5, "myanchor2", - '\n\n', + '\n\n', ), ], ) @@ -1000,11 +1006,11 @@ def test__prepare_table_html( ) align_class = { - "center": "centered", - "left": "left-aligned", - "right": "right-aligned", + "center": "pyreball-centered", + "left": "pyreball-left-aligned", + "right": "pyreball-right-aligned", }[align] - assert html_root.findall(f"./div[@class='table-fit-content {align_class}']") + assert html_root.findall(f"./div[@class='pyreball-table-fit-content {align_class}']") anchor = "table-123-5" if use_reference else "table-5" assert html_root.findall(f"./div/div/a[@name='{anchor}']") @@ -1200,34 +1206,48 @@ def test__construct_image_anchor_link( @pytest.mark.parametrize( - "img_element,align,hidden,expected_result", + "img_element,align,hidden,img_type,expected_result", [ ( "el1", "center", True, - '', + "xyz", + ( + '' + ), ), ( "el2", "left", True, - '', + "abc", + ( + '' + ), ), ( "el3", "right", False, - '
    ' - '
    el3
    ', + "abc", + ( + '
    ' + '
    el3
    ' + ), ), ], ) -def test__wrap_image_element_by_outer_divs(img_element, align, hidden, expected_result): +def test__wrap_image_element_by_outer_divs( + img_element, align, hidden, img_type, expected_result +): assert ( - _wrap_image_element_by_outer_divs(img_element, align, hidden) == expected_result + _wrap_image_element_by_outer_divs(img_element, align, hidden, img_type) + == expected_result ) @@ -1369,7 +1389,7 @@ def test__prepare_image_element__matplotlib(_prepare_matplotlib_image_element_mo _prepare_matplotlib_image_element_mock.assert_called_with( fig=fig, fig_index=3, image_format="svg", embedded=True ) - assert result == "img_element" + assert result == ("img_element", "matplotlib") @mock.patch( @@ -1385,7 +1405,7 @@ def test__prepare_image_element__seaborn( _prepare_matplotlib_image_element_mock.assert_called_with( fig=fig, fig_index=3, image_format="svg", embedded=True ) - assert result == "img_element" + assert result == ("img_element", "matplotlib") @mock.patch("pyreball.html._prepare_altair_image_element", return_value="img_element") @@ -1406,7 +1426,7 @@ def test__prepare_image_element__altair(_prepare_altair_image_element_mock, fig) fig=fig, fig_index=3, matplotlib_format="svg", embedded=True ) _prepare_altair_image_element_mock.assert_called_with(fig=fig, fig_index=3) - assert result == "img_element" + assert result == ("img_element", "altair") @mock.patch("pyreball.html._prepare_plotly_image_element", return_value="img_element") @@ -1418,7 +1438,7 @@ def test__prepare_image_element__plotly( fig=fig, fig_index=3, matplotlib_format="svg", embedded=True ) _prepare_plotly_image_element_mock.assert_called_with(fig=fig) - assert result == "img_element" + assert result == ("img_element", "plotly") @mock.patch("pyreball.html._prepare_bokeh_image_element", return_value="img_element") @@ -1430,7 +1450,7 @@ def test__prepare_image_element__bokeh( fig=fig, fig_index=3, matplotlib_format="svg", embedded=True ) _prepare_bokeh_image_element_mock.assert_called_with(fig=fig) - assert result == "img_element" + assert result == ("img_element", "bokeh") def test__print_figure__stdout__bokeh(simple_dataframe): diff --git a/tests/test_main.py b/tests/test_main.py index e74e0b3..ecd92ab 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -4,17 +4,19 @@ import pkg_resources import pytest from pyreball.__main__ import ( + _contains_class, _get_config_directory, _get_output_dir_and_file_stem, + _insert_heading_title_and_toc, + _insert_js_and_css_links, _parse_heading_info, _replace_ids, - insert_heading_title_and_toc, parse_arguments, ) from pyreball.constants import ( CONFIG_INI_FILENAME, - HTML_BEGIN_TEMPLATE_FILENAME, - HTML_END_TEMPLATE_FILENAME, + HTML_TEMPLATE_FILENAME, + LINKS_INI_FILENAME, STYLES_TEMPLATE_FILENAME, ) @@ -23,7 +25,7 @@ @pytest.mark.parametrize( - "report_before,report_after", + "lines,expected_result", [ ( [ @@ -64,56 +66,45 @@ "", 'Reference to chapter id123', '

    My Chapter' - '\u00B6

    ', + '\u00B6

    ', 'Reference to chapter id123 again', "", ], [ "", 'Reference to chapter My Chapter', - '

    My Chapter\u00B6

    ', + '

    My Chapter\u00B6

    ', 'Reference to chapter My Chapter again', "", ], ), ], ) -def test__replace_ids(report_before, report_after, tmpdir): - report_dir = Path(tmpdir) - report_dir.mkdir(parents=True, exist_ok=True) - report_path = report_dir / "report.py" - - with open(report_path, "w") as f: - f.write("\n".join(report_before)) - - _replace_ids(report_path) - - with open(report_path) as f: - result = f.read().split("\n") - - assert result == report_after +def test__replace_ids(lines, expected_result): + result = _replace_ids(lines) + assert result == expected_result @pytest.mark.parametrize( "test_input,expected_result", [ ( - '

    Result of addition

    ', + '

    Result of addition

    ', (2, "result_of_addition_2", "Result of addition"), ), ( '

    Whatever text - is necessary - 999' - '

    ', + '', (3, "some_id", "Whatever text - is necessary - 999"), ), ( '

    Whatever text - is necessary - 999' - '

    ', + '', (3, "some_id", "Whatever text - is necessary - 999"), ), ( '

    Whatever text - also code and bold emphasis - 999' - '

    ', + '', (3, "some_id", "Whatever text - also code and bold emphasis - 999"), ), ("
    paragraph
    ", None), @@ -131,7 +122,7 @@ def test_parse_heading_info(test_input, expected_result): ], ) @pytest.mark.parametrize("include_toc", [True, False]) -def test_insert_heading_title_and_toc__with_headings(include_toc, title_set, tmpdir): +def test__insert_heading_title_and_toc__with_headings(include_toc, title_set): if title_set: title = 'Custom Title' expected_toc_heading = "Custom Title" @@ -139,81 +130,78 @@ def test_insert_heading_title_and_toc__with_headings(include_toc, title_set, tmp title = "Default Title" expected_toc_heading = "Table of Contents" - report_before = [ + lines = [ "", "", title, "", "", - '
    ', - '

    1  heading h1

    ', - '

    1.1  heading h2

    ', - '

    1.2  heading h2

    ', - '

    1.2.1  heading h3

    ', - '

    1.2.2  heading h3

    ', - '

    2  heading h1

    ', - '

    3  heading h1

    ', - '

    3.1  heading h2

    ', + '
    ', + '

    1  heading h1

    ', + '

    1.1  heading h2

    ', + '

    1.2  heading h2

    ', + '

    1.2.1  heading h3

    ', + '

    1.2.2  heading h3

    ', + '

    2  heading h1

    ', + '

    3  heading h1

    ', + '

    3.1  heading h2

    ', "
    ", "", "", ] - report_dir = Path(tmpdir) - report_dir.mkdir(parents=True, exist_ok=True) - report_path = report_dir / "report.py" - - with open(report_path, "w") as f: - f.write("\n".join(report_before)) - - insert_heading_title_and_toc(report_path, include_toc=include_toc) - - with open(report_path) as f: - result = f.read().split("\n") + result = _insert_heading_title_and_toc(lines=lines, include_toc=include_toc) expected_title_and_toc = [] if title_set and not include_toc: expected_title_and_toc = [ - f'

    {expected_toc_heading}

    ' + f'

    {expected_toc_heading}

    \n' ] elif include_toc: expected_title_and_toc = [ - f'

    {expected_toc_heading}

    ', - '1  heading h1
    ', - '", - '2  heading h1
    ', - '3  heading h1
    ', - '", + f'

    {expected_toc_heading}

    \n', + '1  heading h1
    \n', + '\n", + '2  heading h1
    \n', + '3  heading h1
    \n', + '\n", ] - report_after = ( - ["", "", title, "", "", '
    '] + expected_result = ( + [ + "", + "", + title, + "", + "", + '
    ', + ] + expected_title_and_toc + [ - '

    1  heading h1

    ', - '

    1.1  heading h2

    ', - '

    1.2  heading h2

    ', - '

    1.2.1  heading h3

    ', - '

    1.2.2  heading h3

    ', - '

    2  heading h1

    ', - '

    3  heading h1

    ', - '

    3.1  heading h2

    ', + '

    1  heading h1

    ', + '

    1.1  heading h2

    ', + '

    1.2  heading h2

    ', + '

    1.2.1  heading h3

    ', + '

    1.2.2  heading h3

    ', + '

    2  heading h1

    ', + '

    3  heading h1

    ', + '

    3.1  heading h2

    ', "
    ", "", "", ] ) - assert result == report_after + assert result == expected_result @pytest.mark.parametrize( @@ -224,44 +212,41 @@ def test_insert_heading_title_and_toc__with_headings(include_toc, title_set, tmp ], ) @pytest.mark.parametrize("include_toc", [True, False]) -def test_insert_heading_title_and_toc__without_headings(include_toc, title_set, tmpdir): +def test__insert_heading_title_and_toc__without_headings(include_toc, title_set): if title_set: title = 'Custom Title' else: title = "Default Title" - report_before = [ + lines = [ "", "", title, "", "", - '
    ', + '
    ', "
    ", "", "", ] - report_dir = Path(tmpdir) - report_dir.mkdir(parents=True, exist_ok=True) - report_path = report_dir / "report.py" - - with open(report_path, "w") as f: - f.write("\n".join(report_before)) - - insert_heading_title_and_toc(report_path, include_toc=include_toc) - - with open(report_path) as f: - result = f.read().split("\n") + result = _insert_heading_title_and_toc(lines=lines, include_toc=include_toc) expected_title_and_toc = [] if title_set: expected_title_and_toc = [ - f'

    Custom Title

    ' + f'

    Custom Title

    \n' ] report_after = ( - ["", "", title, "", "", '
    '] + [ + "", + "", + title, + "", + "", + '
    ', + ] + expected_title_and_toc + [ "
    ", @@ -273,6 +258,80 @@ def test_insert_heading_title_and_toc__without_headings(include_toc, title_set, assert result == report_after +@pytest.mark.parametrize( + "html_text,class_name,expected_result", + [ + ("", "inline", False), + ('
    ', "inline", True), + ('
    ', "another", True), + ("
    ", "inline", True), + ('
    ', "inline", True), + ('
    ', "inline", True), + ('
    inline
    ', "inline", False), + ('
    ', "inline", False), + ('
    ', "inline", False), + ], +) +def test__contains_class(html_text, class_name, expected_result): + assert _contains_class(html_text, class_name) == expected_result + + +@pytest.mark.parametrize( + "html_content,external_links,expected_result", + [ + ( + "", + {"bokeh": ["l1", "l2"], "altair": ["l3"]}, + "", + ), + ( + ( + "" + '
    ' + "
    " + ), + {"bokeh": ["l1", "l2"], "altair": ["l3"]}, + 'l1\nl2
    ', + ), + ( + ( + "" + '
    ' + "
    " + '
    ' + "
    " + '
    ' + "
    " + '
    ' + "
    " + "" + ), + { + "altair": ["l1", "l2"], + "jquery": ["l4"], + "highlight_js": ["l3"], + "datatables": ["l5"], + "plotly": ["l6"], + }, + ( + "l4\nl1\nl2\nl5\nl3\nl6" + '
    ' + "
    " + '
    ' + "
    " + '
    ' + "
    " + '
    ' + "
    " + "" + ), + ), + ], +) +def test__insert_js_and_css_links(html_content, external_links, expected_result): + assert _insert_js_and_css_links(html_content, external_links) == expected_result + + def test__get_config_directory__custom_path_does_not_exist(tmpdir): tmpdir = Path(tmpdir) config_dir = "my_config_dir" @@ -294,7 +353,7 @@ def test__get_config_directory__home_path_with_incomplete_files(tmpdir, mocker): fake_home_path = tmpdir / "my_home" mocker.patch.object(Path, "home", return_value=fake_home_path) (fake_home_path / ".pyreball").mkdir(parents=True) - (fake_home_path / ".pyreball" / HTML_BEGIN_TEMPLATE_FILENAME).touch() + (fake_home_path / ".pyreball" / HTML_TEMPLATE_FILENAME).touch() with pytest.raises(FileNotFoundError): _get_config_directory(config_dir_path=None) @@ -323,9 +382,9 @@ def test__get_config_directory__valid_conditions( os.chdir(tmpdir) required_filename = [ CONFIG_INI_FILENAME, + LINKS_INI_FILENAME, STYLES_TEMPLATE_FILENAME, - HTML_BEGIN_TEMPLATE_FILENAME, - HTML_END_TEMPLATE_FILENAME, + HTML_TEMPLATE_FILENAME, ] config_dir = "my_config_dir" (tmpdir / config_dir).mkdir() diff --git a/tests/test_text.py b/tests/test_text.py index 47358e8..6075ff3 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -125,7 +125,9 @@ def test_code_block__without_syntax_highlight(): def test_code_block__with_syntax_highlight(): - expected_result = '
    \na\nb\n
    ' + expected_result = ( + '
    \na\nb\n
    ' + ) assert code_block("a", "b", sep="\n", syntax_highlight="python") == expected_result diff --git a/tests/utils/test_template_utils.py b/tests/utils/test_template_utils.py index d1b5745..8660eeb 100644 --- a/tests/utils/test_template_utils.py +++ b/tests/utils/test_template_utils.py @@ -3,32 +3,26 @@ import pytest -from pyreball.utils.template_utils import get_css, get_html_begin, get_html_end +from pyreball.utils.template_utils import get_css, get_html_begin_and_end -def test_get_html_begin(tmpdir): +def test_get_html_begin_and_end(tmpdir): filename = "styles" template_path = Path(tmpdir) / filename with open(template_path, "w") as f: f.write( - "title: {{title}}, script: {{script_definitions}}, css: {{css_definitions}}" + "title: , " + "css: " + "" + "" ) - result = get_html_begin( + result_begin, result_end = get_html_begin_and_end( template_path=template_path, title="t1", - script_definitions="s1", css_definitions="c1", ) - assert result == "title: t1, script: s1, css: c1" - - -def test_get_html_end(tmpdir): - filename = "styles" - template_path = Path(tmpdir) / filename - with open(template_path, "w") as f: - f.write("") - result = get_html_end(template_path=template_path) - assert result == "" + assert result_begin == "title: t1, css: c1" + assert result_end == "" def test_get_css__existing_file(tmpdir): diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index d8df1b3..0be58de 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -15,6 +15,7 @@ check_integer_within_range, check_paging_sizes_string_parameter, ChoiceParameter, + get_external_links_from_config, get_file_config, get_parameter_value, IntegerParameter, @@ -348,9 +349,9 @@ def test_get_file_config__correct_specification(simple_parameter_specifications) } with mock.patch("pyreball.utils.utils.read_file_config", return_value=config): config_parameters = get_file_config( - filename="arbitrary", + filename="does_not_matter", parameter_specifications=simple_parameter_specifications, - directory=Path("/arbitrary"), + directory=Path("/does_not_matter"), ) assert config_parameters == expected_config_parameters @@ -364,13 +365,81 @@ def test_get_file_config__incorrect_specification( with mock.patch("pyreball.utils.utils.read_file_config", return_value=config): with pytest.raises(SystemExit): get_file_config( - filename="arbitrary", + filename="does_not_matter", parameter_specifications=simple_parameter_specifications, - directory=Path("/arbitrary"), + directory=Path("/does_not_matter"), ) assert "Parameters section not found in" in caplog.text +def test_get_external_links_from_config__correct_specification(): + config = configparser.ConfigParser() + config["Links"] = { + "altair": "\na\nb", + "bokeh": "\nc\nd\n", + "datatables": "\ne\n", + "highlight_js": "\nf\ng", + "jquery": "\nh", + "plotly": "\ni", + } + expected_result = { + "altair": ["a", "b"], + "bokeh": ["c", "d"], + "datatables": ["e"], + "highlight_js": ["f", "g"], + "jquery": ["h"], + "plotly": ["i"], + } + with mock.patch("pyreball.utils.utils.read_file_config", return_value=config): + result = get_external_links_from_config( + filename="does_not_matter", + directory=Path("/does_not_matter"), + ) + assert result == expected_result + + +def test_get_external_links_from_config__incorrect_section( + caplog, simple_parameter_specifications +): + caplog.set_level(logging.ERROR) + config = configparser.ConfigParser() + # Wrong section name + config["Unsupported"] = { + "altair": "\na\nb", + "bokeh": "\nc\nd\n", + "datatables": "\ne\n", + "highlight_js": "\nf\ng", + "jquery": "\nh", + "plotly": "\ni", + } + with mock.patch("pyreball.utils.utils.read_file_config", return_value=config): + with pytest.raises(SystemExit): + get_external_links_from_config( + filename="does_not_matter", + directory=Path("/does_not_matter"), + ) + assert "section not found in" in caplog.text + + +def test_get_external_links_from_config__incorrect_keys( + caplog, simple_parameter_specifications +): + caplog.set_level(logging.ERROR) + config = configparser.ConfigParser() + # contains only some items + config["Links"] = { + "altair": "\na\nb", + "bokeh": "\nc\nd\n", + } + with mock.patch("pyreball.utils.utils.read_file_config", return_value=config): + with pytest.raises(SystemExit): + get_external_links_from_config( + filename="does_not_matter", + directory=Path("/does_not_matter"), + ) + assert "Configuration with items must contain links" in caplog.text + + @pytest.mark.parametrize( "test_input_1,test_input_2,expected_result", [