Module presalytics.story.revealer

Expand source code Browse git
import os
import shutil
import lxml.html
import webbrowser
import logging
import collections
import typing
import tempfile
import lxml
import lxml.html
import lxml.etree
import lxml.html.builder as E
import datetime
import six
import sys
import urllib.parse
import presalytics
import presalytics.lib
import presalytics.lib.plugins
import presalytics.lib.plugins.base
import presalytics.lib.plugins.reveal
import presalytics.lib.templates
import presalytics.lib.templates.base
import presalytics.story.util
import presalytics.story.server
import presalytics.story.components
import presalytics.lib.exceptions
if typing.TYPE_CHECKING:
    from presalytics.story.outline import StoryOutline, Page, Plugin
    from presalytics.story.components import PageTemplateBase, ComponentBase, WidgetBase, ThemeBase


logger = logging.getLogger('presalytics.story.revealer')


class Revealer(presalytics.story.components.Renderer):
    """
    Renders `presalytics.story.outline.StoryOutline` objects to 
    [reveal.js](https://github.com/hakimel/reveal.js/) presentations

    Parameters
    ----------
    story_outline : presalytics.story.outline.StoryOutline
        The presalytics StoryOutline to be rendered and presented
    
    Attributes
    -----------
    base : lxml.etree.Element
        An etree element containing the base html for each slide

    plugins : list of dict
        Plugin data that transform to html `<script>` and `<link>` tags through
        the rendering process

    plugin_mgr : presalytics.lib.plugins.base.PluginManager
        Sorts, validates, and renders plugins

    """
    base: lxml.etree.Element
    reveal_params: typing.Dict[str, typing.Any]

    __component_kind__ = 'revealer'

    def __init__(
            self,
            story_outline: 'StoryOutline',
            pages: typing.List[int] = None,
            **kwargs):
        super(Revealer, self).__init__(story_outline, **kwargs)
        logger.info("Initializing story render for {}".format(story_outline.title))
        self.story_outline.validate()
        if isinstance(pages, int):
            pages = [pages]
        elif not pages:
            pages = [p for p in range(0, len(self.story_outline.pages))]
        elif not isinstance(pages, list) or not isinstance(pages[0], int):
            raise presalytics.lib.exceptions.InvalidArgumentException(message='"pages" must be a list of integers')
        if len([p for p in pages if p >= len(self.story_outline.pages)]) > 0:
            raise presalytics.lib.exceptions.InvalidArgumentException(message='"pages" can only contain integers lower than the number of pages in the story')
        self.pages_to_render = pages
        self.base = self._make_base()
        logger.info("Loading plugins")
        reveal_params = {}
        for key, val in kwargs.items():
            if key in presalytics.lib.plugins.reveal.RevealConfigPlugin.default_config.keys():
                reveal_params.update({key: val})
        if len(self.pages_to_render) == 1:
            reveal_params.update({'controls': False})  # hide controls on single page story
        reveal_plugin_config = {
            'kind': 'script',
            'name': 'reveal',
            'config': {"reveal_params": reveal_params} if len(reveal_params.keys()) > 0 else {}
        }
        overrides_config = {
            'kind': 'style',
            'name': 'local',
            'config': {
                "css_file_id": "reveal_overrides"
            }
        }
        self.update_outline_from_instances()
        self.plugins = [reveal_plugin_config, overrides_config]
        self.get_component_implicit_plugins()
        outline_plugins = presalytics.lib.plugins.base.PluginManager.get_plugins_from_nested_dict(source_dict=self.story_outline.to_dict())
        self.plugins.extend(outline_plugins)
        self.plugin_mgr = presalytics.lib.plugins.base.PluginManager(self.plugins)
        logger.info("Revealer initilized.")

    def _make_base(self):
        base = lxml.etree.Element("div", attrib={
            "class": "reveal",
        })
        lxml.etree.SubElement(base, "div", attrib={"class": "slides"})

        try:
            story_id = self.story_outline.story_id
            base.attrib['data-story-id'] = story_id
        except Exception:
            logger.info("Revealer could not extract story_id from outline.")
        return base
    
    def get_meta_tags(self, body=tuple()):
        """
        Security Note: If supplying a body, ensure that its already been stripped of unauthorized scripts. 
        """

        scripts = body.findall(".//script")

        srcs = []
        for script in scripts:
            src = script.get("src")
            if src:
                root = urllib.parse.urlparse(src).netloc
                if root:
                    srcs.append(root)
        allowed = ' '.join(set(srcs))

        tags = [
            '<meta charset="utf-8">',
            '<meta http-equiv="X-UA-Compatible" content="IE=edge">',
            '<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">',
        #    """<meta http-equiv="Content-Security-Policy" content="default-src 'self' https://*.presalytics.io; script-src 'self' https://*.presalytics.io {0};">""".format(allowed)
        ]
        return tags

    def package_as_standalone(self):
        """
        Render the story outline as a html document with only the 
        reveal.js presentation as conent

        Returns
        ----------
        A `str` containing a complete html document with the presentation
        """
        pres = self.render()
        body = E.BODY()
        body.append(pres)
        body = self.strip_unauthorized_scripts(body)
        for scripts in self.plugin_mgr.get_scripts():
            lxml_scripts = lxml.html.fragments_fromstring(scripts)
            for item in lxml_scripts:
                body.append(item)
        head = E.HEAD()
        for meta in self.get_meta_tags(body):
            lxml_meta = lxml.html.fragment_fromstring(meta)
            head.append(lxml_meta)
        for link in self.plugin_mgr.get_styles():
            lxml_links = lxml.html.fragments_fromstring(link)
            for item in lxml_links:
                head.append(item)
        head = self.strip_unauthorized_scripts(head)
        html = E.HTML(
            head,
            body
        )
        return lxml.html.tostring(html, pretty_print=True)

    # def render_plugin(self, plugin):
    #     plugin_type = plugin['type']
    #     plugin_config = plugin['config']
    #     plugin_class = self.plugin_loader.load(plugin_type)
    #     script_string = plugin_class().to_string(plugin_config)
    #     return lxml.html.fragment_fromstring(script_string)

    def update_info(self):
        """
        Updates story metadata
        """
        info = self.story_outline.info
        info.date_modified = datetime.datetime.utcnow()

    def render(self):
        """
        Creates a reveal.js presenation html fragement

        Returns:
        ---------
        A `str` html fragment containing a reveal.js presentation
        """
        reveal_base = self.base
        for p in range(0, len(self.story_outline.pages)):
            if p in self.pages_to_render:
                page = self.story_outline.pages[p]
                slides_container = reveal_base[0]
                slide = lxml.etree.SubElement(slides_container, "section")
                page_html = self.render_page(page)
                slide_fragment = lxml.html.fragment_fromstring(page_html)
                slide.append(slide_fragment)
        return reveal_base

    def render_page(self, page: 'Page') -> str:
        """
        Creates a reveal.js slide

        Returns
        ----------
        A `str` html framgment of the page
        """
        class_key = "page." + page.kind
        key = class_key + "." + page.name
        try:
            if presalytics.COMPONENTS.get_instance(key):
                page_instance = presalytics.COMPONENTS.get_instance(key)
            else:
                klass = presalytics.COMPONENTS.get(class_key)
                deserialize_method = getattr(klass, "deserialize", None)
                if callable(deserialize_method):
                    page_instance = deserialize_method(page, client_info=self.client_info)
                else:
                    message = 'Page component instance or class (kind) "{0}" unavailable in component registry'.format(key)
                    raise presalytics.lib.exceptions.MissingConfigException(message)
            page_html = page_instance.render()
        except Exception as ex:
            logger.exception(ex)
            t, v, tb = sys.exc_info()
            if not presalytics.CONFIG.get("DEBUG", False):
                page_html = presalytics.lib.exceptions.RenderExceptionHandler(ex, "page", traceback=tb).render_exception()
            else:
                six.reraise(t, v, tb)
        return page_html

    def present(self, files_path=None, debug=True, port=8082, host='127.0.0.1'):
        """
        Creates and opens the rendered story in the browser.  Story files are served by 
        a local flask server.  Not for production use.  Press Ctrl + C to close the server.

        Parameters
        ----------
        files_path : str
            filepath to a local folder that will work as root folder for a local flask
            server.  Defaults to the user's temporary files directory

        debug : str
            Defaults to True.  Indicates whether the flask server should be started
            in debug mode.
        
        port : str
            The network port to serve the story onto.  Defautls to 8082.
        
        host : str
            The host to for the local server.  Typically either localhost or the default gateway.
            Defaults to 127.0.0.1 (localhost).
        
        """
        logger.info("Building story rendering at http://{0}:{1}".format(host, port))
        if not files_path:
            files_path = tempfile.gettempdir()
        logger.info("Buidling standalone package for local rendering.")
        html = self.package_as_standalone().decode('utf-8')
        id = presalytics.story.util.to_title_case(self.story_outline.title)
        if id == '':
            id = 'blank'
        server = presalytics.story.server.LocalServer(host=host, debug=debug, port=port, root_path=files_path)
        pkg_templates_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "lib", "templates")
        shutil.copy(os.path.join(pkg_templates_dir, "favicon.ico"), os.path.join(files_path, "presalytics", "static"))
        html_file = os.path.join(files_path, "presalytics", "templates", id + '.html')
        with open(html_file, 'w') as file:
            file.write(html)
        address = "http://{}:{}/story/{}".format(host, port, id)
        logger.info("Opening browser tab...")
        presalytics.story.server.Browser(address).start()
        server.run()

