diff --git a/.gitignore b/.gitignore index 05881264..a0cf18ba 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ dmypy.json # temp files ~$*.* tests/input_files/headless_export/output/ + +# VSCode Settings +.vscode/ \ No newline at end of file diff --git a/CHANGES.rst b/CHANGES.rst index 0c226094..6ac47291 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,21 @@ +Release 1.9.0 +========================================= + +* **BUGFIX:** Fixed missing serialization/de-serialization of ``ChartEvents.render``. +* **BUGFIX:** Added new ``utility_classes.data_labels.PieDataLabel`` class to ensure support for + the ``.distance`` property on Pie-chart (and descended) data labels. Closes #183. +* **BUGFIX:** Fixed ``options.chart.height`` type validation to accept string values as per JS API. + (Issue reported in #184). +* **BUGFIX:** Added missing support for ``options.plot_options.sunburst.SunburstOptions.border_radius`` + (issue reported in #184). +* **BUGFIX:** Added support for concatenation via `+` operator in JS literal strings when parsed by + ``.from_js_literal()``. Closes #185. +* **ENHANCEMENT:** Added support for ``utility_classes.border_radius.BorderRadius`` object. + +---- + + Release 1.8.2 ========================================= diff --git a/docs/api.rst b/docs/api.rst index 87409468..80e9ff99 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -622,6 +622,8 @@ Core Components :class:`ASTNode ` :class:`TextPath ` :class:`AttributeObject ` + * - :mod:`.utility_classes.border_radius ` + - :class:`BorderRadius ` * - :mod:`.utility_classes.breadcrumbs ` - :class:`BreadcrumbOptions ` :class:`Separator ` @@ -638,6 +640,7 @@ Core Components - :class:`DataGroupingOptions ` * - :mod:`.utility_classes.data_labels ` - :class:`DataLabel ` + :class:`PieDataLabel ` :class:`SunburstDataLabel ` :class:`OrganizationDataLabel ` :class:`NodeDataLabel ` diff --git a/docs/api/utility_classes/border_radius.rst b/docs/api/utility_classes/border_radius.rst new file mode 100644 index 00000000..adb8120f --- /dev/null +++ b/docs/api/utility_classes/border_radius.rst @@ -0,0 +1,29 @@ +########################################################################################## +:mod:`.border_radius ` +########################################################################################## + +.. contents:: Module Contents + :local: + :depth: 3 + :backlinks: entry + +-------------- + +.. module:: highcharts_core.utility_classes.border_radius + +******************************************************************************************************************** +class: :class:`BorderRadius ` +******************************************************************************************************************** + +.. autoclass:: BorderRadius + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: BorderRadius + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + diff --git a/docs/api/utility_classes/data_labels.rst b/docs/api/utility_classes/data_labels.rst index 7f110cf8..47ece5fd 100644 --- a/docs/api/utility_classes/data_labels.rst +++ b/docs/api/utility_classes/data_labels.rst @@ -47,6 +47,24 @@ class: :class:`OrganizationDataLabel ` +******************************************************************************************************************** + +.. autoclass:: PieDataLabel + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: PieDataLabel + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +----------------- + ******************************************************************************************************************** class: :class:`SunburstDataLabel ` ******************************************************************************************************************** diff --git a/docs/api/utility_classes/index.rst b/docs/api/utility_classes/index.rst index ca472ef4..909f65b9 100644 --- a/docs/api/utility_classes/index.rst +++ b/docs/api/utility_classes/index.rst @@ -53,6 +53,8 @@ Sub-components :class:`ASTNode ` :class:`TextPath ` :class:`AttributeObject ` + * - :mod:`.utility_classes.border_radius ` + - :class:`BorderRadius ` * - :mod:`.utility_classes.breadcrumbs ` - :class:`BreadcrumbOptions ` :class:`Separator ` @@ -68,6 +70,7 @@ Sub-components - :class:`DataGroupingOptions ` * - :mod:`.utility_classes.data_labels ` - :class:`DataLabel ` + :class:`PieDataLabel ` :class:`SunburstDataLabel ` :class:`OrganizationDataLabel ` :class:`NodeDataLabel ` diff --git a/highcharts_core/__version__.py b/highcharts_core/__version__.py index aa1a8c4a..e5102d30 100644 --- a/highcharts_core/__version__.py +++ b/highcharts_core/__version__.py @@ -1 +1 @@ -__version__ = '1.8.2' +__version__ = '1.9.0' diff --git a/highcharts_core/js_literal_functions.py b/highcharts_core/js_literal_functions.py index e2dbfc8e..483de24b 100644 --- a/highcharts_core/js_literal_functions.py +++ b/highcharts_core/js_literal_functions.py @@ -581,6 +581,25 @@ def convert_js_property_to_python(property_definition, original_str = None): elif property_definition.value.type == 'ClassExpression': return JavaScriptClass._convert_from_js_ast(property_definition.value, original_str) + elif property_definition.value.type == 'BinaryExpression': + property_value = property_definition.value + operator = property_value.operator + left, right = property_value.left, property_value.right + left_type, right_type = left.type, right.type + + if (left_type not in ['Literal']) or (right_type not in ['Literal']): + raise errors.HighchartsParseError(f'unable to find two Literal values within' + f'a Binary expression. Found: ' + f'{left_type, right_type}') + + left_value, right_value = left.value, right.value + if operator not in ['+', '-', '/']: + raise errors.HighchartsParseError(f'operator "{operator}" not supported within ' + f'Binary expression parsing') + left_value = validators.string(left_value, allow_empty = False) + right_value = validators.string(right_value, allow_empty = False) + + return left_value + right_value elif property_definition.value.type == 'CallExpression': expression = property_definition.value try: @@ -650,6 +669,7 @@ def convert_js_to_python(javascript, original_str = None): 'ObjectExpression', 'ArrayExpression', 'UnaryExpression', + 'BinaryExpression', 'FunctionExpression'): raise errors.HighchartsParseError(f'javascript should contain a ' f'Property, Literal, ObjectExpression, ' diff --git a/highcharts_core/options/chart/__init__.py b/highcharts_core/options/chart/__init__.py index 2ec62b8e..7d5698a6 100644 --- a/highcharts_core/options/chart/__init__.py +++ b/highcharts_core/options/chart/__init__.py @@ -523,7 +523,7 @@ def height(self, value): self._height = validators.numeric(value, allow_empty = False, minimum = 0) - except ValueError: + except (ValueError, TypeError): self._height = validators.string(value, allow_empty = False) except ValueError: diff --git a/highcharts_core/options/plot_options/bar.py b/highcharts_core/options/plot_options/bar.py index 7d9407e7..2c9dddca 100644 --- a/highcharts_core/options/plot_options/bar.py +++ b/highcharts_core/options/plot_options/bar.py @@ -10,6 +10,7 @@ from highcharts_core.utility_classes.patterns import Pattern from highcharts_core.utility_classes.data_grouping import DataGroupingOptions from highcharts_core.utility_classes.partial_fill import PartialFillOptions +from highcharts_core.utility_classes.border_radius import BorderRadius class BaseBarOptions(SeriesOptions): @@ -61,7 +62,7 @@ def border_color(self, value): self._border_color = utility_functions.validate_color(value) @property - def border_radius(self) -> Optional[int | float | Decimal]: + def border_radius(self) -> Optional[int | float | Decimal | str | BorderRadius]: """The corner radius of the border surrounding each column or bar. Defaults to ``0``. @@ -71,9 +72,28 @@ def border_radius(self) -> Optional[int | float | Decimal]: @border_radius.setter def border_radius(self, value): - self._border_radius = validators.numeric(value, - allow_empty = True, - minimum = 0) + if value is None: + self._border_radius = None + else: + try: + self._border_radius = validators.numeric(value, + allow_empty = True, + minimum = 0) + except (ValueError, TypeError): + try: + self._border_radius = validate_types(value, BorderRadius) + except (ValueError, TypeError): + if not isinstance(value, str): + raise errors.HighchartsValueError(f'border_radius must be a numeric value, ' + f'a string, or an instance of BorderRadius. ' + f'Received {value.__class__.__name__}.') + if not value.endswith(('%', 'px', 'em')): + raise errors.HighchartsValueError(f'border_radius must be a numeric value, ' + f'a percentage string, a pixel measurement, ' + f'or an instance of BorderRadius. ' + f'Received: "{value}".') + + self._border_radius = value @property def border_width(self) -> Optional[int | float | Decimal]: diff --git a/highcharts_core/options/plot_options/pie.py b/highcharts_core/options/plot_options/pie.py index 679eb5d3..488e66ae 100644 --- a/highcharts_core/options/plot_options/pie.py +++ b/highcharts_core/options/plot_options/pie.py @@ -1,12 +1,15 @@ from typing import Optional, List from decimal import Decimal -from validator_collection import validators +from validator_collection import validators, checkers from highcharts_core import constants, errors, utility_functions from highcharts_core.options.plot_options.generic import GenericTypeOptions from highcharts_core.utility_classes.gradients import Gradient from highcharts_core.utility_classes.patterns import Pattern +from highcharts_core.utility_classes.data_labels import PieDataLabel +from highcharts_core.utility_classes.border_radius import BorderRadius +from highcharts_core.decorators import validate_types class PieOptions(GenericTypeOptions): @@ -93,7 +96,7 @@ def border_color(self, value): self._border_color = utility_functions.validate_color(value) @property - def border_radius(self) -> Optional[str | int | float | Decimal]: + def border_radius(self) -> Optional[str | int | float | Decimal | BorderRadius]: """ .. versionadded:: Highcharts Core for Python v.1.1.0 / Highcharts Core (JS) v.11.0.0 @@ -104,7 +107,9 @@ def border_radius(self) -> Optional[str | int | float | Decimal]: A numerical value signifies the value is expressed in pixels. A percentage string like `50%` signifies a size relative to the radius and the inner radius. - :rtype: numeric, :class:`str ` or :obj:`None ` + :rtype: numeric, :class:`str `, + :class:`BorderRadius ` or + :obj:`None ` """ return self._border_radius @@ -114,11 +119,14 @@ def border_radius(self, value): self._border_radius = None else: try: - value = validators.string(value) - if '%' not in value: - raise ValueError - except (TypeError, ValueError): - value = validators.numeric(value, minimum = 0) + value = validate_types(value, types = BorderRadius) + except (ValueError, TypeError): + try: + value = validators.string(value) + if '%' not in value: + raise ValueError + except (TypeError, ValueError): + value = validators.numeric(value, minimum = 0) self._border_radius = value @@ -265,6 +273,35 @@ def colors(self, value): value = validators.iterable(value) self._colors = [utility_functions.validate_color(x) for x in value] + @property + def data_labels(self) -> Optional[PieDataLabel | List[PieDataLabel]]: + """Options for the series data labels, appearing next to each data point. + + .. note:: + + To have multiple data labels per data point, you can also supply a collection of + :class:`DataLabel` configuration settings. + + :rtype: :class:`PieDataLabel`, :class:`list ` of :class:`PieDataLabel`, or + :obj:`None ` + """ + return self._data_labels + + @data_labels.setter + def data_labels(self, value): + if not value: + self._data_labels = None + else: + if checkers.is_iterable(value): + self._data_labels = validate_types(value, + types = PieDataLabel, + allow_none = False, + force_iterable = True) + else: + self._data_labels = validate_types(value, + types = PieDataLabel, + allow_none = False) + @property def depth(self) -> Optional[int | float | Decimal]: """The thickness of a 3D pie. Defaults to ``0``. diff --git a/highcharts_core/options/plot_options/sunburst.py b/highcharts_core/options/plot_options/sunburst.py index e470e6b7..78cf3a7e 100644 --- a/highcharts_core/options/plot_options/sunburst.py +++ b/highcharts_core/options/plot_options/sunburst.py @@ -12,6 +12,7 @@ from highcharts_core.utility_classes.breadcrumbs import BreadcrumbOptions from highcharts_core.utility_classes.shadows import ShadowOptions from highcharts_core.utility_classes.data_labels import SunburstDataLabel +from highcharts_core.utility_classes.border_radius import BorderRadius class SunburstOptions(GenericTypeOptions): @@ -28,6 +29,7 @@ class SunburstOptions(GenericTypeOptions): """ def __init__(self, **kwargs): + self._border_radius = None self._color_index = None self._crisp = None self._shadow = None @@ -46,6 +48,7 @@ def __init__(self, **kwargs): self._sliced_offset = None self._start_angle = None + self.border_radius = kwargs.get('border_radius', None) self.color_index = kwargs.get('color_index', None) self.crisp = kwargs.get('crisp', None) self.shadow = kwargs.get('shadow', None) @@ -117,6 +120,35 @@ def border_width(self, value): allow_empty = True, minimum = 0) + @property + def border_radius(self) -> Optional[int | float | Decimal | str | BorderRadius]: + """The corner radius of the border surrounding each column or bar. Defaults to + ``0``. + + :rtype: numeric or :obj:`None ` + """ + return self._border_radius + + @border_radius.setter + def border_radius(self, value): + if value is None: + self._border_radius = None + else: + try: + self._border_radius = validators.numeric(value, + allow_empty = True, + minimum = 0) + except (ValueError, TypeError): + try: + self._border_radius = validate_types(value, BorderRadius) + except (ValueError, TypeError): + if not isinstance(value, str): + raise errors.HighchartsValueError(f'border_radius must be a numeric value, ' + f'a string, or an instance of BorderRadius. ' + f'Received {value.__class__.__name__}.') + + self._border_radius = value + @property def breadcrumbs(self) -> Optional[BreadcrumbOptions]: """Options for the breadcrumbs, the navigation at the top leading the way up @@ -468,6 +500,7 @@ def _get_kwargs_from_dict(cls, as_dict): 'allow_traversing_tree': as_dict.get('allowTraversingTree', None), 'border_color': as_dict.get('borderColor', None), 'border_width': as_dict.get('borderWidth', None), + 'border_radius': as_dict.get('borderRadius', None), 'breadcrumbs': as_dict.get('breadcrumbs', None), 'center': as_dict.get('center', None), 'color_by_point': as_dict.get('colorByPoint', None), @@ -491,6 +524,7 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: 'allowTraversingTree': self.allow_traversing_tree, 'borderColor': self.border_color, 'borderWidth': self.border_width, + 'borderRadius': self.border_radius, 'breadcrumbs': self.breadcrumbs, 'center': self.center, 'colorByPoint': self.color_by_point, diff --git a/highcharts_core/utility_classes/border_radius.py b/highcharts_core/utility_classes/border_radius.py new file mode 100644 index 00000000..de88b7f6 --- /dev/null +++ b/highcharts_core/utility_classes/border_radius.py @@ -0,0 +1,120 @@ +from typing import Optional +from decimal import Decimal + +from validator_collection import validators + +from highcharts_core import errors +from highcharts_core.decorators import class_sensitive +from highcharts_core.metaclasses import HighchartsMeta + + +class BorderRadius(HighchartsMeta): + """Precise configuration of Border Radius behavior.""" + + def __init__(self, **kwargs): + self._radius = None + self._scope = None + self._where = None + + self.radius = kwargs.get('radius', None) + self.scope = kwargs.get('scope', None) + self.where = kwargs.get('where', None) + + @property + def radius(self) -> Optional[str | int | float | Decimal]: + """The border radius. + + A number signifies pixels. + + A percentage string, like for example 50%, signifies a relative size. + + .. note:: + + For columns this is relative to the column width, for pies it is relative to the radius + and the inner radius. + + :rtype: numeric or :class:`str ` or :obj:`None ` + """ + return self._radius + + @radius.setter + def radius(self, value): + if value is None: + self._radius = None + else: + try: + self._radius = validators.numeric(value, allow_empty = None) + except (ValueError, TypeError): + if not isinstance(value, str): + raise errors.HighchartsValueError(f'radius expects a number or string. ' + f'Received: {value.__class__.__name__}') + self._radius = value + + @property + def scope(self) -> Optional[str]: + """The scope of the rounding for column charts. + + .. note:: + + In a stacked column chart: + + * the value ``'point'`` means each single point will get rounded corners + * the value ``'stack'`` means the rounding will apply to the full stack, so that + only points close to the top or bottom will receive rounding. + + :rtype: :class:`str ` or :obj:`None ` + """ + return self._scope + + @scope.setter + def scope(self, value): + if not value: + self._scope = None + else: + value = value.lower() + if value not in ['point', 'stack']: + raise errors.HighchartsValueError(f'scope expects either "point" or "stack". ' + f'Received: {value}') + self._scope = value + + @property + def where(self) -> Optional[str]: + """For column charts, where in the point or stack to apply rounding. + + * The value ``'end'`` means only those corners at the point value will be rounded, + leaving the corners at the base or threshold unrounded. This is the most intuitive behaviour. + * The value ``'all'`` means the base will be rounded, in addition to the corners at the point value. + + :rtype: :class:`str ` or :obj:`None ` + """ + return self._where + + @where.setter + def where(self, value): + if not value: + self._where = None + else: + value = value.lower() + if value not in ['end', 'all']: + raise errors.HighchartsValueError(f'where expects either "end" or "all". ' + f'Received: {value}') + self._where = value + + @classmethod + def _get_kwargs_from_dict(cls, as_dict): + kwargs = { + 'radius': as_dict.get('radius', None), + 'scope': as_dict.get('scope', None), + 'where': as_dict.get('where', None) + } + + return kwargs + + def _to_untrimmed_dict(self, in_cls = None) -> dict: + untrimmed = { + 'radius': self.radius, + 'scope': self.scope, + 'where': self.where + } + + return untrimmed diff --git a/highcharts_core/utility_classes/data_labels.py b/highcharts_core/utility_classes/data_labels.py index c3f4898b..80d86d6b 100644 --- a/highcharts_core/utility_classes/data_labels.py +++ b/highcharts_core/utility_classes/data_labels.py @@ -914,6 +914,102 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class PieDataLabel(DataLabel): + """Variant of :class:`DataLabel` used for pie (and related) series.""" + + def __init__(self, **kwargs): + self._distance = None + + self.distance = kwargs.get('distance', None) + + super().__init__(**kwargs) + + @property + def distance(self) -> Optional[int | float | Decimal | str]: + """The distance of the data label from the pie's edge. + + .. note:: + + Negative numbers put the data label on top of the pie slices. + + .. tip:: + + Can also be defined as a percentage of pie's radius. + + .. warning:: + + Connectors are only shown for data labels outside the pie. + + :rtype: numeric or :class:`str ` or :obj:`None ` + """ + return self._distance + + @distance.setter + def distance(self, value): + if not value: + self._distance = None + else: + try: + value = validators.numeric(value, allow_empty = False) + except (ValueError, TypeError): + if not isinstance(value, str): + raise errors.HighchartsValueError(f'distance must be a number or a string, but received ' + f'{type(value).__name__}.') + + self._distance = value + + @classmethod + def _get_kwargs_from_dict(cls, as_dict): + kwargs = { + 'align': as_dict.get('align', None), + 'allow_overlap': as_dict.get('allowOverlap', None), + 'animation': as_dict.get('animation', None), + 'background_color': as_dict.get('backgroundColor', None), + 'border_color': as_dict.get('borderColor', None), + 'border_radius': as_dict.get('borderRadius', None), + 'border_width': as_dict.get('borderWidth', None), + 'class_name': as_dict.get('className', None), + 'color': as_dict.get('color', None), + 'crop': as_dict.get('crop', None), + 'defer': as_dict.get('defer', None), + 'enabled': as_dict.get('enabled', None), + 'filter': as_dict.get('filter', None), + 'format': as_dict.get('format', None), + 'formatter': as_dict.get('formatter', None), + 'inside': as_dict.get('inside', None), + 'null_format': as_dict.get('nullFormat', None), + 'null_formatter': as_dict.get('nullFormatter', None), + 'overflow': as_dict.get('overflow', None), + 'padding': as_dict.get('padding', None), + 'position': as_dict.get('position', None), + 'rotation': as_dict.get('rotation', None), + 'shadow': as_dict.get('shadow', None), + 'shape': as_dict.get('shape', None), + 'style': as_dict.get('style', None), + 'text_path': as_dict.get('textPath', None), + 'use_html': as_dict.get('useHTML', None), + 'vertical_align': as_dict.get('verticalAlign', None), + 'x': as_dict.get('x', None), + 'y': as_dict.get('y', None), + 'z': as_dict.get('z', None), + + 'distance': as_dict.get('distance', None), + } + + return kwargs + + def _to_untrimmed_dict(self, in_cls = None) -> dict: + untrimmed = { + 'distance': self.distance, + } + + parent_as_dict = super()._to_untrimmed_dict(in_cls = in_cls) or {} + for key in parent_as_dict: + untrimmed[key] = parent_as_dict[key] + + return untrimmed + + class SunburstDataLabel(DataLabel): """Variant of :class:`DataLabel` used for :term:`sunburst` series.""" diff --git a/highcharts_core/utility_classes/events.py b/highcharts_core/utility_classes/events.py index b3127b09..1146d066 100644 --- a/highcharts_core/utility_classes/events.py +++ b/highcharts_core/utility_classes/events.py @@ -233,6 +233,20 @@ def load(self) -> Optional[CallbackFunction]: def load(self, value): self._load = value + @property + def render(self) -> Optional[CallbackFunction]: + """JavaScript callback function that fires when the chart is initially loaded + (directly after the ``load`` event), and after each redraw (directly after the ``redraw`` event). + + :rtype: :class:`CallbackFunction` or :obj:`None ` + """ + return self._render + + @render.setter + @class_sensitive(CallbackFunction) + def render(self, value): + self._render = value + @property def redraw(self) -> Optional[CallbackFunction]: """JavaScript callback function that fires when the chart is redrawn, either after @@ -300,6 +314,7 @@ def _get_kwargs_from_dict(cls, as_dict): 'fullscreen_open': as_dict.get('fullscreenOpen', None), 'load': as_dict.get('load', None), 'redraw': as_dict.get('redraw', None), + 'render': as_dict.get('render', None), 'selection': as_dict.get('selection', None) } @@ -319,6 +334,7 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: 'fullscreenOpen': self.fullscreen_open, 'load': self.load, 'redraw': self.redraw, + 'render': self.render, 'selection': self.selection } diff --git a/tests/options/chart/test_chart.py b/tests/options/chart/test_chart.py index e939f817..b65c9396 100644 --- a/tests/options/chart/test_chart.py +++ b/tests/options/chart/test_chart.py @@ -12,6 +12,9 @@ STANDARD_PARAMS = [ ({}, None), + ({ + 'height': '60%' + }, None), ({ 'align_thresholds': True, 'align_ticks': True, diff --git a/tests/test_js_literal_functions.py b/tests/test_js_literal_functions.py index 7d39ca9f..46d075e3 100644 --- a/tests/test_js_literal_functions.py +++ b/tests/test_js_literal_functions.py @@ -139,7 +139,7 @@ def test_get_key_value_pairs(original_str, override, expected, error): ("""const testObj = {item1:'test string'}""", None, 'test string', None), ("""const testObj = {item1:undefined}""", None, None, None), ("""const testObj = {item1:null}""", None, constants.EnforcedNull, None), - + ('', 'not-a-literal', None, errors.HighchartsParseError), ]) def test_convert_js_literal_to_python(original_str, override, expected, error): @@ -170,6 +170,7 @@ def test_convert_js_literal_to_python(original_str, override, expected, error): ("""const testObj = {item1:null}""", None, constants.EnforcedNull, None), ("""const testObj = {item1:[1,2]}""", None, [1,2], None), ("""const testObj = {item1:{subitem:'test'}}""", None, {'subitem': 'test'}, None), + ("""const testObj = {item1:'abc' + 'def'}""", None, 'abcdef', None), ('', 'not-a-property', None, errors.HighchartsParseError), ])