Source code for highcharts_core.headless_export

try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass

import json
import os
from typing import Optional

import requests
from validator_collection import validators

from highcharts_core import errors, constants
from highcharts_core.decorators import class_sensitive
from highcharts_core.metaclasses import HighchartsMeta
from highcharts_core.utility_classes.javascript_functions import CallbackFunction
from highcharts_core.options import HighchartsOptions
from highcharts_core.options.data import Data


class ExportServer(HighchartsMeta):
    """Class that provides methods for interacting with the Highcharts
    `Export Server <https://github.com/highcharts/node-export-server>`_.

    .. note::

      By default, the :class:`ExportServer` class operates using the Highcharts-provided
      export server. If you wish to use your own (or a custom) export server, you can
      configure the class using either the :meth:`url <ExportServer.url>`,
      :meth:`port <ExportServer.port>`, and
      :meth:`path <ExportServer.path>` properties explicitly or by setting
      the ``HIGHCHARTS_EXPORT_SERVER_DOMAIN`, ``HIGHCHARTS_EXPORT_SERVER_PORT``, or
      ``HIGHCHARTS_EXPORT_SERVER_PATH`` environment variables.

    """

    def __init__(self, **kwargs):
        self._url = None
        self._port = None
        self._path = None
        self._options = None
        self._format_ = None
        self._scale = None
        self._width = None
        self._callback = None
        self._constructor = None
        self._use_base64 = None
        self._no_download = None
        self._async_rendering = None
        self._global_options = None
        self._data_options = None
        self._custom_code = None

        self.protocol = kwargs.get('protocol',
                                   os.getenv('HIGHCHARTS_EXPORT_SERVER_PROTOCOL',
                                             'https'))
        self.domain = kwargs.get('domain', os.getenv('HIGHCHARTS_EXPORT_SERVER_DOMAIN',
                                                     'export.highcharts.com'))
        self.port = kwargs.get('port', os.getenv('HIGHCHARTS_EXPORT_SERVER_PORT',
                                                 ''))
        self.path = kwargs.get('path', os.getenv('HIGHCHARTS_EXPORT_SERVER_PATH',
                                                 ''))
        self.options = kwargs.get('options', None)
        self.format_ = kwargs.get('format_', kwargs.get('type', 'png'))
        self.scale = kwargs.get('scale', 1)
        self.width = kwargs.get('width', None)
        self.callback = kwargs.get('callback', None)
        self.constructor = kwargs.get('constructor', 'Chart')
        self.use_base64 = kwargs.get('use_base64', False)
        self.no_download = kwargs.get('no_download', False)
        self.async_rendering = kwargs.get('async_rendering', False)
        self.global_options = kwargs.get('global_options', None)
        self.data_options = kwargs.get('data_options', None)
        self.custom_code = kwargs.get('custom_code', None)

        super().__init__(**kwargs)

    @property
    def protocol(self) -> Optional[str]:
        """The protocol over which the Highcharts for Python library should communicate
        with the :term:`Export Server`. Accepts either ``'https'`` or ``'http'``. Defaults
        to the ``HIGHCHARTS_EXPORT_SERVER_PROTOCOL`` environment variable if present,
        otherwise falls back to default of ``'https'``.

        .. tip::

          This property is set automatically by the ``HIGHCHARTS_EXPORT_SERVER_PROTOCOL``
          environment variable, if present.

        .. warning::

          If set to :obj:`None <python:None>`, will fall back to the
          ``HIGHCHARTS_EXPORT_SERVER_PROTOCOL`` value if available, and the Highsoft-
          provided server (``'export.highcharts.com'``) if not.

        :rtype: :class:`str <python:str>`
        """
        return self._protocol

    @protocol.setter
    def protocol(self, value):
        value = validators.string(value, allow_empty = True)
        if not value:
            value = os.getenv('HIGHCHARTS_EXPORT_SERVER_PROTOCOL', 'https')

        value = value.lower()
        if value not in ['https', 'http']:
            raise errors.HighchartsUnsupportedProtocolError(f'protocol expects either '
                                                            f'"https" or "http". '
                                                            f'Received: "{value}"')

        self._protocol = value
        self._url = None

    @property
    def domain(self) -> Optional[str]:
        """The domain where the :term:`Export Server` can be found. Defaults to the
        Highsoft-provided Export Server at ``'export.highcharts.com'``, unless over-ridden
        by the ``HIGHCHARTS_EXPORT_SERVER_DOMAIN`` environment variable.

        .. tip::

          This property is set automatically by the ``HIGHCHARTS_EXPORT_SERVER_DOMAIN``
          environment variable, if present.

        .. warning::

          If set to :obj:`None <python:None>`, will fall back to the
          ``HIGHCHARTS_EXPORT_SERVER_DOMAIN`` value if available, and the Highsoft-
          provided server (``'export.highcharts.com'``) if not.

        :rtype: :class:`str <pythoon:str>`
        """
        return self._domain

    @domain.setter
    def domain(self, value):
        value = validators.domain(value, allow_empty = True)
        if not value:
            value = os.getenv('HIGHCHARTS_EXPORT_SERVER_DOMAIN',
                              'export.highcharts.com')
        self._domain = value
        self._url = None

    @property
    def port(self) -> Optional[int]:
        """The port on which the :term:`Export Server` can be found. Defaults to
        :obj:`None <python:None>` (for the Highsoft-provided export server), unless
        over-ridden by the ``HIGHCHARTS_EXPORT_SERVER_PORT`` environment variable.

        .. tip::

          This property is set automatically by the ``HIGHCHARTS_EXPORT_SERVER_PORT``
          environment variable, if present.

        .. warning::

          If set to :obj:`None <python:None>`, will fall back to the
          ``HIGHCHARTS_EXPORT_SERVER_PORT`` value if available. If unavailable, will
          revert to :obj:`None <python:None>`.

        :rtype: :class:`str <pythoon:str>`
        """
        return self._port

    @port.setter
    def port(self, value):
        if value or value == 0:
            value = validators.integer(value,
                                       allow_empty = True,
                                       minimum = 0,
                                       maximum = 65536)
        else:
            value = os.getenv('HIGHCHARTS_EXPORT_SERVER_PORT', None)

        self._port = value
        self._url = None

    @property
    def path(self) -> Optional[str]:
        """The path (at the :meth:`ExportServer.url`) where the :term:`Export Server` can
        be reached. Defaults to :obj:`None <python:None>` (for the Highsoft-provided
        export server), unless over-ridden by the ``HIGHCHARTS_EXPORT_SERVER_PATH``
        environment variable.

        .. tip::

          This property is set automatically by the ``HIGHCHARTS_EXPORT_SERVER_PATH``
          environment variable, if present.

        .. warning::

          If set to :obj:`None <python:None>`, will fall back to the
          ``HIGHCHARTS_EXPORT_SERVER_PATH`` value if available. If unavailable, will
          revert to :obj:`None <python:None>`.

        :rtype: :class:`str <pythoon:str>`
        """
        return self._path

    @path.setter
    def path(self, value):
        value = validators.path(value, allow_empty = True)
        if value is None:
            value = os.getenv('HIGHCHARTS_EXPORT_SERVER_PATH', None)

        self._path = value
        self._url = None

    @property
    def url(self) -> Optional[str]:
        """The fully-formed URL for the :term:`Export Server`, consisting of a
        :meth:`protocol <ExportServer.protocol>`, a :meth:`domain <ExportServer.domain>`,
        and optional :meth:`port <ExportServer.port>` and
        :meth:`path <ExportServer.path>`.

        .. note::

          If explicitly set, will override the values in related properties:

            * :meth:`protocol <ExportServer.protocol>`,
            * :meth:`domain <ExportServer.domain>`,
            * :meth:`port <ExportServer.port>`, and
            * :meth:`path <ExportServer.path>`

        :rtype: :class:`str <python:str>`
        """
        if self._url:
            return self._url
        else:
            return_value = f'{self.protocol}://{self.domain}'
            if self.port is not None:
                return_value += f':{self.port}/'
            if self.path is not None:
                return_value += self.path

            return return_value

    @url.setter
    def url(self, value):
        value = validators.url(
            value,
            allow_empty=True,
            allow_special_ips=os.getenv("HCP_ALLOW_SPECIAL_IPS", False),
        )
        if not value:
            self.protocol = None
            self.domain = None
            self.port = None
            self.path = None
        else:
            original_value = value
            self.protocol = value[:value.index(':')]

            protocol = self.protocol + '://'
            value = value.replace(protocol, '')

            no_port = False
            try:
                end_of_domain = value.index(':')
                self.domain = value[:end_of_domain]
            except ValueError:
                no_port = True
                try:
                    end_of_domain = value.index('/')
                    self.domain = value[:end_of_domain]
                except ValueError:
                    self.domain = value

            domain = self.domain + '/'
            if domain in value:
                value = value.replace(domain, '')
            elif self.domain in value:
                value = value.replace(self.domain, '')

            if value and no_port:
                if value.startswith('/'):
                    self.path = value[1:]
                else:
                    self.path = value
            else:
                if value.startswith(':'):
                    start_of_port = 1
                else:
                    start_of_port = 0
                try:
                    end_of_port = value.index('/')
                except ValueError:
                    end_of_port = None

                if end_of_port:
                    self.port = value[start_of_port:end_of_port]
                else:
                    self.port = value[start_of_port:]

                port = f':{self.port}'
                value = value.replace(port, '')
                if value.startswith('/'):
                    self.path = value[1:]
                elif value:
                    self.path = value
                else:
                    self.path = None

            self._url = original_value

    @property
    def options(self) -> Optional[HighchartsOptions]:
        """The :class:`HighchartsOptions` which should be applied to render the exported
        chart. Defaults to :obj:`None <python:None>`.

        :rtype: :class:`HighchartsOptions` or :obj:`None <pythoN:None>`
        """
        return self._options

    @options.setter
    @class_sensitive(HighchartsOptions)
    def options(self, value):
        self._options = value

    @property
    def format_(self) -> Optional[str]:
        """The format in which the exported chart should be returned. Defaults to
        ``'png'``.

        Accepts:

          * ``'png'``
          * ``'jpeg'``
          * ``'pdf'``
          * ``'svg'``

        :rtype: :class:`str <python:str>` or :obj:`None <python:None>`
        """
        return self._format_

    @format_.setter
    def format_(self, value):
        value = validators.string(value, allow_empty = True)
        if not value:
            self._format_ = None
        else:
            value = value.lower()
            if value not in ['png', 'jpeg', 'pdf', 'svg']:
                raise errors.HighchartsUnsupportedExportTypeError(
                    f'format_ expects either '
                    f'"png", "jpeg", "pdf", or '
                    f'"svg". Received: {value}'
                )
            self._format_ = value

    @property
    def scale(self) -> Optional[int | float]:
        """The scale factor by which the exported chart image should be scaled. Defaults
        to ``1``.

        .. tip::

          Use this setting to improve resolution when exporting PNG or JPEG images. For
          example, setting ``.scale = 2`` on a chart whose width is 600px will produce
          an image with a width of 1200px.

        .. warning::

          If :meth:`width <ExportServer.width>` is explicitly set, this setting will be
          overridden.

        :rtype: numeric
        """
        return self._scale

    @scale.setter
    def scale(self, value):
        value = validators.numeric(value,
                                   allow_empty = True,
                                   minimum = 0)
        if not value:
            value = 1

        self._scale = value

    @property
    def width(self) -> Optional[int | float]:
        """The width that the exported chart should have. Defaults to
        :obj:`None <python:None>`.

        .. warning::

          If explicitly set, this setting will override
          :meth:`scale <ExportServer.scale>`.

        :rtype: numeric or :obj:`None <python:None>`
        """
        return self._width

    @width.setter
    def width(self, value):
        value = validators.numeric(value,
                                   allow_empty = True,
                                   minimum = 0)
        if not value:
            value = None

        self._width = value

    @property
    def callback(self) -> Optional[CallbackFunction]:
        """A JavaScript function to execute in the (JavaScript) Highcharts constructor.

        .. note::

          This setting is equivalent to providing the :meth:`Chart.callback` setting.

        :rtype: :class:`CallbackFunction` or :obj:`None <pythoN:None>`
        """
        return self._callback

    @callback.setter
    @class_sensitive(CallbackFunction)
    def callback(self, value):
        self._callback = value

    @property
    def constructor(self) -> Optional[str]:
        """The (JavaScript) constructor to use when generating the exported chart.
        Defaults to :obj:`None <python:None>`.

        Accepts:

          * ``'Chart'``
          * ``'Stock'``

        :rtype: :class:`str <python:str>` or :obj:`None <python:None>`
        """
        return self._constructor

    @constructor.setter
    def constructor(self, value):
        value = validators.string(value, allow_empty = True)
        if not value:
            self._constructor = None
        else:
            if value not in ['Chart', 'Stock']:
                raise errors.HighchartsUnsupportedConstructorError(f'constructor expects '
                                                                   f'either "Chart" or '
                                                                   f'"Stock", but '
                                                                   f'received: "{value}"')

            self._constructor = value

    @property
    def use_base64(self) -> bool:
        """If ``True``, returns the exported chart in base64 encoding. If ``False``,
        returns the exported chart in binary. Defaults to ``False``.

        :rtype: :class:`bool <python:bool>`
        """
        return self._use_base64

    @use_base64.setter
    def use_base64(self, value):
        self._use_base64 = bool(value)

    @property
    def no_download(self) -> bool:
        """If ``True``, will not send attachment headers in the HTTP response when
        exporting a chart. Defaults to ``False``.

        :rtype: :class:`bool <python:bool>`
        """
        return self._no_download

    @no_download.setter
    def no_download(self, value):
        self._no_download = bool(value)

    @property
    def async_rendering(self) -> bool:
        """If ``True``, will delay the (server-side) rendering of the exported chart
        until all scripts, functions, and event handlers provided have been executed
        and the (JavaScript) method ``highexp.done()`` is called. Defaults to ``False``.

        :rtype: :class:`bool <python:bool>`
        """
        return self._async_rendering

    @async_rendering.setter
    def async_rendering(self, value):
        self._async_rendering = bool(value)

    @property
    def global_options(self) -> Optional[HighchartsOptions]:
        """The global options which will be passed to the (JavaScript)
        ``Highcharts.setOptions()`` method, and which will be applied to the exported
        chart. Defaults to :obj:`None <python:None>`.

        :rtype: :class:`HighchartsOptions`
        """
        return self._global_options

    @global_options.setter
    @class_sensitive(HighchartsOptions)
    def global_options(self, value):
        self._global_options = value

    @property
    def data_options(self) -> Optional[Data]:
        """Configuration of data options to add data to the chart from sources like CSV.
        Defaults to :obj:`None <python:None>`.

        :rtype: :class:`Data` or :obj:`None <python:None>`
        """
        return self._data_options

    @data_options.setter
    @class_sensitive(Data)
    def data_options(self, value):
        self._data_options = value

    @property
    def custom_code(self) -> Optional[CallbackFunction]:
        """When :meth:`data_options <ExportServer.data_options>` is not
        :obj:`None <python:None>`, this (JavaScript) callback function is executed after
        the data options are applied. The only argument it receives is the complete
        set of :class:`HighchartsOptions` (as a JS literal object), which will be passed
        to the Highcharts constructor on return. Defaults to :obj:`None <python:None>`.

        :rtype: :class:`CallbackFunction` or :obj:`None <python:None>`
        """
        return self._custom_code

    @custom_code.setter
    @class_sensitive(CallbackFunction)
    def custom_code(self, value):
        self._custom_code = value