Classes

class Revealer (story_outline, pages=None, **kwargs)

Renders StoryOutline objects to reveal.js presentations

Parameters

story_outline : StoryOutline
The presalytics StoryOutline to be rendered and presented

Attributes

base : lxml.etree.Element
An etree element containing the base html for each slide
plugins : list of dict
Plugin data that transform to html <script> and <link> tags through the rendering process
plugin_mgr : PluginManager
Sorts, validates, and renders plugins
Expand source code Browse git
class Revealer(presalytics.story.components.Renderer):
    """
    Renders `presalytics.story.outline.StoryOutline` objects to 
    [reveal.js](https://github.com/hakimel/reveal.js/) presentations

    Parameters
    ----------
    story_outline : presalytics.story.outline.StoryOutline
        The presalytics StoryOutline to be rendered and presented
    
    Attributes
    -----------
    base : lxml.etree.Element
        An etree element containing the base html for each slide

    plugins : list of dict
        Plugin data that transform to html `<script>` and `<link>` tags through
        the rendering process

    plugin_mgr : presalytics.lib.plugins.base.PluginManager
        Sorts, validates, and renders plugins

    """
    base: lxml.etree.Element
    reveal_params: typing.Dict[str, typing.Any]

    __component_kind__ = 'revealer'

    def __init__(
            self,
            story_outline: 'StoryOutline',
            pages: typing.List[int] = None,
            **kwargs):
        super(Revealer, self).__init__(story_outline, **kwargs)
        logger.info("Initializing story render for {}".format(story_outline.title))
        self.story_outline.validate()
        if isinstance(pages, int):
            pages = [pages]
        elif not pages:
            pages = [p for p in range(0, len(self.story_outline.pages))]
        elif not isinstance(pages, list) or not isinstance(pages[0], int):
            raise presalytics.lib.exceptions.InvalidArgumentException(message='"pages" must be a list of integers')
        if len([p for p in pages if p >= len(self.story_outline.pages)]) > 0:
            raise presalytics.lib.exceptions.InvalidArgumentException(message='"pages" can only contain integers lower than the number of pages in the story')
        self.pages_to_render = pages
        self.base = self._make_base()
        logger.info("Loading plugins")
        reveal_params = {}
        for key, val in kwargs.items():
            if key in presalytics.lib.plugins.reveal.RevealConfigPlugin.default_config.keys():
                reveal_params.update({key: val})
        if len(self.pages_to_render) == 1:
            reveal_params.update({'controls': False})  # hide controls on single page story
        reveal_plugin_config = {
            'kind': 'script',
            'name': 'reveal',
            'config': {"reveal_params": reveal_params} if len(reveal_params.keys()) > 0 else {}
        }
        overrides_config = {
            'kind': 'style',
            'name': 'local',
            'config': {
                "css_file_id": "reveal_overrides"
            }
        }
        self.update_outline_from_instances()
        self.plugins = [reveal_plugin_config, overrides_config]
        self.get_component_implicit_plugins()
        outline_plugins = presalytics.lib.plugins.base.PluginManager.get_plugins_from_nested_dict(source_dict=self.story_outline.to_dict())
        self.plugins.extend(outline_plugins)
        self.plugin_mgr = presalytics.lib.plugins.base.PluginManager(self.plugins)
        logger.info("Revealer initilized.")

    def _make_base(self):
        base = lxml.etree.Element("div", attrib={
            "class": "reveal",
        })
        lxml.etree.SubElement(base, "div", attrib={"class": "slides"})

        try:
            story_id = self.story_outline.story_id
            base.attrib['data-story-id'] = story_id
        except Exception:
            logger.info("Revealer could not extract story_id from outline.")
        return base
    
    def get_meta_tags(self, body=tuple()):
        """
        Security Note: If supplying a body, ensure that its already been stripped of unauthorized scripts. 
        """

        scripts = body.findall(".//script")

        srcs = []
        for script in scripts:
            src = script.get("src")
            if src:
                root = urllib.parse.urlparse(src).netloc
                if root:
                    srcs.append(root)
        allowed = ' '.join(set(srcs))

        tags = [
            '<meta charset="utf-8">',
            '<meta http-equiv="X-UA-Compatible" content="IE=edge">',
            '<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">',
        #    """<meta http-equiv="Content-Security-Policy" content="default-src 'self' https://*.presalytics.io; script-src 'self' https://*.presalytics.io {0};">""".format(allowed)
        ]
        return tags

    def package_as_standalone(self):
        """
        Render the story outline as a html document with only the 
        reveal.js presentation as conent

        Returns
        ----------
        A `str` containing a complete html document with the presentation
        """
        pres = self.render()
        body = E.BODY()
        body.append(pres)
        body = self.strip_unauthorized_scripts(body)
        for scripts in self.plugin_mgr.get_scripts():
            lxml_scripts = lxml.html.fragments_fromstring(scripts)
            for item in lxml_scripts:
                body.append(item)
        head = E.HEAD()
        for meta in self.get_meta_tags(body):
            lxml_meta = lxml.html.fragment_fromstring(meta)
            head.append(lxml_meta)
        for link in self.plugin_mgr.get_styles():
            lxml_links = lxml.html.fragments_fromstring(link)
            for item in lxml_links:
                head.append(item)
        head = self.strip_unauthorized_scripts(head)
        html = E.HTML(
            head,
            body
        )
        return lxml.html.tostring(html, pretty_print=True)

    # def render_plugin(self, plugin):
    #     plugin_type = plugin['type']
    #     plugin_config = plugin['config']
    #     plugin_class = self.plugin_loader.load(plugin_type)
    #     script_string = plugin_class().to_string(plugin_config)
    #     return lxml.html.fragment_fromstring(script_string)

    def update_info(self):
        """
        Updates story metadata
        """
        info = self.story_outline.info
        info.date_modified = datetime.datetime.utcnow()

    def render(self):
        """
        Creates a reveal.js presenation html fragement

        Returns:
        ---------
        A `str` html fragment containing a reveal.js presentation
        """
        reveal_base = self.base
        for p in range(0, len(self.story_outline.pages)):
            if p in self.pages_to_render:
                page = self.story_outline.pages[p]
                slides_container = reveal_base[0]
                slide = lxml.etree.SubElement(slides_container, "section")
                page_html = self.render_page(page)
                slide_fragment = lxml.html.fragment_fromstring(page_html)
                slide.append(slide_fragment)
        return reveal_base

    def render_page(self, page: 'Page') -> str:
        """
        Creates a reveal.js slide

        Returns
        ----------
        A `str` html framgment of the page
        """
        class_key = "page." + page.kind
        key = class_key + "." + page.name
        try:
            if presalytics.COMPONENTS.get_instance(key):
                page_instance = presalytics.COMPONENTS.get_instance(key)
            else:
                klass = presalytics.COMPONENTS.get(class_key)
                deserialize_method = getattr(klass, "deserialize", None)
                if callable(deserialize_method):
                    page_instance = deserialize_method(page, client_info=self.client_info)
                else:
                    message = 'Page component instance or class (kind) "{0}" unavailable in component registry'.format(key)
                    raise presalytics.lib.exceptions.MissingConfigException(message)
            page_html = page_instance.render()
        except Exception as ex:
            logger.exception(ex)
            t, v, tb = sys.exc_info()
            if not presalytics.CONFIG.get("DEBUG", False):
                page_html = presalytics.lib.exceptions.RenderExceptionHandler(ex, "page", traceback=tb).render_exception()
            else:
                six.reraise(t, v, tb)
        return page_html

    def present(self, files_path=None, debug=True, port=8082, host='127.0.0.1'):
        """
        Creates and opens the rendered story in the browser.  Story files are served by 
        a local flask server.  Not for production use.  Press Ctrl + C to close the server.

        Parameters
        ----------
        files_path : str
            filepath to a local folder that will work as root folder for a local flask
            server.  Defaults to the user's temporary files directory

        debug : str
            Defaults to True.  Indicates whether the flask server should be started
            in debug mode.
        
        port : str
            The network port to serve the story onto.  Defautls to 8082.
        
        host : str
            The host to for the local server.  Typically either localhost or the default gateway.
            Defaults to 127.0.0.1 (localhost).
        
        """
        logger.info("Building story rendering at http://{0}:{1}".format(host, port))
        if not files_path:
            files_path = tempfile.gettempdir()
        logger.info("Buidling standalone package for local rendering.")
        html = self.package_as_standalone().decode('utf-8')
        id = presalytics.story.util.to_title_case(self.story_outline.title)
        if id == '':
            id = 'blank'
        server = presalytics.story.server.LocalServer(host=host, debug=debug, port=port, root_path=files_path)
        pkg_templates_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "lib", "templates")
        shutil.copy(os.path.join(pkg_templates_dir, "favicon.ico"), os.path.join(files_path, "presalytics", "static"))
        html_file = os.path.join(files_path, "presalytics", "templates", id + '.html')
        with open(html_file, 'w') as file:
            file.write(html)
        address = "http://{}:{}/story/{}".format(host, port, id)
        logger.info("Opening browser tab...")
        presalytics.story.server.Browser(address).start()
        server.run()

