from typing import Optional, List
from validator_collection import validators, checkers
import esprima
from esprima.error_handler import Error as ParseError
from highcharts_core import errors, ai
from highcharts_core.decorators import validate_types
from highcharts_core.metaclasses import HighchartsMeta
[docs]class CallbackFunction(HighchartsMeta):
"""Representation of a JavaScript callback function's source code."""
def __init__(self, **kwargs):
self._function_name = None
self._arguments = None
self._body = None
self.function_name = kwargs.get('function_name', None)
self.arguments = kwargs.get('arguments', None)
self.body = kwargs.get('body', None)
def __str__(self) -> str:
if self.function_name:
prefix = f'function {self.function_name}'
else:
prefix = 'function'
arguments = '('
if self.arguments:
for argument in self.arguments:
arguments += f'{argument},'
arguments = arguments[:-1]
arguments += ')'
as_str = f'{prefix}{arguments}'
as_str += ' {'
if self.body:
as_str += '\n'
as_str += self.body
as_str += '}'
return as_str
@property
def function_name(self) -> Optional[str]:
"""An optional name to be given to the function.
.. warning::
Most Highcharts Callback function definitions are anonymous, meaning that they
are named within the object into which they are embedded. As a result,
this setting should be used sparingly.
:rtype: :class:`str <python:str>`
"""
return self._function_name
@function_name.setter
def function_name(self, value):
self._function_name = validators.variable_name(value, allow_empty = True)
@property
def arguments(self) -> Optional[List[str]]:
"""Collection of named arguments (parameters) that will be passed to the function.
:rtype: :class:`list <python:list>` of :obj:`str <python:str>`, or
:obj:`None <python:None>`
"""
return self._arguments
@arguments.setter
def arguments(self, value):
if not value:
self._arguments = None
else:
arguments = validators.iterable(value)
validated_value = []
for argument in arguments:
if '=' not in argument:
validated_value.append(validators.variable_name(argument))
else:
variable = argument.split('=')[0]
default_value = argument.split('=')[1]
variable = validators.variable_name(variable)
validated_value.append(f'{variable}={default_value}')
self._arguments = validated_value
@property
def body(self) -> Optional[str]:
"""The source code of the function itself.
.. note::
Should *not* be wrapped in ``{ ... }``. It should just be the source code of the
the function itself.
.. hint::
When writing this code in Python, it is best to use the three-quotation-mark
string pattern, like so:
.. code-block:: python
callback = CallbackFunction()
callback.body = \"\"\"
... some JavaScript logic goes here
\"\"\"
:rtype: :class:`str <python:str>` or :obj:`None <python:None>`
"""
return self._body
@body.setter
def body(self, value):
self._body = validators.string(value, allow_empty = True)
@classmethod
def _get_kwargs_from_dict(cls, as_dict):
kwargs = {
'function_name': as_dict.get('function_name',
as_dict.get('functionName', None)),
'arguments': as_dict.get('arguments', None),
'body': as_dict.get('body', None)
}
return kwargs
def _to_untrimmed_dict(self, in_cls = None) -> dict:
return {
'function_name': self.function_name,
'arguments': self.arguments,
'body': self.body
}
[docs] def to_json(self, encoding = 'utf-8', for_export: bool = False):
if for_export:
return str(self)
return None
[docs] def to_js_literal(self,
filename = None,
encoding = 'utf-8',
careful_validation = False) -> str:
if filename:
filename = validators.path(filename)
as_str = str(self)
if filename:
with open(filename, 'w', encoding = encoding) as file_:
file_.write(as_str)
return as_str
@classmethod
def _convert_from_js_ast(cls, property_definition, original_str):
"""Create a :class:`CallbackFunction` instance from a
:class:`esprima.nodes.FunctionExpression` instance.
:param property_definition: The :class:`esprima.nodes.FunctionExpression`
instance, including ``loc`` (indicating the line and column in the original
string) and ``range`` (indicating the character range for the property
definition in the original string).
:type property_definition: :class:`esprima.nodes.FunctionExpression`
:param original_str: The original :class:`str <python:str>` of the JavaScript from
which ``property_definition`` was parsed.
:type original_str: :class:`str <python:str>`
:returns: :class:`CallbackFunction`
"""
if not checkers.is_type(property_definition, ('FunctionDeclaration',
'FunctionExpression',
'MethodDefinition',
'Property')):
raise errors.HighchartsParseError(f'property_definition should contain a '
f'FunctionExpression, FunctionDeclaration, '
'MethodDefinition, or Property instance. '
f'Received: '
f'{property_definition.__class__.__name__}')
if property_definition.type not in ['MethodDefinition', 'Property']:
body = property_definition.body
else:
body = property_definition.value.body
body_range = body.range
body_start = body_range[0] + 1
body_end = body_range[1] - 1
if property_definition.type == 'FunctionDeclaration':
function_name = property_definition.id.name
elif property_definition.type == 'MethodDefinition':
function_name = property_definition.key.name
elif property_definition.type == 'FunctionExpression' and \
property_definition.id is not None:
function_name = property_definition.id.name
else:
function_name = None
function_body = original_str[body_start:body_end]
arguments = []
if property_definition.type in ['MethodDefinition', 'Property']:
for item in property_definition.value.params:
if item.name:
arguments.append(item.name)
elif item.left.name and item.right.name:
arguments.append(f'{item.left.name}={item.right.name}')
else:
for item in property_definition.params:
if item.name:
arguments.append(item.name)
elif item.left.name and item.right.name:
arguments.append(f'{item.left.name}={item.right.name}')
return cls(function_name = function_name,
arguments = arguments,
body = function_body)
[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_function(as_str)
if parsed.body[0].type == 'FunctionDeclaration':
property_definition = parsed.body[0]
elif parsed.body[0].type == 'MethodDefinition':
property_definition = parsed.body[0].body[0]
elif parsed.body[0].type != 'FunctionDeclaration':
property_definition = parsed.body[0].declarations[0].init
return cls._convert_from_js_ast(property_definition, updated_str)
[docs] @classmethod
def from_python(cls,
callable,
model = 'gpt-3.5-turbo',
api_key = None,
**kwargs):
"""Return a :class:`CallbackFunction` having converted a Python callable into
a JavaScript function using the generative AI ``model`` indicated.
.. note::
Because this relies on the outside APIs exposed by
`OpenAI <https://www.openai.com/>`__ and `Anthropic <https://www.anthropic.com>`__,
if you wish to use one of their models you *must* supply your own API key.
These are paid services which they provide, and so you *will* be incurring
costs by using these generative AIs.
:param callable: The Python callable to convert.
:type callable: callable
:param model: The generative AI model to use.
Defaults to ``'gpt-3.5-turbo'``. Accepts:
* ``'gpt-3.5-turbo'`` (default)
* ``'gpt-3.5-turbo-16k'``
* ``'gpt-4'``
* ``'gpt-4-32k'``
* ``'claude-instant-1'``
* ``'claude-2'``
:type model: :class:`str <python:str>`
:param api_key: The API key used to authenticate against the
generative AI provider. Defaults to
:obj:`None <python:None>`, which then tries to find the API
key in the appropriate environment variable:
* ``OPENAI_API_KEY`` if using an
`OpenAI <https://www.openai.com/>`__ provided model
* ``ANTHROPIC_API_KEY`` if using an
`Anthropic <https://www.anthropic.com/>`__ provided model
:type api_key: :class:`str <python:str>` or :obj:`None <python:None>`
:param **kwargs: Additional keyword arguments which are passed to
the underlying model API. Useful for advanced configuration of
the model's behavior.
:returns: The ``CallbackFunction`` representation of the JavaScript
code that does the same as the ``callable`` argument.
.. warning::
Generating the JavaScript source code is *not* deterministic.
That means that it may not be correct, and we **STRONGLY**
recommend reviewing it before using it in a production
application.
Every single generative AI is known to have issues - whether
"hallucinations", biases, or incoherence. We cannot stress
enough:
**DO NOT RELY ON AI-GENERATED CODE IN PRODUCTION WITHOUT HUMAN REVIEW.**
That being said, for "quick and dirty" EDA, fast prototyping, etc.
the functionality may be "good enough".
:rtype: :class:`CallbackFunction <highcharts_core.utility_classes.javascript_functions.CallbackFunction>`
:raises HighchartsValueError: if ``callable`` is not a Python callable
:raises HighchartsValueError: if no ``api_key`` is available
:raises HighchartsDependencyError: if a required dependency is not
available in the runtime environment
:raises HighchartsModerationError: if using an OpenAI model, and
OpenAI detects that the supplied input violates their usage policies
:raises HighchartsPythonConversionError: if the model was unable to
convert ``callable`` into JavaScript source code
"""
js_str = ai.convert_to_js(callable, model, api_key, **kwargs)
try:
obj = cls.from_js_literal(js_str)
except errors.HighchartsParseError:
raise errors.HighchartsPythonConversionError(
f'The JavaScript function generated by model "{model}" '
f'failed to be validated as a proper JavaScript function. '
f'Please retry, or select a different model and retry.'
)
return obj
@classmethod
def _validate_js_function(cls,
as_str,
range = True,
_break_loop_on_failure = False):
"""Parse a JavaScript function from within ``as_str``.
:param as_str: A string that potentially contains a JavaScript function.
:rtype: :class:`str <python:str>`
:param range: If ``True``, include each node's ``loc`` and ``range`` in the AST
produced. Defaults to ``True``.
:type range: :class:`bool <python:bool>`
:param _break_loop_on_failure: If ``True``, prevents
:returns: 2-member tuple, with the first being a parsed AST of the function and
the second being the string that ultimatley produced that parsed AST.
:rtype: :class:`tuple <python:tuple>` of :class:`esprima.nodes.Script`,
: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 and as_str.startswith('function'):
as_str = f"""const testFunction = {as_str}"""
return cls._validate_js_function(as_str,
range = range,
_break_loop_on_failure = True)
elif not _break_loop_on_failure:
as_str = f"""const testFunction = function {as_str}"""
return cls._validate_js_function(as_str,
range = range,
_break_loop_on_failure = True)
else:
raise errors.HighchartsParseError('._validate_js_function() expects '
'a str containing a valid '
'JavaScript function. Could not '
'find a valid function.')
return parsed, as_str
[docs]class JavaScriptClass(HighchartsMeta):
"""Representation of a JavaScript class."""
def __init__(self, **kwargs):
self._class_name = None
self._methods = None
self.class_name = kwargs.get('class_name', None)
self.methods = kwargs.get('methods', None)
def __str__(self) -> str:
if not self.class_name:
raise errors.HighchartsMissingClassNameError('Unable to serialize. The '
'JavaScriptClass instance has '
'no class_name provided.')
as_str = f'class {self.class_name} '
as_str += '{\n'
for method in self.methods or []:
method_str = f'{method.function_name}'
argument_str = '('
for argument in method.arguments or []:
argument_str += f'{argument},'
if method.arguments:
argument_str = argument_str[:-1]
argument_str += ') {\n'
method_str += argument_str
method_str += method.body + '\n}\n'
as_str += method_str
as_str += '}'
return as_str
@property
def class_name(self) -> Optional[str]:
"""The name of the JavaScript class.
:rtype: :class:`str <python:str>` or :obj:`None <python:None>`
"""
return self._class_name
@class_name.setter
def class_name(self, value):
self._class_name = validators.variable_name(value, allow_empty = True)
@property
def methods(self) -> Optional[List[CallbackFunction]]:
"""Collection of methods that are to be defined within the class. Defaults to
:obj:`None <python:None>`.
.. warning::
All methods *must* have a :meth:`function_name <CallbackFunction.function_name>`
set.
.. warning::
One of the methods *must* have a
:meth:`function_name <CallbackFunction.function_name>` of ``'constructor'`` and
be used as a constructor for the class.
.. note::
For the sake of simplicity, the :class:`JavaScriptClass` does not support
ECMAScript's more robust public/private field declaration syntax, nor does it
support the definition of getters or generators.
:rtype: :class:`list <python:list>` of :class:`CallbackFunction`, or
:obj:`None <python:None>`
:raises HighchartsJavaScriptError: if one or more methods lacks a function name OR
if there is no ``constructor`` method included in
:meth:`.methods <JavaScriptClass.methods>`.
"""
return self._methods
@methods.setter
def methods(self, value):
if not value:
self._methods = None
else:
value = validate_types(value,
types = CallbackFunction,
force_iterable = True)
has_constructor = False
for method in value:
if not method.function_name:
raise errors.HighchartsJavaScriptError('All JavaScriptClass methods '
'require a function name.')
if method.function_name == 'constructor':
has_constructor = True
if not has_constructor:
raise errors.HighchartsJavaScriptError('A JavaScriptClass requires at '
'least one "constructor" method. '
'Yours had none.')
self._methods = value
@classmethod
def _get_kwargs_from_dict(cls, as_dict):
kwargs = {
'class_name': as_dict.get('className', None),
'methods': as_dict.get('methods', None)
}
return kwargs
def _to_untrimmed_dict(self, in_cls = None) -> dict:
return {
'className': self.class_name,
'methods': self.methods
}
@classmethod
def _convert_from_js_ast(cls, definition, original_str):
"""Create a :class:`JavaScriptClass` instance from a
:class:`esprima.nodes.ClassDeclaration` instance.
:param property_definition: The :class:`esprima.nodes.ClassDeclaration` instance,
including ``loc`` (indicating the line and column in the original string) and
``range`` (indicating the character range for the property definition in the
original string).
:type property_definition: :class:`esprima.nodes.ClassDeclaration`
:param original_str: The original :class:`str <python:str>` of the JavaScript from
which ``definition`` was parsed.
:type original_str: :class:`str <python:str>`
:returns: :class:`JavaScriptClass`
"""
if not checkers.is_type(definition, ('ClassDeclaration', 'ClassExpression')):
raise errors.HighchartsParseError(f'definition should contain a '
f'ClassDeclaration or ClassExpression'
' instance. Received: '
f'{definition.__class__.__name__}')
class_name = definition.id.name
method_definitions = [x for x in definition.body.body]
method_strings = []
for method in method_definitions:
method_start = method.range[0]
method_end = method.range[1]
method_string = original_str[method_start:method_end]
method_strings.append(method_string)
methods = [CallbackFunction.from_js_literal(x) for x in method_strings]
return cls(class_name = class_name,
methods = methods)
[docs] @classmethod
def from_js_literal(cls,
as_str_or_file):
"""Return a Python object representation of a JavaScript class.
: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 _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
try:
parsed = esprima.parseScript(as_str, range = True)
except ParseError:
try:
parsed = esprima.parseModule(as_str, range = True)
except ParseError:
raise errors.HighchartsParseError('unable to find a JavaScript class '
'declaration in ``as_str``.')
definition = parsed.body[0]
return cls._convert_from_js_ast(definition, as_str)
[docs] def to_js_literal(self,
filename = None,
encoding = 'utf-8',
careful_validation = False) -> str:
if filename:
filename = validators.path(filename)
as_str = str(self)
if filename:
with open(filename, 'w', encoding = encoding) as file_:
file_.write(as_str)
return as_str
[docs]class VariableName(HighchartsMeta):
"""Object that represents a (JavaScript) variable name that may be referenced in
**Highcharts for Python** items."""
def __init__(self, **kwargs):
self._variable_name = None
self.variable_name = kwargs.get('variable_name', None)
@property
def variable_name(self) -> Optional[str]:
"""The name of the (JavaScript) variable which will be incorporated into
serializations of **Highcharts for Python** objects as needed.
:rtype: :class:`str <python:str>`
"""
return self._variable_name
@variable_name.setter
def variable_name(self, value):
self._variable_name = validators.variable_name(value, allow_empty = True)
@classmethod
def _get_kwargs_from_dict(cls, as_dict):
kwargs = {
'variable_name': as_dict.get('variable_name', as_dict.get('variableName',
None)),
}
return kwargs
def _to_untrimmed_dict(self, in_cls = None) -> dict:
untrimmed = {
'variableName': self.variable_name,
}
return untrimmed