[docs] @classmethod def is_export_supported(cls, options) -> bool: """Evaluates whether the Highcharts Export Server supports exporting the series types in ``options``. :rtype: :class:`bool <python:bool>` """ if not isinstance(options, HighchartsOptions): return False if not options.series: return True series_types = [x.type for x in options.series] for item in series_types: if item in constants.EXPORT_SERVER_UNSUPPORTED_SERIES_TYPES: return False return True
@classmethod def _get_kwargs_from_dict(cls, as_dict): url = as_dict.get('url', None) protocol = None domain = None port = None path = None if not url: protocol = as_dict.get('protocol', None) domain = as_dict.get('domain', None) port = as_dict.get('port', None) path = as_dict.get('path', None) kwargs = { 'options': as_dict.get('options', None), 'format_': as_dict.get('type', as_dict.get('format_', 'png')), 'scale': as_dict.get('scale', 1), 'width': as_dict.get('width', None), 'callback': as_dict.get('callback', None), 'constructor': as_dict.get('constructor', None) or as_dict.get('constr', None), 'use_base64': as_dict.get('use_base64', None) or as_dict.get('b64', False), 'no_download': as_dict.get('noDownload', None) or as_dict.get('no_download', None), 'async_rendering': as_dict.get('asyncRendering', False) or as_dict.get('async_rendering', False), 'global_options': as_dict.get('global_options', None) or as_dict.get('globalOptions', None), 'data_options': as_dict.get('data_options', None) or as_dict.get('dataOptions', None), 'custom_code': as_dict.get('custom_code', None) or as_dict.get('customCode', None) } if url: kwargs['url'] = url if protocol: kwargs['protocol'] = protocol if domain: kwargs['domain'] = domain if port: kwargs['port'] = port if path: kwargs['path'] = path return kwargs def _to_untrimmed_dict(self, in_cls = None) -> dict: untrimmed = { 'url': self.url, 'options': self.options, 'type': self.format_, 'scale': self.scale, 'width': self.width, 'callback': self.callback, 'constr': self.constructor, 'b64': self.use_base64, 'noDownload': self.no_download, 'asyncRendering': self.async_rendering, 'globalOptions': self.global_options, 'dataOptions': self.data_options, 'customCode': self.custom_code } return untrimmed
[docs] def request_chart(self, filename = None, auth_user = None, auth_password = None, timeout = 3, **kwargs): """Execute a request against the export server based on the configuration in the instance. :param filename: The name of the file where the exported chart should (optionally) be persisted. Defaults to :obj:`None <python:None>`. :type filename: Path-like or :obj:`None <python:None>` :param auth_user: The username to use to authenticate against the Export Server, using :term:`basic authentication`. Defaults to :obj:`None <python:None>`. :type auth_user: :class:`str <python:str>` or :obj:`None <python:None>` :param auth_password: The password to use to authenticate against the Export Server (using :term:`basic authentication`). Defaults to :obj:`None <python:None>`. :type auth_password: :class:`str <python:str>` or :obj:`None <python:None>` :param timeout: The number of seconds to wait before issuing a timeout error. The timeout check is passed if bytes have been received on the socket in less than the ``timeout`` value. Defaults to ``3``. :type timeout: numeric or :obj:`None <python:None>` .. note:: All other keyword arguments are as per the :class:`ExportServer` constructor :meth:`ExportServer.__init__() <highcharts_core.headless_export.ExportServer.__init__>` :returns: The exported chart image, either as a :class:`bytes <python:bytes>` binary object or as a base-64 encoded string (depending on the :meth:`use_base64 <ExportServer.use_base64>` property). :rtype: :class:`bytes <python:bytes>` or :class:`str <python:str>` """ self.options = kwargs.get('options', self.options) self.format_ = kwargs.get('format_', kwargs.get('type', self.format_)) self.scale = kwargs.get('scale', self.scale) self.width = kwargs.get('width', self.width) self.callback = kwargs.get('callback', self.callback) self.constructor = kwargs.get('constructor', self.constructor) self.use_base64 = kwargs.get('use_base64', self.use_base64) self.no_download = kwargs.get('no_download', self.no_download) self.async_rendering = kwargs.get('async_rendering', self.async_rendering) self.global_options = kwargs.get('global_options', self.global_options) self.data_options = kwargs.get('data_options', self.data_options) self.custom_code = kwargs.get('custom_code', self.custom_code) missing_details = [] if not self.options: missing_details.append('options') if not self.format_: missing_details.append('format_') if not self.constructor: missing_details.append('constructor') if not self.url: missing_details.append('url') if missing_details: raise errors.HighchartsMissingExportSettingsError( f'Unable to export a chart.' f'ExportServer was missing ' f' following settings: ' f'{missing_details}' ) basic_auth = None if auth_user and auth_password: basic_auth = requests.HTTPBasicAuth(auth_user, auth_password) payload = { 'infile': 'HIGHCHARTS FOR PYTHON: REPLACE WITH OPTIONS', 'type': self.format_, 'scale': self.scale, 'constr': self.constructor, 'b64': self.use_base64, 'noDownload': self.no_download, 'asyncRendering': self.async_rendering } if self.width: payload['width'] = self.width if self.callback: payload['callback'] = 'HIGHCHARTS FOR PYTHON: REPLACE WITH CALLBACK' if self.global_options: payload['globalOptions'] = 'HIGHCHARTS FOR PYTHON: REPLACE WITH GLOBAL' if self.data_options: payload['dataOptions'] = 'HIGHCHARTS FOR PYTHON: REPLACE WITH DATA' if self.custom_code: payload['customCode'] = 'HIGHCHARTS FOR PYTHON: REPLACE WITH CUSTOM' as_json = json.dumps(payload) if not self.is_export_supported(self.options): raise errors.HighchartsUnsupportedExportError('The Highcharts Export Server currently only supports ' 'exports from Highcharts (Javascript) v.10. You are ' 'using a series type introduced in v.11. Sorry, but ' 'that functionality is still forthcoming.') options_as_json = self.options.to_json() if isinstance(options_as_json, bytes): options_as_str = str(options_as_json, encoding = 'utf-8') else: options_as_str = options_as_json as_json = as_json.replace('"HIGHCHARTS FOR PYTHON: REPLACE WITH OPTIONS"', options_as_str) if self.callback: callback_as_json = self.callback.to_json() if isinstance(callback_as_json, bytes): callback_as_str = str(callback_as_json, encoding = 'utf-8') else: callback_as_str = callback_as_json as_json = as_json.replace('"HIGHCHARTS FOR PYTHON: REPLACE WITH CALLBACK"', callback_as_str) if self.global_options: global_as_json = self.global_options.to_json() if isinstance(global_as_json, bytes): global_as_str = str(global_as_json, encoding = 'utf-8') else: global_as_str = global_as_json as_json = as_json.replace('"HIGHCHARTS FOR PYTHON: REPLACE WITH GLOBAL"', global_as_str) if self.data_options: data_as_json = self.data_options.to_json() if isinstance(data_as_json, bytes): data_as_str = str(data_as_json, encoding = 'utf-8') else: data_as_str = data_as_json as_json = as_json.replace('"HIGHCHARTS FOR PYTHON: REPLACE WITH DATA"', data_as_str) if self.custom_code: code_as_json = self.custom_code.to_json() if isinstance(code_as_json, bytes): code_as_str = str(code_as_json, encoding = 'utf-8') else: code_as_str = code_as_json as_json = as_json.replace('"HIGHCHARTS FOR PYTHON: REPLACE WITH CUSTOM"', code_as_str) result = requests.post(self.url, data = as_json.encode('utf-8'), headers = { 'Content-Type': 'application/json' }, auth = basic_auth, timeout = timeout) result.raise_for_status() if filename and self.format_ != 'svg': with open(filename, 'wb') as file_: file_.write(result.content) elif filename and self.format_ == 'svg': content = str(result.content, encoding = 'utf-8') with open(filename, 'wt') as file_: file_.write(content) return result.content
[docs] @classmethod def get_chart(cls, filename = None, auth_user = None, auth_password = None, timeout = 3, **kwargs): """Produce an exported chart image. :param filename: The name of the file where the exported chart should (optionally) be persisted. Defaults to :obj:`None <python:None>`. :type filename: Path-like or :obj:`None <python:None>` :param auth_user: The username to use to authenticate against the Export Server, using :term:`basic authentication`. Defaults to :obj:`None <python:None>`. :type auth_user: :class:`str <python:str>` or :obj:`None <python:None>` :param auth_password: The password to use to authenticate against the Export Server (using :term:`basic authentication`). Defaults to :obj:`None <python:None>`. :type auth_password: :class:`str <python:str>` or :obj:`None <python:None>` :param timeout: The number of seconds to wait before issuing a timeout error. The timeout check is passed if bytes have been received on the socket in less than the ``timeout`` value. Defaults to ``3``. :type timeout: numeric or :obj:`None <python:None>` .. note:: All other keyword arguments are as per the :class:`ExportServer` constructor :meth:`ExportServer.__init__() <highcharts_core.headless_export.ExportServer.__init__>` :returns: The exported chart image, either as a :class:`bytes <python:bytes>` binary object or as a base-64 encoded string (depending on the ``use_base64`` keyword argument). :rtype: :class:`bytes <python:bytes>` or :class:`str <python:str>` """ instance = cls(**kwargs) exported_chart = instance.request_chart(filename = filename, auth_user = auth_user, auth_password = auth_password, timeout = timeout) return exported_chart