Source code for highcharts_core.utility_classes.javascript_functions

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