Ancestors

Methods

def get_meta_tags(self, body=())

Security Note: If supplying a body, ensure that its already been stripped of unauthorized scripts.

Expand source code Browse git
def get_meta_tags(self, body=tuple()):
    """
    Security Note: If supplying a body, ensure that its already been stripped of unauthorized scripts. 
    """

    scripts = body.findall(".//script")

    srcs = []
    for script in scripts:
        src = script.get("src")
        if src:
            root = urllib.parse.urlparse(src).netloc
            if root:
                srcs.append(root)
    allowed = ' '.join(set(srcs))

    tags = [
        '<meta charset="utf-8">',
        '<meta http-equiv="X-UA-Compatible" content="IE=edge">',
        '<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">',
    #    """<meta http-equiv="Content-Security-Policy" content="default-src 'self' https://*.presalytics.io; script-src 'self' https://*.presalytics.io {0};">""".format(allowed)
    ]
    return tags
def package_as_standalone(self)

Render the story outline as a html document with only the reveal.js presentation as conent

Returns

A str containing a complete html document with the presentation

Expand source code Browse git
def package_as_standalone(self):
    """
    Render the story outline as a html document with only the 
    reveal.js presentation as conent

    Returns
    ----------
    A `str` containing a complete html document with the presentation
    """
    pres = self.render()
    body = E.BODY()
    body.append(pres)
    body = self.strip_unauthorized_scripts(body)
    for scripts in self.plugin_mgr.get_scripts():
        lxml_scripts = lxml.html.fragments_fromstring(scripts)
        for item in lxml_scripts:
            body.append(item)
    head = E.HEAD()
    for meta in self.get_meta_tags(body):
        lxml_meta = lxml.html.fragment_fromstring(meta)
        head.append(lxml_meta)
    for link in self.plugin_mgr.get_styles():
        lxml_links = lxml.html.fragments_fromstring(link)
        for item in lxml_links:
            head.append(item)
    head = self.strip_unauthorized_scripts(head)
    html = E.HTML(
        head,
        body
    )
    return lxml.html.tostring(html, pretty_print=True)
