import contextlib
import os
import uuid
import webbrowser
from io import BytesIO
from typing import Any, Iterable, Iterator, Optional, Tuple, Union
from urllib.parse import urljoin
import pybloqs.htmlconv as htmlconv
from pybloqs.config import user_config
from pybloqs.email import send_html_report
from pybloqs.html import append_to, id_generator, js_elem, render, root
from pybloqs.static import Css, DependencyTracker, register_interactive, script_block_core, script_inflate
from pybloqs.util import Cfg, cfg_to_css_string
default_css_main = Css(os.path.join("css", "pybloqs_default", "main"))
register_interactive(default_css_main)
[docs]
class BaseBlock:
"""
Base class for all blocks. Provides infrastructure for rendering the block
in an IPython Notebook or saving it to disk in HTML, PDF, PNG or JPG format.
"""
container_tag = "div"
resource_deps = ()
def __init__(
self,
title: Optional[str] = None,
title_level: int = 3,
title_wrap: bool = False,
width=None,
height=None,
inherit_cfg: bool = True,
styles=None,
classes: Union[str, Iterable[str]] = (),
anchor=None,
id_: Optional[str] = None,
**kwargs,
) -> None:
self._settings = Cfg(
title=title,
title_level=title_level,
title_wrap=title_wrap,
cascading_cfg=Cfg(**kwargs).override(styles or Cfg()),
default_cfg=Cfg(),
inherit_cfg=inherit_cfg,
width=width,
height=height,
classes=["pybloqs"] + ([classes] if isinstance(classes, str) else list(classes)),
)
# Anchor should not be inherited, so keep outside of Cfg
self._anchor = anchor
self._id = id_ or uuid.uuid4().hex
[docs]
def render_html(
self,
pretty: bool = True,
static_output: bool = False,
header_block: Optional["BaseBlock"] = None,
footer_block: Optional["BaseBlock"] = None,
permit_compression: bool = True,
) -> str:
"""
Returns html output of the block
:param pretty: Toggles pretty printing of the resulting HTML. Not applicable for non-HTML output.
:param static_output: Passed down to _write_block. Will render static version of blocks which support this.
:param header_block: If not None, header_block is inlined into a HTML body as table.
:param footer_block: If not None, footer_block is inlined into a HTML body as table.
:param permit_compression: If set, resources will be embedded as base64 gzipped files
:return html-code of the block
"""
# Render the contents
html = root("html", doctype="html")
head = append_to(html, "head")
append_to(head, "meta", charset="utf-8")
body = append_to(html, "body")
# Make sure that the main style sheet is always included
resource_deps = DependencyTracker(default_css_main)
# If header or footer are passed into this function, inline them in the following structure:
#
# <body>
# <table>
# <thead><tr><td>Header html</td></tr></thead>
# <tfoot><tr><td>Footer html</td></tr></tfoot>
# <tbody><tr><td>Body html</td></tr></tbody>
# </table>
# </body>
if header_block is not None or footer_block is not None:
content_table = append_to(body, "table")
if header_block is not None:
header_thead = append_to(content_table, "thead")
header_tr = append_to(header_thead, "tr")
header_td = append_to(header_tr, "th")
header_block._write_block(
header_td, Cfg(), id_generator(), resource_deps=resource_deps, static_output=static_output
)
if footer_block is not None:
footer_tfoot = append_to(content_table, "tfoot", id="footer")
footer_tr = append_to(footer_tfoot, "tr")
footer_td = append_to(footer_tr, "td")
footer_block._write_block(
footer_td, Cfg(), id_generator(), resource_deps=resource_deps, static_output=static_output
)
body_tbody = append_to(content_table, "tbody")
body_tr = append_to(body_tbody, "tr")
body_td = append_to(body_tr, "td")
self._write_block(body_td, Cfg(), id_generator(), resource_deps=resource_deps, static_output=static_output)
else:
self._write_block(body, Cfg(), id_generator(), resource_deps=resource_deps, static_output=static_output)
if permit_compression:
script_inflate.write(head)
script_block_core.write(head)
if static_output:
# Add the load wait poller if there are any JS resources
js_elem(body, "var loadWaitPoller=runWaitPoller();")
# Write out resources
for res in resource_deps:
res.write(head, permit_compression=permit_compression)
# Render the whole document (the parent of the html tag)
content = render(html.parent, pretty=pretty)
return content
[docs]
def save(
self,
filename: Optional[str] = None,
fmt: Optional[str] = None,
pdf_zoom: float = 1,
pdf_page_size: str = htmlconv.html_converter.A4,
pdf_auto_shrink: bool = True,
orientation: str = htmlconv.html_converter.PORTRAIT,
header_block: Optional["BaseBlock"] = None,
header_spacing: Union[str, float] = 5,
footer_block: Optional["BaseBlock"] = None,
footer_spacing: Union[str, float] = 5,
**kwargs,
) -> str:
"""
Render and save the block. Depending on whether the filename or the format is
provided, the content will either be written out to a file or returned as a string.
:param filename: Format will be based on the file extension.
The following formats are supported:
- HTML
- PDF
- PNG
- JPG
:param fmt: Specifies the format of a temporary output file. When supplied, the filename
parameter must be omitted.
:param pdf_zoom: The zooming to apply when rendering the page.
:param pdf_page_size: The page size to use when rendering the page to PDF.
:param pdf_auto_shrink: Toggles auto-shrinking content to fit the desired page size (wkhtmltopdf only)
:param orientation: Either html_converter.PORTRAIT or html_converter.LANDSCAPE
:param header_block: Block to be used as header (and repeated on every page). Only used for PDF output.
:param header_spacing: Size of header block. Numbers are in mm. HTML sizes (e.g. '5cm') in chrome_headless only.
:param footer_block: Block to be used as footer (and repeated on every page). Only used for PDF output.
:param footer_spacing: Size of header block. Numbers are in mm. HTML sizes (e.g. '5cm') in chrome_headless only.
:return: html filename
"""
# Ensure that exactly one of filename or fmt is provided
if filename is None and fmt is None:
raise ValueError("One of `filename` or `fmt` must be provided.")
tempdir = user_config["tmp_html_dir"]
if filename:
_, fmt_from_name = os.path.splitext(filename)
# Exclude the dot from the extension, gosh darn it!
fmt_from_name = fmt_from_name[1:]
if fmt is None:
if fmt_from_name == "":
raise ValueError("If fmt is not specified, filename must contain extension")
fmt = fmt_from_name
else:
if fmt != fmt_from_name:
filename += "." + fmt
else:
name = self._id[: user_config["id_precision"]] + "." + fmt
filename = os.path.join(tempdir, name)
# Force extension to be lower case so format checks are easier later
fmt = fmt.lower()
is_html = "htm" in fmt
if is_html:
content = self.render_html(static_output=False, header_block=header_block, footer_block=footer_block)
with open(filename, "w", encoding="utf-8") as f:
f.write(content)
else:
converter = htmlconv.get_converter(fmt)
converter.htmlconv(
self,
filename,
header_block=header_block,
header_spacing=header_spacing,
footer_block=footer_block,
footer_spacing=footer_spacing,
pdf_page_size=pdf_page_size,
orientation=orientation,
pdf_auto_shrink=pdf_auto_shrink,
pdf_zoom=pdf_zoom,
**kwargs,
)
return filename
[docs]
def publish(self, name: str, *args, **kwargs) -> str:
"""
Publish the block so that others can access it.
:param name: Name to publish under. Can be a filename or a relative path.
:param args: Arguments to pass to `Block.save`.
:param kwargs: Keyword arguments to pass to `Block.save`.
:return: Path to the published block file.
"""
full_path = os.path.join(user_config["public_dir"], name)
full_path = os.path.expanduser(full_path)
base_dir = os.path.dirname(full_path)
with contextlib.suppress(OSError): # Ignore if directory already exists
os.makedirs(base_dir)
self.save(full_path, *args, **kwargs)
return full_path
[docs]
def show(
self, fmt: str = "html", header_block: Optional["BaseBlock"] = None, footer_block: Optional["BaseBlock"] = None
) -> str:
"""
Show the block in a browser.
:param fmt: The format of the saved block. Supports the same output as `Block.save`
:return: Path to the block file.
"""
file_name = self._id[: user_config["id_precision"]] + "." + fmt
file_path = self.publish(
os.path.expanduser(os.path.join(user_config["tmp_html_dir"], file_name)),
header_block=header_block,
footer_block=footer_block,
)
try:
url_base = user_config["public_dir"]
except KeyError:
path = os.path.expanduser(file_path)
else:
path = urljoin(url_base, os.path.expanduser(user_config["tmp_html_dir"] + "/" + file_name))
webbrowser.open_new_tab(path)
return path
[docs]
def email(
self,
title: str = "",
recipients: Tuple[str, ...] = (user_config["user_email_address"],),
header_block: Optional["BaseBlock"] = None,
footer_block: Optional["BaseBlock"] = None,
from_address: Optional[str] = None,
cc: Optional[str] = None,
bcc: Optional[str] = None,
attachments=None,
convert_to_ascii: bool = True,
**kwargs,
) -> None:
"""
Send the rendered blocks as email. Each output format chosen will be added as an
attachment.
:param title: title of the email
:param recipients: recipient of the email
:param fmt: One or more output formats that should be included as attachments.
The following formats are supported:
- HTML
- PDF
- PNG
- JPG
:param body_block: The block to use as the email body. The default behavior is
to use the current block.
:param from_address: sender of the message. Defaults to user name.
Can be overwritten in .pybloqs.cfg with yaml format: 'user_email_address: a@b.com'
:param cc: cc recipient
:param bcc: bcc recipient
:param convert_to_ascii: bool to control convertion of html email to ascii or to leave in current format
:param kwargs: Optional arguments to pass to `Block.render_html()`
"""
if from_address is None:
from_address = user_config["user_email_address"]
# The email body needs to be static without any dynamic elements.
email_html = self.render_html(header_block=header_block, footer_block=footer_block, **kwargs)
send_html_report(
email_html,
recipients,
subject=title,
attachments=attachments,
From=from_address,
Cc=cc,
Bcc=bcc,
convert_to_ascii=convert_to_ascii,
)
def to_static(self) -> "BaseBlock":
return self._visit(lambda block: block._to_static())
def _to_static(self) -> "BaseBlock":
"""
Subclasses can override this method to provide a static content version.
"""
return self
def _visit(self, visitor) -> Any:
"""
Calls the supplied visitor function on this block and any sub-blocks
:param visitor: Visitor function
:return: Return value of the visitor
"""
return visitor(self)
def _provide_default_cfg(self, defaults) -> None:
"""
Makes the supplied config to be part of the defaults for the block.
:param defaults: The default parameters that should be inherited.
"""
self._settings.default_cfg = self._settings.default_cfg.inherit(defaults)
def _combine_parent_cfg(self, parent_cfg) -> Cfg:
"""from pybloqs.config import user_config
Combine the supplied parent and the current Block's config.
:param parent_cfg: Parent config to inherit from.
:return: Combined config.
"""
# Combine parameters only if inheritance is turned on
if self._settings.inherit_cfg:
actual_cfg = self._settings.cascading_cfg.inherit(parent_cfg)
else:
actual_cfg = self._settings.cascading_cfg
# Any undefined settings will use the defaults
actual_cfg = actual_cfg.inherit(self._settings.default_cfg)
return actual_cfg
def _get_styles_string(self, styles_cfg) -> str:
"""
Converts the styles configuration to a CSS styles string.
:param styles_cfg: The configuration object to convert.
:return: CSS string
"""
sizing_cfg = Cfg()
if self._settings.width is not None:
sizing_cfg["width"] = self._settings.width
if self._settings.height is not None:
sizing_cfg["height"] = self._settings.height
# Replace `_` with `-` and make values lowercase to get valid CSS names
return cfg_to_css_string(styles_cfg.override(sizing_cfg))
def _write_block(self, parent, parent_cfg, id_gen, resource_deps=None, static_output: bool = False) -> None:
"""
Writes out the block into the supplied stream, inheriting the parent_parameters.
:param parent: Parent element
:param parent_cfg: Parent parameters to inherit.
:param id_gen: Unique ID generator.
:param resource_deps: Object used to register resource dependencies.
:param static_output: A value of True signals to blocks that the final output will
be a static format. Certain dynamic content will render with
alternate options.
"""
if resource_deps is not None:
for res in self.resource_deps:
resource_deps.add(res)
actual_cfg = self._combine_parent_cfg(parent_cfg)
if self.container_tag is not None:
container = append_to(parent, self.container_tag)
self._write_container_attrs(container, actual_cfg)
else:
container = parent
self._write_anchor(container)
self._write_title(container)
self._write_contents(container, actual_cfg, id_gen, resource_deps=resource_deps, static_output=static_output)
def _write_container_attrs(self, container, actual_cfg) -> None:
"""
Writes out the container attributes (styles, class, etc...).
Note that this method will only be called if the container tag is not `None`.
:param container: Container element.
:param actual_cfg: Actual parameters to use.
"""
styles = self._get_styles_string(actual_cfg)
if len(styles) > 0:
container["style"] = styles
container["class"] = self._settings.classes
def _write_title(self, container) -> None:
"""
Write out the title (if there is any).
:param container: Container element.
"""
if self._settings.title is not None and (self._settings.title != ""):
title = append_to(
container,
f"H{self._settings.title_level}",
style="white-space: %s" % ("normal" if self._settings.title_wrap else "nowrap"),
)
title.string = self._settings.title
def _write_anchor(self, container) -> None:
"""
Write HTML anchor for linking within page
:param container: Container element.
"""
if self._anchor is not None:
append_to(container, "a", name=self._anchor)
def _write_contents(
self, container, actual_cfg, id_gen: Iterator[str], resource_deps=None, static_output: Optional[bool] = None
) -> None:
"""
Write out the actual contents of the block. Deriving classes must override
this method.
:param container: Container element.
:param actual_cfg: Actual parameters to use.
:param id_gen: Unique ID generator.
:param resource_deps: Object used to register resource dependencies.
:param static_output: A value of True signals to blocks that the final output will
be a static format. Certain dynamic content will render with
alternate options.
"""
raise NotImplementedError("_write_contents")
def _repr_html_(self, *_) -> str:
"""
Function required to support interactive IPython plopping and plotting.
Should not be used directly.
:return: Data to be displayed
"""
return self.data.decode()
@property
def data(self) -> bytes:
"""
Function required to support interactive IPython plotting.
Should not be used directly.
:return: Data to be displayed
"""
container = root("div")
resource_deps = DependencyTracker(default_css_main)
self._write_block(container, Cfg(), id_generator(), resource_deps=resource_deps)
for resource in resource_deps:
resource.write(container, permit_compression=False)
# Write children into the output
output = BytesIO()
for child in container.children:
output.write(render(child).encode("utf-8"))
return output.getvalue()
[docs]
class HRule(BaseBlock):
"""
Draws a horizontal divider line.
"""
def _write_block(self, parent, *_args, **_kwargs) -> None:
# Add a `hr` element to the parent
append_to(parent, "hr")