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


[docs]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