def update_info(self)

Updates story metadata

Expand source code Browse git
def update_info(self):
    """
    Updates story metadata
    """
    info = self.story_outline.info
    info.date_modified = datetime.datetime.utcnow()
def render(self)

Creates a reveal.js presenation html fragement

Returns:

A str html fragment containing a reveal.js presentation

Expand source code Browse git
def render(self):
    """
    Creates a reveal.js presenation html fragement

    Returns:
    ---------
    A `str` html fragment containing a reveal.js presentation
    """
    reveal_base = self.base
    for p in range(0, len(self.story_outline.pages)):
        if p in self.pages_to_render:
            page = self.story_outline.pages[p]
            slides_container = reveal_base[0]
            slide = lxml.etree.SubElement(slides_container, "section")
            page_html = self.render_page(page)
            slide_fragment = lxml.html.fragment_fromstring(page_html)
            slide.append(slide_fragment)
    return reveal_base
def render_page(self, page)

Creates a reveal.js slide

Returns

A str html framgment of the page

Expand source code Browse git
def render_page(self, page: 'Page') -> str:
    """
    Creates a reveal.js slide

    Returns
    ----------
    A `str` html framgment of the page
    """
    class_key = "page." + page.kind
    key = class_key + "." + page.name
    try:
        if presalytics.COMPONENTS.get_instance(key):
            page_instance = presalytics.COMPONENTS.get_instance(key)
        else:
            klass = presalytics.COMPONENTS.get(class_key)
            deserialize_method = getattr(klass, "deserialize", None)
            if callable(deserialize_method):
                page_instance = deserialize_method(page, client_info=self.client_info)
            else:
                message = 'Page component instance or class (kind) "{0}" unavailable in component registry'.format(key)
                raise presalytics.lib.exceptions.MissingConfigException(message)
        page_html = page_instance.render()
    except Exception as ex:
        logger.exception(ex)
        t, v, tb = sys.exc_info()
        if not presalytics.CONFIG.get("DEBUG", False):
            page_html = presalytics.lib.exceptions.RenderExceptionHandler(ex, "page", traceback=tb).render_exception()
        else:
            six.reraise(t, v, tb)
    return page_html
