Source code for highcharts_core.metaclasses
"""Set of metaclasses used throughout the library."""
import datetime
from abc import ABC, abstractmethod
from collections import UserDict
from typing import Optional, List
try:
import orjson as json
except ImportError:
try:
import rapidjson as json
except ImportError:
try:
import simplejson as json
except ImportError:
import json
try:
import numpy as np
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
import esprima
from esprima.error_handler import Error as ParseError
from validator_collection import validators, checkers, errors as validator_errors
from highcharts_core import constants, errors, utility_functions
from highcharts_core.decorators import validate_types
from highcharts_core.js_literal_functions import serialize_to_js_literal, assemble_js_literal,\
get_key_value_pairs
[docs]class HighchartsMeta(ABC):
"""Metaclass that is used to define the standard interface exposed for serializable
objects."""
def __init__(self, **kwargs):
for key in kwargs:
setattr(self, key, kwargs.get(key, None))
def __eq__(self, other):
if self.__class__ != other.__class__:
return False
self_js_literal = self.to_js_literal()
other_js_literal = other.to_js_literal()
return self_js_literal == other_js_literal
def __repr__(self):
"""Generate an unambiguous and complete :class:`str <python:str>` representation
of the object.
:returns: An unambiguous and complete :class:`str <python:str>` representation
of the object (which may have varying degrees of readability).
:rtype: :class:`str <python:str>`
"""
as_dict = self.to_dict()
kwargs = {utility_functions.to_snake_case(key): as_dict[key]
for key in as_dict}
kwargs_as_str = ', '.join([f'{key} = {repr(kwargs[key])}'
for key in kwargs])
return f'{self.__class__.__name__}({kwargs_as_str})'
@property
def _dot_path(self) -> Optional[str]:
"""The dot-notation path to the options key for the current class.
:rtype: :class:`str <python:str>` or :obj:`None <python:None>`
"""
return None
[docs] def _process_required_modules(self, scripts = None, include_extension = False) -> List[str]:
"""Return the list of URLs from which the Highcharts JavaScript modules
needed to render the chart can be retrieved.
:param scripts: Initial set of scripts that are context-dependent.
:type scripts: :class:`list <python:list>` of :class:`str <python:str>`
:param include_extension: if ``True``, will return script names with the
``'.js'`` extension included. Defaults to ``False``.
:type include_extension: :class:`bool <python:bool>`
:rtype: :class:`list <python:list>`
"""
if not scripts:
scripts = []
properties = {}
for key in self.__dict__:
if key[0] != '_':
continue
properties[key[1:]] = getattr(self, key[1:], None)
for property_name in properties:
property_value = properties[property_name]
if property_value is None:
continue
if not utility_functions.is_ndarray(
property_value
) and hasattr(
property_value, '__iter__'
) and not isinstance(
property_value,
(str, bytes, dict, UserDict)
):
additional_scripts = []
for item in property_value:
if hasattr(item, 'get_required_modules'):
item_scripts = [x for x in item.get_required_modules()
if x not in scripts]
additional_scripts.extend(item_scripts)
if additional_scripts:
scripts.extend(additional_scripts)
continue
if isinstance(property_value, HighchartsMeta):
additional_scripts = [x for x in property_value.get_required_modules()
if x not in scripts]
if additional_scripts:
scripts.extend(additional_scripts)
continue
property_name_as_camelCase = utility_functions.to_camelCase(property_name)
dot_path = f'{self._dot_path}.' or ''
dot_path += property_name_as_camelCase
scripts.extend([x for x in constants.MODULE_REQUIREMENTS.get(dot_path, [])
if x not in scripts])
if include_extension:
final_scripts = []
for script in scripts:
if script.endswith('.css') or script.endswith('.js'):
final_scripts.append(script)
elif script.startswith('css/'):
final_scripts.append(f'{script}.css')
else:
final_scripts.append(f'{script}.js')
return final_scripts
return scripts
[docs] def get_required_modules(self, include_extension = False) -> List[str]:
"""Return the list of URLs from which the Highcharts JavaScript modules
needed to render the chart can be retrieved.
:param include_extension: if ``True``, will return script names with the
``'.js'`` extension included. Defaults to ``False``.
:type include_extension: :class:`bool <python:bool>`
:rtype: :class:`list <python:list>` of :class:`str <python:str>`
"""
initial_scripts = constants.MODULE_REQUIREMENTS.get(self._dot_path, [])
prelim_scripts = self._process_required_modules(initial_scripts,
include_extension = include_extension)
scripts = []
has_all_indicators = False
for script in prelim_scripts:
if script.endswith('indicators-all.js') or script.endswith('indicators-all'):
has_all_indicators = True
for script in prelim_scripts:
if '/indicators/' in script and has_all_indicators:
continue
scripts.append(script)
return scripts
[docs] def _untrimmed_mro_ancestors(self, in_cls = None) -> dict:
"""Walk through the parent classes and consolidate the results of their
:meth:`_to_untrimmed_dict() <HighchartsMeta._to_untrimmed_dict__>` methods into
a single :class:`dict <python:dict>`.
:rtype: :class:`dict <python:dict>`
"""
return utility_functions.mro__to_untrimmed_dict(self, in_cls = in_cls)
[docs] @abstractmethod
def _to_untrimmed_dict(self, in_cls = None) -> dict:
"""Generate the first-level of the :class:`dict <python:dict>` representation of
the object.
.. note::
This method does *not* traverse the object structure to convert the object into
a full :class:`dict <python:dict>` representation - it merely goes "part" of the
way there to replace the Highcharts for Python keys with their correpsond
Highcharts JS keys.
:rtype: :class:`dict <python:dict>`
"""
raise NotImplementedError()
[docs] @staticmethod
def trim_iterable(untrimmed,
to_json = False,
context: str = None,
for_export: bool = False):
"""Convert any :class:`EnforcedNullType` values in ``untrimmed`` to ``'null'``.
:param untrimmed: The iterable whose members may still be
:obj:`None <python:None>` or Python objects.
:type untrimmed: iterable
:param to_json: If ``True``, will remove all members from ``untrimmed`` that are
not serializable to JSON. Defaults to ``False``.
:type to_json: :class:`bool <python:bool>`
:param context: If provided, will inform the method of the context in which it is
being run which may inform special handling cases (e.g. where empty strings may
be important / allowable). Defaults to :obj:`None <python:None>`.
:type context: :class:`str <python:str>` or :obj:`None <python:None>`
:param for_export: If ``True``, indicates that the method is being run to
produce a JSON for consumption by the export server. Defaults to ``False``.
:type for_export: :class:`bool <python:bool>`
:rtype: iterable
"""
if HAS_NUMPY and isinstance(untrimmed, np.ndarray):
return untrimmed
if isinstance(untrimmed,
(str, bytes, dict, UserDict)) or not hasattr(untrimmed,
'__iter__'):
if to_json and isinstance(untrimmed, str) and untrimmed.startswith('${'):
return untrimmed[1:]
return untrimmed
trimmed = []
for item in untrimmed:
if checkers.is_type(item, 'CallbackFunction') and to_json:
continue
elif item is None or item == constants.EnforcedNull:
if to_json:
trimmed.append(None)
else:
trimmed.append('null')
elif hasattr(item, 'trim_dict'):
updated_context = item.__class__.__name__
untrimmed_item = item._to_untrimmed_dict()
item_as_dict = HighchartsMeta.trim_dict(untrimmed_item,
to_json = to_json,
context = updated_context,
for_export = for_export)
if item_as_dict:
trimmed.append(item_as_dict)
elif isinstance(item, dict):
if item:
trimmed.append(HighchartsMeta.trim_dict(item,
to_json = to_json,
context = context,
for_export = for_export))
elif not isinstance(item,
(str, bytes, dict, UserDict)) and hasattr(item,
'__iter__'):
if item:
trimmed.append(HighchartsMeta.trim_iterable(item,
to_json = to_json,
context = context,
for_export = for_export))
else:
trimmed.append(item)
return trimmed
[docs] @staticmethod
def trim_dict(untrimmed: dict,
to_json: bool = False,
context: str = None,
for_export: bool = False) -> dict:
"""Remove keys from ``untrimmed`` whose values are :obj:`None <python:None>` and
convert values that have ``.to_dict()`` methods.
:param untrimmed: The :class:`dict <python:dict>` whose values may still be
:obj:`None <python:None>` or Python objects.
:type untrimmed: :class:`dict <python:dict>`
:param to_json: If ``True``, will remove all keys from ``untrimmed`` that are not
serializable to JSON. Defaults to ``False``.
:type to_json: :class:`bool <python:bool>`
:param context: If provided, will inform the method of the context in which it is
being run which may inform special handling cases (e.g. where empty strings may
be important / allowable). Defaults to :obj:`None <python:None>`.
:type context: :class:`str <python:str>` or :obj:`None <python:None>`
:param for_export: If ``True``, indicates that the method is being run to
produce a JSON for consumption by the export server. Defaults to ``False``.
:type for_export: :class:`bool <python:bool>`
:returns: Trimmed :class:`dict <python:dict>`
:rtype: :class:`dict <python:dict>`
"""
as_dict = {}
for key in untrimmed:
context_key = f'{context}.{key}'
value = untrimmed.get(key, None)
# bool -> Boolean
if isinstance(value, bool):
as_dict[key] = value
# ndarray -> (for json) -> list
elif HAS_NUMPY and to_json and isinstance(value, np.ndarray):
untrimmed_value = utility_functions.from_ndarray(value)
trimmed_value = HighchartsMeta.trim_iterable(value,
to_json = to_json,
context = context,
for_export = for_export)
if trimmed_value:
as_dict[key] = trimmed_value
continue
# ndarray -> ndarray
elif HAS_NUMPY and isinstance(value, np.ndarray):
as_dict[key] = value
# Callback Function
elif checkers.is_type(value, 'CallbackFunction') and to_json:
if not for_export:
continue
elif value:
trimmed_value = str(value)
if trimmed_value and trimmed_value != 'None':
as_dict[key] = trimmed_value
# MapData -> dict --> object
elif checkers.is_type(value, 'MapData') and to_json and for_export:
untrimmed_value = value._to_untrimmed_dict()
updated_context = value.__class__.__name__
topology = untrimmed_value.get('topology', None)
if topology:
trimmed_value = topology.to_dict()
else:
trimmed_value = None
if trimmed_value:
as_dict[key] = trimmed_value
# HighchartsMeta -> dict --> object
elif value and hasattr(value, '_to_untrimmed_dict'):
untrimmed_value = value._to_untrimmed_dict()
updated_context = value.__class__.__name__
trimmed_value = HighchartsMeta.trim_dict(untrimmed_value,
to_json = to_json,
context = updated_context,
for_export=for_export)
if trimmed_value:
as_dict[key] = trimmed_value
# Enforced null
elif isinstance(value, constants.EnforcedNullType):
if to_json:
as_dict[key] = None
else:
as_dict[key] = value
# dict -> object
elif isinstance(value, dict):
trimmed_value = HighchartsMeta.trim_dict(value,
to_json = to_json,
context = context,
for_export = for_export)
if trimmed_value:
as_dict[key] = trimmed_value
# iterable -> array
elif not isinstance(value,
(str, bytes, dict, UserDict)) and hasattr(value,
'__iter__'):
trimmed_value = HighchartsMeta.trim_iterable(value,
to_json = to_json,
context = context,
for_export = for_export)
if trimmed_value:
as_dict[key] = trimmed_value
# Datetime or Datetime-like
elif checkers.is_datetime(value):
trimmed_value = value
if to_json:
if not value.tzinfo:
trimmed_value = value.replace(tzinfo = datetime.timezone.utc)
as_dict[key] = trimmed_value.timestamp() * 1000
elif hasattr(trimmed_value, 'to_pydatetime'):
as_dict[key] = trimmed_value.to_pydatetime()
else:
as_dict[key] = trimmed_value
# Date or Time
elif checkers.is_date(value) or checkers.is_time(value):
if for_export and checkers.is_date(value):
trimmed_value = validators.datetime(value)
if not trimmed_value.tzinfo:
trimmed_value = trimmed_value.replace(tzinfo=datetime.timezone.utc)
as_dict[key] = trimmed_value.timestamp() * 1000
elif to_json:
as_dict[key] = value.isoformat()
else:
as_dict[key] = value
# other truthy -> str / number
elif value:
trimmed_value = HighchartsMeta.trim_iterable(value,
to_json = to_json,
context = context,
for_export = for_export)
if trimmed_value:
as_dict[key] = trimmed_value
# other falsy -> str / number
elif value in [0, 0., False]:
as_dict[key] = value
# other falsy -> str, but empty string is allowed
elif value == '' and context_key in constants.EMPTY_STRING_CONTEXTS:
as_dict[key] = ''
elif value is None and context_key in constants.ALLOWED_NONE_CONTEXTS:
if to_json:
as_dict[key] = None
return as_dict
[docs] @classmethod
@abstractmethod
def _get_kwargs_from_dict(cls, as_dict):
"""Convenience method which returns the keyword arguments used to initialize the
class from a Highcharts Javascript-compatible :class:`dict <python:dict>` object.
:param as_dict: The HighCharts JS compatible :class:`dict <python:dict>`
representation of the object.
:type as_dict: :class:`dict <python:dict>`
:returns: The keyword arguments that would be used to initialize an instance.
:rtype: :class:`dict <python:dict>`
"""
raise NotImplementedError()
[docs] @classmethod
def from_dict(cls,
as_dict: dict,
allow_snake_case: bool = True):
"""Construct an instance of the class from a :class:`dict <python:dict>` object.
:param as_dict: A :class:`dict <python:dict>` representation of the object.
:type as_dict: :class:`dict <python:dict>`
:param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
to ``camelCase`` keys. Defaults to ``True``.
:type allow_snake_case: :class:`bool <python:bool>`
:returns: A Python object representation of ``as_dict``.
:rtype: :class:`HighchartsMeta`
"""
as_dict = validators.dict(as_dict, allow_empty = True) or {}
clean_as_dict = {}
if allow_snake_case:
clean_as_dict = {utility_functions.to_camelCase(key): as_dict[key]
for key in as_dict}
else:
clean_as_dict = {key: as_dict[key]
for key in as_dict}
kwargs = cls._get_kwargs_from_dict(clean_as_dict)
return cls(**kwargs)
[docs] @classmethod
def from_json(cls,
as_json_or_file,
allow_snake_case: bool = True):
"""Construct an instance of the class from a JSON string.
:param as_json_or_file: The JSON string for the object or the filename of a file
that contains the JSON string.
:type as_json: :class:`str <python:str>` or :class:`bytes <python:bytes>`
:param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
to ``camelCase`` keys. Defaults to ``True``.
:type allow_snake_case: :class:`bool <python:bool>`
:returns: A Python objcet representation of ``as_json``.
:rtype: :class:`HighchartsMeta`
"""
is_file = checkers.is_file(as_json_or_file)
if is_file:
with open(as_json_or_file, 'r') as file_:
as_str = file_.read()
else:
as_str = as_json_or_file
as_dict = json.loads(as_str)
if checkers.is_iterable(as_dict, forbid_literals = (str, bytes, dict, UserDict)):
return [cls.from_dict(x, allow_snake_case = allow_snake_case)
for x in as_dict]
return cls.from_dict(as_dict,
allow_snake_case = allow_snake_case)
[docs] def to_dict(self) -> dict:
"""Generate a :class:`dict <python:dict>` representation of the object compatible
with the Highcharts JavaScript library.
.. note::
The :class:`dict <python:dict>` representation has a property structure and
naming convention that is *intentionally* consistent with the Highcharts
JavaScript library. This is not Pythonic, but it makes managing the interplay
between the two languages much, much simpler.
:returns: A :class:`dict <python:dict>` representation of the object.
:rtype: :class:`dict <python:dict>`
"""
untrimmed = self._to_untrimmed_dict()
return self.trim_dict(untrimmed,
context = self.__class__.__name__)
[docs] def to_json(self,
filename = None,
encoding = 'utf-8',
for_export: bool = False):
"""Generate a JSON string/byte string representation of the object compatible with
the Highcharts JavaScript library.
.. note::
This method will either return a standard :class:`str <python:str>` or a
:class:`bytes <python:bytes>` object depending on the JSON serialization library
you are using. For example, if your environment has
`orjson <https://github.com/ijl/orjson>`_, the result will be a
:class:`bytes <python:bytes>` representation of the string.
:param filename: The name of a file to which the JSON string should be persisted.
Defaults to :obj:`None <python:None>`
:type filename: Path-like
:param encoding: The character encoding to apply to the resulting object. Defaults
to ``'utf-8'``.
:type encoding: :class:`str <python:str>`
:param for_export: If ``True``, indicates that the method is being run to
produce a JSON for consumption by the export server. Defaults to ``False``.
:type for_export: :class:`bool <python:bool>`
:returns: A JSON representation of the object compatible with the Highcharts
library.
:rtype: :class:`str <python:str>` or :class:`bytes <python:bytes>`
"""
if filename:
filename = validators.path(filename)
untrimmed = self._to_untrimmed_dict()
as_dict = self.trim_dict(untrimmed,
to_json = True,
context = self.__class__.__name__,
for_export = for_export)
for key in as_dict:
if as_dict[key] == constants.EnforcedNull or as_dict[key] is None:
as_dict[key] = None
try:
as_json = json.dumps(as_dict, encoding = encoding)
except TypeError:
as_json = json.dumps(as_dict)
if filename:
if isinstance(as_json, bytes):
write_type = 'wb'
else:
write_type = 'w'
with open(filename, write_type, encoding = encoding) as file_:
file_.write(as_json)
return as_json
[docs] def to_js_literal(self,
filename = None,
encoding = 'utf-8',
careful_validation = False) -> Optional[str]:
"""Return the object represented as a :class:`str <python:str>` containing the
JavaScript object literal.
:param filename: The name of a file to which the JavaScript object literal should
be persisted. Defaults to :obj:`None <python:None>`
:type filename: Path-like
:param encoding: The character encoding to apply to the resulting object. Defaults
to ``'utf-8'``.
:type encoding: :class:`str <python:str>`
:param careful_validation: if ``True``, will carefully validate JavaScript values
along the way using the
`esprima-python <https://github.com/Kronuz/esprima-python>`__ library. Defaults
to ``False``.
.. warning::
Setting this value to ``True`` will significantly degrade serialization
performance, though it may prove useful for debugging purposes.
:type careful_validation: :class:`bool <python:bool>`
:rtype: :class:`str <python:str>` or :obj:`None <python:None>`
"""
if filename:
filename = validators.path(filename)
untrimmed = self._to_untrimmed_dict()
as_dict = {}
for key in untrimmed:
item = untrimmed[key]
serialized = serialize_to_js_literal(item,
encoding = encoding,
careful_validation = careful_validation)
if serialized is not None:
as_dict[key] = serialized
as_str = assemble_js_literal(as_dict,
careful_validation = careful_validation)
if filename:
with open(filename, 'w', encoding = encoding) as file_:
file_.write(as_str)
return as_str
[docs] @classmethod
def _validate_js_literal(cls,
as_str,
range = True,
_break_loop_on_failure = False):
"""Parse ``as_str`` as a valid JavaScript literal object.
:param as_str: The string to parse as a JavaScript literal object.
:type as_str: :class:`str <python:str>`
:param range: If ``True``, includes location and range data for each node in the
AST returned. Defaults to ``False``.
:type range: :class:`bool <python:bool>`
:param _break_loop_on_failure: If ``True``, will not loop if the method fails to
parse/validate ``as_str``. Defaults to ``False``.
:type _break_loop_on_failure: :class:`bool <python:bool>`
:returns: The parsed AST representation of ``as_str`` and the updated string.
:rtype: 2-member :class:`tuple <python:tuple>` of :class:`esprima.nodes.Script`
and :class:`str <python:str>`
"""
if """document.addEventListener('DOMContentLoaded', function() {\n""" in as_str:
as_str = as_str.replace(
"""document.addEventListener('DOMContentLoaded', function() {\n""", ''
)
as_str = as_str[:-3]
try:
parsed = esprima.parseScript(as_str, loc = range, range = range)
except ParseError:
try:
parsed = esprima.parseModule(as_str, loc = range, range = range)
except ParseError:
if not _break_loop_on_failure:
as_str = f"""var randomVariable = {as_str}"""
return cls._validate_js_literal(as_str,
range = range,
_break_loop_on_failure = True)
else:
raise errors.HighchartsParseError('._validate_js_literal() expects '
'a str containing a valid '
'JavaScript literal object. Could '
'not find a valid literal.')
return parsed, as_str
[docs] @classmethod
def from_js_literal(cls,
as_str_or_file,
allow_snake_case: bool = True,
_break_loop_on_failure: bool = False):
"""Return a Python object representation of a Highcharts JavaScript object
literal.
:param as_str_or_file: The JavaScript object literal, represented either as a
:class:`str <python:str>` or as a filename which contains the JS object literal.
:type as_str_or_file: :class:`str <python:str>`
:param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
to ``camelCase`` keys. Defaults to ``True``.
:type allow_snake_case: :class:`bool <python:bool>`
:param _break_loop_on_failure: If ``True``, will break any looping operations in
the event of a failure. Otherwise, will attempt to repair the failure. Defaults
to ``False``.
:type _break_loop_on_failure: :class:`bool <python:bool>`
:returns: A Python object representation of the Highcharts JavaScript object
literal.
:rtype: :class:`HighchartsMeta`
"""
is_file = checkers.is_file(as_str_or_file)
if is_file:
with open(as_str_or_file, 'r') as file_:
as_str = file_.read()
else:
as_str = as_str_or_file
parsed, updated_str = cls._validate_js_literal(as_str)
as_dict = {}
if not parsed.body:
return cls()
if len(parsed.body) > 1:
raise errors.HighchartsCollectionError(f'each JavaScript object literal is '
f'expected to contain one object. '
f'However, you attempted to parse '
f'{len(parsed.body)} objects.')
body = parsed.body[0]
if not checkers.is_type(body, 'VariableDeclaration') and \
_break_loop_on_failure is False:
prefixed_str = f'var randomVariable = {as_str}'
return cls.from_js_literal(prefixed_str,
_break_loop_on_failure = True)
elif not checkers.is_type(body, 'VariableDeclaration'):
raise errors.HighchartsVariableDeclarationError('To parse a JavaScript '
'object literal, it is '
'expected to be either a '
'variable declaration or a'
'standalone block statement.'
'Input received did not '
'conform.')
declarations = body.declarations
if not declarations:
return cls()
if len(declarations) > 1:
raise errors.HighchartsCollectionError(f'each JavaScript object literal is '
f'expected to contain one object. '
f'However, you attempted to parse '
f'{len(parsed.body)} objects.')
object_expression = declarations[0].init
if not checkers.is_type(object_expression, 'ObjectExpression'):
raise errors.HighchartsParseError(f'Highcharts expects an object literal to '
f'to be defined as a standard '
f'ObjectExpression. Received: '
f'{type(object_expression)}')
properties = object_expression.properties
if not properties:
return cls()
key_value_pairs = [(x[0], x[1]) for x in get_key_value_pairs(properties,
updated_str)]
for pair in key_value_pairs:
as_dict[pair[0]] = pair[1]
return cls.from_dict(as_dict,
allow_snake_case = allow_snake_case)
[docs] @classmethod
def _copy_dict_key(cls,
key,
original,
other,
overwrite = True,
**kwargs):
"""Copies the value of ``key`` from ``original`` to ``other``.
:param key: The key that is to be copied.
:type key: :class:`str <python:str>`
:param original: The original :class:`dict <python:dict>` from which it should
be copied.
:type original: :class:`dict <python:dict>`
:param other: The :class:`dict <python:dict>` to which it should be copied.
:type other: :class:`dict <python:dict>`
:returns: The value that should be placed in ``other`` for ``key``.
"""
if not isinstance(original, (dict, UserDict)):
return original
original_value = original[key]
if other is None:
other_value = None
else:
other_value = other.get(key, None)
if isinstance(original_value, (dict, UserDict)):
new_value = {}
for subkey in original_value:
new_key_value = cls._copy_dict_key(subkey,
original_value,
other_value,
overwrite = overwrite,
**kwargs)
new_value[subkey] = new_key_value
return new_value
elif checkers.is_iterable(original_value,
forbid_literals = (str,
bytes,
dict,
UserDict)):
if overwrite:
new_value = [x for x in original_value]
return new_value
return other_value
elif other_value and not overwrite:
return other_value
return original_value
[docs] def copy(self,
other = None,
overwrite = True,
**kwargs):
"""Copy the configuration settings from this instance to the ``other`` instance.
:param other: The target instance to which the properties of this instance should
be copied. If :obj:`None <python:None>`, will create a new instance and populate
it with properties copied from ``self``. Defaults to :obj:`None <python:None>`.
:type other: :class:`HighchartsMeta`
:param overwrite: if ``True``, properties in ``other`` that are already set will
be overwritten by their counterparts in ``self``. Defaults to ``True``.
:type overwrite: :class:`bool <python:bool>`
:param kwargs: Additional keyword arguments. Some special descendents of
:class:`HighchartsMeta` may have special implementations of this method which
rely on additional keyword arguments.
:returns: A mutated version of ``other`` with new property values
"""
if not other:
other = self.__class__()
if not isinstance(other, self.__class__):
raise errors.HighchartsValueError(f'other is expected to be a '
f'{self.__class__.__name__} instance. Was: '
f'{other.__class__.__name__}')
self_as_dict = self.to_dict()
other_as_dict = other.to_dict()
new_dict = {}
for key in self_as_dict:
new_dict[key] = self._copy_dict_key(key,
original = self_as_dict,
other = other_as_dict,
overwrite = overwrite,
**kwargs)
cls = other.__class__
other = cls.from_dict(new_dict)
return other
[docs]class JavaScriptDict(UserDict):
"""Special :class:`dict <python:dict>` class which constructs a JavaScript
object that can be represented as a string.
Keys are validated to be valid variable names, while values are validated to be
strings.
When serialized to :class:`str <python:str>`, keys are **not** wrapped in double
quotes (as they would be in JSON) to ensure that the resulting string can be evaluated
as JavaScript code.
"""
_valid_value_types = None
_allow_empty_value = True
def __setitem__(self, key, item):
validate_key = False
try:
validate_key = key not in self
except AttributeError:
validate_key = True
if validate_key:
try:
key = validators.variable_name(key, allow_empty = False)
except validator_errors.InvalidVariableNameError as error:
if '-' in key:
try:
test_key = key.replace('-', '_')
validators.variable_name(test_key, allow_empty = False)
except validator_errors.InvalidVariableNameError:
raise error
else:
raise error
if self._valid_value_types:
try:
item = validate_types(item,
types = self._valid_value_types,
allow_none = self._allow_empty_value)
except errors.HighchartsValueError as error:
if self._allow_empty_value and not item:
item = None
else:
try:
item = self._valid_value_types(item)
except (TypeError, ValueError, AttributeError):
raise error
super().__setitem__(key, item)
[docs] @classmethod
def from_dict(cls,
as_dict: dict,
allow_snake_case: bool = True):
"""Construct an instance of the class from a :class:`dict <python:dict>` object.
:param as_dict: A :class:`dict <python:dict>` representation of the object.
:type as_dict: :class:`dict <python:dict>`
:param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
to ``camelCase`` keys. Defaults to ``True``.
:type allow_snake_case: :class:`bool <python:bool>`
:returns: A Python object representation of ``as_dict``.
:rtype: :class:`HighchartsMeta`
"""
as_dict = validators.dict(as_dict, allow_empty = True)
if not as_dict:
return cls()
as_obj = cls()
for key in as_dict:
as_obj[key] = as_dict.get(key, None)
return as_obj
[docs] @classmethod
def from_json(cls, as_json):
"""Construct an instance of the class from a JSON string.
:param as_json: The JSON string for the object.
:type as_json: :class:`str <python:str>` or :class:`bytes <python:bytes>`
:returns: A Python objcet representation of ``as_json``.
:rtype: :class:`HighchartsMeta`
"""
as_dict = json.loads(as_json)
return cls.from_dict(as_dict)
def _to_untrimmed_dict(self, in_cls = None) -> dict:
return self.data
[docs] @staticmethod
def trim_iterable(untrimmed,
to_json = False,
context: str = None,
for_export: bool = False):
"""Convert any :class:`EnforcedNullType` values in ``untrimmed`` to ``'null'``.
:param untrimmed: The iterable whose members may still be
:obj:`None <python:None>` or Python objects.
:type untrimmed: iterable
:param to_json: If ``True``, will remove all members from ``untrimmed`` that are
not serializable to JSON. Defaults to ``False``.
:type to_json: :class:`bool <python:bool>`
:param context: If provided, will inform the method of the context in which it is
being run which may inform special handling cases (e.g. where empty strings may
be important / allowable). Defaults to :obj:`None <python:None>`.
:type context: :class:`str <python:str>` or :obj:`None <python:None>`
:param for_export: If ``True``, indicates that the method is being run to
produce a JSON for consumption by the export server. Defaults to ``False``.
:type for_export: :class:`bool <python:bool>`
:rtype: iterable
"""
if not checkers.is_iterable(untrimmed, forbid_literals = (str, bytes, dict)):
return untrimmed
trimmed = []
for item in untrimmed:
if checkers.is_type(item, 'CallbackFunction') and to_json:
continue
elif item is None or item == constants.EnforcedNull:
trimmed.append('null')
elif hasattr(item, 'trim_dict'):
updated_context = item.__class__.__name__
untrimmed_item = item._to_untrimmed_dict()
item_as_dict = HighchartsMeta.trim_dict(untrimmed_item,
to_json = to_json,
context = updated_context,
for_export = for_export)
if item_as_dict:
trimmed.append(item_as_dict)
elif isinstance(item, dict):
if item:
trimmed.append(HighchartsMeta.trim_dict(item,
to_json = to_json,
context = context,
for_export = for_export))
elif checkers.is_iterable(item, forbid_literals = (str, bytes, dict)):
if item:
trimmed.append(HighchartsMeta.trim_iterable(item,
to_json = to_json,
context = context,
for_export = for_export))
else:
trimmed.append(item)
return trimmed
[docs] @staticmethod
def trim_dict(untrimmed: dict,
to_json: bool = False,
context: str = None,
for_export: bool = False) -> dict:
"""Remove keys from ``untrimmed`` whose values are :obj:`None <python:None>` and
convert values that have ``.to_dict()`` methods.
:param untrimmed: The :class:`dict <python:dict>` whose values may still be
:obj:`None <python:None>` or Python objects.
:type untrimmed: :class:`dict <python:dict>`
:param to_json: If ``True``, will remove all keys from ``untrimmed`` that are not
serializable to JSON. Defaults to ``False``.
:type to_json: :class:`bool <python:bool>`
:param context: If provided, will inform the method of the context in which it is
being run which may inform special handling cases (e.g. where empty strings may
be important / allowable). Defaults to :obj:`None <python:None>`.
:type context: :class:`str <python:str>` or :obj:`None <python:None>`
:param for_export: If ``True``, indicates that the method is being run to
produce a JSON for consumption by the export server. Defaults to ``False``.
:type for_export: :class:`bool <python:bool>`
:returns: Trimmed :class:`dict <python:dict>`
:rtype: :class:`dict <python:dict>`
"""
as_dict = {}
for key in untrimmed:
context_key = f'{context}.{key}'
value = untrimmed.get(key, None)
# bool -> Boolean
if isinstance(value, bool):
as_dict[key] = value
# Callback Function
elif checkers.is_type(value, 'CallbackFunction') and to_json:
continue
# HighchartsMeta -> dict --> object
elif value and hasattr(value, '_to_untrimmed_dict'):
untrimmed_value = value._to_untrimmed_dict()
updated_context = value.__class__.__name__
trimmed_value = HighchartsMeta.trim_dict(untrimmed_value,
to_json = to_json,
context = updated_context,
for_export = for_export)
if trimmed_value:
as_dict[key] = trimmed_value
# Enforced null
elif isinstance(value, constants.EnforcedNullType):
if to_json:
as_dict[key] = None
else:
as_dict[key] = value
# dict -> object
elif isinstance(value, dict):
trimmed_value = HighchartsMeta.trim_dict(value,
to_json = to_json,
context = context,
for_export = for_export)
if trimmed_value:
as_dict[key] = trimmed_value
# iterable -> array
elif checkers.is_iterable(value, forbid_literals = (str, bytes, dict)):
trimmed_value = HighchartsMeta.trim_iterable(value,
to_json = to_json,
context = context,
for_export = for_export)
if trimmed_value:
as_dict[key] = trimmed_value
# Datetime or Datetime-like
elif checkers.is_datetime(value):
trimmed_value = value
if to_json:
if not value.tzinfo:
trimmed_value = value.replace(tzinfo = datetime.timezone.utc)
as_dict[key] = trimmed_value.timestamp() * 1000
elif hasattr(trimmed_value, 'to_pydatetime'):
as_dict[key] = trimmed_value.to_pydatetime()
else:
as_dict[key] = trimmed_value
# Date or Time
elif checkers.is_date(value) or checkers.is_time(value):
if for_export and checkers.is_date(value):
trimmed_value = validators.datetime(value)
if not trimmed_value.tzinfo:
trimmed_value = trimmed_value.replace(
tzinfo=datetime.timezone.utc
)
as_dict[key] = trimmed_value.timestamp() * 1000
elif to_json:
as_dict[key] = value.isoformat()
else:
as_dict[key] = value
# other truthy -> str / number
elif value:
trimmed_value = HighchartsMeta.trim_iterable(value,
to_json = to_json,
context = context,
for_export = for_export)
if trimmed_value:
as_dict[key] = trimmed_value
# other falsy -> str / number
elif value in [0, 0., False]:
as_dict[key] = value
# other falsy -> str, but empty string is allowed
elif value == '' and context_key in constants.EMPTY_STRING_CONTEXTS:
as_dict[key] = ''
elif value is None and context_key in constants.ALLOWED_NONE_CONTEXTS:
if to_json:
as_dict[key] = None
return as_dict
[docs] def to_dict(self):
"""Generate a :class:`dict <python:dict>` representation of the object compatible
with the Highcharts JavaScript library.
.. note::
The :class:`dict <python:dict>` representation has a property structure and
naming convention that is *intentionally* consistent with the Highcharts
JavaScript library. This is not Pythonic, but it makes managing the interplay
between the two languages much, much simpler.
:returns: A :class:`dict <python:dict>` representation of the object.
:rtype: :class:`dict <python:dict>`
"""
return self.data
[docs] def to_json(self,
filename = None,
encoding = 'utf-8',
for_export: bool = False):
"""Generate a JSON string/byte string representation of the object compatible with
the Highcharts JavaScript library.
.. note::
This method will either return a standard :class:`str <python:str>` or a
:class:`bytes <python:bytes>` object depending on the JSON serialization library
you are using. For example, if your environment has
`orjson <https://github.com/ijl/orjson>`_, the result will be a
:class:`bytes <python:bytes>` representation of the string.
:param filename: The name of a file to which the JSON string should be persisted.
Defaults to :obj:`None <python:None>`
:type filename: Path-like
:param encoding: The character encoding to apply to the resulting object. Defaults
to ``'utf8'``.
:type encoding: :class:`str <python:str>`
:param for_export: If ``True``, indicates that the method is being run to
produce a JSON for consumption by the export server. Defaults to ``False``.
:type for_export: :class:`bool <python:bool>`
:returns: A JSON representation of the object compatible with the Highcharts
library.
:rtype: :class:`str <python:str>` or :class:`bytes <python:bytes>`
"""
if filename:
filename = validators.path(filename)
untrimmed = self._to_untrimmed_dict()
as_dict = self.trim_dict(untrimmed,
to_json = True,
context = self.__class__.__name__,
for_export = for_export)
for key in as_dict:
if as_dict[key] == constants.EnforcedNull or as_dict[key] is None:
as_dict[key] = None
try:
as_json = json.dumps(as_dict, encoding = encoding)
except TypeError:
as_json = json.dumps(as_dict)
if filename:
if isinstance(as_json, bytes):
write_type = 'wb'
else:
write_type = 'w'
with open(filename, write_type, encoding = encoding) as file_:
file_.write(as_json)
return as_json
[docs] def to_js_literal(self,
filename = None,
encoding = 'utf-8',
careful_validation = False) -> Optional[str]:
"""Return the object represented as a :class:`str <python:str>` containing the
JavaScript object literal.
:param filename: The name of a file to which the JavaScript object literal should
be persisted. Defaults to :obj:`None <python:None>`
:type filename: Path-like
:param encoding: The character encoding to apply to the resulting object. Defaults
to ``'utf-8'``.
:type encoding: :class:`str <python:str>`
:param careful_validation: if ``True``, will carefully validate JavaScript values
along the way using the
`esprima-python <https://github.com/Kronuz/esprima-python>`__ library. Defaults
to ``False``.
.. warning::
Setting this value to ``True`` will significantly degrade serialization
performance, though it may prove useful for debugging purposes.
:type careful_validation: :class:`bool <python:bool>`
:rtype: :class:`str <python:str>` or :obj:`None <python:None>`
"""
if filename:
filename = validators.path(filename)
untrimmed = self._to_untrimmed_dict()
as_dict = {}
for key in untrimmed:
item = untrimmed[key]
serialized = serialize_to_js_literal(item,
encoding = encoding,
careful_validation = careful_validation)
if serialized is not None:
as_dict[key] = serialized
as_str = assemble_js_literal(as_dict,
keys_as_strings = True,
careful_validation = careful_validation)
if filename:
with open(filename, 'w', encoding = encoding) as file_:
file_.write(as_str)
return as_str
[docs] @classmethod
def _validate_js_literal(cls,
as_str,
range = True,
_break_loop_on_failure = False):
"""Parse ``as_str`` as a valid JavaScript literal object.
:param as_str: The string to parse as a JavaScript literal object.
:type as_str: :class:`str <python:str>`
:param range: If ``True``, includes location and range data for each node in the
AST returned. Defaults to ``False``.
:type range: :class:`bool <python:bool>`
:param _break_loop_on_failure: If ``True``, will not loop if the method fails to
parse/validate ``as_str``. Defaults to ``False``.
:type _break_loop_on_failure: :class:`bool <python:bool>`
:returns: The parsed AST representation of ``as_str`` and the updated string.
:rtype: 2-member :class:`tuple <python:tuple>` of :class:`esprima.nodes.Script`
and :class:`str <python:str>`
"""
try:
parsed = esprima.parseScript(as_str, loc = range, range = range)
except ParseError:
try:
parsed = esprima.parseModule(as_str, loc = range, range = range)
except ParseError:
if not _break_loop_on_failure:
as_str = f"""var randomVariable = {as_str}"""
return cls._validate_js_literal(as_str,
range = range,
_break_loop_on_failure = True)
else:
raise errors.HighchartsParseError('._validate_js_literal() expects '
'a str containing a valid '
'JavaScript literal object. Could '
'not find a valid JS literal '
'object.')
return parsed, as_str
[docs] @classmethod
def from_js_literal(cls,
as_str_or_file,
allow_snake_case: bool = True,
_break_loop_on_failure: bool = False):
"""Return a Python object representation of a Highcharts JavaScript object
literal.
:param as_str_or_file: The JavaScript object literal, represented either as a
:class:`str <python:str>` or as a filename which contains the JS object literal.
:type as_str_or_file: :class:`str <python:str>`
:param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
to ``camelCase`` keys. Defaults to ``True``.
:type allow_snake_case: :class:`bool <python:bool>`
:param _break_loop_on_failure: If ``True``, will break any looping operations in
the event of a failure. Otherwise, will attempt to repair the failure. Defaults
to ``False``.
:type _break_loop_on_failure: :class:`bool <python:bool>`
:returns: A Python object representation of the Highcharts JavaScript object
literal.
:rtype: :class:`HighchartsMeta`
"""
is_file = checkers.is_file(as_str_or_file)
if is_file:
with open(as_str_or_file, 'r') as file_:
as_str = file_.read()
else:
as_str = as_str_or_file
parsed, updated_str = cls._validate_js_literal(as_str)
as_dict = {}
if not parsed.body:
return cls()
if len(parsed.body) > 1:
raise errors.HighchartsCollectionError(f'each JavaScript object literal is '
f'expected to contain one object. '
f'However, you attempted to parse '
f'{len(parsed.body)} objects.')
body = parsed.body[0]
if not checkers.is_type(body, 'VariableDeclaration') and \
_break_loop_on_failure is False:
prefixed_str = f'var randomVariable = {as_str}'
return cls.from_js_literal(prefixed_str,
_break_loop_on_failure = True)
elif not checkers.is_type(body, 'VariableDeclaration'):
raise errors.HighchartsVariableDeclarationError('To parse a JavaScript '
'object literal, it is '
'expected to be either a '
'variable declaration or a'
'standalone block statement.'
'Input received did not '
'conform.')
declarations = body.declarations
if not declarations:
return cls()
if len(declarations) > 1:
raise errors.HighchartsCollectionError(f'each JavaScript object literal is '
f'expected to contain one object. '
f'However, you attempted to parse '
f'{len(parsed.body)} objects.')
object_expression = declarations[0].init
if not checkers.is_type(object_expression, 'ObjectExpression'):
raise errors.HighchartsParseError(f'Highcharts expects an object literal to '
f'to be defined as a standard '
f'ObjectExpression. Received: '
f'{type(object_expression)}')
properties = object_expression.properties
if not properties:
return cls()
key_value_pairs = [(x[0], x[1]) for x in get_key_value_pairs(properties,
updated_str)]
for pair in key_value_pairs:
as_dict[pair[0]] = pair[1]
return cls.from_dict(as_dict,
allow_snake_case = allow_snake_case)