def present(self, files_path=None, debug=True, port=8082, host='127.0.0.1')

Creates and opens the rendered story in the browser. Story files are served by a local flask server. Not for production use. Press Ctrl + C to close the server.

Parameters

files_path : str
filepath to a local folder that will work as root folder for a local flask server. Defaults to the user's temporary files directory
debug : str
Defaults to True. Indicates whether the flask server should be started in debug mode.
port : str
The network port to serve the story onto. Defautls to 8082.
host : str
The host to for the local server. Typically either localhost or the default gateway. Defaults to 127.0.0.1 (localhost).
Expand source code Browse git
def present(self, files_path=None, debug=True, port=8082, host='127.0.0.1'):
    """
    Creates and opens the rendered story in the browser.  Story files are served by 
    a local flask server.  Not for production use.  Press Ctrl + C to close the server.

    Parameters
    ----------
    files_path : str
        filepath to a local folder that will work as root folder for a local flask
        server.  Defaults to the user's temporary files directory

    debug : str
        Defaults to True.  Indicates whether the flask server should be started
        in debug mode.
    
    port : str
        The network port to serve the story onto.  Defautls to 8082.
    
    host : str
        The host to for the local server.  Typically either localhost or the default gateway.
        Defaults to 127.0.0.1 (localhost).
    
    """
    logger.info("Building story rendering at http://{0}:{1}".format(host, port))
    if not files_path:
        files_path = tempfile.gettempdir()
    logger.info("Buidling standalone package for local rendering.")
    html = self.package_as_standalone().decode('utf-8')
    id = presalytics.story.util.to_title_case(self.story_outline.title)
    if id == '':
        id = 'blank'
    server = presalytics.story.server.LocalServer(host=host, debug=debug, port=port, root_path=files_path)
    pkg_templates_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "lib", "templates")
    shutil.copy(os.path.join(pkg_templates_dir, "favicon.ico"), os.path.join(files_path, "presalytics", "static"))
    html_file = os.path.join(files_path, "presalytics", "templates", id + '.html')
    with open(html_file, 'w') as file:
        file.write(html)
    address = "http://{}:{}/story/{}".format(host, port, id)
    logger.info("Opening browser tab...")
    presalytics.story.server.Browser(address).start()
    server.run()

Inherited members