diff --git a/.gitignore b/.gitignore index 756f1e6d..05881264 100644 --- a/.gitignore +++ b/.gitignore @@ -109,7 +109,7 @@ venv/ ENV/ env.bak/ venv.bak/ -.py310/ +.py31*/ # Spyder project settings .spyderproject diff --git a/.travis.yml b/.travis.yml index fcfc7d39..7e2a5ceb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,6 @@ language: python job: include: - #- python: "3.8" - # env: TOXENV=py38 - #- python: "3.9" - # env: TOXENV=py39 - python: "3.10" dist: focal env: TOXENV=py310 diff --git a/CHANGES.rst b/CHANGES.rst index 05cef5f7..28a31cae 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,29 @@ +Release 1.4.0 +========================================= + +* **MAJOR** performance gains in the ``.to_js_literal()`` method. Implementation seems to + improve performance by 50 - 90%. (#51) +* *SIGNIFICANT* performance gains in the ``.to_json()`` method. Implementation seems to + improve performance by 30 - 90%. +* **ENHANCEMENT:** Significantly simplified use of the ``.from_pandas()`` method to support: + + * creation of multiple series from one DataFrame in one method call + * creation of series without needing to specify a full property map + * support for creating series by DataFrame row, rather than just by DataFrame column + +* **ENHANCEMENT:** Added the ``.from_pandas_in_rows()`` method to support creation of + charts and series from simple two-dimensional DataFrames laid out in rows. +* **ENHANCEMENT:** Added one-shot chart creation and rendering from Series objects (#89). +* **ENHANCEMENT:** Added one-shot chart creation using ``series`` and ``data``/``series_type`` keywords. (#90). +* **ENHANCEMENT:** Added ``.convert_to()`` convenience method to Series objects (#107). +* **ENHANCEMENT:** Added ``CallbackFunction.from_python()`` method which converts a Python function + to its JavaScript equivalent using generative AI, with support for both OpenAI and Anthropic (#109). +* **BUGFIX:** Fixed instability issues in Jupyter Notebooks, both when operating as a Notebook (outside of + Jupyter Lab) and when saved to a static HTML file (#66). + +-------------------- + Release 1.3.7 ========================================= diff --git a/README.rst b/README.rst index 9f2f2fdf..9f5970ba 100644 --- a/README.rst +++ b/README.rst @@ -212,49 +212,53 @@ Hello World, and Basic Usage .. code-block:: python + # from a primitive array, using keyword arguments + my_chart = Chart(data = [[1, 23], [2, 34], [3, 45]], + series_type = 'line') + + # from a primitive array, using the .from_array() method + my_chart = Chart.from_array([[1, 23], [2, 34], [3, 45]], + series_type = 'line') + + # from a Numpy ndarray, using keyword arguments + my_chart = Chart(data = numpy_array, series_type = 'line') + + # from a Numpy ndarray, using the .from_array() method + my_chart = Chart.from_array(data = numpy_array, series_type = 'line') + # from a JavaScript file - my_chart = highcharts.Chart.from_js_literal('my_js_literal.js') + my_chart = Chart.from_js_literal('my_js_literal.js') # from a JSON file - my_chart = highcharts.Chart.from_json('my_json.json') + my_chart = Chart.from_json('my_json.json') # from a Python dict - my_chart = highcharts.Chart.from_dict(my_dict_obj) + my_chart = Chart.from_dict(my_dict_obj) # from a Pandas dataframe - my_chart = highcharts.Chart.from_pandas(df, - property_map = { - 'x': 'transactionDate', - 'y': 'invoiceAmt', - 'id': 'id' - }, - series_type = 'line') + my_chart = Chart.from_pandas(df) # from a PySpark dataframe - my_chart = highcharts.Chart.from_pyspark(df, - property_map = { - 'x': 'transactionDate', - 'y': 'invoiceAmt', - 'id': 'id' - }, - series_type = 'line') + my_chart = Chart.from_pyspark(df, + property_map = { + 'x': 'transactionDate', + 'y': 'invoiceAmt', + 'id': 'id' + }, + series_type = 'line') # from a CSV - my_chart = highcharts.Chart.from_csv('/some_file_location/filename.csv' - column_property_map = { - 'x': 0, - 'y': 4, - 'id': 14 - }, - series_type = 'line') + my_chart = Chart.from_csv('/some_file_location/filename.csv') # from a HighchartsOptions configuration object - my_chart = highcharts.Chart.from_options(my_options) - - # from a Series configuration - my_chart = highcharts.Chart.from_series(my_series) + my_chart = Chart.from_options(my_options) + # from a Series configuration, using keyword arguments + my_chart = Chart(series = my_series) + # from a Series configuration, using .from_series() + my_chart = Chart.from_series(my_series) + 3. Configure Global Settings (optional) ============================================= @@ -284,6 +288,7 @@ Hello World, and Basic Usage from highcharts_core.options.title import Title from highcharts_core.options.credits import Credits + # EXAMPLE 1. # Using dicts my_chart.title = { 'align': 'center' @@ -294,7 +299,7 @@ Hello World, and Basic Usage my_chart.credits = { 'enabled': True, - 'href': 'https://www.highcharts.com/', + 'href': 'https://www.highchartspython.com/', 'position': { 'align': 'center', 'vertical_align': 'bottom', @@ -309,17 +314,21 @@ Hello World, and Basic Usage 'text': 'Chris Modzelewski' } + # EXAMPLE 2. # Using direct objects from highcharts_core.options.title import Title from highcharts_core.options.credits import Credits - my_title = Title(text = 'The Title for My Chart', floating = True, align = 'center') + my_title = Title(text = 'The Title for My Chart', + floating = True, + align = 'center') my_chart.options.title = my_title - my_credits = Credits(text = 'Chris Modzelewski', enabled = True, href = 'https://www.highcharts.com') + my_credits = Credits(text = 'Chris Modzelewski', + enabled = True, + href = 'https://www.highchartspython.com') my_chart.options.credits = my_credits - 5. Generate the JavaScript Code for Your Chart ================================================= @@ -328,9 +337,11 @@ that will render the chart wherever it is you want it to go: .. code-block:: python + # EXAMPLE 1. # as a string js_as_str = my_chart.to_js_literal() + # EXAMPLE 2. # to a file (and as a string) js_as_str = my_chart.to_js_literal(filename = 'my_target_file.js') @@ -359,6 +370,14 @@ that will render the chart wherever it is you want it to go: my_image_bytes = my_chart.download_chart(filename = 'my_target_file.png', format = 'png') + +8. Render Your Chart in a Jupyter Notebook +=============================================== + + .. code-block:: python + + my_chart.display() + -------------- *********************** diff --git a/docs/_dependencies.rst b/docs/_dependencies.rst index 230dc555..87d834fb 100644 --- a/docs/_dependencies.rst +++ b/docs/_dependencies.rst @@ -56,11 +56,14 @@ $ pip install highcharts-core[soft] * `IPython `__ v. 8.10 or higher + * `Jupyter Notebook `__ v.6.4 or higher * `orjson `__ v.3.7.7 or higher + * `NumPy `__ v.1.19.3 or higher * `pandas `_ v. 1.3 or higher * `pyspark `_ v.3.3 or higher + .. tab:: Developer .. warning:: diff --git a/docs/_static/census-time-series.png b/docs/_static/census-time-series.png new file mode 100644 index 00000000..453a0004 Binary files /dev/null and b/docs/_static/census-time-series.png differ diff --git a/docs/_static/highcharts-chart-anatomy.png b/docs/_static/highcharts-chart-anatomy.png new file mode 100644 index 00000000..60ec37b6 Binary files /dev/null and b/docs/_static/highcharts-chart-anatomy.png differ diff --git a/docs/_static/tutorials/census-time-series-01.png b/docs/_static/tutorials/census-time-series-01.png new file mode 100644 index 00000000..8b0d3088 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-01.png differ diff --git a/docs/_static/tutorials/census-time-series-02.png b/docs/_static/tutorials/census-time-series-02.png new file mode 100644 index 00000000..19135f62 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-02.png differ diff --git a/docs/_static/tutorials/census-time-series-03.png b/docs/_static/tutorials/census-time-series-03.png new file mode 100644 index 00000000..c268ac37 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-03.png differ diff --git a/docs/_static/tutorials/census-time-series-04.png b/docs/_static/tutorials/census-time-series-04.png new file mode 100644 index 00000000..16ed17bd Binary files /dev/null and b/docs/_static/tutorials/census-time-series-04.png differ diff --git a/docs/_static/tutorials/census-time-series-05.png b/docs/_static/tutorials/census-time-series-05.png new file mode 100644 index 00000000..98414fe3 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-05.png differ diff --git a/docs/_static/tutorials/census-time-series-06.png b/docs/_static/tutorials/census-time-series-06.png new file mode 100644 index 00000000..07627cc4 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-06.png differ diff --git a/docs/_static/tutorials/census-time-series-07.png b/docs/_static/tutorials/census-time-series-07.png new file mode 100644 index 00000000..73d7611e Binary files /dev/null and b/docs/_static/tutorials/census-time-series-07.png differ diff --git a/docs/_static/tutorials/census-time-series-08.png b/docs/_static/tutorials/census-time-series-08.png new file mode 100644 index 00000000..0a1ea2d1 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-08.png differ diff --git a/docs/_static/tutorials/census-time-series-09.png b/docs/_static/tutorials/census-time-series-09.png new file mode 100644 index 00000000..90c222c2 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-09.png differ diff --git a/docs/_static/tutorials/census-time-series-10.png b/docs/_static/tutorials/census-time-series-10.png new file mode 100644 index 00000000..49ce2583 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-10.png differ diff --git a/docs/_static/tutorials/census-time-series-csv-01.png b/docs/_static/tutorials/census-time-series-csv-01.png new file mode 100644 index 00000000..e4445672 Binary files /dev/null and b/docs/_static/tutorials/census-time-series-csv-01.png differ diff --git a/docs/_static/tutorials/raw-data-as-numpy.png b/docs/_static/tutorials/raw-data-as-numpy.png new file mode 100644 index 00000000..542b285e Binary files /dev/null and b/docs/_static/tutorials/raw-data-as-numpy.png differ diff --git a/docs/api.rst b/docs/api.rst index 802f01ef..5f6f1447 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -449,51 +449,81 @@ Core Components - :class:`DataPointAccessibility ` * - :mod:`.options.series.data.arcdiagram ` - :class:`ArcDiagramData ` + :class:`ArcDiagramDataCollection ` * - :mod:`.options.series.data.bar ` - :class:`BarData ` + :class:`BarDataCollection ` :class:`WaterfallData ` + :class:`WaterfallDataCollection ` :class:`WindBarbData ` + :class:`WindBarbDataCollection ` :class:`XRangeData ` + :class:`XRangeDataCollection ` * - :mod:`.options.series.data.base ` - :class:`DataBase ` * - :mod:`.options.series.data.boxplot ` - :class:`BoxPlotData ` + :class:`BoxPlotDataCollection ` * - :mod:`.options.series.data.bullet ` - :class:`BulletData ` + :class:`BulletDataCollection ` * - :mod:`.options.series.data.cartesian ` - :class:`CartesianData ` + :class:`CartesianDataCollection ` :class:`Cartesian3DData ` + :class:`Cartesian3DDataCollection ` :class:`CartesianValueData ` + :class:`CartesianValueDataCollection ` + * - :mod:`.options.series.data.collections ` + - :class:`DataPointCollection ` * - :mod:`.options.series.data.connections ` - :class:`ConnectionData ` + :class:`ConnectionDataCollection ` :class:`WeightedConnectionData ` + :class:`WeightedConnectionDataCollection ` :class:`OutgoingWeightedConnectionData ` + :class:`OutgoingWeightedConnectionDataCollection ` :class:`ConnectionBase ` * - :mod:`.options.series.data.pie ` - :class:`PieData ` + :class:`PieDataCollection ` :class:`VariablePieData ` + :class:`VariablePieDataCollection ` * - :mod:`.options.series.data.range ` - :class:`RangeData ` + :class:`RangeDataCollection ` :class:`ConnectedRangeData ` + :class:`ConnectedRangeDataCollection ` * - :mod:`.options.series.data.single_point ` - :class:`SinglePointData ` + :class:`SinglePointDataCollection ` :class:`SingleValueData ` + :class:`SingleValueDataCollection ` :class:`SingleXData ` + :class:`SingleXDataCollection ` :class:`LabeledSingleXData ` + :class:`LabeledSingleXDataCollection ` :class:`ConnectedSingleXData ` + :class:`ConnectedSingleXDataCollection ` :class:`SinglePointBase ` * - :mod:`.options.series.data.sunburst ` - :class:`SunburstData ` + :class:`SunburstDataCollection ` * - :mod:`.options.series.data.treegraph ` - :class:`TreegraphData ` + :class:`TreegraphDataCollection ` * - :mod:`.options.series.data.treemap ` - :class:`TreemapData ` + :class:`TreemapDataCollection ` * - :mod:`.options.series.data.vector ` - :class:`VectorData ` + :class:`VectorDataCollection ` * - :mod:`.options.series.data.venn ` - :class:`VennData ` + :class:`VennDataCollection ` * - :mod:`.options.series.data.wordcloud ` - :class:`WordcloudData ` + :class:`WordcloudDataCollection ` * - :mod:`.options.series.dependencywheel ` - :class:`DependencyWheelSeries ` * - :mod:`.options.series.dumbbell ` @@ -563,10 +593,10 @@ Core Components - :class:`PointGrouping ` * - :mod:`.options.sonification.mapping ` - :class:`SonificationMapping ` - :class:`AudioParameter ` + :class:`AudioParameter ` :class:`AudioFilter ` :class:`PitchParameter ` - :class:`TremoloEffect ` + :class:`TremoloEffect ` * - :mod:`.options.sonification.track_configurations ` - :class:`InstrumentTrackConfiguration ` :class:`SpeechTrackConfiguration ` @@ -715,6 +745,12 @@ familiarize yourself with these internals. :func:`to_camelCase() ` :func:`to_snake_case() ` :func:`parse_csv() ` + * - :mod:`.ai ` + - :func:`convert_to_js() ` + :func:`openai_moderate() ` + :func:`openai_conversion() ` + :func:`anthropic_conversion() ` + :func:`get_source() ` .. target-notes:: diff --git a/docs/api/internals.rst b/docs/api/internals.rst index fe7d8677..c2bda4a3 100644 --- a/docs/api/internals.rst +++ b/docs/api/internals.rst @@ -174,3 +174,42 @@ function:: :func:`parse_csv() ` ===================================================================================================== .. autofunction:: parse_csv + +-------------- + +.. module:: highcharts_core.ai + +****************************************************************************** +module: :mod:`.ai ` +****************************************************************************** + +The :mod:`.ai ` module contains - as one might +expect - functions that enable Highcharts for Python to communicate with +supported generative AI platforms. These functions are used to convert +Python callables to their JavaScript equivalents in the +:meth:`CallbackFunction.from_python() ` method. + +function:: :func:`convert_to_js() ` +===================================================================================================== + +.. autofunction:: convert_to_js + +function:: :func:`openai_moderate() ` +===================================================================================================== + +.. autofunction:: openai_moderate + +function:: :func:`openai_conversion() ` +===================================================================================================== + +.. autofunction:: openai_conversion + +function:: :func:`anthropic_conversion() ` +===================================================================================================== + +.. autofunction:: anthropic_conversion + +function:: :func:`get_source() ` +===================================================================================================== + +.. autofunction:: get_source diff --git a/docs/api/options/index.rst b/docs/api/options/index.rst index 973d95e9..ddae72b3 100644 --- a/docs/api/options/index.rst +++ b/docs/api/options/index.rst @@ -379,51 +379,81 @@ Sub-components - :class:`DataPointAccessibility ` * - :mod:`.options.series.data.arcdiagram ` - :class:`ArcDiagramData ` + :class:`ArcDiagramDataCollection ` * - :mod:`.options.series.data.bar ` - :class:`BarData ` + :class:`BarDataCollection ` :class:`WaterfallData ` + :class:`WaterfallDataCollection ` :class:`WindBarbData ` + :class:`WindBarbDataCollection ` :class:`XRangeData ` + :class:`XRangeDataCollection ` * - :mod:`.options.series.data.base ` - :class:`DataBase ` * - :mod:`.options.series.data.boxplot ` - :class:`BoxPlotData ` + :class:`BoxPlotDataCollection ` * - :mod:`.options.series.data.bullet ` - :class:`BulletData ` + :class:`BulletDataCollection ` * - :mod:`.options.series.data.cartesian ` - :class:`CartesianData ` + :class:`CartesianDataCollection ` :class:`Cartesian3DData ` + :class:`Cartesian3DDataCollection ` :class:`CartesianValueData ` + :class:`CartesianValueDataCollection ` + * - :mod:`.options.series.data.collections ` + - :class:`DataPointCollection ` * - :mod:`.options.series.data.connections ` - :class:`ConnectionData ` + :class:`ConnectionDataCollection ` :class:`WeightedConnectionData ` + :class:`WeightedConnectionDataCollection ` :class:`OutgoingWeightedConnectionData ` + :class:`OutgoingWeightedConnectionDataCollection ` :class:`ConnectionBase ` * - :mod:`.options.series.data.pie ` - :class:`PieData ` + :class:`PieDataCollection ` :class:`VariablePieData ` + :class:`VariablePieDataCollection ` * - :mod:`.options.series.data.range ` - :class:`RangeData ` + :class:`RangeDataCollection ` :class:`ConnectedRangeData ` + :class:`ConnectedRangeDataCollection ` * - :mod:`.options.series.data.single_point ` - :class:`SinglePointData ` + :class:`SinglePointDataCollection ` :class:`SingleValueData ` + :class:`SingleValueDataCollection ` :class:`SingleXData ` + :class:`SingleXDataCollection ` :class:`LabeledSingleXData ` + :class:`LabeledSingleXDataCollection ` :class:`ConnectedSingleXData ` + :class:`ConnectedSingleXDataCollection ` :class:`SinglePointBase ` * - :mod:`.options.series.data.sunburst ` - :class:`SunburstData ` + :class:`SunburstDataCollection ` * - :mod:`.options.series.data.treegraph ` - :class:`TreegraphData ` + :class:`TreegraphDataCollection ` * - :mod:`.options.series.data.treemap ` - :class:`TreemapData ` + :class:`TreemapDataCollection ` * - :mod:`.options.series.data.vector ` - :class:`VectorData ` + :class:`VectorDataCollection ` * - :mod:`.options.series.data.venn ` - :class:`VennData ` + :class:`VennDataCollection ` * - :mod:`.options.series.data.wordcloud ` - :class:`WordcloudData ` + :class:`WordcloudDataCollection ` * - :mod:`.options.series.dependencywheel ` - :class:`DependencyWheelSeries ` * - :mod:`.options.series.dumbbell ` @@ -493,10 +523,10 @@ Sub-components - :class:`PointGrouping ` * - :mod:`.options.sonification.mapping ` - :class:`SonificationMapping ` - :class:`AudioParameter ` + :class:`AudioParameter ` :class:`AudioFilter ` :class:`PitchParameter ` - :class:`TremoloEffect ` + :class:`TremoloEffect ` * - :mod:`.options.sonification.track_configurations ` - :class:`InstrumentTrackConfiguration ` :class:`SpeechTrackConfiguration ` diff --git a/docs/api/options/series/data/arcdiagram.rst b/docs/api/options/series/data/arcdiagram.rst index 53ebca69..0c7159c1 100644 --- a/docs/api/options/series/data/arcdiagram.rst +++ b/docs/api/options/series/data/arcdiagram.rst @@ -26,3 +26,23 @@ class: :class:`ArcDiagramData ` +******************************************************************************************************************** + +.. autoclass:: ArcDiagramDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: ArcDiagramDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/bar.rst b/docs/api/options/series/data/bar.rst index 51684af1..aa2070ff 100644 --- a/docs/api/options/series/data/bar.rst +++ b/docs/api/options/series/data/bar.rst @@ -27,6 +27,24 @@ class: :class:`BarData ` | +-------------- + +******************************************************************************************************************** +class: :class:`BarDataCollection ` +******************************************************************************************************************** + +.. autoclass:: BarDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: BarDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + ----------------- ******************************************************************************************************************** @@ -47,6 +65,24 @@ class: :class:`WaterfallData ` +******************************************************************************************************************** + +.. autoclass:: WaterfallDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: WaterfallDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +----------------- + ******************************************************************************************************************** class: :class:`WindBarbData ` ******************************************************************************************************************** @@ -65,6 +101,24 @@ class: :class:`WindBarbData ` +******************************************************************************************************************** + +.. autoclass:: WindBarbDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: WindBarbDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +----------------- + ******************************************************************************************************************** class: :class:`XRangeData ` ******************************************************************************************************************** @@ -80,3 +134,21 @@ class: :class:`XRangeData ` :parts: -1 | + +----------------- + +******************************************************************************************************************** +class: :class:`XRangeDataCollection ` +******************************************************************************************************************** + +.. autoclass:: XRangeDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: XRangeDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/boxplot.rst b/docs/api/options/series/data/boxplot.rst index cf9c3fdd..f843e1b9 100644 --- a/docs/api/options/series/data/boxplot.rst +++ b/docs/api/options/series/data/boxplot.rst @@ -26,3 +26,21 @@ class: :class:`BoxPlotData ` +******************************************************************************************************************** + +.. autoclass:: BoxPlotDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: BoxPlotDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/bullet.rst b/docs/api/options/series/data/bullet.rst index e70feebd..1829a17d 100644 --- a/docs/api/options/series/data/bullet.rst +++ b/docs/api/options/series/data/bullet.rst @@ -26,3 +26,21 @@ class: :class:`BulletData ` +******************************************************************************************************************** + +.. autoclass:: BulletDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: BulletDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/cartesian.rst b/docs/api/options/series/data/cartesian.rst index b13ad201..8402098f 100644 --- a/docs/api/options/series/data/cartesian.rst +++ b/docs/api/options/series/data/cartesian.rst @@ -29,6 +29,24 @@ class: :class:`CartesianData ` +******************************************************************************************************************** + +.. autoclass:: CartesianDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: CartesianDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +-------------- + ******************************************************************************************************************** class: :class:`Cartesian3DData ` ******************************************************************************************************************** @@ -47,6 +65,24 @@ class: :class:`Cartesian3DData ` +******************************************************************************************************************** + +.. autoclass:: Cartesian3DDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: Cartesian3DDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +-------------- + ******************************************************************************************************************** class: :class:`CartesianValueData ` ******************************************************************************************************************** @@ -62,3 +98,21 @@ class: :class:`CartesianValueData ` +******************************************************************************************************************** + +.. autoclass:: CartesianValueDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: CartesianValueDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/collections.rst b/docs/api/options/series/data/collections.rst new file mode 100644 index 00000000..59d7f45a --- /dev/null +++ b/docs/api/options/series/data/collections.rst @@ -0,0 +1,28 @@ +########################################################################################## +:mod:`.collections ` +########################################################################################## + +.. contents:: Module Contents + :local: + :depth: 3 + :backlinks: entry + +-------------- + +.. module:: highcharts_core.options.series.data.collections + +******************************************************************************************************************** +class: :class:`DataPointCollection ` +******************************************************************************************************************** + +.. autoclass:: DataPointCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: DataPointCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/connections.rst b/docs/api/options/series/data/connections.rst index 4fbdd1a1..de4cc683 100644 --- a/docs/api/options/series/data/connections.rst +++ b/docs/api/options/series/data/connections.rst @@ -29,6 +29,24 @@ class: :class:`ConnectionData ` +******************************************************************************************************************** + +.. autoclass:: ConnectionDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: ConnectionDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +--------------- + ******************************************************************************************************************** class: :class:`WeightedConnectionData ` ******************************************************************************************************************** @@ -47,6 +65,24 @@ class: :class:`WeightedConnectionData ` +******************************************************************************************************************** + +.. autoclass:: WeightedConnectionDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: WeightedConnectionDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +---------------------- + ************************************************************************************************************************************* class: :class:`OutgoingWeightedConnectionData ` ************************************************************************************************************************************* @@ -65,6 +101,24 @@ class: :class:`OutgoingWeightedConnectionData ` +************************************************************************************************************************************* + +.. autoclass:: OutgoingWeightedConnectionDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: OutgoingWeightedConnectionDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +--------------------------- + ******************************************************************************************************************** class: :class:`ConnectionBase ` ******************************************************************************************************************** diff --git a/docs/api/options/series/data/index.rst b/docs/api/options/series/data/index.rst index 6850b91b..85ccd13f 100644 --- a/docs/api/options/series/data/index.rst +++ b/docs/api/options/series/data/index.rst @@ -17,6 +17,7 @@ boxplot bullet cartesian + collections connections pie range @@ -46,48 +47,78 @@ Sub-components - :class:`DataPointAccessibility ` * - :mod:`.options.series.data.arcdiagram ` - :class:`ArcDiagramData ` + :class:`ArcDiagramDataCollection ` * - :mod:`.options.series.data.bar ` - :class:`BarData ` + :class:`BarDataCollection ` :class:`WaterfallData ` + :class:`WaterfallDataCollection ` :class:`WindBarbData ` + :class:`WindBarbDataCollection ` :class:`XRangeData ` + :class:`XRangeDataCollection ` * - :mod:`.options.series.data.base ` - :class:`DataBase ` * - :mod:`.options.series.data.boxplot ` - :class:`BoxPlotData ` + :class:`BoxPlotDataCollection ` * - :mod:`.options.series.data.bullet ` - :class:`BulletData ` + :class:`BulletDataCollection ` * - :mod:`.options.series.data.cartesian ` - :class:`CartesianData ` + :class:`CartesianDataCollection ` :class:`Cartesian3DData ` + :class:`Cartesian3DDataCollection ` :class:`CartesianValueData ` + :class:`CartesianValueDataCollection ` + * - :mod:`.options.series.data.collections ` + - :class:`DataPointCollection ` * - :mod:`.options.series.data.connections ` - :class:`ConnectionData ` + :class:`ConnectionDataCollection ` :class:`WeightedConnectionData ` + :class:`WeightedConnectionDataCollection ` :class:`OutgoingWeightedConnectionData ` + :class:`OutgoingWeightedConnectionDataCollection ` :class:`ConnectionBase ` * - :mod:`.options.series.data.pie ` - :class:`PieData ` + :class:`PieDataCollection ` :class:`VariablePieData ` + :class:`VariablePieDataCollection ` * - :mod:`.options.series.data.range ` - :class:`RangeData ` + :class:`RangeDataCollection ` :class:`ConnectedRangeData ` + :class:`ConnectedRangeDataCollection ` * - :mod:`.options.series.data.single_point ` - :class:`SinglePointData ` + :class:`SinglePointDataCollection ` :class:`SingleValueData ` + :class:`SingleValueDataCollection ` :class:`SingleXData ` + :class:`SingleXDataCollection ` :class:`LabeledSingleXData ` + :class:`LabeledSingleXDataCollection ` :class:`ConnectedSingleXData ` + :class:`ConnectedSingleXDataCollection ` :class:`SinglePointBase ` - * - :mod:`.options.series.data.treegraph ` - - :class:`TreegraphData ` * - :mod:`.options.series.data.sunburst ` - :class:`SunburstData ` + :class:`SunburstDataCollection ` + * - :mod:`.options.series.data.treegraph ` + - :class:`TreegraphData ` + :class:`TreegraphDataCollection ` * - :mod:`.options.series.data.treemap ` - :class:`TreemapData ` + :class:`TreemapDataCollection ` * - :mod:`.options.series.data.vector ` - :class:`VectorData ` + :class:`VectorDataCollection ` * - :mod:`.options.series.data.venn ` - :class:`VennData ` + :class:`VennDataCollection ` * - :mod:`.options.series.data.wordcloud ` - :class:`WordcloudData ` + :class:`WordcloudDataCollection ` diff --git a/docs/api/options/series/data/pie.rst b/docs/api/options/series/data/pie.rst index 45397bd8..0f197e1f 100644 --- a/docs/api/options/series/data/pie.rst +++ b/docs/api/options/series/data/pie.rst @@ -29,6 +29,24 @@ class: :class:`PieData ` ----------------- +******************************************************************************************************************** +class: :class:`PieDataCollection ` +******************************************************************************************************************** + +.. autoclass:: PieDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: PieDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +----------------- + ******************************************************************************************************************** class: :class:`VariablePieData ` ******************************************************************************************************************** @@ -44,3 +62,21 @@ class: :class:`VariablePieData ` +******************************************************************************************************************** + +.. autoclass:: VariablePieDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: VariablePieDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/range.rst b/docs/api/options/series/data/range.rst index 07b2b7f2..c34a3b67 100644 --- a/docs/api/options/series/data/range.rst +++ b/docs/api/options/series/data/range.rst @@ -29,6 +29,24 @@ class: :class:`RangeData ` --------------- +******************************************************************************************************************** +class: :class:`RangeDataCollection ` +******************************************************************************************************************** + +.. autoclass:: RangeDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: RangeDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +--------------- + ******************************************************************************************************************** class: :class:`ConnectedRangeData ` ******************************************************************************************************************** @@ -44,3 +62,21 @@ class: :class:`ConnectedRangeData ` +*********************************************************************************************************************** + +.. autoclass:: ConnectedRangeDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: ConnectedRangeDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/single_point.rst b/docs/api/options/series/data/single_point.rst index ae312b69..47beab9c 100644 --- a/docs/api/options/series/data/single_point.rst +++ b/docs/api/options/series/data/single_point.rst @@ -29,6 +29,24 @@ class: :class:`SinglePointData ` +************************************************************************************************************************ + +.. autoclass:: SinglePointDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: SinglePointDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +----------------------- + ******************************************************************************************************************** class: :class:`SingleValueData ` ******************************************************************************************************************** @@ -47,6 +65,24 @@ class: :class:`SingleValueData ` +*********************************************************************************************************************** + +.. autoclass:: SingleValueDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: SingleValueDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +--------------------- + ******************************************************************************************************************** class: :class:`SingleXData ` ******************************************************************************************************************** @@ -65,6 +101,24 @@ class: :class:`SingleXData ` +******************************************************************************************************************** + +.. autoclass:: SingleXDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: SingleXDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +--------------- + ******************************************************************************************************************** class: :class:`LabeledSingleXData ` ******************************************************************************************************************** @@ -83,6 +137,24 @@ class: :class:`LabeledSingleXData ` +******************************************************************************************************************** + +.. autoclass:: LabeledSingleXDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: LabeledSingleXDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +--------------------- + ******************************************************************************************************************** class: :class:`ConnectedSingleXData ` ******************************************************************************************************************** @@ -101,6 +173,24 @@ class: :class:`ConnectedSingleXData ` +******************************************************************************************************************** + +.. autoclass:: ConnectedSingleXDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: ConnectedSingleXDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | + +-------------------- + ******************************************************************************************************************** class: :class:`SinglePointBase ` ******************************************************************************************************************** diff --git a/docs/api/options/series/data/sunburst.rst b/docs/api/options/series/data/sunburst.rst index b3430a33..36882daa 100644 --- a/docs/api/options/series/data/sunburst.rst +++ b/docs/api/options/series/data/sunburst.rst @@ -26,3 +26,21 @@ class: :class:`SunburstData ` +******************************************************************************************************************** + +.. autoclass:: SunburstDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: SunburstDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/treegraph.rst b/docs/api/options/series/data/treegraph.rst index f78541ea..1b305f23 100644 --- a/docs/api/options/series/data/treegraph.rst +++ b/docs/api/options/series/data/treegraph.rst @@ -26,3 +26,21 @@ class: :class:`TreegraphData ` +******************************************************************************************************************** + +.. autoclass:: TreegraphDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: TreegraphDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/treemap.rst b/docs/api/options/series/data/treemap.rst index ea87eb31..f3fe64bc 100644 --- a/docs/api/options/series/data/treemap.rst +++ b/docs/api/options/series/data/treemap.rst @@ -26,3 +26,21 @@ class: :class:`TreemapData ` +******************************************************************************************************************** + +.. autoclass:: TreemapDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: TreemapDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/vector.rst b/docs/api/options/series/data/vector.rst index 5012dc9f..f23109b0 100644 --- a/docs/api/options/series/data/vector.rst +++ b/docs/api/options/series/data/vector.rst @@ -26,3 +26,21 @@ class: :class:`VectorData ` +******************************************************************************************************************** + +.. autoclass:: VectorDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: VectorDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/venn.rst b/docs/api/options/series/data/venn.rst index 4449f66d..d2491e44 100644 --- a/docs/api/options/series/data/venn.rst +++ b/docs/api/options/series/data/venn.rst @@ -26,3 +26,21 @@ class: :class:`VennData ` :parts: -1 | + +-------------- + +******************************************************************************************************************** +class: :class:`VennDataCollection ` +******************************************************************************************************************** + +.. autoclass:: VennDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: VennDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/data/wordcloud.rst b/docs/api/options/series/data/wordcloud.rst index 6e64dcbd..b14c8984 100644 --- a/docs/api/options/series/data/wordcloud.rst +++ b/docs/api/options/series/data/wordcloud.rst @@ -26,3 +26,21 @@ class: :class:`WordcloudData ` +******************************************************************************************************************** + +.. autoclass:: WordcloudDataCollection + :members: + :inherited-members: + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: WordcloudDataCollection + :top-classes: highcharts_core.metaclasses.HighchartsMeta + :parts: -1 + + | diff --git a/docs/api/options/series/index.rst b/docs/api/options/series/index.rst index 70d19f8d..187ee8c3 100644 --- a/docs/api/options/series/index.rst +++ b/docs/api/options/series/index.rst @@ -99,51 +99,81 @@ Sub-components - :class:`DataPointAccessibility ` * - :mod:`.options.series.data.arcdiagram ` - :class:`ArcDiagramData ` + :class:`ArcDiagramDataCollection ` * - :mod:`.options.series.data.bar ` - :class:`BarData ` + :class:`BarDataCollection ` :class:`WaterfallData ` + :class:`WaterfallDataCollection ` :class:`WindBarbData ` + :class:`WindBarbDataCollection ` :class:`XRangeData ` + :class:`XRangeDataCollection ` * - :mod:`.options.series.data.base ` - :class:`DataBase ` * - :mod:`.options.series.data.boxplot ` - :class:`BoxPlotData ` + :class:`BoxPlotDataCollection ` * - :mod:`.options.series.data.bullet ` - :class:`BulletData ` + :class:`BulletDataCollection ` * - :mod:`.options.series.data.cartesian ` - :class:`CartesianData ` + :class:`CartesianDataCollection ` :class:`Cartesian3DData ` + :class:`Cartesian3DDataCollection ` :class:`CartesianValueData ` + :class:`CartesianValueDataCollection ` + * - :mod:`.options.series.data.collections ` + - :class:`DataPointCollection ` * - :mod:`.options.series.data.connections ` - :class:`ConnectionData ` + :class:`ConnectionDataCollection ` :class:`WeightedConnectionData ` + :class:`WeightedConnectionDataCollection ` :class:`OutgoingWeightedConnectionData ` + :class:`OutgoingWeightedConnectionDataCollection ` :class:`ConnectionBase ` * - :mod:`.options.series.data.pie ` - :class:`PieData ` + :class:`PieDataCollection ` :class:`VariablePieData ` + :class:`VariablePieDataCollection ` * - :mod:`.options.series.data.range ` - :class:`RangeData ` + :class:`RangeDataCollection ` :class:`ConnectedRangeData ` + :class:`ConnectedRangeDataCollection ` * - :mod:`.options.series.data.single_point ` - :class:`SinglePointData ` + :class:`SinglePointDataCollection ` :class:`SingleValueData ` + :class:`SingleValueDataCollection ` :class:`SingleXData ` + :class:`SingleXDataCollection ` :class:`LabeledSingleXData ` + :class:`LabeledSingleXDataCollection ` :class:`ConnectedSingleXData ` + :class:`ConnectedSingleXDataCollection ` :class:`SinglePointBase ` * - :mod:`.options.series.data.sunburst ` - :class:`SunburstData ` + :class:`SunburstDataCollection ` * - :mod:`.options.series.data.treegraph ` - :class:`TreegraphData ` + :class:`TreegraphDataCollection ` * - :mod:`.options.series.data.treemap ` - :class:`TreemapData ` + :class:`TreemapDataCollection ` * - :mod:`.options.series.data.vector ` - :class:`VectorData ` + :class:`VectorDataCollection ` * - :mod:`.options.series.data.venn ` - :class:`VennData ` + :class:`VennDataCollection ` * - :mod:`.options.series.data.wordcloud ` - :class:`WordcloudData ` + :class:`WordcloudDataCollection ` * - :mod:`.options.series.dependencywheel ` - :class:`DependencyWheelSeries ` * - :mod:`.options.series.dumbbell ` diff --git a/docs/conf.py b/docs/conf.py index 83970ce9..042ad581 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -103,6 +103,7 @@ 'python': ('https://docs.python.org/3.10', None), 'validator-collection': ('http://validator-collection.readthedocs.io/en/latest/', None), 'ipython': ('https://ipython.readthedocs.io/en/stable/', None), + 'numpy': ('https://numpy.org/doc/stable/', None), 'pandas': ('https://pandas.pydata.org/pandas-docs/stable/', None), 'pyspark': ('https://spark.apache.org/docs/latest/api/python/', None), } diff --git a/docs/errors.rst b/docs/errors.rst index 675d23c0..2ec53a41 100644 --- a/docs/errors.rst +++ b/docs/errors.rst @@ -275,3 +275,40 @@ HighchartsMissingExportSettingsError (from :class:`ValueError `) +========================================================================================== + +.. autoexception:: HighchartsMissingSeriesError + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: HighchartsMissingSeriesError + :parts: -1 + +------------ + +HighchartsPythonConversionError (from :class:`ValueError `) +========================================================================================== + +.. autoexception:: HighchartsPythonConversionError + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: HighchartsPythonConversionError + :parts: -1 + +------------ + +HighchartsModerationError (from :class:`ValueError `) +========================================================================================== + +.. autoexception:: HighchartsModerationError + + .. collapse:: Class Inheritance + + .. inheritance-diagram:: HighchartsModerationError + :parts: -1 + diff --git a/docs/faq.rst b/docs/faq.rst index 10a712a1..7f833359 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -84,3 +84,95 @@ provided that you adhere to the terms of our :doc:`license`. Absolutely! We encourage contributions to the toolkit from the community! For more information, please read our :doc:`Contributor Guide `. +----------- + +****************************** +Running into Issues +****************************** + +**In Jupyter Notebook, I keep getting an error that says "Something went wrong with the Highcharts.js script." What gives?** + +This is a known issue affecting Jupyter Notebook users, but it can be caused by multiple different things: + +*Network Connectivity*. When calling :meth:`Chart.display() `, +**Highcharts for Python** will attempt to load the required Highcharts (JS) JavaScript libraries into the +environment where where Jupyter Notebook is running. By default, **Highcharts for Python** tries for 5 seconds. +But if the relevant scripts have not (yet) loaded in 5 seconds, it will display an error. You can tell Jupyter +to wait longer by adjusting the ``retries`` and ``interval`` properties on the ``.display()`` method. For example: + + .. code-block:: python + + # To wait for 7 seconds + my_chart.display(retries = 7, interval = 1000) + + # To wait for 10 seconds + my_chart.display(retries = 10) + +*Incorrect IPython, Jupyter Notebook, or Jupyter Lab versions*. Please be sure to check the versions of +IPython, Jupyter Notebook, and Jupyter Lab that you are using in your runtime environment. You can do this +by running: + + .. code-block:: bash + + $ pip list + + and then finding the entries for ``ipython``, ``notebook``, and ``jupyterlab``. Please check those versions + against our (soft) dependencies: + + .. include:: _dependencies.rst + +If you are using older versions, you can upgrade it by executing: + + .. code-block:: bash + + $ pip install --upgrade ipython notebook jupyterlab + +*VSCode Extension Conflict*. This is the most pernicious cause of this behavior. When you are running +Jupyter Notebook within VSCode *and* have various Jupyter Notebook-related extensions installed/enabled, +those extensions can *sometimes* cause this error. Unfortunately, it is not consistent, and the same +extensions in two different environments may or may not produce this behavior. However, users report that +disabling those extensions, restarting VSCode, and then re-enabling +those extensions seems to solve that problem. However, to reproduce this *very* inconsistent error, we'd +appreciate if you could comment in `the relevant Github issue `__ to let us know which extensions you have installed when this problem occurs. + +**I'm getting a Highcharts error about boost/turbo mode - what does that mean?** + +Highcharts (JS) supports two similar features called "boost mode" and "turbo mode" that +accelerate rendering of the visualization in your users' browsers. However, these modes +work best when your chart's data is represented as a primitive array instead of as a full +object: + + .. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Primitive Array + - Full Object + * - .. code-block:: python + + [ + [1, 23], + [2, 34], + [3, 45] + ] + - .. code-block:: python + + [ + {'x': 1, 'y': 23}, + {'x': 2, 'y': 34}, + {'x': 3, 'y': 45} + ] + +When rendering a chart whose data is contained in full objects, by default Highcharts (JS) disables +boost/turbo mode if there are more data points than a configurable threshold. You can adjust the thresholds +using various options, including :meth:`.turbo_threshold `, +:meth:`.boost_threshold ` and the +configuration of chart-level :class:`Boost ` options. + + .. seealso:: + + * Highcharts (JS) Documentation: `Boost module `__ + * :class:`Boost ` options + * Plot Options: :meth:`.boost_threshold ` + * Series Configuration: :meth:`.boost_threshold ` + * Series Configuration: :meth:`.turbo_threshold ` diff --git a/docs/index.rst b/docs/index.rst index 62b604cf..427c632e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,8 +13,6 @@ Highcharts Core for Python **High-end data visualization for the Python ecosystem** -.. include:: _unit_tests_code_coverage.rst - .. toctree:: :hidden: :maxdepth: 3 @@ -27,6 +25,7 @@ Highcharts Core for Python FAQ Toolkit Components and Roadmap Using Highcharts Core for Python + Tutorials API Reference Error Reference Getting Help @@ -46,7 +45,8 @@ Highcharts Core for Python * Highcharts Core (JS) 10.2 or higher, * Jupyter Notebook 6.4 or higher, * IPython 8.10 or higher, - * Pandas 1.3 or higher + * NumPy 1.19 or higher, + * Pandas 1.3 or higher, * PySpark 3.3 or higher **Highcharts Core for Python** is a Python library that provides a Python wrapper @@ -210,48 +210,52 @@ Hello World, and Basic Usage .. code-block:: python + # from a primitive array, using keyword arguments + my_chart = Chart(data = [[1, 23], [2, 34], [3, 45]], + series_type = 'line') + + # from a primitive array, using the .from_array() method + my_chart = Chart.from_array([[1, 23], [2, 34], [3, 45]], + series_type = 'line') + + # from a Numpy ndarray, using keyword arguments + my_chart = Chart(data = numpy_array, series_type = 'line') + + # from a Numpy ndarray, using the .from_array() method + my_chart = Chart.from_array(data = numpy_array, series_type = 'line') + # from a JavaScript file - my_chart = highcharts.Chart.from_js_literal('my_js_literal.js') + my_chart = Chart.from_js_literal('my_js_literal.js') # from a JSON file - my_chart = highcharts.Chart.from_json('my_json.json') + my_chart = Chart.from_json('my_json.json') # from a Python dict - my_chart = highcharts.Chart.from_dict(my_dict_obj) + my_chart = Chart.from_dict(my_dict_obj) # from a Pandas dataframe - my_chart = highcharts.Chart.from_pandas(df, - property_map = { - 'x': 'transactionDate', - 'y': 'invoiceAmt', - 'id': 'id' - }, - series_type = 'line') + my_chart = Chart.from_pandas(df) # from a PySpark dataframe - my_chart = highcharts.Chart.from_pyspark(df, - property_map = { - 'x': 'transactionDate', - 'y': 'invoiceAmt', - 'id': 'id' - }, - series_type = 'line') + my_chart = Chart.from_pyspark(df, + property_map = { + 'x': 'transactionDate', + 'y': 'invoiceAmt', + 'id': 'id' + }, + series_type = 'line') # from a CSV - my_chart = highcharts.Chart.from_csv('/some_file_location/filename.csv' - column_property_map = { - 'x': 0, - 'y': 4, - 'id': 14 - }, - series_type = 'line') + my_chart = Chart.from_csv('/some_file_location/filename.csv') # from a HighchartsOptions configuration object - my_chart = highcharts.Chart.from_options(my_options) + my_chart = Chart.from_options(my_options) - # from a Series configuration - my_chart = highcharts.Chart.from_series(my_series) + # from a Series configuration, using keyword arguments + my_chart = Chart(series = my_series) + # from a Series configuration, using .from_series() + my_chart = Chart.from_series(my_series) 3. Configure Global Settings (optional) ============================================= @@ -282,6 +286,7 @@ Hello World, and Basic Usage from highcharts_core.options.title import Title from highcharts_core.options.credits import Credits + # EXAMPLE 1. # Using dicts my_chart.title = { 'align': 'center' @@ -292,7 +297,7 @@ Hello World, and Basic Usage my_chart.credits = { 'enabled': True, - 'href': 'https://www.highcharts.com/', + 'href': 'https://www.highchartspython.com/', 'position': { 'align': 'center', 'vertical_align': 'bottom', @@ -307,17 +312,21 @@ Hello World, and Basic Usage 'text': 'Chris Modzelewski' } + # EXAMPLE 2. # Using direct objects from highcharts_core.options.title import Title from highcharts_core.options.credits import Credits - my_title = Title(text = 'The Title for My Chart', floating = True, align = 'center') + my_title = Title(text = 'The Title for My Chart', + floating = True, + align = 'center') my_chart.options.title = my_title - my_credits = Credits(text = 'Chris Modzelewski', enabled = True, href = 'https://www.highcharts.com') + my_credits = Credits(text = 'Chris Modzelewski', + enabled = True, + href = 'https://www.highchartspython.com') my_chart.options.credits = my_credits - 5. Generate the JavaScript Code for Your Chart ================================================= @@ -357,6 +366,13 @@ that will render the chart wherever it is you want it to go: my_image_bytes = my_chart.download_chart(filename = 'my_target_file.png', format = 'png') +8. Render Your Chart in a Jupyter Notebook +=============================================== + + .. code-block:: python + + my_chart.display() + -------------- *********************** diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 236763c6..d07b029b 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -72,9 +72,17 @@ Populating Series with Data .. include:: using/populating_series_data/_with_data_property.rst - .. tab:: w/ ``.from_array()`` + .. tab:: from Array + + .. tabs:: + + .. tab:: Create a New Series - .. include:: using/populating_series_data/_with_from_array.rst + .. include:: using/populating_series_data/_new_from_array.rst + + .. tab:: Update an Existing Series + + .. include:: using/populating_series_data/_load_from_array.rst .. tab:: from CSV @@ -120,6 +128,10 @@ Assembling Your Chart and Options .. tabs:: + .. tabs:: Using Keyword Arguments + + .. include:: using/assembling_your_chart/_using_kwargs.rst + .. tab:: Using ``.add_series()`` .. include:: using/assembling_your_chart/_using_add_series.rst diff --git a/docs/tutorials.rst b/docs/tutorials.rst new file mode 100644 index 00000000..00ba76ed --- /dev/null +++ b/docs/tutorials.rst @@ -0,0 +1,38 @@ +######################################## +Highcharts Core for Python: Tutorials +######################################## + +.. toctree:: + :maxdepth: 2 + + Getting Started + Using Highcharts Core for Python with Pandas + Using Highcharts Core for Python with CSVs + Working with Data in Highcharts for Python + Working with Highcharts for Python Series Instances + Creating JavaScript Callback Functions + Exporting Static Charts + +-------------------- + +.. contents:: + :depth: 2 + :backlinks: entry + +------------------- + +.. tip:: + + You can find even more tutorials on the + `Highcharts website `__. + +Please use the table of contents to the left to find a tutorial that is +relevant to your needs. And if you would like us to prepare additional tutorials, +please: + + #. Check if it has + `already been requested `__ + #. If it has, then please upvote it with a comment. + #. If the tutorial you would like to see hasn't been requested yet, then please + `file a new request `__ - + \ No newline at end of file diff --git a/docs/tutorials/_assembling_a_chart.rst b/docs/tutorials/_assembling_a_chart.rst new file mode 100644 index 00000000..5474dfd3 --- /dev/null +++ b/docs/tutorials/_assembling_a_chart.rst @@ -0,0 +1,35 @@ + .. code-block:: python + + my_chart = Chart(data = my_iterable, series_type = 'line') + +This will create a :class:`Chart ` instance +with one :term:`series` of type ``line`` (represented as a +:class:`LineSeries ` instance). + +Depending on how we've wrangled our data, we can similarly produce a chart +from a :class:`pandas.DataFrame `, +:class:`numpy.ndarray `, or Python :class:`dict `: + + .. code-block:: python + + # From a Pandas DataFrame + + my_chart = Chart.from_pandas(df, series_type = 'line') + + # From a Numpy ndarray + + my_chart = Chart.from_array(data = as_ndarray, series_type = 'line') + + # From a Python dict + + my_chart = Chart(data = as_dict, series_type = 'line') + +All of these lines of code are equivalent, and should produce an identical +``my_chart``. + + .. seealso:: + + * :doc:`Using Highcharts Core for Python with Pandas ` + * :doc:`Using Highcharts Core for Python with CSVs ` + * :doc:`Working with Highcharts for Python Series Instances ` + * :doc:`Working with Data in the Highcharts for Python Toolkit ` diff --git a/docs/tutorials/callbacks.rst b/docs/tutorials/callbacks.rst new file mode 100644 index 00000000..83e45e15 --- /dev/null +++ b/docs/tutorials/callbacks.rst @@ -0,0 +1,245 @@ +############################################ +Creating JavaScript Callback Functions +############################################ + +.. contents:: + :depth: 2 + :backlinks: entry + +------------------- + +Highcharts (JS) is known for how easy it is to configure and style +beautiful, interactive data visualizations. One of the core tools +it uses to achieve this is +:term:`callback functions `. + +These can be used throughout your chart configuration, and let you +write custom code that will do what you need it to do in specific +situations. It is incredibly powerful! + +However, Highcharts (JS) is a *JavaScript* suite, and that means +that it only works with *JavaScript* callback functions. So if +we're using **Highcharts for Python**, how do we create JavaScript +:term:`callback functions ` that Highcharts (JS) +will know how to leverage? + +The answer is simple: **Highcharts for Python** provides a special +:class:`CallbackFunction ` +class that you can use to create JavaScript +:term:`callback functions `. When using this class, +you can either: + +#. Write your own JavaScript function and the + :class:`CallbackFunction ` + class will serialize it to JavaScript when needed, or +#. You can write a :term:`callback function` in *Python*, and rely on either + `OpenAI `__'s GPT or `Anthropic `__'s + Claude generative AI model to suggest an equivalent JavaScript function. + +Because you're using **Highcharts for Python**, let's look at the AI-driven +approach first, because we already know how to write Python code. No JavaScript +needed! + +---------------------------- + +******************************************************** +Creating Callback Functions using Generative AI +******************************************************** + +The +:class:`CallbackFunction ` +class has a special helper class method called +:meth:`.from_python() ` +which can automatically create a +:class:`CallbackFunction ` +instance containing the JavaScript function that you need. + +Here's how that works. Let's imagine a scenario where we want a custom tooltip formatter function +that customizes the content of each data point's tooltips. We can write that function in Python like +so: + + .. code-block:: python + + def my_custom_formatter(): + return f'The value for {this.x} is {this.y}.' + +Really pretty straightforward, right? Now we can produce its equivalent by passing +``my_custom_formatter`` as an argument to +:meth:`.from_python() `: + + .. code-block:: python + + my_callback = CallbackFunction.from_python(my_custom_formatter) + +What the +:meth:`.from_python() ` +method call will do is: + + #. It will take the *Python* function's source code, and pass it to the generative AI model of your + choice. + #. The AI will return a *JavaScript* function that the AI believes will do the same thing as your + *Python* function. + #. And it will then load that *JavaScript* function into a new + :class:`CallbackFunction ` + instance. + +Now, when you use this +:class:`CallbackFunction ` +instance in your chart configuration, it will get serialized to its approrpriate *JavaScript* source +code form when appropriate, for example when calling +:meth:`Chart.display() ` or +:meth:`Chart.to_js_literal() `. + +Using Different Models +=============================== + +**Highcharts for Python** supports different models provided by `OpenAI `__ +and `Anthropic `__. + +OpenAI's models in particular differ based on the *version* of GPT that the model supports, as +well as the number of tokens that they allow (more tokens mean they can convert more complicated/longer +function). Most typical callback functions should be converted reasonably reliably using +the default model ``gpt-3.5-turbo``, though others are available: + +* **OpenAI** + + * ``'gpt-3.5-turbo'`` (default) + * ``'gpt-3.5-turbo-16k'`` + * ``'gpt-4'`` + * ``'gpt-4-32k'`` + +* **Anthropic** + + * ``'claude-instant-1'`` + * ``'claude-2'`` + +To use a different model, simply pass the ``model`` argument to the +:meth:`.from_python() ` +method: + + .. code-block:: python + + my_callback = CallbackFunction.from_python(my_custom_formatter, model = "gpt-4") + +Authenticating with Your AI Provider +========================================== + + .. caution:: + + Because this relies on the outside APIs exposed by + `OpenAI `__ and `Anthropic `__, + 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. + +To use one of the supported AI models, you *must* have a valid user/customer account with either +`OpenAI `__ or `Anthropic `__. You must also have +an API key to their respective platform that has permission to use the model you request. You can +set your account up and get the relevant API key from each of the AI providers, respectively. + +When you have the API key, you can pass it in as an argument (``api_key``) to the +:meth:`.from_python() ` +method: + + .. code-block:: python + + my_callback = CallbackFunction.from_python(my_custom_formatter, api_key = "YOUR-API-KEY-GOES-HERE") + +However, if you do not supply an explicit ``api_key`` value, **Highcharts for Python** will look for +the API key in your ``OPENAI_API_KEY`` or ``ANTHROPIC_API_KEY`` environment variables. + +.. tip:: + + **BEST PRACTICE:** Treat your API key as a highly-sensitive piece of information. It should never + be listed in your source code, or in your Jupyter Notebook. It should *only* be read from environment + variables, which in turn should get set with as few places where your API key is visible/available as + possible. + +Reviewing Your JavaScript Code +=================================== + + .. 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". + +Once you have created a +:class:`CallbackFunction ` +instance using the +:meth:`.from_python() ` +method, you can review the JavaScript source code that was generated by calling ``str()`` on your +:class:`CallbackFunction ` instance: + + .. code-block:: + + print(str(my_callback)) + + # Output: + # function my_custom_formatter() { return 'The value for ' + this.x + ' is ' + this.y + '.'; } + +We **STRONGLY** recommend reviewing the JavaScript source code that was generated before using it in +production. Even if you are not a JavaScript expert, since you know Python and you know what your function *should* +be doing, you can probably follow along close-enough to make sure the JavaScript code "looks right". + + .. tip:: + + **BEST PRACTICE:** Never let the AI generate JavaScript code based on *user-entered* Python code. + + Doing so may introduce unintended security vulnerabilities into your application, and should be + considered *VERY* bad practice. + +------------------------------------- + +******************************************************** +Creating Callback Functions Directly +******************************************************** + +If you do not wish to use generative AI to create your :term:`callback functions `, +you can simply create +:class:`CallbackFunction ` +instances directly. You can do this by: + +Instantiating the ``CallbackFunction`` Directly +=================================================== + +.. code-block:: python + + my_callback = CallbackFunction(function_name = 'my_formatter', + arguments = None, + body = """return 'The value for ' + this.x + ' is ' + this.y + '.';""") + +When instantiating the callback function directly, you supply the body of the function as a string to +the ``body`` argument. A best practice is to use Python's triple-quote syntax to make it easier to +handle quotation marks *within* your JavaScript code. + +Using ``.from_js_literal()`` +================================= + +If you have your JavaScript function in a string, you can use the +:meth:`CallbackFunction.from_js_literal() ` class method to create the callback function instance: + + .. code-block:: python + + callback_as_str = """function my_formatter() { + return 'The value for ' + this.x + ' is ' + this.y + '.'; + }""" + + my_callback = CallbackFunction.from_js_literal(callback_as_str) + +---------- + +And that's it! When your +:class:`CallbackFunction ` +instances are used in your chart configuration, they will automatically be serialized to the +appropriate JavaScript syntax when needed. \ No newline at end of file diff --git a/docs/tutorials/csv.rst b/docs/tutorials/csv.rst new file mode 100644 index 00000000..11cc4379 --- /dev/null +++ b/docs/tutorials/csv.rst @@ -0,0 +1,471 @@ +######################################################## +Using Highcharts Core for Python with CSVs +######################################################## + +.. contents:: + :depth: 2 + :backlinks: entry + +------------------- + +CSV (Comma-Separated Value) is one of the most common formats for exchanging +data files - both large and small. Because of its popularity, the +**Highcharts for Python Toolkit** is designed to work seamlessly with it. + +.. sidebar:: What Can Highcharts for Python do with CSVs? + + The **Highcharts for Python Toolkit** is a *visualization* library. It is + not used to perform *arithmetical*, *algebraic*, *statistical*, or *matrix* + calculations. It is exclusively concerned with visualizing data - and that + data is typically the product of calculations that are performed up-stream. + + So given that focus, **Highcharts for Python** is designed to *read* data + that is contained in CSV files and load the data they contain into + appropriate **Highcharts for Python** classes, which can then be rendered + using Highcharts (JS). + +********************************* +General Approach +********************************* + +The **Highcharts for Python Toolkit** provides a number of standard methods +that are used to interact with CSV files. These methods generally take the form: + + * ``.from_csv(as_string_or_file)`` This is always a class method which produces one or more + instances, with data pulled from the CSV content found in ``as_string_or_file``. + * ``.from_csv_in_rows(as_string_or_file)`` This is always a class method which produces one + instance for every *row* in the CSV (``as_string_or_file``). + * ``.load_from_csv(as_string_or_file)`` This is an instance method which updates an instance + with data read from the ``as_string_or_file`` argument. + +.. tip:: + + All three of these standard methods are packaged to have batteries included. + This means that for simple use cases, you can simply pass a + CSV to the method, and the method wlil attempt to determine the best way to + deserialize the CSV into the appropriate **Highcharts for Python** objects. + + However, if you find that you need more fine-grained control, the methods + provide powerful tools to give you the control you need when you need it. + +These standard methods - with near-identical syntax - are available: + + * On all :term:`series` classes (descended from + :class:`SeriesBase `) + * On the :class:`Chart ` class + +------------------------------- + +***************************** +Preparing Your CSV Data +***************************** + +So let's try a real-world example. Let's say you've got some annual population +counts stored in a CSV file named ``'census-time-series.csv'`` that looks like this: + + .. image:: /_static/tutorials/census-time-series-csv-01.png + :width: 100% + :alt: Rendering of the data contained in 'census-time-series.csv' + +The first column contains the names of geographic regions, while each of the subsequent +columns contains the population counts for a given year. Now, let's say we wanted +to visualize this data in various ways. + +------------------------------ + +********************************************* +Creating the Chart: ``Chart.from_csv()`` +********************************************* + +Relying on the Defaults +=========================== + +The simplest way to create a chart from a CSV file is to call +:class:`Chart.from_csv() ` like +so: + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + wrapper_character = '"') + my_chart.display() + +.. image:: /_static/tutorials/census-time-series-03.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_csv('census-time-series.csv') + +As you can see, we haven't provided any more instructions besides telling it to +generate a chart from the file ``'census-time-series.csv'``, and to interpret a single +quotation mark as a wrapper character. The result is a line chart, with one series for +each year, and one point for each region. + + .. tip:: + + Unless instructed otherwise, **Highcharts for Python** will default to using a + line chart. + +Setting the Series Type +============================== + +Why don't we switch it to a bar chart? + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'bar', + wrapper_character = '"') + +.. image:: /_static/tutorials/census-time-series-04.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_csv('census-time-series.csv', series_type = 'bar') + +Now the result is a little more readable, but still not great: After all, there are more than +fifty geographic regions represented for each year, which makes the chart super crowded. +Besides, maybe we're only interested in a specific year: 2019. + +Let's try focusing our chart. + +Basic Property Mapping +========================== + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'bar', + property_column_map = { + 'x': 'Geographic Area', + 'y': '2019' + }) + +.. image:: /_static/tutorials/census-time-series-05.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_csv('census-time-series.csv', series_type = 'bar', property_column_map = {'x': 'Geographic Area', 'y': '2019'}) + +Much better! We've now added a ``property_column_map`` argument to the ``.from_csv()`` method call. +This argument tells Highcharts for Python how to map columns in your data to properties in the +resulting chart. In this case, the keys ``'x'`` and ``'y'`` tell **Highcharts for Python** that you want to map the ``'Geographic Area'`` column to the resulting series' data points' +:meth:`.x `, +and to map the ``'2019'`` column to the :meth:`.y ` +properties, respectively. + +The net result is that ``my_chart`` contains one +:class:`BarSeries ` whose +:meth:`.data ` property contains a +:class:`BarDataCollection ` instance populated +with the data from the ``'Geographic Area'`` and ``'2019'`` columns in ``census-time-series.csv``. + +But maybe we actually want to compare a couple different years? Let's try that. + + .. tip:: + + Not all CSV data contains a header row. If your CSV data does not contain a header row, + ``property_column_map`` accepts :class:`int ` values, which indicate the + index of the column that you want to map. So the method call above would be equivalent to: + + .. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'bar', + property_column_map = { + 'x': 0, + 'y': 10 + }) + +Property Mapping with Multiple Series +======================================== + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'column', + property_column_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'] + }) + +.. image:: /_static/tutorials/census-time-series-06.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_csv('census-time-series.csv', series_type = 'bar', property_column_map = {'x': 'Geographic Area', 'y': ['2017', '2018', '2019']}) + +Now we're getting somewhere! We've added a list of column names to the ``'y'`` key in the ``property_column_map`` +argument. Each of those columns has now produced a *separate* +:class:`BarSeries ` instance - but they're +all still sharing the ``'Geographic Area'`` column as their +:meth:`.x ` value. + + .. note:: + + You can supply multiple values to any property in the ``property_column_map``. The example + provided above is equivalent to: + + .. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'column', + property_column_map = { + 'x': ['Geographic Area', 'Geographic Area', 'Geographic Area'], + 'y': ['2017', '2018', '2019'] + }) + + The only catch is that the ultimate number of values for each key must match. If there's + only one value, then it will get repeated for all of the others. But if there's a mismatch, + then **Highcharts for Python** will throw a + :exc:`HighchartsCSVDeserializationError `. + +But so far, we've only been using the ``'x'`` and ``'y'`` keys in our ``property_column_map``. What if we wanted to +configure additional properties? Easy! + +Configuring Additional Properties +===================================== + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'bar', + property_column_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'], + 'id': 'some other column' + }) + +Now, our CSV is pretty simple does not contain a column named ``'some other column'`. But *if* it did, +then it would use that column to set the :meth:`.id ` property of each data point. + + .. note:: + + You can supply any property you want to the ``property_map``. If the property is not + supported by the series type you've selected, then it will be ignored. + +But our chart is still looking a little basic - why don't we tweak some series configuration options? + +Configuring Series Options +=============================== + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'bar', + property_column_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'], + }, + series_kwargs = { + 'point_padding': 5 + }) + +.. image:: /_static/tutorials/census-time-series-07.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_csv('census-time-series.csv', series_type = 'bar', property_column_map = {'x': 'Geographic Area', 'y': ['2017', '2018', '2019'], 'id': 'Geographic Area'}, series_kwargs = {'point_padding': 0.25}) + +As you can see, we supplied a new ``series_kwargs`` argument to the ``.from_csv()`` method call. This +argument receives a :class:`dict ` with keys that correspond to properties on the series. In +this case, by supplying ``'point_padding'`` we have set the resulting +:meth:`BarSeries.point_padding ` property to a +value of ``0.25`` - leading to a bit more spacing between the bars. + +But our chart is *still* a little basic - why don't we give it a reasonable title? + +Configuring Options +============================= + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'bar', + wrapper_character = '"', + property_column_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'] + }, + series_kwargs = { + 'point_padding': 5 + }, + options_kwargs = { + 'title': { + 'text': 'This Is My Chart Title' + } + }) + +.. image:: /_static/tutorials/census-time-series-08.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_csv('census-time-series.csv', series_type = 'bar', property_column_map = {'x': 'Geographic Area', 'y': ['2017', '2018', '2019'], 'id': 'Geographic Area'}, series_kwargs = {'point_padding': 0.25}, options_kwargs = {'title': {'text': 'This Is My Chart Title'}}) + +As you can see, we've now given our chart a title. We did this by adding a new ``options_kwargs`` argument, +which likewise takes a :class:`dict ` with keys that correspond to properties on the chart's +:class:`HighchartsOptions ` configuration.` + +Now let's say we wanted our chart to render in an HTML ``
`` with an ``id`` of ``'my_target_div`` - +we can configure that in the same method call. + +Configuring Chart Settings +============================== + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'bar', + wrapper_character = '"', + property_column_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'], + }, + series_kwargs = { + 'point_padding': 0.25 + }, + options_kwargs = { + 'title': { + 'text': 'This Is My Chart Title' + } + }, + chart_kwargs = { + 'container': 'my_target_div' + }) + +While you can't really *see* the difference here, by adding the ``chart_kwargs`` argument to +the method call, we now set the :meth:`.container ` property +on ``my_chart``. + +But maybe we want to do something a little different - like compare the change in population over time. +Well, we can do that easily by visualizing each *row* of ``census-time-series.csv`` rather than each *column*.` + +Visualizing Data in Rows +============================== + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'line', + series_in_rows = True, + wrapper_character = '"') + +.. image:: /_static/tutorials/census-time-series-09.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_csv('census-time-series.csv', series_type = 'line', series_in_rows = True) + +Okay, so here we removed some of the other arguments we'd been using to simplify the example. You'll see we've now +added the ``series_in_rows`` argument, and set it to ``True``. This tells **Highcharts for Python** that we expect +to produce one series for every *row* in ``census-time-series.csv``. + +Because we have not specified a ``property_column_map``, the series +:meth:`.name ` values are populated from the ``'Geographic Area'`` +column, while the data point :meth:`.x ` values come from each additional column (e.g. ``'2010'``, ``'2011'``, ``'2012'``, etc.) + + .. tip:: + + To simplify the code further, any class that supports the ``.from_csv()`` method also + supports the ``.from_csv_in_rows()`` method. The latter method is equivalent to passing + ``series_in_rows = True`` to ``.from_csv()``. + + For more information, please see: + + * :class:`Chart.from_csv_in_rows() ` + * :class:`SeriesBase.from_csv_in_rows() ` + +But maybe we don't want *all* geographic areas shown on the chart - maybe we only want to compare a few. + +Filtering Rows +======================= + +.. code-block:: python + + my_chart = Chart.from_csv('census-time-series.csv', + series_type = 'line', + series_in_rows = True, + wrapper_character = '"', + series_index = slice(7, 10)) + +.. image:: /_static/tutorials/census-time-series-10.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_csv('census-time-series.csv', series_type = 'line', series_in_rows = True, series_index = slice(7, 10)) + +What we did here is we added a ``series_index`` argument, which tells **Highcharts for Python** to only +include the series found at that index in the resulting chart. In this case, we supplied a :func:`slice ` +object, which operates just like ``list_of_series[7:10]``. The result only returns those series between index 7 and 10. + +------------------------ + +********************************************************************** +Creating Series: ``.from_csv()`` and ``.from_csv_in_rows()`` +********************************************************************** + +All **Highcharts for Python** :term:`series` descend from the +:class:`SeriesBase ` class. And they all +therefore support the ``.from_csv()`` class method. + +When called on a series class, it produces one or more series from the CSV supplied. +The method supports all of the same options +as :class:`Chart.from_csv() ` *except for* ``options_kwargs`` and +``chart_kwargs``. This is because the ``.from_csv()`` method on a series class is only responsible for +creating series instances - not the chart itself. + +Creating Series from Columns +================================== + +So let's say we wanted to create one series for each of the years in ``census-time-series.csv``. +We could do that like so: + + .. code-block:: python + + my_series = BarSeries.from_csv('census-time-series.csv') + +Unlike when calling :meth:`Chart.from_csv() `, we +did not have to specify a ``series_type`` - that's because the ``.from_csv()`` class method on a +series class already knows the series type. + +In this case, ``my_series`` now contains ten separate :class:`BarSeries ` +instances, each corresponding to one of the year columns in ``census-time-series.csv``. + +But maybe we wanted to create our series from rows instead? + +Creating Series from Rows +================================= + +.. code-block:: python + + my_series = LineSeries.from_csv_in_rows('census-time-series.csv') + +This will produce one :class:`LineSeries ` +instance for each row in ``census-time-series.csv``, ultimately producing a +:class:`list ` of 57 +:class:`LineSeries ` instances. + +Now what if we don't need all 57, but instead only want the first five? + +Filtering Series Created from Rows +====================================== + +.. code-block:: python + + my_series = LineSeries.from_csv_in_rows('census-time-series.csv', series_index = slice(0, 5)) + +This will return the first five series in the list of 57. +-------------------------- + +*********************************************************** +Updating an Existing Series: ``.load_from_csv()`` +*********************************************************** + +So far, we've only been creating new series and charts. But what if we want to update +the data within an existing series? That's easy to do using the +:meth:`.load_from_csv() ` method. + +Let's say we take the first series returned in ``my_series`` up above, and we want to replace +its data with the data from the *10th* series. We can do that by: + + .. code-block:: python + + my_series[0].load_from_csv('census-time-series.csv', + series_in_rows = True, + series_index = 9) + +The ``series_in_rows`` argument tells the method to generate series per row, and then +the ``series_index`` argument tells it to only use the 10th series generated. + + .. caution:: + + While the :meth:`.load_from_csv() ` + method supports the same arguments as + :meth:`.from_csv() `, it expects that + the arguments supplied lead to an unambiguous *single* series. If they are ambiguous - meaning they + lead to multiple series generated from the CSV - then the method will throw a + :exc:`HighchartsCSVDeserializationError ` \ No newline at end of file diff --git a/docs/tutorials/data.rst b/docs/tutorials/data.rst new file mode 100644 index 00000000..a9ec5f37 --- /dev/null +++ b/docs/tutorials/data.rst @@ -0,0 +1,412 @@ +######################################################## +Using Highcharts Core for Python with Pandas +######################################################## + +.. contents:: + :depth: 2 + :backlinks: entry + +------------------- + +The **Highcharts for Python Toolkit** is a data *visualizaiton* library. +That means that it is designed to let you visualize the data that you +or your users are analyzing, rather than to do the analysis itself. But +while there are better tools to actually crunch the numbers, +**Highcharts for Python** still has to work closely with your data in +order to visualize it. + +When working with **Highcharts for Python**, it can be useful to +understand: + + #. How **Highcharts for Python** represents your data + #. How to load your data into a **Highcharts for Python** object + #. How to adjust your data in **Highcharts for Python** + #. How **Highcharts for Python** serializes your data for + Highcharts (JS). + +------------------- + +************************************* +Highcharts for Python Data Model +************************************* + +In broad brushstrokes, you can think of your **Highcharts for Python** +chart as a tree. + +.. list-table:: + :widths: 30 70 + + * - .. image:: /_static/highcharts-chart-anatomy.png + :width: 100% + :alt: Diagram of chart structure: Chart > Options > Series Collection > Series > Data Collection > Data Point + +At the root of the tree is a +:class:`Chart `, and that chart contains +options (:class:`HighchartsOptions `). +Those options in turn contain a collection of :term:`series`, +each of which can be thought of as one "line" of data in your visualization. + +Each series instance (descended from +:class:`SeriesBase `) +contains a :meth:`.data ` +property, which contains a set of :term:`data points `. + +Depending on your data and your configuration, this set of data points may +be represented as: + + * a :class:`DataPointCollection ` + instance (or a descendent of it) which in turn contains your data values and related + configuration options + * an iterable of + :class:`DataBase `-descended instances, + each of which contains the data value and configuration of an invidual :term:`data point` + +This model is relatively straightforward, but there is one important complexity: the +relationship between +:class:`DataPointCollection ` +instances and :class:`DataBase ` instances. + +:class:`DataPointCollection ` vs :class:`DataBase ` +======================================================================================================================================================================================= + +The :class:`DataPointCollection ` +class stores your individual data points in a combination of three different list-like structures: + + * as a :class:`numpy.ndarray ` in the + :meth:`.ndarray ` property + * as a :class:`list ` of + :class:`DataBase ` instances in the + :meth:`.data_points ` + property + * as a :class:`list ` of primitives (e.g. numbers, strings, etc.) in the + :meth:`.array ` + property + +Why split it up like this? The purpose is to maximize performance within both +**Highcharts for Python** and Highcharts (JS), while still minimizing outside dependencies. + +Highcharts (JS) supports data organized in primitive arrays. So it can easily visualize something +like the following: + + .. code-block:: python + + [ + [0, 12], + [1, 34], + [2, 56], + [3, 78], + [4, 90] + ] + +This way of representing your data gives you the fastest performance in Highcharts (JS), +leading to lightening-fast rendering of your chart. And since it's just a simple list of +numbers, **Highcharts for Python** doesn't have to apply any fancy logic to serialize it to +:term:`JS literal notation ` - leading to fast +performance in Python as well. + +This is why the +:class:`DataPointCollection ` +separates the data that can be represented as a primitive array (stored in either +:meth:`.ndarray ` or +:meth:`.array `), from +data point properties that need to be represented as a full Highcharts (JS) data point object +(stored in +:meth:`.data_points `). + +And if you're familiar with `NumPy `__, that looks *just* like +a :class:`ndarray ` - and for good reason! If you have +`NumPy ` installed, **Highcharts for Python** will leave your +:class:`ndarray ` objects as-is to benefit from its vectorization +and performance. + +Internally, +:class:`DataPointCollection ` +instances will intelligently combine the information stored in these three different properties +to serialize your data points. This is done as-appropriately, generating a list of renderable +data points represented either as a primitive array, or as full objects, depending on the +properties that have been configured. + +So do you have to worry to about this complexity? Not really! All of this happens under the +hood in the **Highcharts for Python** code. You can simply load your data using the +convenience methods available on your series instances +:class:`DataPointCollection ` +or its descendents, or simply pass your data to the series +:class:`.data ` property. + +Let's see how this works in practice. + +------------------------ + +***************************************** +Loading Data into Highcharts for Python +***************************************** + +Preparing Your Data +=========================== + +So let's try a real-world example. Let's say you've got some annual population +counts stored in a CSV file named ``'census-time-series.csv'``. There are four +different ways you can represent this data: + + #. As-is in the CSV file. Meaning you don't do anything, just leave it + in the file as-is. + #. Loaded into a Python iterable (i.e. a :class:`list ` of + :class:`list `, where each inner list represents a row from + the CSV). This might look something like this: + + .. code-block:: python + + raw_data = [ + ['United States', 309321666, 311556874, 313830990, 315993715, 318301008, 320635163, 322941311, 324985539, 326687501, 328239523], + ['Northeast', 55380134, 55604223, 55775216, 55901806, 56006011, 56034684, 56042330, 56059240, 56046620, 55982803], + ['Midwest', 66974416, 67157800, 67336743, 67560379, 67745167, 67860583, 67987540, 68126781, 68236628, 68329004], + ... + ] + + #. As a :class:`numpy.ndarray `, which might look like this: + + .. list-table:: + :widths: 30 70 + + - .. image:: /_static/tutorials/raw-data-as-numpy.png + :width: 100% + :alt: Rendering of the numpy.ndarray produced by np.genfromtext('census-time-series.csv', delimiter = ',', names = True) + + #. As a :class:`pandas.DataFrame `, which might look like this: + + .. list-table:: + :widths: 30 70 + + * - .. code-block:: python + + raw_data = pandas.read_csv('census-time-series.csv', + index_col = 0, + thousands = ',', + delimiter = ',') + + * - .. image:: /_static/tutorials/census-time-series-02.png + :width: 100% + :alt: Rendering of the Pandas DataFrame loaded from "census-time-series.csv" + +Now that we've got our data prepared, let's add it to a series or chart. + +Creating a Series/Chart with Data +====================================== + +.. note:: + + In this tutorial, we'll focus on assembling one or more :term:`series` of data, rather than + a complete chart. This is because chart's have many more configuration options, but + fundamentally the data that they contain is stored within one or more series instances, + which themselves contain data points in a + :class:`DataPointCollection ` + or an iterable of + :class:`DataBase ` instances. + +So now that we have ``raw_data`` prepared, we can now load it into a series. There are four ways to do +this: + + #. By passing it to the :meth:`.data ` property + of our series when instantiating the series: + + .. code-block:: python + + from highcharts_core.options.series.area import LineSeries + + my_series = LineSeries(data = raw_data) + + #. By calling one of the "helper" methods: + + .. code-block:: python + + from highcharts_core.options.series.area import LineSeries + + # If my data is either a numpy.ndarray or Python iterable + my_series = LineSeries.from_array(raw_data) + + # If my data is in a Pandas DataFrame + my_series = LineSeries.from_pandas(raw_data) + + # If my data is in a CSV file + my_series = LineSeries.from_csv('census-time-series.csv') + + .. seealso:: + + Depending on the arguments you supply to the helper methods, they + may produce *multiple* series for inclusion on your chart. For more + information, please see: + + * :doc:`Using Highcharts for Python with Pandas ` + * :doc:`Using Highcharts for Python with CSVs ` + + #. By instantiating your *set* of data directly, and passing it to the + :meth:`.data ` property + of our series: + + .. code-block:: python + + from highcharts_core.options.series.area import LineSeries + from highcharts_core.options.series.data.cartesian import CartesianData + + my_data = CartesianData.from_array(raw_data) + + my_series = LineSeries(data = my_data) + + .. seealso:: + + Depending on the arguments you supply to the helper methods, they + may produce *multiple* series for inclusion on your chart. For more + information, please see: + + * :doc:`Using Highcharts for Python with Pandas ` + * :doc:`Using Highcharts for Python with CSVs ` + + #. By instantiating *individual* data points directly, and passing it to + the :meth:`.data ` property + of our series: + + .. code-block:: python + + from highcharts_core.options.series.area import LineSeries + from highcharts_core.options.series.data.cartesian import CartesianData + + my_data = [CartesianData(x = record[0], y = record[1] for record in raw_data] + + my_series = LineSeries(data = my_data) + + +In all cases, the result is the same: a +:class:`LineSeries ` instance (or a +:class:`list ` of +:class:`LineSeries ` that contain your data. + +Now that your data has been loaded into your series, you can configure it as needed. + +Configuring Your Data +========================================= + +In most cases, you shouldn't have to worry about the internals of how **Highcharts for Python** +stores your data. Depending on whether you supplied a primitive array, a +:class:`numpy.ndarray `, or data from a Pandas +:class:`DataFrame `, your series' data will either be represented as +a :class:`DataPointCollection ` +or as a :class:`list ` of data point objects (descended from +:class:`DataBase `). + +In all cases, you can easily set properties on your data via your series object itself. For +example, let's say we wanted to configure the +:meth:`.target ` values on data points +in a :class:`BulletSeries ` instance. We +can do that easily by working at the *series* level: + + .. code-block:: python + + # EXAMPLE 1. + # Supplying one value per data point. + + my_series.target = [1, 2, 3, 4, 5, 6] + + # EXAMPLE 2. + # Supplying one value, which will be applied to ALL data points. + + my_series.target = 2 + +This propagation of data point properties extends to *all* data point properties. If a +property of the same name exists on the series, it will be set on the *series*. But if +it only exists on the data point, it will be propagated to the relevant data points. + +In some circumstances, you may want to set data point properties that have identically-named +properties on the series. For example, data points and series both support the ``.id`` property. +But you can set this property at the data point level in two ways: + + #. If your data point is represented as a + :class:`DataPointCollection `, + you can simply set it as a sub-property of the series + :meth:`.data ` property: + + .. code-block:: python + + # EXAMPLE 1. + # Supplying one value per data point. + my_series.data.id = ['id1', 'id2', 'id3', 'id4', 'id5', 'id6'] + + # EXAMPLE 2. + # Supplying one value, which will be applied to ALL data points. + + my_series.data.id = 'id2' + + The :class:`DataPointCollection ` + will worry about proagating the relevant property / value to the individual data points as needed. + + #. If you data points are represented as a :class:`list ` of + :class:`DataBase `-descended objects, then you can + adjust them the same way you would adjust any member of a list: + + .. code-block:: + + id_list = ['id1', 'id2', 'id3', 'id4', 'id5', 'id6'] + for index in range(len(series.data)): + series.data[index].id = id_list[index] + + In this case, you are adjusting the data points directly, so you do need to make sure you are + adjusting the exact properties you need to adjust in the exact right location. + +Updating Your Data +======================== + +You can also update your data after it has been loaded into your series. This is done by calling one +of the ``.load_from_*`` series helper methods, which makes it possible to update your series' data +just like when creating the series: + + .. code-block:: python + + # EXAMPLE 1. + # Updating the .data property + + my_series.data = updated_data + + # EXAMPLE 2. + # If my data is either a numpy.ndarray or Python iterable + + my_series.load_from_array(updated_data) + + # EXAMPLE 3. + # If my data is in a Pandas DataFrame + + my_series.load_from_pandas(updated_data) + + # EXAMPLE 4. + # If my data is in a CSV file + + my_series.load_from_csv('updated-data.csv') + +--------------------------- + +*************************************** +Serializing Your Data for Rendering +*************************************** + +While you shouldn't have to serialize your data directly using **Highcharts for Python**, it +may be useful to understand how this process works. + +First, it's important to understand that Highcharts (JS) supports data represented in two different +forms: + + * as :term:`JavaScript literal objects `, and + * as primitive arrays, which are basically collections of strings and numbers + +JS literal objects are the most flexible, because they allow you to take advantage of all of the +different data point configuration options supported by Highcharts. However, primitive arrays +perform much faster: Highcharts for Python generates them faster, there's less data to transfer on the wire, and Highcharts (JS) can render them faster. + +For this reason, **Highcharts for Python** will always try to serialize your data points to a +primitive array first. If the series type supports a primitive array, and there is no information configured +on the data points that prevents it from being serialized as a primitive array, Highcharts for Python +will default to that form of serialization. + +However, if there are special properties (not supported by primitive arrays) set on the data points, or if +the series type is one that does not support primitive arrays, then Highcharts for Python will generate +a JavaScript literal object instead. + +This logic all happens automatically whenever you call +:class:`.to_js_literal() ` on your series. diff --git a/docs/tutorials/exporting.rst b/docs/tutorials/exporting.rst new file mode 100644 index 00000000..f122fa3c --- /dev/null +++ b/docs/tutorials/exporting.rst @@ -0,0 +1,161 @@ +####################################################### +Exporting Static Charts with Highcharts for Python +####################################################### + +.. contents:: + :depth: 2 + :backlinks: entry + +------------------- + +Highcharts (JS) and **Highcharts for Python** are designed to produce beautiful, powerful, highly-interactive +data visualizations. However, there are moments when you or your users want to create a static chart for inclusion in +presentations, documents, etc. Highcharts has you covered for that as well! + +------------ + +********************** +Client-side Exporting +********************** + +Using **Highcharts for Python**, you can configure client-side export within the +:meth:`Chart.options ` settings by configuring the +:meth:`options.exporting ` +property. + +In particular, you can apply configuration through an +:class:`Exporting ` instance, which +lets you configure how your chart will support exporting from within the user's +browser when the chart is rendered. Here’s a quick example, assuming you have a +:meth:`Chart `` instance called ``my_chart``: + + .. code-block:: python + + from highcharts_core.options.exporting import Exporting + + exporting_options = Exporting(enabled = True, + filename = 'your-exported-chart', + show_table = True, + table_caption = "Your Chart's Caption Goes Here", + accessibility = { + 'enabled': True + }) + + exporting_options.buttons['contextButton'].menu_items = ['printChart', 'separator', 'downloadPNG'] + + my_chart.options.exporting = exporting_options + +And that's it. With the code above you've now configured some basic logic that: + + * Enables client-side export of the ``my_chart`` visualization. + * Gives an exported chart image a default filename of ``'your-exported-chart'`` (not + including the file's extension). + * Makes sure that the exported or printed version of your chart includes the chart's + underlying data table (thanks to the ``show_table`` property being set to ``True``), + and + * Gives users the ability to either print the chart or download a PNG version of the chart, + but nothing else (by setting the relevant buttons shown in the context menu). + +Highcharts Core supports client-side export to a number of formats, including: + + * PNG + * JPEG + * SVG + * CSV + * Excel + +And you can also configure the client-side export to fall back to server-side export should it fail. + + .. seealso:: + + * For more details on the extensive options, please see :mod:`highcharts_core.options.exporting` + +--------- + +************************************** +Programmatic (Server-side) Export +************************************** + +So now that your users can download a PNG image of your chart, maybe you want to create an image programmatically. **Highcharts for Python** makes that possible through an integration with the **Highcharts Export Server**. It's actually trivially easy to do since you can do it with just one method call in your Python code: + + .. code-block:: python + + # EXAMPLE 1. + # Download a PNG version of the chart in memory within your Python code. + + my_png_image = my_chart.download_chart(format = 'png') + + # EXAMPLE 2. + # Download a PNG version of the chart and save it the file "/images/my-chart-file.png" + my_png_image = my_chart.download_chart( + format = 'png', + filename = '/images/my-chart-file.png' + ) + +The two examples shown above both download a PNG of your chart: + + #. The first example keeps that PNG image in your Python code only, storing its binary data in the + ``my_png_image`` variable. + #. The second example not only stores its binary data in the ``my_png_image`` variable, but it *also* saves + the PNG image to the file ``'/images/my-chart-file.png'``. + +The format argument is really the one doing the heavy lifting above. In the example above, it tells the method to generate a PNG image, but you can also create: + + * ``'jpeg'`` + * ``'pdf'``, and + * ``'svg' + +And that's it! There's really nothing simpler. + +.. note:: + + Under the hood, this method defaults to calling the Highcharts Export Server that is maintained by + `Highsoft `__ (creators of Highcharts Core (JS)). This publicly-available server + is available to all licensees of Highcharts Core, and you are free to use it to generate downloadable + versions of your data visualizations. + + However, it is rate-limited and it does mean transmitting your chart's data across the wire. There are + various situations in which this is inappropriate, which is why Highsoft allows you to configure and deploy + your own Highcharts Export Server. And Highcharts for Python supports using your own custom Export Server + for your programmatic chart exports. + +------------------------ + +************************************ +Using a Custom Export Server +************************************ + +.. tip:: + + While deploying your own Highcharts Export Server is beyond the scope of this tutorial, we strongly recommend that + you review the + `Highcharts Export Server documentation `__ + +If you have your own Highcharts Export Server, you can override **Highcharts for Python**'s default to +have your code rely on your own export server. While you can do this by creating an instance of +:class:`highcharts_core.headless_export.ExportServer` with your custom configuration and passing it to the +:meth:`.download_chart() ` method in the ``server_instance`` +argument, it is far easier to simply set some environment variables wherever your Python code will be running: + + * ``HIGHCHARTS_EXPORT_SERVER_DOMAIN`` is the environment variable that specifies the domain where the Highcharts + Export Server exists. If this environment variable is not set, it will default to ``"export.highcharts.com"``, + which is the Highsoft-provided export server. + * ``HIGHCHARTS_EXPORT_SERVER_PATH`` is the path at the domain where your Export Server is reachable. If this + environment variable is not set, it will default to :obj:`None ` since there is no path when using the + Highsoft-provided export server. + * ``HIGHCHARTS_EXPORT_SERVER_PORT`` is the port where your Export Server is reachable. If this environment variable + is not set, it will default to :obj:`None ` since there is no need to specify a port when using the + Highsoft-provided export server. + +In addition to the three environment variables above, the +:meth:`.download_chart() ` method also supports +three additional arguments which may prove useful: + + * ``auth_user`` which is the user to supply to your custom Export Server using Basic authentication. This defaults to + :obj:`None ` (since the default Highsoft-provided Export Server has no authentication). + * ``auth_password`` which is the password to supply to your custom Export Server using Basic authentication. This + :obj:`None ` (since the default Highsoft-provided Export Server has no authentication). + * ``timeout`` which is the number of seconds to wait before issuing a timeout error. The timeout check is passed if + any bytes have been received on the socket in less than this number of seconds. It defaults to ``0.5``, but you may + want to adjust this when using your own custom Export Server. + diff --git a/docs/tutorials/getting_started.rst b/docs/tutorials/getting_started.rst new file mode 100644 index 00000000..2cd09aea --- /dev/null +++ b/docs/tutorials/getting_started.rst @@ -0,0 +1,274 @@ +############################################## +Highcharts Core for Python: Getting Started +############################################## + +.. contents:: + :depth: 2 + :backlinks: entry + +------------------- + +We're so glad you want to use **Highcharts Core for Python**! This tutorial will help +you get started, and give you a jumping off point to dive into all the great features +of the Highcharts visualization suite. Before you really get going, we suggest you take +a look at all of the great :doc:`visualization types <../visualizations>` you can create +using **Highcharts for Python** and `Highcharts (JS) `__. + +*************************** +Installation +*************************** + +First things first, to use **Highcharts for Python** the first step is to install the +library (likely to a virtual environment). That's pretty straightforward: + +.. include:: ../_installation.rst + +------------------- + +****************************************** +Importing Highcharts Core for Python +****************************************** + +Once you've installed **Highcharts Core for Python**, you can import it into your project +in two different ways: + +.. include:: ../using/_importing.rst + +------------------- + +****************************************** +Wrangle Your Data +****************************************** + +Since you want to use **Highcharts Core for Python** to visualize some data, first +you're going to have to wrangle the data into a form you can work with. How you do +this really depends on the data you are working with and the other tools you are +using in your tech stack. + +The **Highcharts for Python** toolkit works with most of the "usual suspects" in the +Python ecosystem, including: + + * `Pandas `__ + * `Numpy `__ + * `PySpark `__ + * CSV files + * JSON files + * Python :class:`dict ` instances + * Python iterables (e.g. :class:`list `, :class:`tuple `, etc.) + +For the sake of simplicity, we'll work with Python iterables to show how you +can quickly get started. Let's say we have a simple 2-dimensional set of x/y values +that we want to plot: + + .. code-block:: python + + my_iterable = [ + [0, 123], + [1, 456], + [2, 789], + [3, 987], + [4, 654], + [5, 321] + ] + +That's all I need to wrangle my data! **Highcharts for Python** can work with +``my_iterable`` directly and easily, wherever data is referenced. + +.. tip:: + + Different Highcharts :term:`series` types support different structures of + iterable. + + Please review the detailed :ref:`series documentation ` for + series type-specific details of relevant iterable/array structures. + +Alternatively, we can convert ``my_iterable`` into a +:class:`pandas.DataFrame `, :class:`numpy.ndarray `, +or Python :class:`dict `: + + .. code-block:: python + + # As a Pandas DataFrame + df = pandas.DataFrame(my_iterable, columns=['x', 'y']) + + # As a Numpy ndarray + as_ndarray = numpy.as_ndarray(my_iterable) + + # As a Python dict + as_dict = {'x': x[0], 'y': x[1] for x in my_iterable} + +Now, we can consider our data "wrangled" and ready for visualization. + +-------------------- + +****************************************** +Assembling a Chart +****************************************** + +With our data wrangled, we can construct a chart with one line of code: + +.. include:: _assembling_a_chart.rst + +********************************** +Configuring the Chart +********************************** + +`Highcharts (JS) `__ sets the standard for +data visualization because it supports a huge number of easy-to-understand +configuration options. **Highcharts for Python** makes it easy to configure +any of those options directly within Python. + +To do that, we can use the :meth:`Chart.options ` +property. Having assembled our chart following the instructions above, ``my_chart`` +already contains a :class:`HighchartsOptions ` +instance in the :meth:`Chart.options ` property. +You can access the :class:`LineSeries ` +we created at :meth:`Chart.options.series `, +and you can set any other options you need on +:meth:`Chart.options `. + +For example, let's say we want to set the chart's title to "My First Chart". To do that, +we can configure the +:meth:`Chart.options.title ` property +using either a :class:`Title ` instance, or a +:class:`dict `: + + .. code-block:: python + + # as a Title instance + + from highcharts_core.options.title import Title + + my_chart.options.title = Title(text = 'My First Chart') + + # as a dict + + my_chart.options.title = {'text': 'My First Chart'} + +Either way, the chart's title will now be set to "My First Chart". + + .. seealso:: + + * :doc:`Templating and Shared Options ` + * :doc:`Creating JavaScript Callback Functions ` + +---------------- + +****************************************** +Rendering the Chart +****************************************** + +Once we've assembled and configured our chart, we can render it. How we want +to render it depends on our exact needs. We can: + + * Display the chart in a `Jupyter Notebook `__ + * Save the chart as a static image + * Generate the JavaScript that will render the chart in your users' + web browser. + +Displaying in Jupyter Notebook / Jupyter Lab +=============================================== + +If you're using **Highcharts Core for Python** in a Jupyter Notebook or Jupyter Lab, +you can display the chart right in your notebook. Doing so is super simple - just +call the :meth:`.display() ` method on +``my_chart``: + + .. code-block:: python + + my_chart.display() + +That's it! The chart will now render in the output of the cell. + + .. seealso:: + + * :doc:`Using Highcharts Core for Python with Jupyter ` + +Saving the Chart as a Static Image +======================================= + +If you want to save the chart as a static image, you can do so by calling the +:meth:`.download_chart() ` method: + + .. code-block:: python + + my_chart.download_chart(filename = 'my_chart.png') + +If you need it in a different format, you can specify the format using the +``format`` parameter. Highcharts for Python supports PNG (the default), +JPEG, PDF, and SVG. For example, to save the chart as a PDF, you can do: + + .. code-block:: python + + my_chart.download_chart(filename = 'my_chart.pdf', format = 'pdf') + +And that's it! + + .. seealso:: + + * :doc:`Exporting Static Charts ` + +Rendering in the Web Browser +================================ + +If you want to render your chart in your user's web browser, then you can +use **Highcharts for Python** to automatically generate the JavaScript code +you will need. The best way to do this is to call the +:meth:`.to_js_literal() ` method +on ``my_chart``. + +This will produce a string (or write to a file) containing the +:term:`JS literal ` form of your chart and +its configuration. If the code contained in this string gets executed in your +user's browser (within a set of ```` tags), it will render +your chart. + +So the way to get the JS literal is very straightforward: + + .. code-block:: python + + # EXAMPLE 1. + # Storing the JS literal in a string. + + my_js_literal = my_chart.to_js_literal() + + # EXAMPLE 2. + # Saving the JS literal to a file named + # "my-js-literal.js" + + my_chart.to_js_literal('my-js-literal.js') + +Now the way to render this chart will ultimately depend on how your application +is architected. We see three - typical - patterns employed: + + #. If your Python code is responsible for preparing the client-side + HTML + JavaScript, then you can include ``my_js_literal`` in your + template file. This pattern works for practically every Python web + framework, including `Django `__, + and `Flask `__. + #. If your Python code exposes RESTful or GraphQL APIs that are + consumed by your client-side application, then you can return the JS + literal object as a string via your API. This can then be evaluated + in your client-side application using JavaScript's ``new Function()`` + feature. + + .. caution:: + + **DO NOT USE JAVASCRIPT'S eval() FUNCTION**. + + It is deprecated, and for good reason: + + It represents a major security risk. When using ``new Function()``, + we recommend balancing the need for simplicity against the need for + security. You can secure your code by applying whitelisting techniques, + sandboxing the scope of your ``new Function()`` context, adding + additional layers of M2M signed encryption, or employing sanitization + techniques on the content of the JS literal returned by your API. + + Which specific techniques make sense will depend on your application + and your use case. + + #. If the data in your front-end application is generated on a periodic / batch + basis, then you can save your JS literal to a static file (saved where + consumable by your front-end application) and have your front-end application + simply load it as-needed. \ No newline at end of file diff --git a/docs/tutorials/pandas.rst b/docs/tutorials/pandas.rst new file mode 100644 index 00000000..0af4d9eb --- /dev/null +++ b/docs/tutorials/pandas.rst @@ -0,0 +1,498 @@ +######################################################## +Using Highcharts Core for Python with Pandas +######################################################## + +.. contents:: + :depth: 2 + :backlinks: entry + +------------------- + +`Pandas `__ is probably the single +most popular library for data analysis in the Python ecosystem. Together +with `NumPy `__, it is ubquitous. And the +**Highcharts for Python Toolkit** is designed to natively integrate with +it. + +.. sidebar:: What Can Pandas Do in Highcharts? + + The **Highcharts for Python Toolkit** is a *visualization* library. It is + not used to perform *arithmetical*, *algebraic*, *statistical*, or *matrix* + calculations. It is exclusively concerned with visualizing data - and that + data is typically the product of calculations that are performed up-stream. + + So given that focus, **Highcharts for Python** is designed to *read* data + that is contained in :class:`pandas.DataFrame ` + instances, or in :class:`numpy.ndarray ` instances. The + toolkit will take those instances and load the data they contain into + appropriate **Highcharts for Python** classes, and then support the + serialization of that data to + :term:`JS literal notation `. + +********************************* +General Approach +********************************* + +The **Highcharts for Python Toolkit** provides a number of standard methods +that are used to interact with :class:`pandas.DataFrame ` +instances. These methods generally take the form: + + * ``.from_pandas(df)`` This is always a class method which produces one or more + instances, with data pulled from the ``df`` argument. + * ``.from_pandas_in_rows(df)`` This is always a class method which produces one + instance for every *row* in the :class:`DataFrame ` + (``df``). + * ``.load_from_pandas(df)`` This is an instance method which updates an instance + with data read from the ``df`` argument. + +.. tip:: + + All three of these standard methods are packaged to have batteries included. + This means that for simple use cases, you can simply pass a + :class:`pandas.DataFrame ` to the method, and the + method wlil attempt to determine the optimum way to deserialize the + :class:`DataFrame ` into the appropriate + **Highcharts for Python** objects. + + However, if you find that you need more fine-grained control, the methods + provide powerful tools to give you the control you need when you need it. + +These standard methods - with near-identical syntax - are available: + + * On all :term:`series` classes (descended from + :class:`SeriesBase `) + * On the :class:`Chart ` class + * On the :class:`options.data.Data ` + class + +------------------------------- + +***************************** +Preparing Your DataFrame +***************************** + + .. tip:: + + While it is theoretically possible for **Highcharts for Python** to + work with a nested :class:`DataFrame `, such + structures are generally considered an anti-pattern. We recommend + keeping your :class:`DataFrame ` contents + 2-dimensional, organized into a single "flat" table of rows and + columns. + +So let's try a real-world example. Let's say you've got some annual population +counts stored in a CSV file named ``'census-time-series.csv'``. Using Pandas, you +can construct a DataFrame from that CSV file very simply: + + .. code-block:: python + + df = pandas.read_csv('census-time-series.csv') + +This produces a simple 2-dimensional :class:`DataFrame `. +In our case, the resulting table looks like this: + + .. image:: /_static/tutorials/census-time-series-01.png + :width: 100% + :alt: Rendering of the DataFrame produced by pandas.read_csv('census-time-series.csv') + +The first column contains the names of geographic regions, while each of the subsequent +columns contains the population counts for a given year. However, you'll notice that the +:class:`DataFrame ` index is not set. Unless told otherwise, +**Highcharts for Python** will look for x-axis values in the index. + +Secondly, if you were to look under the hood, you'd see that the +:class:`DataFrame ` imported all of the numbers in our CSV as +strings (because of the presence of the comma), which is obviously a bit of a problem. So +let's fix both of these issues: + + .. code-block:: python + + df = pandas.read_csv('census-time-series.csv', index_col = 0, thousands = ',')) + +produces: + + .. image:: /_static/tutorials/census-time-series-02.png + :width: 100% + :alt: Rendering of the DataFrame produced by pandas.read_csv('census-time-series.csv') + +Great! Now, let's say we wanted to visualize this data in various ways. + +------------------------------ + +********************************************* +Creating the Chart: ``Chart.from_pandas()`` +********************************************* + +Relying on the Defaults +=========================== + +The simplest way to create a chart from a :class:`DataFrame ` +is to call :class:`Chart.from_pandas() ` like +so: + +.. code-block:: python + + my_chart = Chart.from_pandas(df) + my_chart.display() + +.. image:: /_static/tutorials/census-time-series-03.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_pandas(df) + +As you can see, we haven't provided any more instructions besides telling it to +generate a chart from ``df``. The result is a line chart, with one series for each year, and +one point for each region. But because of the structure of our data file, this isn't a great chart: +all the series are stacked on each other! So let's fix that. + + .. tip:: + + Unless instructed otherwise, **Highcharts for Python** will default to using a + line chart. + +Setting the Series Type +============================== + +Why don't we switch it to a bar chart? + +.. list-table:: + :widths: 30 70 + +.. code-block:: python + + my_chart = Chart.from_pandas(df, series_type = 'bar') + my_chart.display() + +.. image:: /_static/tutorials/census-time-series-04.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_pandas(df, series_type = 'bar') + +Now the result is a little more readable, but still not great: After all, there are more than +fifty geographic regions represented for each year, which makes the chart super crowded. +Besides, maybe we're only interested in a specific year: 2019. + +Let's try focusing our chart. + +Basic Property Mapping +========================== + +.. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'bar', + property_map = { + 'x': 'Geographic Area', + 'y': '2019' + }) + +.. image:: /_static/tutorials/census-time-series-05.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_pandas(df, series_type = 'bar', property_map = {'x': 'Geographic Area', 'y': '2019'}) + +Much better! We've now added a ``property_map`` argument to the ``.from_pandas()`` method call. +This argument tells Highcharts for Python how to map columns in your +:class:`DataFrame ` to properties in the resulting chart. In this case, +the keys ``'x'`` and ``'y'`` tell **Highcharts for Python** that you want to map the ``'Geographic Area'`` +column to the resulting series' data points' :meth:`.x `, +and to map the ``'2019'`` column to the :meth:`.y ` +properties, respectively. + +The net result is that ``my_chart`` contains one +:class:`BarSeries ` whose +:meth:`.data ` property contains a +:class:`BarDataCollection ` instance populated +with the data from the ``'Geographic Area'`` and ``'2019'`` columns in ``df`` - and even though +``'Geographic Area'`` is not technically a column, but instead is used as the index, +**Highcharts for Python** still uses it correctly. + +But maybe we actually want to compare a couple different years? Let's try that. + +Property Mapping with Multiple Series +======================================== + +.. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'column', + property_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'] + }) + my_chart.display() + +.. image:: /_static/tutorials/census-time-series-06.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_pandas(df, series_type = 'bar', property_map = {'x': 'Geographic Area', 'y': ['2017', '2018', '2019']}) + +Now we're getting somewhere! First, we changed our series type to a :class:`ColumnSeries ` to make it (a little) easier to read. Then we added a list of column names to the ``'y'`` key in the ``property_map`` argument. Each of those columns has now produced a *separate* :class:`ColumnSeries ` instance - but they're all still sharing the ``'Geographic Area'`` column as their :meth:`.x ` value. + + .. note:: + + You can supply multiple values to any property in the ``property_map``. The example + provided above is equivalent to: + + .. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'column', + property_map = { + 'x': ['Geographic Area', 'Geographic Area', 'Geographic Area'], + 'y': ['2017', '2018', '2019'] + }) + + The only catch is that the ultimate number of values for each key must match. If there's + only one value, then it will get repeated for all of the others. But if there's a mismatch, + then **Highcharts for Python** will throw a + :exc:`HighchartsPandasDeserializationError `. + +But so far, we've only been using the ``'x'`` and ``'y'`` keys in our ``property_map``. What if we wanted to +configure additional properties? Easy! + +Configuring Additional Properties +===================================== + +.. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'column', + property_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'], + 'id': 'some other column' + }) + +Now, our data frame is pretty simple does not contain a column named ``'some other column'`. But *if* it did, +then it would use that column to set the :meth:`.id ` property of each data point. + + .. note:: + + You can supply any property you want to the ``property_map``. If the property is not + supported by the series type you've selected, then it will be ignored. + +But our chart is still looking a little basic - why don't we tweak some series configuration options? + +Configuring Series Options +=============================== + +.. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'column', + property_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'], + }, + series_kwargs = { + 'point_padding': 5 + }) + my_chart.display() + +.. image:: /_static/tutorials/census-time-series-07.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_pandas(df, series_type = 'bar', property_map = {'x': 'Geographic Area', 'y': ['2017', '2018', '2019'], 'id': 'Geographic Area'}, series_kwargs = {'point_padding': 0.5}) + +As you can see, we supplied a new ``series_kwargs`` argument to the ``.from_pandas()`` method call. This +argument receives a :class:`dict ` with keys that correspond to properties on the series. In +this case, by supplying ``'point_padding'`` we have set the resulting +:meth:`ColumnSeries.point_padding ` property to a +value of ``0.5`` - leading to a bit more spacing between the bars. + +But our chart is *still* a little basic - why don't we give it a reasonable title? + +Configuring Options +============================= + +.. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'column', + property_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'], + }, + series_kwargs = { + 'point_padding': 0.5 + }, + options_kwargs = { + 'title': { + 'text': 'This Is My Chart Title' + } + }) + my_chart.display() + +.. image:: /_static/tutorials/census-time-series-08.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_pandas(df, series_type = 'bar', property_map = {'x': 'Geographic Area', 'y': ['2017', '2018', '2019'], 'id': 'Geographic Area'}, series_kwargs = {'point_padding': 0.25}, options_kwargs = {'title': {'text': 'This Is My Chart Title'}}) + +As you can see, we've now given our chart a title. We did this by adding a new ``options_kwargs`` argument, +which likewise takes a :class:`dict ` with keys that correspond to properties on the chart's +:class:`HighchartsOptions ` configuration.` + +Now let's say we wanted our chart to render in an HTML ``
`` with an ``id`` of ``'my_target_div`` - +we can configure that in the same method call. + +Configuring Chart Settings +============================== + +.. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'bar', + property_map = { + 'x': 'Geographic Area', + 'y': ['2017', '2018', '2019'], + 'id': 'Geographic Area' + }, + series_kwargs = { + 'point_padding': 0.25 + }, + options_kwargs = { + 'title': { + 'text': 'This Is My Chart Title' + } + }, + chart_kwargs = { + 'container': 'my_target_div' + }) + +While you can't really *see* the difference here, by adding the ``chart_kwargs`` argument to +the method call, we now set the :meth:`.container ` property +on ``my_chart``. + +But maybe we want to do something a little different - like compare the change in population over time. +Well, we can do that easily by visualizing each *row* of ``df`` rather than each *column*.` + +Visualizing Data in Rows +============================== + +.. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'line', + series_in_rows = True) + my_chart.display() + +.. image:: /_static/tutorials/census-time-series-09.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_pandas(df, series_type = 'line', series_in_rows = True) + +Okay, so here we removed some of the other arguments we'd been using to simplify the example. You'll see we've now +added the ``series_in_rows`` argument, and set it to ``True``. This tells **Highcharts for Python** that we expect +to produce one series for every *row* in ``df``. Because we have not specified a ``property_map``, the series +:meth:`.name ` values are populated from the ``'Geographic Area'`` +column, while the data point :meth:`.x ` values come from each additional column (e.g. ``'2010'``, ``'2011'``, ``'2012'``, etc.) + + .. tip:: + + To simplify the code further, any class that supports the ``.from_pandas()`` method also + supports the ``.from_pandas_in_rows()`` method. The latter method is equivalent to passing + ``series_in_rows = True`` to ``.from_pandas()``. + + For more information, please see: + + * :class:`Chart.from_pandas_in_rows() ` + * :class:`SeriesBase.from_pandas_in_rows() ` + +But maybe we don't want *all* geographic areas shown on the chart - maybe we only want to compare a few. + +Filtering Rows +======================= + +.. code-block:: python + + my_chart = Chart.from_pandas(df, + series_type = 'line', + series_in_rows = True, + series_index = slice(7, 10)) + +.. image:: /_static/tutorials/census-time-series-10.png + :width: 100% + :alt: Rendering of the chart produced by Chart.from_pandas(df, series_type = 'line', series_in_rows = True, series_index = slice(7, 10)) + +What we did here is we added a ``series_index`` argument, which tells **Highcharts for Python** to only +include the series found at that index in the resulting chart. In this case, we supplied a :func:`slice ` +object, which operates just like ``list_of_series[7:10]``. The result only returns those series between index 7 and 10. + +------------------------ + +********************************************************************** +Creating Series: ``.from_pandas()`` and ``.from_pandas_in_rows()`` +********************************************************************** + +All **Highcharts for Python** :term:`series` descend from the +:class:`SeriesBase ` class. And they all +therefore support the ``.from_pandas()`` class method. + +When called on a series class, it produces one or more series from the +:class:`DataFrame ` supplied. The method supports all of the same options +as :class:`Chart.from_pandas() ` *except for* ``options_kwargs`` and +``chart_kwargs``. This is because the ``.from_pandas()`` method on a series class is only responsible for +creating series instances - not the charts. + +Creating Series from Columns +================================== + +So let's say we wanted to create one series for each of the years in ``df``. We could that like so: + + .. code-block:: python + + my_series = BarSeries.from_pandas(df) + +Unlike when calling :meth:`Chart.from_pandas() `, we +did not have to specify a ``series_type`` - that's because the ``.from_pandas()`` class method on a +series class already knows the series type! + +In this case, ``my_series`` now contains ten separate :class:`BarSeries ` +instances, each corresponding to one of the year columns in ``df``. + +But maybe we wanted to create our series from rows instead? + +Creating Series from Rows +================================= + +.. code-block:: python + + my_series = LineSeries.from_pandas_in_rows(df) + +This will produce one :class:`LineSeries ` +instance for each row in ``df``, ultimately producing a :class:`list ` of +57 :class:`LineSeries ` instances. + +Now what if we don't need all 57, but instead only want the first five? + +Filtering Series Created from Rows +====================================== + +.. code-block:: python + + my_series = LineSeries.from_pandas_in_rows(df, series_index = slice(0, 5)) + +This will return the first five series in the list of 57. +-------------------------- + +*********************************************************** +Updating an Existing Series: ``.load_from_pandas()`` +*********************************************************** + +So far, we've only been creating new series and charts. But what if we want to update +the data within an existing series? That's easy to do using the +:meth:`.load_from_pandas() ` method. + +Let's say we take the first series returned in ``my_series`` up above, and we want to replace +its data with the data from the *10th* series. We can do that by: + + .. code-block:: python + + my_series[0].load_from_pandas(df, series_in_rows = True, series_index = 9) + +The ``series_in_rows`` argument tells the method to generate series per row, and then +the ``series_index`` argument tells it to only use the 10th series generated. + + .. caution:: + + While the :meth:`.load_from_pandas() ` + method supports the same arguments as + :meth:`.from_pandas() `, it expects that + the arguments supplied lead to an unambiguous *single* series. If they are ambiguous - meaning they + lead to multiple series generated from the :class:`DataFrame ` - then + the method will throw a + :exc:`HighchartsPandasDeserializationError ` \ No newline at end of file diff --git a/docs/tutorials/series.rst b/docs/tutorials/series.rst new file mode 100644 index 00000000..f9ab4466 --- /dev/null +++ b/docs/tutorials/series.rst @@ -0,0 +1,398 @@ +################################################### +Working with Series in Highcharts for Python +################################################### + +.. contents:: + :depth: 2 + :backlinks: entry + +------------------- + +**Highcharts for Python** (and Highcharts (JS), of course) are both built around +the concept of :term:`data series `. A data series can be thought of as +a set of data points that describe the same "thing", and which represent how +the data can be organized. Think: + + * a single line on a line chart + * a set of columns all the same color on a column chart + * all of a pie or donut chart + +As a result, when you constructing your chart in **Highcharts for Python**, +what you are really doing is constructing one or more :term:`series` that +are then placed on a shared canvas, with shared axes, a shared legend, etc. + + .. image:: /_static/highcharts-chart-anatomy.png + :width: 75 + :align: right + :alt: Diagram showing the conceptual components of a Highcharts chart + +This tutorial is designed to help you understand how to manage series in your +Highcharts visualizations using **Highcharts for Python**. + +---------------------------------- + +********************************* +How Series are Organized +********************************* + +As the diagram above shows, Highcharts visualizations are configured +within a :class:`Chart ` object, which has +the :meth:`.options ` property where +the chart's configuration "lives", represented as a +:class:`HighchartsOptions ` +instance. + +The :class:`HighchartsOptions ` +configuration allows you to define *all* details of what gets displayed on +the chart, how that content behaves, etc. The configuration options available in +:class:`HighchartsOptions ` can be +thought of as chart-level configurations: They configure or define things that apply to +everything shown on the chart: the axes to display, the legend, the title, the settings shared +across all of the series on the chart, etc. + +But within the :class:`HighchartsOptions ` +you will find the :meth:`.series ` +property. This is where you define the *specific* :term:`series` to render on your chart. + +This property gets one or more series instances, all of which are descended from +:class:`SeriesBase `. They descend from +this single base series class because many of their properties are shared across all types +of series. For example, whether the series is to render as a line or as a bar, all series will +have an :meth:`.id ` option. + +All visualizations supported by Highcharts have a corresponding series type, which means +they all have their corresponding series class. To see this mapping, take a look at our +:doc:`Supported Visualizations `. + +Each series type has its set of shared properties that derive from +:class:`SeriesBase , but specific series types +may have their own type-specific settings. For example, a +:class:`GaugeSeries ` will have options to +configure the gauge's dial (:meth:`.dial `), +overshot-handling (:meth:`.overshoot `), +and pivot point (:meth:`.pivot `) - settings +which would be completely *irrelevant* for a +:class:`LineSeries `, which does not have a dial, +does not have a concept of overshooting the bounds of the dial, and does not have any pivot points. + +.. list-table:: + :widths: 50 50 + + * - .. figure:: ../../../_static/gauge-example.png + :alt: Gauge Example Chart + :align: center + - .. figure:: ../../../_static/line-example.png + :alt: Line Example Chart + :align: center + +And all series (technically, *almost* all) have a +:meth:`.data ` property, which contains the +data that should be visualized in the series. + +.. sidebar:: Exceptions without Data + + Certain types of visualiation - like + :class:`BellCurveSeries ` + do not receive their own data, but instead are tied to an underlying + :meth:`.base_series ` + whose data is used to shape their visualization. + + .. figure:: ../../../_static/bellcurve-example.png + :alt: Bell Curve Example Chart + :align: center + :width: 35% + +So as you can see, series are pretty fundamental to your Highcharts visualizations: They are +what actually gets visualized. + +So how do we start creating series using **Highcharts for Python**? + +--------------------------------------- + +*************************************************** +Creating Series in Highcharts for Python +*************************************************** + + +Of course, you can always construct your series using direct instantiation: + + .. code-block:: python + + from highcharts_core.chart import Chart + from highcharts_core.options import HighchartsOptions + from highcharts_core.options.series.area import LineSeries + + my_line_series = LineSeries(data = my_data, id = 'my_series_id123') + my_options = HighchartsOptions(series = [my_line_series]) + my_chart = Chart(options = my_options) + +And there may be situations where there is the best way for you to construct your +series, depending on how you are managing your full Highcharts for Python application. + +But there are much simpler / faster ways to rapidly create your chart/series: + +Assembling a Chart with Series at Once +========================================= + +.. include:: _assembling_a_chart.rst + +Assembling Series Alone +========================== + +You can create series similarly: + + .. code-block:: python + + from highcharts_core.options.series.area import LineSeries + + my_line_series = LineSeries(data = my_iterable) + +This will create a :class:`LineSeries ` +instance. + +Depending on how we've wrangled our data, we can similarly produce one or more +series from a :class:`pandas.DataFrame `, +:class:`numpy.ndarray `, or Python :class:`dict `: + + .. code-block:: python + + # From a Pandas DataFrame + + my_series = LineSeries.from_pandas(df) + + # From a Numpy ndarray + + my_series = LineSeries.from_array(data = as_ndarray) + + # From a CSV file + + my_series = LineSeries.from_csv('my-data.csv') + + # From a Python iterable + + my_series = LineSeries.from_array(data = as_iterable) + +All of these lines of code are equivalent, and should produce an identical +``my_series``. Depending on the arguments you supply to the helper methods, +they may either produce *one* series instance or a :class:`list ` +of series instances. + +Adding Series to a Chart +=========================== + +If you have created series on their own, you can add them to an existing +:class:`Chart ` very easily: + + .. code-block:: python + + # EXAMPLE 1. + # Adding one series + + my_chart.add_series(my_series) + + # EXAMPLE 2. + # Adding multiple series if they are in one iterable + + my_chart.add_series(my_list_of_series) + + # EXAMPLE 3. + # Adding multiple individual series + + my_chart.add_series(series1, series2, series3) + +Or you can also create a new chart *from* a list of series: + + .. code-block:: python + + # EXAMPLE 1. + # With one series + + my_chart = Chart.from_series(my-series) + + # EXAMPLE 2. + # With multiple series if they are in one iterable + + my_chart = Chart.from_series(my_list_of_series) + + # EXAMPLE 3. + # Adding multiple individual series + + my_chart = Chart.from_series(series1, series2, series3) + +.. tip:: + + The same :meth:`.add_series ` and + :meth:`.from_series ` helper methods + are also available on the + :class:`HighchartsOptions ` class. + +So now that we've created a chart and a bunch of series, what else can we do? + +---------------------------- + +********************************* +Configuring Your Series +********************************* + +You can configure the options available on each series very simply. Highcharts (JS) - +and so **Highcharts for Python** - have a very extensive API, with lots of configuration +options. + +.. tip:: + + We recommend reviewing the :doc:`API Reference ` to really explore + the options available for different series types. + +Updating Data Points +========================= + +However, the most important configuration you will do is to manage the data points +you wish to display in your series. You can do this by: + + #. Passing data directly to the + :meth:`.data ` + property: + + .. code-block:: python + + my_series.data = updated_data + + #. Using any of the helper methods provided on the series instance: + + .. code-block:: python + + # EXAMPLE 1. + # Updating data points from a new Pandas DataFrame + + my_series.load_from_pandas(df) + + # EXAMPLE 2. + # Updating data points from a new numpy.ndarray + + my_series.load_from_array(as_ndarray) + + # EXAMPLE 3. + # Updating data points from a new iterable. + + my_series.load_from_array(as_iterable) + + # EXAMPLE 4. + # Updating data points from a CSV file. + + my_series.load_from_csv('my-updated-csv-file.csv') + +Updating Data Point Properties +================================== + +In addition, all series instances make it easy to propagate information throughout your +underlying data. When you try to set a property on your series object, **Highcharts for Python** +will first see if it is a valid property on the series itself. But if it is not, then it will +check whether it is a supported property on that series' *data*. + +So as an example, if we want to give our series an ID, we can simply call: + + .. code-block:: python + + my_series.id = 'my-updated-id-value' + +However, if we want to set a :class:`BulletSeries ` data points' :meth:`.target ` value, we can simply reference it on the series. +For example, if our :class:`BulletSeries ` contains three data points, we can set their targets easily using the series: + + .. code-block:: python + + my_bullet_series.target = [1, 2, 3] + +By passing an iterable (or a :class:`numpy.ndarray `), *all* of your data +points will get updated with the appropriate value. This makes it very easy to execute your data point +configurations by operating on the series, rather than working with *individual* data points - though if you want +to work with *individual* data points, you can do so as well. + +.. seealso:: + + * :doc:`Working with Data in Highcharts for Python ` + +Converting Series Types +=========================== + +Every series type has its own type-specific set of configuration options. However, there may be +times when you want to change how your data is to be visualized / rendered. **Highcharts for Python** +provides a useful helper method for that, too. For example, if we want to convert our +:class:`LineSeries ` to a +:class:`BarSeries `, we can do that by calling the +:meth:`.convert_to ` method: + + .. code-block:: python + + # EXAMPLE 1 + # Indicating the target type with a string label + + my_series.convert_to(series_type = 'bar') + + # EXAMPLE 2 + # Indicating the target type with a SeriesBase class + + my_series.convert_to(series_type = BarSeries) + +So now that we've constructed, configured, and adjusted our series, we can also render them +easily. + +----------------------- + +*************************** +Rendering Series +*************************** + +Series can be rendered within the chart that they are a part of, simply by following +the process to render the chart: + +Rendering a Series within a Chart +======================================= + + #. When in Jupyter Notebook/Lab, just execute the :meth:`.display() ` + method. + + .. code-block:: python + + my_chart.display() + + #. When rendering within a web application, or saving to a file for rendering in a separate application, + you can serialize your chart to :term:`JavaScript object literal notation`: + + .. code-block:: python + + as_js_literal = my_chart.to_js_literal() + + which will produce the JavaScript code to render your complete chart. + +Rendering a Series Alone +============================ + +The *exact same* helper methods are available on your series as well. So if you have assembled your +series as ``my_series``, you can take a shortcut to visualize it by calling: + + .. code-block:: python + + my_series.display() + +which will assemble a generic :class:`Chart ` instance, include +your series, and render it in Jupyter Notebook/Lab. + +You can also produce a :class:`Chart ` instance containing your +series in a single method call as well: + + .. code-block:: python + + my_chart = my_series.to_chart() + +And similarly: + + .. code-block:: + + series_as_js_literal = my_series.to_js_literal() + +will produce the JS literal representation of your series, for use in a JavaScript application. + +------------ + +Given all of this flexibliity, we hope you have a great time assembling +high-end visualizations and exploring **Highcharts for Python**! \ No newline at end of file diff --git a/docs/using.rst b/docs/using.rst index 31543290..7ccea4d4 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -326,75 +326,149 @@ can think of a series as a single line on a graph that shows a set of values. Th of values that make up the series are :term:`data points `, which are defined by a set of properties that indicate the data point's position on one or more axes. -As a result, `Highcharts (JS) `__ and -**Highcharts for Python** both represent the data points in series as a list of data point -objects in the ``data`` property within the series: - -.. list-table:: - :widths: 50 50 - :header-rows: 1 - - * - Highcharts (JS) - - Highcharts for Python - * - .. code-block:: javascript - - // Example Series Object - // (for a Line series type): - { - data: [ - { - id: 'first-data-point', - x: 1, - y: 123, - // ... - // optional additional properties - // for styling/behavior go here - // ... - }, +**Highcharts for Python** represents data points in series in two fashions: + + #. As a list of data point objects in the ``data`` property within the series, or + + .. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Highcharts (JS) + - Highcharts for Python + * - .. code-block:: javascript + + // Example Series Object + // (for a Line series type): { - id: 'second-data-point', - x: 2, - y: 456, - // ... - // optional additional properties - // for styling/behavior go here + data: [ + { + id: 'first-data-point', + x: 1, + y: 123, + // ... + // optional additional properties + // for styling/behavior go here + // ... + }, + { + id: 'second-data-point', + x: 2, + y: 456, + // ... + // optional additional properties + // for styling/behavior go here + // ... + }, + { + id: 'third-data-point', + x: 3, + y: 789, + // ... + // optional additional properties + // for styling/behavior go here + // ... + } + ], // ... - }, + // other Series properties go here + // to configure styling/behavior + } + - .. code-block:: python + + # Corresponding LineSeries object + my_series = Series(data = [ + CartesianData(id = 'first-data-point1', + x = 1, + y = 123), + CartesianData(id = 'second-data-point1', + x = 2, + y = 456), + CartesianData(id = 'third-data-point1', + x = 3, + y = 789), + ]) + + #. As a single :class:`DataPointCollection ` + object in the ``data`` property within the series, which in turn contains the + individual data points. + + .. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - Highcharts (JS) + - Highcharts for Python + * - .. code-block:: javascript + + // Example Series Object + // (for a Line series type): { - id: 'third-data-point', - x: 3, - y: 789, - // ... - // optional additional properties - // for styling/behavior go here + data: [ + { + name: 'first-data-point', + x: 1, + y: 123, + // ... + // optional additional properties + // for styling/behavior go here + // ... + }, + { + name: 'second-data-point', + x: 2, + y: 456, + // ... + // optional additional properties + // for styling/behavior go here + // ... + }, + { + name: 'third-data-point', + x: 3, + y: 789, + // ... + // optional additional properties + // for styling/behavior go here + // ... + } + ], // ... + // other Series properties go here + // to configure styling/behavior } - ], - // ... - // other Series properties go here - // to configure styling/behavior - } - - .. code-block:: python - - # Corresponding LineSeries object - my_series = Series(data = [ - CartesianData(id = 'first-data-point1', - x = 1, - y = 123), - CartesianData(id = 'second-data-point1', - x = 2, - y = 456), - CartesianData(id = 'third-data-point1', - x = 3, - y = 789), - ]) + - .. code-block:: python + + # EXAMPLE 1. Corresponding LineSeries object, with data as an + # numpy.ndarray. + + my_series = Series(data = CartesianDataCollection( + ndarray = [ + [1, 123, 'first-data-point1'], + [2, 456, 'second-data-point1'], + [3, 789, 'third-data-point1']) + ]) + + # EXAMPLE 2. Corresponding LineSeries object, with data as a + # primitive array. + + my_series = Series(data = CartesianDataCollection( + array = [ + [1, 123, 'first-data-point1'], + [2, 456, 'second-data-point1'], + [3, 789, 'third-data-point1']) + ] + )) As you can see, **Highcharts for Python** represents its data the same way that `Highcharts (JS) `__ does. That should be expected. However, constructing tens, hundreds, or possibly thousands of data points individually in your code would be a nightmare. For that reason, the -**Highcharts for Python Toolkit** provides a number of convenience methods to make it -easier to populate your series. +**Highcharts for Python Toolkit** natively supports vectorized +:class:`numpy.ndarray ` values, and automatically assembles data +point collections for easy management/manipulation. In addition, the Toolkit +provides a number of convenience methods to make it easier to populate your +series. .. _populating_series_data: @@ -425,8 +499,11 @@ instance), or to create a new series instance with data already loaded. :classmethod: :noindex: - Creates a collection of data point instances, parsing the contents of ``value`` as an - array (iterable). This method is specifically used to parse data that is input to + Creates a + :class:`DataPointCollection ` + instance if possible, or falls back to a collection of data point + instances, parsing the contents of ``value`` as an array (iterable). This method + is specifically used to parse data that is input to **Highcharts for Python** without property names, in an array-organized structure as described in the `Highcharts (JS) `__ documentation. @@ -449,12 +526,37 @@ instance), or to create a new series instance with data already loaded. my_series = LineSeries() + # EXAMPLE 1. + # A simple array of numerical values which correspond to the Y value of the data + # point, passed to the .from_array() method. + + my_series = LineSeries.from_array([0, 5, 3, 5]) + + # EXAMPLE 2. # A simple array of numerical values which correspond to the Y value of the data - # point + # point, passed to the series data property. + my_series.data = [0, 5, 3, 5] + # EXAMPLE 3. + # A simple array of numerical values which correspond to the Y value of the data + # point, updated in the series using the .load_from_array() method. + + my_series.load_from_array([0, 5, 3, 5]) + + # EXAMPLE 4. + # An array containing 2-member arrays (corresponding to the X and Y values of the + # data point), passed to the .from_array() class method. + my_series = LineSeries.from_array([ + [0, 0], + [1, 5], + [2, 3], + [3, 5] + ]) + + # EXAMPLE 5. # An array containing 2-member arrays (corresponding to the X and Y values of the - # data point) + # data point), passed to the series data property. my_series.data = [ [0, 0], [1, 5], @@ -462,7 +564,37 @@ instance), or to create a new series instance with data already loaded. [3, 5] ] - # An array of dict with named values + # EXAMPLE 6. + # An array of dict with named values, passed to the .from_array() class method. + my_series = LineSeries.from_array([ + { + 'x': 0, + 'y': 0, + 'name': 'Point1', + 'color': '#00FF00' + }, + { + 'x': 1, + 'y': 5, + 'name': 'Point2', + 'color': '#CCC' + }, + { + 'x': 2, + 'y': 3, + 'name': 'Point3', + 'color': '#999' + }, + { + 'x': 3, + 'y': 5, + 'name': 'Point4', + 'color': '#000' + } + ]) + + # EXAMPLE 6. + # An array of dict with named values, passed to the series data property. my_series.data = [ { 'x': 0, @@ -490,6 +622,36 @@ instance), or to create a new series instance with data already loaded. } ] + # EXAMPLE 6. + # An array of dict with named values, passed to .load_from_array() + # method. + my_series.load_from_array([ + { + 'x': 0, + 'y': 0, + 'name': 'Point1', + 'color': '#00FF00' + }, + { + 'x': 1, + 'y': 5, + 'name': 'Point2', + 'color': '#CCC' + }, + { + 'x': 2, + 'y': 3, + 'name': 'Point3', + 'color': '#999' + }, + { + 'x': 3, + 'y': 5, + 'name': 'Point4', + 'color': '#000' + } + ]) + :param value: The value that should contain the data which will be converted into data point instances. @@ -504,12 +666,18 @@ instance), or to create a new series instance with data already loaded. :class:`DataBase `) :rtype: :class:`list ` of :class:`DataBase `-descendant - instances + instances or + :class:`DataPointCollection ` + instance .. tab:: Update an Existing Series .. tabs:: + .. tab:: Using ``.load_from_array()`` + + .. include:: using/populating_series_data/_load_from_array.rst + .. tab:: Using ``.load_from_csv()`` .. include:: using/populating_series_data/_load_from_csv.rst @@ -526,6 +694,10 @@ instance), or to create a new series instance with data already loaded. .. tabs:: + .. tab:: Using ``.from_array()`` + + .. include:: using/populating_series_data/_new_from_array.rst + .. tab:: Using ``.from_csv()`` .. include:: using/populating_series_data/_new_from_csv.rst @@ -562,6 +734,10 @@ property. You can do this in several ways: .. tabs:: + .. tab:: Using Keyword Arguments + + .. include:: using/adding_series_to_charts/_using_kwargs.rst + .. tab:: Using ``.options.series`` .. include:: using/assembling_your_chart/_using_series_property.rst diff --git a/docs/using/assembling_your_chart/_using_kwargs.rst b/docs/using/assembling_your_chart/_using_kwargs.rst new file mode 100644 index 00000000..ee109af0 --- /dev/null +++ b/docs/using/assembling_your_chart/_using_kwargs.rst @@ -0,0 +1,23 @@ + .. note:: + + The keyword pattern outlined below is supported by both the + :class:`Chart ` and + :class:`HighchartsOptions ` + classes + +.. code-block:: python + + from highcharts_core.chart import Chart + from highcharts_core.options.series.area import LineSeries + + # EXAMPLE 1. Indicating data and series_type. + my_chart = Chart(data = [[0, 1], [1, 2], [2, 3]], + series_type = 'line') + + # EXAMPLE 2. Supplying the Series instance(s) directly. + my_chart = Chart(series = LineSeries(data = [ + [0, 1], + [1, 2], + [2, 3] + ])) + \ No newline at end of file diff --git a/docs/using/download_visualizations/_using_custom.rst b/docs/using/download_visualizations/_using_custom.rst index a5351837..9b95d578 100644 --- a/docs/using/download_visualizations/_using_custom.rst +++ b/docs/using/download_visualizations/_using_custom.rst @@ -1,19 +1,13 @@ .. code-block:: python from highcharts_core.chart import Chart - from highcharts_core.options.series.area import LineSeries from highcharts_core.headless_export import ExportServer custom_server = ExportServer(url = 'https://www.mydomain.dev/some_pathname_goes_here') - my_chart = Chart(container = 'target_div', - options = { - 'series': [ - LineSeries(data = [0, 5, 3, 5]) - ] - }, - variable_name = 'myChart') - + my_chart = Chart(data = [0, 5, 3, 5], + series_type = 'line') + # Download a PNG version of the chart in memory within your Python code. my_png_image = my_chart.download_chart(format = 'png', server_instance = custom_server) diff --git a/docs/using/download_visualizations/_using_highsoft.rst b/docs/using/download_visualizations/_using_highsoft.rst index aa9f33b6..ac06389d 100644 --- a/docs/using/download_visualizations/_using_highsoft.rst +++ b/docs/using/download_visualizations/_using_highsoft.rst @@ -1,15 +1,9 @@ .. code-block:: python from highcharts_core.chart import Chart - from highcharts_core.options.series.area import LineSeries - - my_chart = Chart(container = 'target_div', - options = { - 'series': [ - LineSeries(data = [0, 5, 3, 5]) - ] - }, - variable_name = 'myChart') + + my_chart = Chart(data = [0, 5, 3, 5], + series_type = 'line') # Download a PNG version of the chart in memory within your Python code. my_png_image = my_chart.download_chart(format = 'png') diff --git a/docs/using/populating_series_data/_load_from_array.rst b/docs/using/populating_series_data/_load_from_array.rst new file mode 100644 index 00000000..73e91093 --- /dev/null +++ b/docs/using/populating_series_data/_load_from_array.rst @@ -0,0 +1,85 @@ +.. code-block:: python + + # EXAMPLE 1 + # A simple array of numerical values which correspond to the Y value of the data + # point + + my_series.load_from_array([0, 5, 3, 5]) + + # EXAMPLE 2 + # An array containing 2-member arrays (corresponding to the X and Y values of the + # data point) + + my_series.load_from_array([ + [0, 0], + [1, 5], + [2, 3], + [3, 5] + ]) + + # EXAMPLE 3 + # An array of dict with named values + + my_series.load_from_array([ + { + 'x': 0, + 'y': 0, + 'name': 'Point1', + 'color': '#00FF00' + }, + { + 'x': 1, + 'y': 5, + 'name': 'Point2', + 'color': '#CCC' + }, + { + 'x': 2, + 'y': 3, + 'name': 'Point3', + 'color': '#999' + }, + { + 'x': 3, + 'y': 5, + 'name': 'Point4', + 'color': '#000' + } + ]) + + # EXAMPLE 5 + # using a NumPy ndarray named "numpy_array" + + my_series.load_from_array(numpy_array) + +.. collapse:: Method Signature + + .. method:: load_from_array(cls, value) + :noindex: + :classmethod: + + Update the :term:`series` instance's ``data`` property with data populated from + an array-like ``value``. + + This method is specifically used to parse data that is input to + **Highcharts for Python** without property names, in an array-organized structure as + described in the `Highcharts JS `__ documentation. + + .. seealso:: + + The specific structure of the expected array is highly dependent on the type of data + point that the series needs, which itself is dependent on the series type itself. + + Please review the detailed :ref:`series documentation ` for + series type-specific details of relevant array structures. + + :param value: The value that should contain the data which will be converted into data + point instances. + + .. note:: + + If ``value`` is not an iterable, it will be converted into an iterable to be + further de-serialized correctly. + + :type value: iterable + diff --git a/docs/using/populating_series_data/_load_from_csv.rst b/docs/using/populating_series_data/_load_from_csv.rst index 8fb1d018..33692520 100644 --- a/docs/using/populating_series_data/_load_from_csv.rst +++ b/docs/using/populating_series_data/_load_from_csv.rst @@ -1,6 +1,13 @@ .. code-block:: python # Given a LineSeries named "my_series", and a CSV file named "updated-data.csv" + + my_series.load_from_csv('updated-data.csv') + + # For more precise control over how the CSV data is parsed, + # you can supply a mapping of series properties to their CSV column + # either by index position *or* by column header name. + my_series.load_from_csv('updated-data.csv', property_column_map = { 'x': 0, @@ -10,7 +17,7 @@ .. collapse:: Method Signature - .. method:: .load_from_csv(self, as_string_or_file, property_column_map, has_header_row = True, delimiter = ',', null_text = 'None', wrapper_character = "'", line_terminator = '\r\n', wrap_all_strings = False, double_wrapper_character_when_nested = False, escape_character = '\\') + .. method:: .load_from_csv(self, as_string_or_file, property_column_map = None, has_header_row = True, delimiter = ',', null_text = 'None', wrapper_character = "'", line_terminator = '\r\n', wrap_all_strings = False, double_wrapper_character_when_nested = False, escape_character = '\\', series_in_rows = 'line', series_index = None, **kwargs) :noindex: Updates the series instance with a collection of data points (descending from @@ -32,21 +39,40 @@ .. code-block:: python my_series = LineSeries() - my_series.load_from_csv('some-csv-file.csv', - property_column_map = { - 'x': 0, - 'y': 3, - 'id': 'id' - }) - - As the example above shows, data is loaded into the ``my_series`` instance from - the CSV file with a filename ``some-csv-file.csv``. The - :meth:`x ` - values for each data point will be taken from the first (index 0) column in the - CSV file. The :meth:`y ` values will be taken from the fourth - (index 3) column in the CSV file. And the :meth:`id ` values - will be taken from a column whose header row is labeled ``'id'`` (regardless of - its index). + + # Minimal code - will attempt to update the line series + # taking x-values from the first column, and y-values from + # the second column. If there are too many columns in the CSV, + # will throw an error. + my_series = my_series.from_csv('some-csv-file.csv') + + # More precise code - will attempt to update the line series + # mapping columns in the CSV file to properties on the series + # instance. + my_series = my_series.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': 3, + 'id': 'id' + }) + + As the example above shows, data is loaded into the ``my_series`` instance + from the CSV file with a filename ``some-csv-file.csv``. Unless otherwise + specified, the :meth:`.x ` values for each data point will + be taken from the first (index 0) column in the CSV file, while the + :meth:`.y ` values will be taken from the second column. + + If the CSV has more than 2 columns, then this will throw an + :exc:`HighchartsCSVDeserializationError` because the function is not certain + which columns to use to update the series. If this happens, you can precisely + specify which columns to use by providing a ``property_column_map`` argument, + as shown in the second example. In that second example, the + :meth:`.x ` values for each data point will be taken from + the first (index 0) column in the CSV file. The :meth:`.y ` + values will be taken from the fourth (index 3) column in the CSV file. And + the :meth:`.id ` + values will be taken from a column whose header row is labeled ``'id'`` + (regardless of its index). :param as_string_or_file: The CSV data to load, either as a :class:`str ` or as the name of a file in the runtime envirnoment. If a file, data will be read @@ -59,11 +85,12 @@ :type as_string_or_file: :class:`str ` or Path-like - :param property_column_map: A :class:`dict ` used to indicate which - data point property should be set to which CSV column. The keys in the + :param property_column_map: An optional :class:`dict ` used to indicate + which data point property should be set to which CSV column. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value can either be a numerical index (starting with 0) or a - :class:`str ` indicating the label for the CSV column. + :class:`str ` indicating the label for the CSV column. Defaults to + :obj:`None `. .. warning:: @@ -125,6 +152,28 @@ Python's native escape character). :type escape_character: :class:`str ` + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param series_index: if :obj:`None `, will raise a + :exc:`HighchartsCSVDeserializationError ` + if the CSV data contains more than one series and no ``property_column_map`` + is provided. Otherwise, will update the instance with the series found + in the CSV at the ``series_index`` value. Defaults to + :obj:`None `. + + .. tip:: + + This argument is *ignored* if ``property_column_map`` is provided. + + :type series_index: :class:`int ` or :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + :returns: A collection of data points descended from :class:`DataBase ` as appropriate for the series class. diff --git a/docs/using/populating_series_data/_load_from_pandas.rst b/docs/using/populating_series_data/_load_from_pandas.rst index cf3e0b4c..1a4ae6a9 100644 --- a/docs/using/populating_series_data/_load_from_pandas.rst +++ b/docs/using/populating_series_data/_load_from_pandas.rst @@ -1,6 +1,14 @@ .. code-block:: python # Given a LineSeries named "my_series", and a Pandas DataFrame variable named "df" + + # EXAMPLE 1. The minimum code required to update the series: + + my_series.load_from_pandas(df) + + # EXAMPLE 2. For more precise control over how the ``df`` is parsed, + # you can supply a mapping of series properties to their dataframe column. + my_series.load_from_pandas(df, property_map = { 'x': 'date', @@ -8,28 +16,55 @@ 'id': 'id' }) + # EXAMPLE 3. For more precise control, specify the index of the + # Highcharts for Python series instance to use in updating your series' data. + + my_series.load_from_pandas(df, series_index = 3) + + .. collapse:: Method Signature - .. method:: .load_from_pandas(self, df, property_map) + .. method:: .load_from_pandas(self, df, property_map = None, series_in_rows = False, series_index = None) :noindex: Replace the contents of the :meth:`.data ` property with data points populated from a `pandas `__ - :class:`DataFrame `. + :class:`DataFrame `. - :param df: The :class:`DataFrame ` from which data should be + :param df: The :class:`DataFrame ` from which data should be loaded. - :type df: :class:`DataFrame ` + :type df: :class:`DataFrame ` :param property_map: A :class:`dict ` used to indicate which data point property should be set to which column in ``df``. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value should indicate the label for the - :class:`DataFrame ` column. + :class:`DataFrame ` column. :type property_map: :class:`dict ` + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param series_index: If supplied, return the series that Highcharts for Python + generated from ``df`` at the ``series_index`` value. Defaults to + :obj:`None `, which returns all series generated from ``df``. + + .. warning:: + + If :obj:`None ` and Highcharts for Python generates multiple + series, then a :exc:`HighchartsPandasDeserializationError` will be raised. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + :raises HighchartsPandasDeserializationError: if ``property_map`` references a column that does not exist in the data frame + :raises HighchartsPandasDeserializationError: if ``series_index`` is + :obj:`None `, and it is ambiguous which series generated from + the dataframe should be used :raises HighchartsDependencyError: if `pandas `__ is not available in the runtime environment diff --git a/docs/using/populating_series_data/_new_from_array.rst b/docs/using/populating_series_data/_new_from_array.rst new file mode 100644 index 00000000..d2c7d4d6 --- /dev/null +++ b/docs/using/populating_series_data/_new_from_array.rst @@ -0,0 +1,213 @@ +.. code-block:: python + + from highcharts_core.options.series.area import LineSeries + from highcharts_core.options.series.data.cartesian import CartesianData + from highcharts_core.options.series.data.cartesian import CartesianDataCollection + + # Creating CartesianData instances from an array + + # EXAMPLE 1 + # A simple array of numerical values which correspond to the Y value of the data + # point + + my_data = CartesianData.from_array([0, 5, 3, 5]) + + # EXAMPLE 2 + # An array containing 2-member arrays (corresponding to the X and Y values of the + # data point) + + my_data = CartesianData.from_array([ + [0, 0], + [1, 5], + [2, 3], + [3, 5] + ]) + + # EXAMPLE 3 + # An array of dict with named values + + my_data = CartesianData.from_array([ + { + 'x': 0, + 'y': 0, + 'name': 'Point1', + 'color': '#00FF00' + }, + { + 'x': 1, + 'y': 5, + 'name': 'Point2', + 'color': '#CCC' + }, + { + 'x': 2, + 'y': 3, + 'name': 'Point3', + 'color': '#999' + }, + { + 'x': 3, + 'y': 5, + 'name': 'Point4', + 'color': '#000' + } + ]) + + # EXAMPLE 5 + # using a NumPy ndarray named "numpy_array" + + my_data = CartesianData.from_array(numpy_array) + + + my_series = LineSeries(data = my_data) + + # Creating a CartesianDataCollection instance from an array + + # EXAMPLE 1 + # A simple array of numerical values which correspond to the Y value of the data + # point + + my_data = CartesianDataCollection.from_array([0, 5, 3, 5]) + + # EXAMPLE 2 + # An array containing 2-member arrays (corresponding to the X and Y values of the + # data point) + + my_data = CartesianDataCollection.from_array([ + [0, 0], + [1, 5], + [2, 3], + [3, 5] + ]) + + # EXAMPLE 3 + # An array of dict with named values + + my_data = CartesianDataCollection.from_array([ + { + 'x': 0, + 'y': 0, + 'name': 'Point1', + 'color': '#00FF00' + }, + { + 'x': 1, + 'y': 5, + 'name': 'Point2', + 'color': '#CCC' + }, + { + 'x': 2, + 'y': 3, + 'name': 'Point3', + 'color': '#999' + }, + { + 'x': 3, + 'y': 5, + 'name': 'Point4', + 'color': '#000' + } + ]) + + # EXAMPLE 5 + # using a NumPy ndarray named "numpy_array" + + my_data = CartesianDataCollection.from_array(numpy_array) + + my_series = LineSeries(data = my_data) + + # Creating CartesianData instances from an array + + # EXAMPLE 1 + # A simple array of numerical values which correspond to the Y value of the data + # point + + my_series = LineSeries.from_array([0, 5, 3, 5]) + + # EXAMPLE 2 + # An array containing 2-member arrays (corresponding to the X and Y values of the + # data point) + + my_series = LineSeries.from_array([ + [0, 0], + [1, 5], + [2, 3], + [3, 5] + ]) + + # EXAMPLE 3 + # An array of dict with named values + + my_series = LineSeries.from_array([ + { + 'x': 0, + 'y': 0, + 'name': 'Point1', + 'color': '#00FF00' + }, + { + 'x': 1, + 'y': 5, + 'name': 'Point2', + 'color': '#CCC' + }, + { + 'x': 2, + 'y': 3, + 'name': 'Point3', + 'color': '#999' + }, + { + 'x': 3, + 'y': 5, + 'name': 'Point4', + 'color': '#000' + } + ]) + + # EXAMPLE 5 + # using a NumPy ndarray named "numpy_array" + + my_series = LineSeries.from_array(numpy_array) + +.. collapse:: Method Signature + + .. seealso:: + + * :meth:`SeriesBase.from_array() ` + * :meth:`Chart.from_array() ` + + .. method:: from_array(cls, value) + :noindex: + :classmethod: + + Creates a collection of data point instances, parsing the contents of ``value`` as an + array (iterable). This method is specifically used to parse data that is input to + **Highcharts for Python** without property names, in an array-organized structure as + described in the `Highcharts JS `__ documentation. + + .. seealso:: + + The specific structure of the expected array is highly dependent on the type of data + point that the series needs, which itself is dependent on the series type itself. + + Please review the detailed :ref:`series documentation ` for + series type-specific details of relevant array structures. + + :param value: The value that should contain the data which will be converted into data + point instances. + + .. note:: + + If ``value`` is not an iterable, it will be converted into an iterable to be + further de-serialized correctly. + + :type value: iterable + + :returns: Collection of :term:`data point` instances (descended from + :class:`DataBase `) + :rtype: `:class:`list ` of + :class:`DataBase `-descendant + instances, or + :class:`DataPointCollection ` diff --git a/docs/using/populating_series_data/_new_from_csv.rst b/docs/using/populating_series_data/_new_from_csv.rst index efe51843..6d6201e8 100644 --- a/docs/using/populating_series_data/_new_from_csv.rst +++ b/docs/using/populating_series_data/_new_from_csv.rst @@ -9,7 +9,15 @@ from highcharts_core.chart import Chart from highcharts_core.options.series.area import LineSeries - # Create a new LineSeries instance from the CSV file "some-csv-file.csv". + # Create one or more LineSeries instances from the CSV file "some-csv-file.csv". + + # EXAMPLE 1. The minimum code to produce one series for each + # column in the CSV file (excluding the first column): + + my_series = LineSeries.from_csv('some-csv-file.csv') + + # EXAMPLE 2. Produces ONE series with more precise configuration: + my_series = LineSeries.from_csv('some-csv-file.csv', property_column_map = { 'x': 0, @@ -17,7 +25,25 @@ 'id': 'id' }) - # Create a new LineSeries instance from the CSV file "some-csv-file.csv". + # EXAMPLE 3. Produces THREE series instances with + # more precise configuration: + + my_series = LineSeries.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': [3, 5, 8], + 'id': 'id' + }) + + # Create a chart with one or more LineSeries instances from + # the CSV file "some-csv-file.csv". + + # EXAMPLE 1: The minimum code: + + my_chart = Chart.from_csv('some-csv-file.csv', series_type = 'line') + + # EXAMPLE 2: For more precise configuration and *one* series: + my_chart = Chart.from_csv('some-csv-file.csv', property_column_map = { 'x': 0, @@ -25,29 +51,44 @@ 'id': 'id' }, series_type = 'line') + + # EXAMPLE 3: For more precise configuration and *multiple* series: + + my_chart = Chart.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': [3, 5, 8], + 'id': 'id' + }, + series_type = 'line') .. collapse:: Method Signature .. seealso:: * :meth:`Chart.from_csv() ` + * :meth:`SeriesBase.from_csv_in_rows() ` - .. method:: .from_csv(cls, as_string_or_file, property_column_map, series_kwargs = None, has_header_row = True, delimiter = ',', null_text = 'None', wrapper_character = "'", line_terminator = '\r\n', wrap_all_strings = False, double_wrapper_character_when_nested = False, escape_character = '\\') + .. method:: .from_csv(cls, as_string_or_file, property_column_map = None, series_kwargs = None, has_header_row = True, delimiter = ',', null_text = 'None', wrapper_character = "'", line_terminator = '\r\n', wrap_all_strings = False, double_wrapper_character_when_nested = False, escape_character = '\\', series_in_rows = False, series_index = None, **kwargs) :noindex: :classmethod: - Create a new :term:`series` instance with a - :meth:`.data ` property - populated from data in a CSV string or file. + Create one or more :term:`series` instances with + :meth:`.data ` populated from data in a CSV string + or file. .. note:: - For an example - :class:`LineSeries `, the + To produce one or more + :class:`LineSeries ` instances, the minimum code required would be: .. code-block:: python + # EXAMPLE 1. The minimum code: + my_series = LineSeries.from_csv('some-csv-file.csv') + + # EXAMPLE 2. For more precise configuration and ONE series: my_series = LineSeries.from_csv('some-csv-file.csv', property_column_map = { 'x': 0, @@ -55,14 +96,39 @@ 'id': 'id' }) + # EXAMPLE 3. For more precise configuration and MULTIPLE series: + my_series = LineSeries.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': [3, 5, 8], + 'id': 'id' + }) + As the example above shows, data is loaded into the ``my_series`` instance - from the CSV file with a filename ``some-csv-file.csv``. The - :meth:`x ` + from the CSV file with a filename ``some-csv-file.csv``. + + In EXAMPLE 1, the method will return one or more series where each series + will default to having its :meth:`.x ` values taken from + the first (index 0) column in the CSV, and one + :class:`LineSeries ` + instance will be created for each subsequent column (which will populate + that series' :meth:`.y ` values. + + In EXAMPLE 2, the chart will contain one series, where the + :meth:`.x ` values for each data point will be taken from the first (index 0) column in - the CSV file. The :meth:`y ` values will be taken from the - fourth (index 3) column in the CSV file. And the :meth:`id ` + the CSV file. The :meth:`.y ` values will be taken from the + fourth (index 3) column in the CSV file. And the :meth:`.id ` values will be taken from a column whose header row is labeled ``'id'`` (regardless of its index). + + In EXAMPLE 3, the chart will contain three series, all of which will have + :meth:`.x ` values taken from the first (index 0) column, + :meth:`.id ` values from the column whose header row is + labeled ``'id'``, and whose :meth:`.y ` will be taken + from the fourth (index 3) column for the first series, the sixth (index 5) + column for the second series, and the ninth (index 8) column for the third + series. :param as_string_or_file: The CSV data to use to pouplate data. Accepts either the raw CSV data as a :class:`str ` or a path to a file in the @@ -81,14 +147,45 @@ class, while the value can either be a numerical index (starting with 0) or a :class:`str ` indicating the label for the CSV column. + .. note:: + + If any of the values in ``property_column_map`` contain an iterable, then + one series will be produced for each item in the iterable. For example, + the following: + + .. code-block:: python + + { + 'x': 0, + 'y': [3, 5, 8] + } + + will return *three* series, each of which will have its + :meth:`.x ` value populated from the first column + (index 0), and whose :meth:`.y ` + values will be populated from the fourth, sixth, and ninth columns (indices + 3, 5, and 8), respectively. + + .. warning:: + + If the ``property_column_map`` uses :class:`str ` values, the CSV + file *must* have a header row (this is expected, by default). If there is no + header row and a :class:`str ` value is found, a + :exc:`HighchartsCSVDeserializationError` will be raised. + + :type property_column_map: :class:`dict ` + + :param series_type: Indicates the series type that should be created from the CSV + data. Defaults to ``'line'``. + .. warning:: - If the ``property_column_map`` uses :class:`str ` values, the CSV - file *must* have a header row (this is expected, by default). If there is no - header row and a :class:`str ` value is found, a - :exc:`HighchartsCSVDeserializationError` will be raised. + This argument is *not supported* when calling + :meth:`.from_csv() ` on + a :term:`series` instance. It is only supported when calling + :meth:`Chart.from_csv() `. - :type property_column_map: :class:`dict ` + :type series_type: :class:`str ` :param has_header_row: If ``True``, indicates that the first row of ``as_string_or_file`` contains column labels, rather than actual data. Defaults @@ -156,12 +253,29 @@ Python's native escape character). :type escape_character: :class:`str ` - :returns: A :term:`series` instance (descended from + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to ``False``. + :type series_in_rows: :class:`bool ` + + :param series_index: if :obj:`None `, will attempt to populate + the chart with multiple series from the CSV data. If an :class:`int ` + is supplied, will populate the chart only with the series found at + ``series_index``. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + + :returns: One or more :term:`series` instances (descended from :class:`SeriesBase `) with its :meth:`.data ` property populated from the CSV data in ``as_string_or_file``. :rtype: :class:`list ` of series instances (descended from - :class:`SeriesBase `) + :class:`SeriesBase `) or + :class:`SeriesBase ` instance :raises HighchartsCSVDeserializationError: if ``property_column_map`` references CSV columns by their label, but the CSV data does not contain a header row diff --git a/docs/using/populating_series_data/_new_from_pandas.rst b/docs/using/populating_series_data/_new_from_pandas.rst index d3401aba..e62f862f 100644 --- a/docs/using/populating_series_data/_new_from_pandas.rst +++ b/docs/using/populating_series_data/_new_from_pandas.rst @@ -2,7 +2,7 @@ The ``.from_pandas()`` method is available on all :term:`series` classes and on the :class:`Chart ` class, allowing you to either assemble - a series or an entire chart from Pandas :class:`DataFrame ` + a series or an entire chart from Pandas :class:`DataFrame ` with only one method call. .. code-block:: python @@ -12,14 +12,54 @@ from highcharts_core.options.series.area import LineSeries # Creating a Series from the DataFrame + + ## EXAMPLE 1. Minimum code required. Creates one or more series. + + my_series = LineSeries.from_pandas(df) + + ## EXAMPLE 2. More precise configuration. Creates ONE series. + + my_series = LineSeries.from_pandas(df, series_index = 2) + + ## EXAMPLE 3. More precise configuration. Creates ONE series. + my_series = LineSeries.from_pandas(df, property_map = { 'x': 'date', 'y': 'value', 'id': 'id' }) + + ## EXAMPLE 4. More precise configuration. Creates THREE series. + my_series = LineSeries.from_pandas(df, + property_map = { + 'x': 'date', + 'y': ['value1', 'value2', 'value3'], + 'id': 'id' + }) + + ## EXAMPLE 5. Minimum code required. Creates one or more series + ## from a dataframe where each row in the dataframe is a + ## Highcharts series. The two lines of code below are equivalent. + + my_series = LineSeries.from_pandas_in_rows(df) + # Creating a Chart with a lineSeries from the DataFrame. + + ## EXAMPLE 1. Minimum code required. Populates the chart with + ## one or more series. + + my_chart = Chart.from_pandas(df) + + ## EXAMPLE 2. More precise configuration. Populates the chart with + ## one series. + + my_chart = Chart.from_pandas(df, series_index = 2) + + ## EXAMPLE 3. More precise configuration. Populates the chart with + ## ONE series. + my_chart = Chart.from_pandas(df, property_map = { 'x': 'date', @@ -27,6 +67,23 @@ 'id': 'id' }, series_type = 'line') + + ## EXAMPLE 4. More precise configuration. Populates the chart with + ## THREE series. + + my_chart = Chart.from_pandas(df, + property_map = { + 'x': 'date', + 'y': ['value1', 'value2', 'value3'], + 'id': 'id' + }, + series_type = 'line') + + ## EXAMPLE 5. Minimum code required. Creates a Chart populated + ## with series from a dataframe where each row in the dataframe + ## becomes a series on the chart. + + my_chart = Chart.from_pandas_in_rows(df) .. collapse:: Method Signature @@ -34,27 +91,61 @@ .. seealso:: * :meth:`Chart.from_pandas() ` + * :meth:`Chart.from_pandas_in_rows() ` + * :meth:`SeriesBase.from_pandas_in_rows() ` - .. method:: .from_pandas(cls, df, property_map, series_kwargs = None) + .. method:: .from_pandas(cls, df, property_map = None, series_kwargs = None, series_in_rows = False, series_index = None, **kwargs) :noindex: :classmethod: - Create a :term:`series` instance whose - :meth:`.data ` property - is populated from a `pandas `__ - :class:`DataFrame `. + Create one or more :term:`series` instances whose + :meth:`.data ` properties + are populated from a `pandas `_ + :class:`DataFrame `. - :param df: The :class:`DataFrame ` from which data should be + :param df: The :class:`DataFrame ` from which data should be loaded. - :type df: :class:`DataFrame ` + :type df: :class:`DataFrame ` - :param property_map: A :class:`dict ` used to indicate which + :param property_map: An optional :class:`dict ` used to indicate which data point property should be set to which column in ``df``. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value should indicate the label for the - :class:`DataFrame ` column. + :class:`DataFrame ` column. + + .. note:: + + If any of the values in ``property_map`` contain an iterable, then + one series will be produced for each item in the iterable. For example, + the following: + + .. code-block:: python + + { + 'x': 'timestamp', + 'y': ['value1', 'value2', 'value3'] + } + + will return *three* series, each of which will have its + :meth:`.x ` value populated from the column + labeled ``'timestamp'``, and whose :meth:`.y ` + values will be populated from the columns labeled ``'value1'``, + ``'value2'``, and ``'value3'``, respectively. + :type property_map: :class:`dict ` + :param series_type: Indicates the series type that should be created from the CSV + data. Defaults to ``'line'``. + + .. warning:: + + This argument is *not supported* when calling + :meth:`.from_pandas() ` on + a :term:`series`. It is only supported when calling + :meth:`Chart.from_csv() `. + + :type series_type: :class:`str ` + :param series_kwargs: An optional :class:`dict ` containing keyword arguments that should be used when instantiating the series instance. Defaults to :obj:`None `. @@ -66,12 +157,30 @@ :type series_kwargs: :class:`dict ` - :returns: A :term:`series` instance (descended from - :class:`SeriesBase `) with its + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to ``False``. + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param series_index: If supplied, return the series that Highcharts for Python + generated from ``df`` at the ``series_index`` value. Defaults to + :obj:`None `, which returns all series generated from ``df``. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + + :returns: One or more :term:`series` instances (descended from + :class:`SeriesBase `) with the :meth:`.data ` property populated from the data in ``df``. :rtype: :class:`list ` of series instances (descended from - :class:`SeriesBase `) + :class:`SeriesBase `), or + a :class:`SeriesBase `-descended + instance :raises HighchartsPandasDeserializationError: if ``property_map`` references a column that does not exist in the data frame diff --git a/docs/using/populating_series_data/_with_data_property.rst b/docs/using/populating_series_data/_with_data_property.rst index 5fe5289e..c264faa5 100644 --- a/docs/using/populating_series_data/_with_data_property.rst +++ b/docs/using/populating_series_data/_with_data_property.rst @@ -2,12 +2,16 @@ my_series = LineSeries() + # EXAMPLE 1 # A simple array of numerical values which correspond to the Y value of the data # point + my_series.data = [0, 5, 3, 5] + # EXAMPLE 2 # An array containing 2-member arrays (corresponding to the X and Y values of the # data point) + my_series.data = [ [0, 0], [1, 5], @@ -15,7 +19,9 @@ [3, 5] ] + # EXAMPLE 3 # An array of dict with named values + my_series.data = [ { 'x': 0, diff --git a/docs/using/populating_series_data/_with_from_array.rst b/docs/using/populating_series_data/_with_from_array.rst deleted file mode 100644 index 27efc8d9..00000000 --- a/docs/using/populating_series_data/_with_from_array.rst +++ /dev/null @@ -1,134 +0,0 @@ -.. code-block:: python - - from highcharts_core.options.series.area import LineSeries - from highcharts_core.options.series.data import CartesianData - - # A simple array of numerical values which correspond to the Y value of the data - # point - my_data = CartesianData.from_array([0, 5, 3, 5]) - - # An array containing 2-member arrays (corresponding to the X and Y values of the - # data point) - my_data = CartesianData.from_array([ - [0, 0], - [1, 5], - [2, 3], - [3, 5] - ]) - - # An array of dict with named values - my_data = CartesianData.from_array([ - { - 'x': 0, - 'y': 0, - 'name': 'Point1', - 'color': '#00FF00' - }, - { - 'x': 1, - 'y': 5, - 'name': 'Point2', - 'color': '#CCC' - }, - { - 'x': 2, - 'y': 3, - 'name': 'Point3', - 'color': '#999' - }, - { - 'x': 3, - 'y': 5, - 'name': 'Point4', - 'color': '#000' - } - ]) - - my_series = LineSeries(data = my_data) - -.. collapse:: Method Signature - - .. method:: from_array(cls, value) - :noindex: - :classmethod: - - Creates a collection of data point instances, parsing the contents of ``value`` as an - array (iterable). This method is specifically used to parse data that is input to - **Highcharts for Python** without property names, in an array-organized structure as - described in the `Highcharts JS `__ documentation. - - .. seealso:: - - The specific structure of the expected array is highly dependent on the type of data - point that the series needs, which itself is dependent on the series type itself. - - Please review the detailed :ref:`series documentation ` for - series type-specific details of relevant array structures. - - .. note:: - - An example of how this works for a simple - :class:`LineSeries ` (which uses - :class:`CartesianData ` - data points) would be: - - .. code-block:: python - - my_series = LineSeries() - - # A simple array of numerical values which correspond to the Y value of the data - # point - my_series.data = [0, 5, 3, 5] - - # An array containing 2-member arrays (corresponding to the X and Y values of the - # data point) - my_series.data = [ - [0, 0], - [1, 5], - [2, 3], - [3, 5] - ] - - # An array of dict with named values - my_series.data = [ - { - 'x': 0, - 'y': 0, - 'name': 'Point1', - 'color': '#00FF00' - }, - { - 'x': 1, - 'y': 5, - 'name': 'Point2', - 'color': '#CCC' - }, - { - 'x': 2, - 'y': 3, - 'name': 'Point3', - 'color': '#999' - }, - { - 'x': 3, - 'y': 5, - 'name': 'Point4', - 'color': '#000' - } - ] - - :param value: The value that should contain the data which will be converted into data - point instances. - - .. note:: - - If ``value`` is not an iterable, it will be converted into an iterable to be - further de-serialized correctly. - - :type value: iterable - - :returns: Collection of :term:`data point` instances (descended from - :class:`DataBase `) - :rtype: :class:`list ` of - :class:`DataBase `-descendant - instances diff --git a/docs/using/rendering_your_visualizations/_as_jupyter.rst b/docs/using/rendering_your_visualizations/_as_jupyter.rst index 5b202337..6925eac1 100644 --- a/docs/using/rendering_your_visualizations/_as_jupyter.rst +++ b/docs/using/rendering_your_visualizations/_as_jupyter.rst @@ -1,16 +1,9 @@ .. code-block:: python from highcharts_core.chart import Chart - from highcharts_core.options.series.area import LineSeries from highcharts_core.global_options.shared_options import SharedOptions - my_chart = Chart(container = 'target_div', - options = { - 'series': [ - LineSeries(data = [0, 5, 3, 5]) - ] - }, - variable_name = 'myChart') + my_chart = Chart(data = [0, 5, 3, 5], series_type = 'line') # Now this will render the contents of "my_chart" in your Jupyter Notebook my_chart.display() @@ -24,7 +17,7 @@ .. collapse:: Method Signature - .. method:: display(self, global_options = None) + .. method:: display(self, global_options = None, container = None, retries = 5, interval = 1000) :noindex: Display the chart in `Jupyter Labs `__ or @@ -35,6 +28,31 @@ :type global_options: :class:`SharedOptions ` or :obj:`None ` + :param container: The ID to apply to the HTML container when rendered in Jupyter Labs. Defaults to + :obj:`None `, which applies the :meth:`.container ` + property if set, and ``'highcharts_target_div'`` if not set. + + .. note:: + + Highcharts for Python will append a 6-character random string to the value of ``container`` + to ensure uniqueness of the chart's container when rendering in a Jupyter Notebook/Labs context. The + :class:`Chart ` instance will retain the mapping between container and the + random string so long as the instance exists, thus allowing you to easily update the rendered chart by + calling the :meth:`.display() ` method again. + + If you wish to create a new chart from the instance that does not update the existing chart, then you can do + so by specifying a new ``container`` value. + + :type container: :class:`str ` or :obj:`None ` + + :param retries: The number of times to retry rendering the chart. Used to avoid race conditions with the + Highcharts script. Defaults to 5. + :type retries: :class:`int ` + + :param interval: The number of milliseconds to wait between retrying rendering the chart. Defaults to 1000 (1 + second). + :type interval: :class:`int ` + :raises HighchartsDependencyError: if `ipython `__ is not available in the runtime environment diff --git a/docs/using/rendering_your_visualizations/_as_web_content.rst b/docs/using/rendering_your_visualizations/_as_web_content.rst index 69900c6e..4e710254 100644 --- a/docs/using/rendering_your_visualizations/_as_web_content.rst +++ b/docs/using/rendering_your_visualizations/_as_web_content.rst @@ -7,13 +7,7 @@ from highcharts_core.chart import Chart from highcharts_core.options.series.area import LineSeries - my_chart = Chart(container = 'target_div', - options = { - 'series': [ - LineSeries(data = [0, 5, 3, 5]) - ] - }, - variable_name = 'myChart') + my_chart = Chart(data = [0, 5, 3, 5], series_type = 'line') as_js_literal = my_chart.to_js_literal() diff --git a/highcharts_core/__version__.py b/highcharts_core/__version__.py index 83ad9b45..bdbb22b7 100644 --- a/highcharts_core/__version__.py +++ b/highcharts_core/__version__.py @@ -1 +1 @@ -__version__ = '1.3.7' \ No newline at end of file +__version__ = '1.4.0' \ No newline at end of file diff --git a/highcharts_core/ai.py b/highcharts_core/ai.py new file mode 100644 index 00000000..b2f46794 --- /dev/null +++ b/highcharts_core/ai.py @@ -0,0 +1,622 @@ +import os +from collections import UserDict + +try: + from dill.source import getsource + HAS_DILL = True +except ImportError: + HAS_DILL = False + +try: + import openai + HAS_OPENAI = True +except ImportError: + HAS_OPENAI = False + +try: + from anthropic import Anthropic + HAS_ANTHROPIC = True +except ImportError: + HAS_ANTHROPIC = False + +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + pass + +from validator_collection import validators, checkers + +from highcharts_core import errors + + +OPENAI_MESSAGES = [ + { + 'role': 'system', + 'content': """Read the Python source code provided. Consider its arguments, logic, and output, and produce a JavaScript function that accepts the same arguments, follows the same logic, replaces snake_case variables with camelCase variables, and produces the same output. Ensure that if Timer or sleep is used in the Python code, it is replaced by setTimeout in the JavaScript code.""" # noqa: E501 + }, + { + 'role': 'user', + 'name': 'example-input-1', + 'content': """```def addPoint(e): point = e.point series = e.target if not series.pulse: series.pulse = series.chart.renderer.circle().add(series.marker_group) def pulse(): series.pulse.attr({ 'x': series.x_axis.to_pixels(point.x, True), 'y': series.y_axis.to_pixels(point.y, True), 'r': series.options.marker.radius, 'opacity': 1, 'fill': series.color }).animate({ 'r': 20, 'opacity': 0 }, { 'duration': 1000 }) Timer(1/1000, pulse).start()```""" # noqa: E501 + }, + { + 'role': 'assistant', + 'name': 'example-output-1', + 'content': """```function addPoint(e) { const point = e.point; const series = e.target; if (!series.pulse) { series.pulse = series.chart.renderer.circle().add(series.markerGroup); } setTimeout(() => { series.pulse.attr({ 'x': series.xAxis.toPixels(point.x, true), 'y': series.yAxis.toPixels(point.y, true), 'r': series.options.marker.radius, 'opacity': 1, 'fill': series.color }).animate({ 'r': 20, 'opacity': 0 }, { 'duration': 1000 }); }, 1);```""" # noqa: E501 + }, + { + 'role': 'user', + 'name': 'example-input-2', + 'content': """```def click(e): x = round(e.x_axis[0].value) y = round(e.y_axis[0].value) series = this.series[0] series.addPoint([x, y])```""" # noqa: E501 + }, + { + 'role': 'assistant', + 'name': 'example-output-2', + 'content': """```function click(e) { const x = Math.round(e.xAxis[0].value); const y = Math.round(e.yAxis[0].value); const series = this.series[0]; series.addPoint([x, y]); }```""" # noqa: E501 + }, + { + 'role': 'user', + 'name': 'example-input-3', + 'content': """```def click(): if len(this.series.data) > 1: this.remove()```""" # noqa: E501 + }, + { + 'role': 'assistant', + 'name': 'example-output-3', + 'content': """```function click() { if (this.series.data.length > 1) { this.remove(); } }```""" # noqa: E501 + }, + { + 'role': 'user', + 'name': 'task', + 'content': """The Python source code, wrapped in three backticks, is: `````` Please produce the JavaScript code, wrapping the JavaScript code in three backticks. If you are unable to do so, please say "I cannot convert the Python code to JavaScript." """ # noqa: E501 + } +] + + +ANTHROPIC_PROMPT = """\n\nHuman: Read the Python source code provided. Consider its arguments, logic, and output, and produce a JavaScript function that accepts the same arguments, follows the same logic, replaces snake_case variables with camelCase variables, and produces the same output. Ensure that if Timer or sleep is used in the Python code, it is replaced by setTimeout in the JavaScript code. + +Here are some examples: + +H: def addPoint(e): point = e.point series = e.target if not series.pulse: series.pulse = series.chart.renderer.circle().add(series.marker_group) def pulse(): series.pulse.attr({ 'x': series.x_axis.to_pixels(point.x, True), 'y': series.y_axis.to_pixels(point.y, True), 'r': series.options.marker.radius, 'opacity': 1, 'fill': series.color }).animate({ 'r': 20, 'opacity': 0 }, { 'duration': 1000 }) Timer(1/1000, pulse).start() +A: function addPoint(e) { const point = e.point; const series = e.target; if (!series.pulse) { series.pulse = series.chart.renderer.circle().add(series.markerGroup); } setTimeout(() => { series.pulse.attr({ 'x': series.xAxis.toPixels(point.x, true), 'y': series.yAxis.toPixels(point.y, true), 'r': series.options.marker.radius, 'opacity': 1, 'fill': series.color }).animate({ 'r': 20, 'opacity': 0 }, { 'duration': 1000 }); }, 1); + + +H: def click(e): x = round(e.x_axis[0].value) y = round(e.y_axis[0].value) series = this.series[0] series.addPoint([x, y]) +A: function click(e) { const x = Math.round(e.xAxis[0].value); const y = Math.round(e.yAxis[0].value); const series = this.series[0]; series.addPoint([x, y]); } + + +H: function click() { if (this.series.data.length > 1) { this.remove(); } } +A: function click() { if (this.series.data.length > 1) { this.remove(); } } + + +The Python source code, wrapped in , is: + + + +Please produce the JavaScript code, wrapping the JavaScript code in tags. If you are unable to do so, please say "I cannot convert the Python code to JavaScript." + +Assistant: +""" # noqa: E501 + +SUPPORTED_MODELS = { + 'gpt-3.5-turbo': ('OpenAI', OPENAI_MESSAGES), + 'gpt-3.5-turbo-16k': ('OpenAI', OPENAI_MESSAGES), + 'gpt-4': ('OpenAI', OPENAI_MESSAGES), + 'gpt-4-32k': ('OpenAI', OPENAI_MESSAGES), + 'claude-instant-1': ('Anthropic', ANTHROPIC_PROMPT), + 'claude-2': ('Anthropic', ANTHROPIC_PROMPT), +} + + +def get_source(callable): + """Retrieve the source of ``callable``. + + :param callable: The Python callable object (function or method). + :type callable: callable + + :returns: The source code of ``callable``. + :rtype: :class:`str ` + + :raises HighchartsValueError: if ``callable`` is not a Python callable + :raises HighchartsDependencyError: if `dill `__ + is not installed + + """ + if not HAS_DILL: + raise errors.HighchartsDependencyError('dill is required to retrieve the ' + 'source of a callable, however it was ' + 'not found in the runtime environment. ' + 'Please install using "pip install dill"') + + if not checkers.is_callable(callable): + raise errors.HighchartsValueError(f'callable must be a Python callable. Was: ' + f'{callable.__class__.__name__}') + + source_code = getsource(callable, force = True) + + return source_code + + +def convert_to_js(callable, + model = 'gpt-3.5-turbo', + api_key = None, + openai_api_type = None, + openai_api_base = None, + openai_api_version = None, + openai_deployment_id = None, + **kwargs): + """Converts ``source`` into a JavaScript function. + + :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 ` + + :param api_key: The API key used to authenticate against the + generative AI provider. Defaults to + :obj:`None `, which then tries to find the API + key in the appropriate environment variable: + + * ``OPENAI_API_KEY`` if using an + `OpenAI `__ provided model + * ``ANTHROPIC_API_KEY`` if using an + `Anthropic `__ provided model + + :type api_key: :class:`str ` or :obj:`None ` + + :param openai_api_type: If using `OpenAI `__ + and Azure endpoints, this value should be set to ``'azure'``. + Defaults to :obj:`None `. + :type openai_api_type: :class:`str ` or + :obj:`None ` + + :param openai_api_base: If using `OpenAI `__ + and Azure endpoints, this value should be set to your base API + endpoint. Defaults to :obj:`None `. + :type openai_api_base: :class:`str ` or + :obj:`None ` + + :param openai_api_version: If using `OpenAI `__ + and Azure endpoints, this value should be set to your API version. + Defaults to :obj:`None `. + :type openai_api_version: :class:`str ` or + :obj:`None ` + + :param openai_deployment_id: If using `OpenAI `__ + and Azure endpoints, this value should be set to your deployment + ID. Defaults to :obj:`None `. + :type openai_deployment_id: :class:`str ` or + :obj:`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 JavaScript source code produced by the model. + + .. 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:`str ` + + :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 + + """ + model = validators.string(model, allow_empty = False) + model = model.lower() + if model not in SUPPORTED_MODELS: + raise errors.HighchartsValueError(f'The model supplied is not supported. ' + f'Received: {model}.') + + source = get_source(callable) + + provider = SUPPORTED_MODELS[model][0] + prompt = SUPPORTED_MODELS[model][1] + + if provider == 'OpenAI': + prompt[-1]['content'] = prompt[-1]['content'].replace( + '', source + ) + api_key = api_key or os.getenv('OPENAI_API_KEY', None) + convert = openai_conversion + elif provider == 'Anthropic': + api_key = api_key or os.getenv('ANTHROPIC_API_KEY', None) + prompt = prompt.replace('', source) + convert = anthropic_conversion + else: + convert = None + + if not api_key: + raise errors.HighchartsValueError('No API key was provided, and none ' + 'was found in supported environment ' + 'variables.') + + if provider == 'OpenAI': + is_acceptable, flags = openai_moderate(prompt[-1], + api_key, + api_type = openai_api_type, + api_base = openai_api_base, + api_version = openai_api_version, + deployment_id = openai_deployment_id) + if not is_acceptable: + raise errors.HighchartsModerationError( + f'The supplied prompt violates OpenAI moderation policies. ' + f'Please review your callable / Python function, and address ' + f'the topics flagged in the following moderation report:\n{flags}' + ) + + result = convert(prompt, + model, + api_key, + api_type = openai_api_type, + api_base = openai_api_base, + api_version = openai_api_version, + deployment_id = openai_deployment_id, + **kwargs) + else: + result = convert(prompt, model, api_key, **kwargs) + + return result + + +def openai_moderate(prompt, + api_key = None, + api_type = None, + api_base = None, + api_version = None, + deployment_id = None): + """Evaluates ``prompt`` against OpenAI's content moderation policies to determine if + it violates their usage policies. + + This function calls OpenAI's `moderation API `__ + to evaluate whether ``prompt`` violates their content moderation policies. + + :param prompt: The prompt to evaluate. + :type prompt: :class:`str ` + + :param api_key: The API key used to authenticate with OpenAI. + Defaults to :obj:`None `, which then tries to find the API + key in the ``OPENAI_API_KEY``. + :type api_key: :class:`str ` or :obj:`None ` + + :param api_type: If using `OpenAI `__ + and Azure endpoints, this value should be set to ``'azure'``. + Defaults to :obj:`None `. + :type api_type: :class:`str ` or + :obj:`None ` + + :param api_base: If using `OpenAI `__ + and Azure endpoints, this value should be set to your base API + endpoint. Defaults to :obj:`None `. + :type api_base: :class:`str ` or + :obj:`None ` + + :param api_version: If using `OpenAI `__ + and Azure endpoints, this value should be set to your API version. + Defaults to :obj:`None `. + :type api_version: :class:`str ` or + :obj:`None ` + + :param deployment_id: If using `OpenAI `__ + and Azure endpoints, this value should be set to your deployment + ID. Defaults to :obj:`None `. + :type deployment_id: :class:`str ` or + :obj:`None ` + + :returns: A :class:`tuple ` containing two members: + + 1. ``True`` if the prompt is acceptable, ``False`` otherwise + 2. A :class:`dict ` containing the topics flagged by + OpenAI's moderation tools. This :class:`dict ` will + be empty if the prompt is acceptable. + + :rtype: :class:`tuple ` of :class:`bool ` and + :class:`dict ` + + :raises HighchartsDependencyError: if `openai `__ + is not installed/available in the runtime environment + :raises HighchartsValueError: if no ``api_key`` is available + + """ + if not HAS_OPENAI: + raise errors.HighchartsDependencyError('openai is required to use OpenAI ' + 'models, however it was not found in ' + 'the runtime environment. Please ' + 'install using "pip install openai"') + api_key = api_key or os.getenv('OPENAI_API_KEY', None) + if not api_key: + raise errors.HighchartsValueError('No API key was provided, and none ' + 'was found in supported environment ' + 'variables.') + + openai.api_key = api_key + if api_type: + openai.api_type = api_type + openai.api_base = api_base + openai.api_version = api_version + + kwargs = { + 'input': prompt['content'] + } + if deployment_id: + kwargs['deployment_id'] = deployment_id + + result = openai.Moderation.create(**kwargs) + is_flagged = result['results'][0]['flagged'] + flags = result['results'][0]['categories'] + is_acceptable = is_flagged is False + if is_acceptable: + flags = {} + + return is_acceptable, flags + + +def openai_conversion(prompt, + model = 'gpt-3.5-turbo', + api_key = None, + api_type = None, + api_base = None, + api_version = None, + deployment_id = None, + **kwargs): + """Submits ``prompt`` to the OpenAI API for conversion into JavaScript source code. + + :param prompt: The prompt to evaluate, using the Chat Completions form with a + few-shot strategy. + :type prompt: :class:`list ` of :class:`dict ` + + :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'`` + + :type model: :class:`str ` + + :param api_key: The API key used to authenticate against + OpenAI. Defaults to :obj:`None `, which + then tries to find the ``OPENAI_API_KEY`` environment + variable. + :type api_key: :class:`str ` or :obj:`None ` + + :param api_type: If using `OpenAI `__ + and Azure endpoints, this value should be set to ``'azure'``. + Defaults to :obj:`None `. + :type api_type: :class:`str ` or + :obj:`None ` + + :param api_base: If using `OpenAI `__ + and Azure endpoints, this value should be set to your base API + endpoint. Defaults to :obj:`None `. + :type api_base: :class:`str ` or + :obj:`None ` + + :param api_version: If using `OpenAI `__ + and Azure endpoints, this value should be set to your API version. + Defaults to :obj:`None `. + :type api_version: :class:`str ` or + :obj:`None ` + + :param deployment_id: If using `OpenAI `__ + and Azure endpoints, this value should be set to your deployment + ID. Defaults to :obj:`None `. + :type deployment_id: :class:`str ` or + :obj:`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 JavaScript source code produced by the model. + + .. 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:`str ` + + :raises HighchartsValueError: if no ``api_key`` is available + :raises HighchartsDependencyError: if a required dependency is not + available in the runtime environment + """ + if not HAS_OPENAI: + raise errors.HighchartsDependencyError('openai is required to use OpenAI ' + 'models, however it was not found in ' + 'the runtime environment. Please ' + 'install using "pip install openai"') + api_key = api_key or os.getenv('OPENAI_API_KEY', None) + if not api_key: + raise errors.HighchartsValueError('No API key was provided, and none ' + 'was found in supported environment ' + 'variables.') + + model = validators.string(model, allow_empty = False) + model = model.lower() + if model not in ['gpt-3.5-turbo', 'gpt-3.5-turbo-16k', 'gpt-4', 'gpt-4-32k']: + raise errors.HighchartsValueError(f'The model supplied is not supported. ' + f'Received: {model}.') + + openai.api_key = api_key + if api_type: + openai.api_type = api_type + openai.api_base = api_base + openai.api_version = api_version + + if not deployment_id: + result = openai.ChatCompletion.create(model = model, + messages = prompt, + **kwargs) + else: + result = openai.ChatCompletion.create(deployment_id = deployment_id, + model = model, + messages = prompt, + **kwargs) + + raw_response = result.choices[0].message.content + starting_index = raw_response.find('```') + ending_index = raw_response.rfind('```') + + if 'I cannot convert the Python code to JavaScript.' in raw_response: + return errors.HighchartsPythonConversionError( + f'OpenAI was unable to convert the ' + f'callable to a JavaScript function ' + f'using the "{model}" model. Please ' + f'try again, possibly selecting a ' + f'different model.' + ) + elif starting_index == -1 or ending_index == -1: + return errors.HighchartsPythonConversionError( + f'OpenAI was unable to convert the ' + f'callable to a JavaScript function ' + f'using the "{model}" model. Please ' + f'try again, possibly selecting a ' + f'different model.') + + js_as_str = raw_response[starting_index + 3:ending_index] + if js_as_str.startswith('javascript\n') or js_as_str.startswith('JavaScript\n'): + js_as_str = js_as_str[11:] + + return js_as_str + + +def anthropic_conversion(prompt, + model = 'gpt-3.5-turbo', + api_key = None, + **kwargs): + """Submits ``prompt`` to the Anthropic API for conversion into JavaScript source code. + + :param prompt: The prompt to evaluate. + :type prompt: :class:`str ` + + :param model: The generative AI model to use. + Defaults to ``'claude-instant-1'``. Accepts: + + * ``'claude-instant-1'`` + * ``'claude-2'`` + + :type model: :class:`str ` + + :param api_key: The API key used to authenticate against + OpenAI. Defaults to :obj:`None `, which + then tries to find the ``ANTHROPIC_API_KEY`` environment + variable. + :type api_key: :class:`str ` or :obj:`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 JavaScript source code produced by the model. + + .. 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:`str ` + + :raises HighchartsValueError: if no ``api_key`` is available + :raises HighchartsDependencyError: if a required dependency is not + available in the runtime environment + """ + if not HAS_ANTHROPIC: + raise errors.HighchartsDependencyError('anthropic is required to use Anthropic ' + 'models, however it was not found in ' + 'the runtime environment. Please ' + 'install using "pip install anthropic"') + api_key = api_key or os.getenv('ANTHROPIC_API_KEY', None) + if not api_key: + raise errors.HighchartsValueError('No API key was provided, and none ' + 'was found in supported environment ' + 'variables.') + + model = validators.string(model, allow_empty = False) + model = model.lower() + if model not in ['claude-instant-1', 'claude-2']: + raise errors.HighchartsValueError(f'The model supplied is not supported. ' + f'Received: {model}.') + + anthropic = Anthropic(api_key = api_key) + result = anthropic.completions.create(model = model, + prompt = prompt, + **kwargs) + raw_response = result.completion + + starting_index = raw_response.find('') + ending_index = raw_response.rfind('') + + if 'I cannot convert the Python code to JavaScript.' in raw_response: + return errors.HighchartsPythonConversionError( + f'Anthropic was unable to convert the ' + f'callable to a JavaScript function ' + f'using the "{model}" model. Please ' + f'try again, possibly selecting a ' + f'different model.' + ) + elif starting_index == -1 or ending_index == -1: + return errors.HighchartsPythonConversionError( + f'Anthropic was unable to convert the ' + f'callable to a JavaScript function ' + f'using the "{model}" model. Please ' + f'try again, possibly selecting a ' + f'different model.' + ) + + js_as_str = raw_response[starting_index + 12:ending_index] + + return js_as_str diff --git a/highcharts_core/chart.py b/highcharts_core/chart.py index cd8e29e1..26099432 100644 --- a/highcharts_core/chart.py +++ b/highcharts_core/chart.py @@ -19,6 +19,51 @@ class Chart(HighchartsMeta): """Python representation of a Highcharts ``Chart`` object.""" def __init__(self, **kwargs): + """Creates a :class:`Chart ` instance. + + When creating a :class:`Chart ` instance, you can + provide any of the object's properties as keyword arguments. + **Positional arguments are not supported**. + + In addition to the standard properties, there are three special keyword + arguments which streamline the creation of + :class:`Chart ` instances: + + * ``series`` which accepts an iterable of + :class:`SeriesBase ` descendents. + These are automatically then populated as series within the chart. + + .. note:: + + Each member of ``series`` must be coercable into a Highcharts Core for Python + series. And it must contain a ``type`` property. + + * ``data`` which accepts an iterable of objects that are coercable to Highcharts + data point objects, which are then automatically used to create/populate a + series on your chart instance + * ``series_type`` which accepts a string indicating the type of series to render + for your data. + + .. warning:: + + If you supply ``series``, the ``data`` and ``series_type`` keywords will be + *ignored*. + + If you supply ``data``, then ``series_type`` must *also* be supplied. Failure + to do so will raise a + :exc:`HighchartsValueError `. + + If you are also supplying an + :meth:`options ` keyword argument, then + any series derived from ``series``, ``data``, and ``series_type`` will be + *added* to any series defined in that ``options`` value. + + :raises: :exc:`HighchartsValueError ` + if supplying ``data`` with no ``series_type``. + + :returns: A :class:`Chart ` instance. + :rtype: :class:`Chart ` + """ self._callback = None self._container = None self._options = None @@ -35,7 +80,27 @@ def __init__(self, **kwargs): None) or os.environ.get('HIGHCHARTS_MODULE_URL', 'https://code.highcharts.com/') - super().__init__(**kwargs) + series = kwargs.get('series', None) + series_type = kwargs.get('series_type', None) + data = kwargs.get('data', None) + + if series_type and not data: + data = [] + + if series is not None: + if not checkers.is_iterable(series, forbid_literals = (str, bytes, dict, UserDict)): + series = [series] + self.add_series(*series) + elif data is not None and series_type: + series_as_dict = { + 'data': data, + 'type': series_type + } + self.add_series(series_as_dict) + elif data is not None: + raise errors.HighchartsValueError('If ``data`` is provided, then ' + '``series_type`` must also be provided. ' + '``series_type`` was empty.') def __str__(self): """Return a human-readable :class:`str ` representation of the chart. @@ -78,7 +143,7 @@ def __str__(self): return f'{self.__class__.__name__}({kwargs_as_str})' - def _jupyter_include_scripts(self): + def _jupyter_include_scripts(self, **kwargs): """Return the JavaScript code that is used to load the Highcharts JS libraries. :rtype: :class:`str ` @@ -89,7 +154,7 @@ def _jupyter_include_scripts(self): for item in required_modules: js_str += utility_functions.jupyter_add_script(item) js_str += """.then(() => {""" - + for item in required_modules: js_str += """});""" @@ -122,7 +187,7 @@ def _jupyter_javascript(self, :type retries: :class:`int ` :param interval: The number of milliseconds to wait between retrying rendering the chart. Defaults to 1000 (1 - seocnd). + second). :type interval: :class:`int ` :rtype: :class:`str ` @@ -232,6 +297,57 @@ def get_required_modules(self, return scripts + def _get_jupyter_script_loader(self, chart_js_str) -> str: + """Return the JavaScript code that loads ``required_modules`` in a Jupyter + context. + + :param chart_js_str: The JavaScript code that renders the chart. + :type chart_js_str: :class:`str ` + + :returns: The JavaScript code that loads the required modules. + :rtype: :class:`str ` + """ + if_no_requirejs = '' + + if_requirejs = """require.config({\n""" + if_requirejs += """ packages: [{\n""" + if_requirejs += """ name: 'highcharts', main: 'highcharts' }],\n""" + if_requirejs += """ paths: {\n""" + if_requirejs += f""" 'highcharts': '{self.module_url}'\n""" + if_requirejs += """ }\n\n});""" + + if_requirejs += """ require([""" + requirejs_modules = [] + for item in self.get_required_modules(): + if item == 'highcharts' and item not in requirejs_modules: + requirejs_modules.append(item) + else: + revised_item = f'highcharts/{item}' + if revised_item not in requirejs_modules: + requirejs_modules.append(revised_item) + + for index, item in enumerate(requirejs_modules): + is_last = index == len(requirejs_modules) - 1 + if_requirejs += f"""'{item}'""" + if not is_last: + if_requirejs += ', ' + if_requirejs += """], function (Highcharts) {\n""" + if_requirejs += chart_js_str + if_requirejs += """\n});""" + + required_modules = [f'{self.module_url}{x}' + for x in self.get_required_modules(include_extension = True)] + for item in required_modules: + if_no_requirejs += utility_functions.jupyter_add_script(item) + if_no_requirejs += """.then(() => {""" + + for item in required_modules: + if_no_requirejs += """});""" + + js_str = utility_functions.wrap_for_requirejs(if_requirejs, if_no_requirejs) + + return js_str + @property def callback(self) -> Optional[CallbackFunction]: """A (JavaScript) function that is run when the chart has loaded and all external @@ -391,7 +507,8 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: def to_js_literal(self, filename = None, - encoding = 'utf-8') -> Optional[str]: + encoding = 'utf-8', + careful_validation = False) -> Optional[str]: """Return the object represented as a :class:`str ` containing the JavaScript object literal. @@ -403,6 +520,18 @@ def to_js_literal(self, to ``'utf-8'``. :type encoding: :class:`str ` + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + .. note:: If :meth:`variable_name ` is set, will render a string as @@ -422,7 +551,9 @@ def to_js_literal(self, as_dict = {} for key in untrimmed: item = untrimmed[key] - serialized = serialize_to_js_literal(item, encoding = encoding) + serialized = serialize_to_js_literal(item, + encoding = encoding, + careful_validation = careful_validation) if serialized is not None: as_dict[key] = serialized @@ -434,13 +565,19 @@ def to_js_literal(self, container_as_str = """null""" if self.options: - options_as_str = "{}".format(self.options.to_js_literal(encoding = encoding)) + options_as_str = "{}".format( + self.options.to_js_literal(encoding = encoding, + careful_validation = careful_validation) + ) else: options_as_str = """null""" callback_as_str = '' if self.callback: - callback_as_str = "{}".format(self.callback.to_js_literal(encoding = encoding)) + callback_as_str = "{}".format( + self.callback.to_js_literal(encoding = encoding, + careful_validation = careful_validation) + ) signature_elements += 1 signature = """Highcharts.chart(""" @@ -826,7 +963,7 @@ def display(self, :type retries: :class:`int ` :param interval: The number of milliseconds to wait between retrying rendering the chart. Defaults to 1000 (1 - seocnd). + second). :type interval: :class:`int ` :raises HighchartsDependencyError: if @@ -842,9 +979,6 @@ def display(self, 'your runtime environment. To install,' 'use: pip install ipython') - include_js_str = self._jupyter_include_scripts() - include_display = display_mod.Javascript(data = include_js_str) - container = container or self.container or 'highcharts_target_div' if not self._random_slug: self._random_slug = {} @@ -863,17 +997,290 @@ def display(self, random_slug = random_slug, retries = retries, interval = interval) - javascript_display = display_mod.Javascript(data = chart_js_str) + wrapped_chart_js_str = utility_functions.wrap_for_requirejs('', chart_js_str) + javascript_display = display_mod.Javascript(data = wrapped_chart_js_str) + + include_js_str = self._get_jupyter_script_loader(chart_js_str) + include_display = display_mod.Javascript(data = include_js_str) - display(include_display) display(html_display) + display(include_display) display(javascript_display) + @classmethod + def from_array(cls, + value, + series_type = 'line', + series_kwargs = None, + options_kwargs = None, + chart_kwargs = None): + """Create a :class:`Chart ` instance with + one series populated from the array contained in ``value``. + + .. seealso:: + + The specific structure of the expected array is highly dependent on the type of data + point that the series needs, which itself is dependent on the series type itself. + + Please review the detailed :ref:`series documentation ` for + series type-specific details of relevant array structures. + + :param value: The array to use to populate the series data. + :type value: iterable + + :param series_type: Indicates the series type that should be created from the array + data. Defaults to ``'line'``. + :type series_type: :class:`str ` + + :param series_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the series instance. Defaults + to :obj:`None `. + + .. warning:: + + If ``series_kwargs`` contains a ``data`` key, its value will be *overwritten*. + The ``data`` value will be created from ``df`` instead. + + :type series_kwargs: :class:`dict ` + + :param options_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the :class:`HighchartsOptions` + instance. Defaults to :obj:`None `. + + .. warning:: + + If ``options_kwargs`` contains a ``series`` key, the ``series`` value will be + *overwritten*. The ``series`` value will be created from the data in ``df``. + + :type options_kwargs: :class:`dict ` or :obj:`None ` + + :param chart_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the :class:`Chart` instance. + Defaults to :obj:`None `. + + .. warning:: + + If ``chart_kwargs`` contains an ``options`` key, ``options`` will be + *overwritten*. The ``options`` value will be created from the + ``options_kwargs`` and the data in ``df`` instead. + + :type chart_kwargs: :class:`dict ` or :obj:`None ` + + :returns: A :class:`Chart ` instance with its + data populated from the data in ``value``. + :rtype: :class:`Chart ` + + """ + series_type = validators.string(series_type, allow_empty = False) + series_type = series_type.lower() + if series_type not in SERIES_CLASSES: + raise errors.HighchartsValueError(f'series_type expects a valid Highcharts ' + f'series type. Received: {series_type}') + + series_kwargs = validators.dict(series_kwargs, allow_empty = True) or {} + options_kwargs = validators.dict(options_kwargs, allow_empty = True) or {} + chart_kwargs = validators.dict(chart_kwargs, allow_empty = True) or {} + + series_cls = SERIES_CLASSES.get(series_type, None) + + series = series_cls.from_array(value, series_kwargs = series_kwargs) + + options_kwargs['series'] = [series] + options = HighchartsOptions(**options_kwargs) + + instance = cls(**chart_kwargs) + instance.options = options + + return instance + + @classmethod + def from_csv_in_rows(cls, + as_string_or_file, + series_type = 'line', + has_header_row = True, + series_kwargs = None, + options_kwargs = None, + chart_kwargs = None, + delimiter = ',', + null_text = 'None', + wrapper_character = "'", + line_terminator = '\r\n', + wrap_all_strings = False, + double_wrapper_character_when_nested = False, + escape_character = "\\", + series_index = None, + **kwargs): + """Create a new :class:`Chart ` instance with + data populated from a CSV string or file. + + .. note:: + + For an example + :class:`LineSeries `, the + minimum code required would be: + + .. code-block:: python + + my_chart = Chart.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': 3, + 'id': 'id' + }, + series_type = 'line') + + As the example above shows, data is loaded into the ``my_chart`` instance + from the CSV file with a filename ``some-csv-file.csv``. The + :meth:`x ` + values for each data point will be taken from the first (index 0) column in + the CSV file. The :meth:`y ` values will be taken from the + fourth (index 3) column in the CSV file. And the :meth:`id ` + values will be taken from a column whose header row is labeled ``'id'`` + (regardless of its index). + + :param as_string_or_file: The CSV data to use to pouplate data. Accepts either + the raw CSV data as a :class:`str ` or a path to a file in the + runtime environment that contains the CSV data. + + .. tip:: + + Unwrapped empty column values are automatically interpreted as null + (:obj:`None `). + + :type as_string_or_file: :class:`str ` or Path-like + + :param series_type: Indicates the series type that should be created from the CSV + data. Defaults to ``'line'``. + :type series_type: :class:`str ` + + :param has_header_row: If ``True``, indicates that the first row of + ``as_string_or_file`` contains column labels, rather than actual data. Defaults + to ``True``. + :type has_header_row: :class:`bool ` + + :param series_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the series instance. Defaults + to :obj:`None `. + + .. warning:: + + If ``series_kwargs`` contains a ``data`` key, its value will be *overwritten*. + The ``data`` value will be created from the CSV file instead. + + :type series_kwargs: :class:`dict ` or :obj:`None ` + + :param options_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the :class:`HighchartsOptions` + instance. Defaults to :obj:`None `. + + .. warning:: + + If ``options_kwargs`` contains a ``series`` key, the ``series`` value will be + *overwritten*. The ``series`` value will be created from the CSV file instead. + + :type options_kwargs: :class:`dict ` or :obj:`None ` + + :param chart_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the :class:`Chart` instance. + Defaults to :obj:`None `. + + .. warning:: + + If ``chart_kwargs`` contains an ``options`` key, ``options`` will be + *overwritten*. The ``options`` value will be created from the + ``options_kwargs`` and CSV file instead. + + :type chart_kwargs: :class:`dict ` or :obj:`None ` + + :param delimiter: The delimiter used between columns. Defaults to ``,``. + :type delimiter: :class:`str ` + + :param wrapper_character: The string used to wrap string values when + wrapping is applied. Defaults to ``'``. + :type wrapper_character: :class:`str ` + + :param null_text: The string used to indicate an empty value if empty + values are wrapped. Defaults to `None`. + :type null_text: :class:`str ` + + :param line_terminator: The string used to indicate the end of a line/record in + the CSV data. Defaults to ``'\\r\\n'``. + + .. note:: + + The Python :mod:`csv ` currently ignores the ``line_terminator`` + parameter and always applies ``'\\r\\n'``, by design. The Python docs say this + may change in the future, so for future backwards compatibility we are + including it here. + + :type line_terminator: :class:`str ` + + :param wrap_all_strings: If ``True``, indicates that the CSV file has all string + data values wrapped in quotation marks. Defaults to ``False``. + + .. warning:: + + If set to ``True``, the :mod:`csv ` module will try to coerce + any value that is *not* wrapped in quotation marks to a + :class:`float `. This can cause unexpected behavior, and + typically we recommend leaving this as ``False`` and then re-casting values + after they have been parsed. + + :type wrap_all_strings: :class:`bool ` + + :param double_wrapper_character_when_nested: If ``True``, quote character is + doubled when appearing within a string value. If ``False``, the + ``escape_character`` is used to prefix quotation marks. Defaults to ``False``. + :type double_wrapper_character_when_nested: :class:`bool ` + + :param escape_character: A one-character string that indicates the character used + to escape quotation marks if they appear within a string value that is already + wrapped in quotation marks. Defaults to ``\\\\`` (which is Python for ``'\\'``, + which is Python's native escape character). + :type escape_character: :class:`str ` + + :param series_index: If supplied, generate the chart with the series that + Highcharts for Python generated from ``df`` at the ``series_index`` position. + Defaults to :obj:`None `, which includes all series generated + from ``df`` on the chart. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + + :returns: A :class:`Chart ` instance with its + data populated from the CSV data. + :rtype: :class:`Chart ` + + :raises HighchartsCSVDeserializationError: if ``property_column_map`` references + CSV columns by their label, but the CSV data does not contain a header row + + """ + return cls.from_csv(as_string_or_file, + property_column_map = None, + series_type = series_type, + has_header_row = has_header_row, + series_kwargs = series_kwargs, + options_kwargs = options_kwargs, + chart_kwargs = chart_kwargs, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = wrap_all_strings, + double_wrapper_character_when_nested = double_wrapper_character_when_nested, + escape_character = escape_character, + series_in_rows = True, + series_index = series_index, + **kwargs) + @classmethod def from_csv(cls, as_string_or_file, - property_column_map, - series_type, + property_column_map = None, + series_type = 'line', has_header_row = True, series_kwargs = None, options_kwargs = None, @@ -884,18 +1291,25 @@ def from_csv(cls, line_terminator = '\r\n', wrap_all_strings = False, double_wrapper_character_when_nested = False, - escape_character = "\\"): + escape_character = "\\", + series_in_rows = False, + series_index = None, + **kwargs): """Create a new :class:`Chart ` instance with data populated from a CSV string or file. .. note:: - For an example - :class:`LineSeries `, the - minimum code required would be: + For an example chart containing + :class:`LineSeries ` + instances, the minimum code required would be: .. code-block:: python + # EXAMPLE 1: The minimum code: + my_chart = Chart.from_csv('some-csv-file.csv', series_type = 'line') + + # EXAMPLE 2: For more precise configuration and *one* series: my_chart = Chart.from_csv('some-csv-file.csv', property_column_map = { 'x': 0, @@ -903,15 +1317,42 @@ def from_csv(cls, 'id': 'id' }, series_type = 'line') + + # EXAMPLE 3: For more precise configuration and *multiple* series: + my_chart = Chart.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': [3, 5, 8], + 'id': 'id' + }, + series_type = 'line') + As the example above shows, data is loaded into the ``my_chart`` instance - from the CSV file with a filename ``some-csv-file.csv``. The + from the CSV file with a filename ``some-csv-file.csv``. + + In EXAMPLE 1, the chart will contain one or more series where each series + will default to having its :meth:`.x ` values taken from + the first (index 0) column in the CSV, and one + :class:`LineSeries ` + instance will be created for each subsequent column (which will populate + that series' :meth:`.y ` values. + + In EXAMPLE 2, the chart will contain one series, where the :meth:`x ` values for each data point will be taken from the first (index 0) column in the CSV file. The :meth:`y ` values will be taken from the fourth (index 3) column in the CSV file. And the :meth:`id ` values will be taken from a column whose header row is labeled ``'id'`` (regardless of its index). + + In EXAMPLE 3, the chart will contain three series, all of which will have + :meth:`.x ` values taken from the first (index 0) column, + :meth:`.id ` values from the column whose header row is + labeled ``'id'``, and whose :meth:`.y ` will be taken + from the fourth (index 3) column for the first series, the sixth (index 5) + column for the second series, and the ninth (index 8) column for the third + series. :param as_string_or_file: The CSV data to use to pouplate data. Accepts either the raw CSV data as a :class:`str ` or a path to a file in the @@ -928,7 +1369,27 @@ def from_csv(cls, data point property should be set to which CSV column. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value can either be a numerical index (starting with 0) or a - :class:`str ` indicating the label for the CSV column. + :class:`str ` indicating the label for the CSV column. Defaults to + :obj:`None `. + + .. note:: + + If any of the values in ``property_column_map`` contain an iterable, then + one series will be produced for each item in the iterable. For example, + the following: + + .. code-block:: python + + { + 'x': 0, + 'y': [3, 5, 8] + } + + will return *three* series, each of which will have its + :meth:`.x ` value populated from the first column + (index 0), and whose :meth:`.y ` + values will be populated from the fourth, sixth, and ninth columns (indices + 3, 5, and 8), respectively. .. warning:: @@ -940,7 +1401,7 @@ def from_csv(cls, :type property_column_map: :class:`dict ` :param series_type: Indicates the series type that should be created from the CSV - data. + data. Defaults to ``'line'``. :type series_type: :class:`str ` :param has_header_row: If ``True``, indicates that the first row of @@ -1029,6 +1490,22 @@ def from_csv(cls, which is Python's native escape character). :type escape_character: :class:`str ` + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to ``False``. + :type series_in_rows: :class:`bool ` + + :param series_index: if :obj:`None `, will attempt to populate + the chart with multiple series from the CSV data. If an :class:`int ` + is supplied, will populate the chart only with the series found at + ``series_index``. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + :returns: A :class:`Chart ` instance with its data populated from the CSV data. :rtype: :class:`Chart ` @@ -1048,51 +1525,213 @@ def from_csv(cls, series_cls = SERIES_CLASSES.get(series_type, None) - series = series_cls.from_csv(as_string_or_file, - property_column_map, - has_header_row = has_header_row, - series_kwargs = series_kwargs, - delimiter = delimiter, - null_text = null_text, - wrapper_character = wrapper_character, - line_terminator = line_terminator, - wrap_all_strings = wrap_all_strings, - double_wrapper_character_when_nested = double_wrapper_character_when_nested, - escape_character = escape_character) + if series_in_rows: + series = series_cls.from_csv_in_rows( + as_string_or_file, + has_header_row = has_header_row, + series_kwargs = series_kwargs, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = wrap_all_strings, + double_wrapper_character_when_nested = double_wrapper_character_when_nested, + escape_character = escape_character, + **kwargs + ) + else: + series = series_cls.from_csv(as_string_or_file, + property_column_map = property_column_map, + has_header_row = has_header_row, + series_kwargs = series_kwargs, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = wrap_all_strings, + double_wrapper_character_when_nested = double_wrapper_character_when_nested, + escape_character = escape_character, + **kwargs) + + if isinstance(series, list) and series_index is not None: + series = series[series_index] + + if not isinstance(series, list): + series = [series] + + options_kwargs['series'] = series options = HighchartsOptions(**options_kwargs) - options.series = [series] instance = cls(**chart_kwargs) instance.options = options return instance + @classmethod + def from_pandas_in_rows(cls, + df, + series_type = 'line', + series_kwargs = None, + options_kwargs = None, + chart_kwargs = None, + series_index = None, + **kwargs): + """Create a chart from a Pandas :class:`DataFrame `, + treating each row in the dataframe as a :term:`series` instances. + + :param df: The :class:`DataFrame ` from which data + should be loaded. + :type df: :class:`DataFrame ` + + :param series_type: Indicates the series type that should be created from the data + in ``df``. Defaults to ``'line'``. + :type series_type: :class:`str ` + + :param series_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the series instance. Defaults + to :obj:`None `. + + .. warning:: + + If ``series_kwargs`` contains a ``data`` key, its value will be *overwritten*. + The ``data`` value will be created from ``df`` instead. + + :type series_kwargs: :class:`dict ` + + :param options_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the :class:`HighchartsOptions` + instance. Defaults to :obj:`None `. + + .. warning:: + + If ``options_kwargs`` contains a ``series`` key, the ``series`` value will be + *overwritten*. The ``series`` value will be created from the data in ``df``. + + :type options_kwargs: :class:`dict ` or :obj:`None ` + + :param chart_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the :class:`Chart` instance. + Defaults to :obj:`None `. + + .. warning:: + + If ``chart_kwargs`` contains an ``options`` key, ``options`` will be + *overwritten*. The ``options`` value will be created from the + ``options_kwargs`` and the data in ``df`` instead. + + :type chart_kwargs: :class:`dict ` or :obj:`None ` + + :param series_index: If supplied, generate the chart with the series that + Highcharts for Python generated from ``df`` at the ``series_index`` position. + Defaults to :obj:`None `, which includes all series generated + from ``df`` on the chart. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Additional keyword arguments that are - in turn - propagated to + the series created from the ``df``. + + :returns: A :class:`Chart ` instance with its + data populated from the data in ``df``. + :rtype: :class:`Chart ` + + :raises HighchartsDependencyError: if `pandas `_ is + not available in the runtime environment + + """ + return cls.from_pandas(df, + property_map = None, + series_type = series_type, + series_kwargs = series_kwargs, + options_kwargs = options_kwargs, + chart_kwargs = chart_kwargs, + series_in_rows = True, + series_index = series_index, + **kwargs) + @classmethod def from_pandas(cls, df, - property_map, - series_type, + property_map = None, + series_type = 'line', series_kwargs = None, options_kwargs = None, - chart_kwargs = None): + chart_kwargs = None, + series_in_rows = False, + series_index = None, + **kwargs): """Create a :class:`Chart ` instance whose - data is populated from a `pandas `_ - :class:`DataFrame `. + series are populated from a `pandas `_ + :class:`DataFrame `. + + .. code-block:: python + + from highcharts_core.chart import Chart + from highcharts_core.options.series.area import LineSeries + + # Create a chart with one or more LineSeries instances from + # the CSV file "some-csv-file.csv". + + # EXAMPLE 1: The minimum code: + + my_chart = Chart.from_csv('some-csv-file.csv', series_type = 'line') + + # EXAMPLE 2: For more precise configuration and *one* series: + + my_chart = Chart.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': 3, + 'id': 'id' + }, + series_type = 'line') + + # EXAMPLE 3: For more precise configuration and *multiple* series: + + my_chart = Chart.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': [3, 5, 8], + 'id': 'id' + }, + series_type = 'line') - :param df: The :class:`DataFrame ` from which data should be + :param df: The :class:`DataFrame ` from which data should be loaded. - :type df: :class:`DataFrame ` + :type df: :class:`DataFrame ` :param property_map: A :class:`dict ` used to indicate which data point property should be set to which column in ``df``. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value should indicate the label for the - :class:`DataFrame ` column. - :type property_map: :class:`dict ` + :class:`DataFrame ` column. Defaults to + :obj:`None `. + + .. note:: + + If any of the values in ``property_map`` contain an iterable, then + one series will be produced for each item in the iterable. For example, + the following: + + .. code-block:: python + + { + 'x': 'timestamp', + 'y': ['value1', 'value2', 'value3'] + } + + will return *three* series, each of which will have its + :meth:`.x ` value populated from the column + labeled ``'timestamp'``, and whose :meth:`.y ` + values will be populated from the columns labeled ``'value1'``, + ``'value2'``, and ``'value3'``, respectively. + + :type property_map: :class:`dict ` or :obj:`None ` :param series_type: Indicates the series type that should be created from the data - in ``df``. + in ``df``. Defaults to ``'line'``. :type series_type: :class:`str ` :param series_kwargs: An optional :class:`dict ` containing keyword @@ -1128,6 +1767,23 @@ def from_pandas(cls, ``options_kwargs`` and the data in ``df`` instead. :type chart_kwargs: :class:`dict ` or :obj:`None ` + + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param series_index: If supplied, generate the chart with the series that + Highcharts for Python generated from ``df`` at the ``series_index`` position. + Defaults to :obj:`None `, which includes all series generated + from ``df`` on the chart. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Additional keyword arguments that are - in turn - propagated to + the series created from the ``df``. :returns: A :class:`Chart ` instance with its data populated from the data in ``df``. @@ -1138,23 +1794,50 @@ def from_pandas(cls, :raises HighchartsDependencyError: if `pandas `_ is not available in the runtime environment """ - series_type = validators.string(series_type, allow_empty = False) - series_type = series_type.lower() + if not series_type: + raise errors.HighchartsValueError('series_type cannot be empty') + series_type = str(series_type).lower() if series_type not in SERIES_CLASSES: raise errors.HighchartsValueError(f'series_type expects a valid Highcharts ' f'series type. Received: {series_type}') - options_kwargs = validators.dict(options_kwargs, allow_empty = True) or {} - chart_kwargs = validators.dict(chart_kwargs, allow_empty = True) or {} + if not isinstance(options_kwargs, (dict, UserDict, type(None))): + raise errors.HighchartsValueError(f'options_kwarts expects a dict. ' + f'Received: {options_kwargs.__class__.__name__}') + if not options_kwargs: + options_kwargs = {} + + if not isinstance(chart_kwargs, (dict, UserDict, type(None))): + raise errors.HighchartsValueError(f'chart_kwargs expects a dict. ' + f'Received: {chart_kwargs.__class__.__name__}') + if not chart_kwargs: + chart_kwargs = {} + + if not isinstance(kwargs, (dict, UserDict, type(None))): + raise errors.HighchartsValueError(f'kwargs expects a dict. ' + f'Received: {kwargs.__class__.__name__}') + if not kwargs: + kwargs = {} series_cls = SERIES_CLASSES.get(series_type, None) - series = series_cls.from_pandas(df, - property_map, - series_kwargs) + if series_in_rows: + series = series_cls.from_pandas_in_rows(df, + series_kwargs = series_kwargs, + series_index = series_index, + **kwargs) + else: + series = series_cls.from_pandas(df, + property_map = property_map, + series_kwargs = series_kwargs, + series_index = series_index, + **kwargs) + if isinstance(series, series_cls): + series = [series] + + options_kwargs['series'] = series options = HighchartsOptions(**options_kwargs) - options.series = [series] instance = cls(**chart_kwargs) instance.options = options diff --git a/highcharts_core/errors.py b/highcharts_core/errors.py index d16f8da3..7c6f4256 100644 --- a/highcharts_core/errors.py +++ b/highcharts_core/errors.py @@ -136,4 +136,16 @@ class HighchartsMissingExportSettingsError(HighchartsExportServerError): class HighchartsMissingSeriesError(HighchartsValueError): """:exc:`ValueError ` encountered when trying to reference a series that does not actually exist in the chart.""" + pass + + +class HighchartsPythonConversionError(HighchartsValueError): + """:exc:`ValueError ` encountered when a generative AI model + failed to convert a Python callable into a valid JavaScript function.""" + pass + + +class HighchartsModerationError(HighchartsValueError): + """:exc:`ValueError ` encountered when a generative AI model + determined that the content supplied to it fails its content moderation criteria.""" pass \ No newline at end of file diff --git a/highcharts_core/global_options/shared_options.py b/highcharts_core/global_options/shared_options.py index 8e246c6e..82370276 100644 --- a/highcharts_core/global_options/shared_options.py +++ b/highcharts_core/global_options/shared_options.py @@ -16,7 +16,8 @@ class SharedOptions(HighchartsOptions): def to_js_literal(self, filename = None, - encoding = 'utf-8') -> Optional[str]: + encoding = 'utf-8', + careful_validation = False) -> Optional[str]: """Return the object represented as a :class:`str ` containing the JavaScript object literal. @@ -28,6 +29,18 @@ def to_js_literal(self, to ``'utf-8'``. :type encoding: :class:`str ` + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + .. note:: Returns a JavaScript string which applies the Highcharts global options. The @@ -40,7 +53,8 @@ def to_js_literal(self, :rtype: :class:`str ` """ prefix = 'Highcharts.setOptions(' - options_body = super().to_js_literal(encoding = encoding) + options_body = super().to_js_literal(encoding = encoding, + careful_validation = careful_validation) as_str = prefix + options_body + ');' diff --git a/highcharts_core/headless_export.py b/highcharts_core/headless_export.py index c62b1793..520a15cd 100644 --- a/highcharts_core/headless_export.py +++ b/highcharts_core/headless_export.py @@ -760,7 +760,7 @@ def request_chart(self, code_as_str) result = requests.post(self.url, - data = as_json, + data = as_json.encode('utf-8'), headers = { 'Content-Type': 'application/json' }, auth = basic_auth, timeout = timeout) diff --git a/highcharts_core/js_literal_functions.py b/highcharts_core/js_literal_functions.py index 088273ba..628592e4 100644 --- a/highcharts_core/js_literal_functions.py +++ b/highcharts_core/js_literal_functions.py @@ -8,10 +8,19 @@ import esprima from esprima.error_handler import Error as ParseError -from highcharts_core import constants, errors +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False + +from highcharts_core import constants, errors, utility_functions -def serialize_to_js_literal(item, encoding = 'utf-8') -> Optional[str]: +def serialize_to_js_literal(item, + encoding = 'utf-8', + ignore_to_array = False, + careful_validation = False) -> Optional[str]: """Convert ``item`` to the contents of a JavaScript object literal code snippet. :param item: A value that is to be converted into a JS object literal notation value. @@ -19,27 +28,82 @@ def serialize_to_js_literal(item, encoding = 'utf-8') -> Optional[str]: :param encoding: The character encoding to apply to the resulting object. Defaults to ``'utf-8'``. :type encoding: :class:`str ` + + :param ignore_to_array: If ``True``, will ignore handling of the ``.to_array()`` method + to break recursion. Defaults to ``False``. + :type ignore_to_array: :class:`bool ` + + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` :returns: A JavaScript object literal code snippet, expressed as a string. Or :obj:`None ` if ``item`` is not serializable. :rtype: :class:`str ` or :obj:`None ` """ - if checkers.is_iterable(item, forbid_literals = (str, bytes, dict, UserDict)): - requires_js_objects = any([getattr(x, 'requires_js_object', True) - for x in item]) + if not ignore_to_array and hasattr(item, 'to_array'): + requires_js_objects = getattr(item, 'requires_js_object', True) + if requires_js_objects and hasattr(item, 'to_js_literal'): + return item.to_js_literal(encoding = encoding, + careful_validation = careful_validation) + elif requires_js_objects: + return serialize_to_js_literal(item, + encoding = encoding, + ignore_to_array = True, + careful_validation = careful_validation) + else: + return serialize_to_js_literal(item.to_array(), + encoding = encoding, + careful_validation = careful_validation) + elif HAS_NUMPY and utility_functions.is_ndarray(item): + return utility_functions.from_ndarray(item) + elif hasattr(item, 'to_js_literal'): + return item.to_js_literal(encoding = encoding, + careful_validation = careful_validation) + elif not isinstance(item, + (str, bytes, dict, UserDict)) and hasattr(item, '__iter__'): + requires_js_objects = False + for x in item: + try: + if getattr(x, 'requires_js_object', True) is True: + requires_js_objects = True + break + except ValueError as error: + if utility_functions.is_ndarray(x): + continue + else: + raise error if requires_js_objects: - return [serialize_to_js_literal(x, encoding = encoding) + return [serialize_to_js_literal(x, + encoding = encoding, + ignore_to_array = True, + careful_validation = careful_validation) for x in item] else: - return [serialize_to_js_literal(x.to_array(), encoding = encoding) - for x in item] - elif hasattr(item, 'to_js_literal'): - return item.to_js_literal(encoding = encoding) + result = [] + for x in item: + if not utility_functions.is_ndarray(x): + js_literal = serialize_to_js_literal(x.to_array(), + encoding = encoding, + careful_validation = careful_validation) + result.append(js_literal) + else: + result.append(utility_functions.from_ndarray(x)) + + return result elif isinstance(item, constants.EnforcedNullType) or item == 'null': return constants.EnforcedNull elif isinstance(item, bool): return item - elif checkers.is_string(item): + elif isinstance(item, str): return_value = item.replace("'", "\\'") return return_value elif checkers.is_numeric(item) and not isinstance(item, Decimal): @@ -48,15 +112,17 @@ def serialize_to_js_literal(item, encoding = 'utf-8') -> Optional[str]: return float(item) elif checkers.is_type(item, ('CallbackFunction')): return str(item) - elif checkers.is_type(item, (dict, UserDict)): + elif isinstance(item, (dict, UserDict)): as_dict = {} for key in item: - as_dict[key] = serialize_to_js_literal(item[key], encoding = encoding) + as_dict[key] = serialize_to_js_literal(item[key], + encoding = encoding, + careful_validation = careful_validation) return str(as_dict) elif checkers.is_datetime(item): if not item.tzinfo: item = item.replace(tzinfo = datetime.timezone.utc) - return item.timestamp()*1000 + return item.timestamp() * 1000 elif checkers.is_date(item): return f'Date.UTC({item.year}, {item.month - 1}, {item.day})' elif checkers.is_time(item): @@ -67,39 +133,65 @@ def serialize_to_js_literal(item, encoding = 'utf-8') -> Optional[str]: return None -def is_js_object(as_str): +def is_js_object(as_str, careful_validation = False): """Determine whether ``as_str`` is a JavaScript object. :param as_str: The string to evaluate. :type as_str: :class:`str ` + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + :returns: ``True`` if ``as_str`` is a JavaScript function. ``False`` if not. :rtype: :class:`bool ` """ - expression_item = f'const testName = {as_str}' - try: - parsed = esprima.parseScript(expression_item) - except ParseError: + + if not careful_validation: + is_empty = as_str[1:-1].strip() == '' + if is_empty: + return True + has_colon = ':' in as_str + if has_colon: + return True + if 'new ' in as_str: + return True + if 'Object.create(' in as_str: + return True + return False + else: + expression_item = f'const testName = {as_str}' try: - parsed = esprima.parseModule(expression_item) + parsed = esprima.parseScript(expression_item) except ParseError: + try: + parsed = esprima.parseModule(expression_item) + except ParseError: + return False + + body = parsed.body + if not body: return False - body = parsed.body - if not body: - return False + first_item = body[0] + if first_item.type != 'VariableDeclaration': + return False - first_item = body[0] - if first_item.type != 'VariableDeclaration': - return False + init = first_item.declarations[0].init + if not init: + return False + if init.type in ('ObjectExpression'): + return True - init = first_item.declarations[0].init - if not init: return False - if init.type in ('ObjectExpression'): - return True - - return False def attempt_variable_declaration(as_str): @@ -143,63 +235,118 @@ def attempt_variable_declaration(as_str): return False -def is_js_function_or_class(as_str) -> bool: +def is_js_function_or_class(as_str, careful_validation = False) -> bool: """Determine whether ``as_str`` is a JavaScript function or not. :param as_str: The string to evaluate. :type as_str: :class:`str ` + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + :returns: ``True`` if ``as_str`` is a JavaScript function. ``False`` if not. :rtype: :class:`bool ` """ - if not checkers.is_string(as_str): + if not isinstance(as_str, str): return False + if not careful_validation: + is_function = as_str.startswith('function ') or as_str.startswith('function*') + if is_function: + return True - try: - parsed = esprima.parseScript(as_str) - except ParseError: - try: - parsed = esprima.parseModule(as_str) - except ParseError: - if as_str.startswith('function') is False: - return False - else: - return attempt_variable_declaration(as_str) + is_function = 'function(' in as_str or 'function*(' in as_str + if is_function: + return True - body = parsed.body - if not body: - return False + is_function = ')=>' in as_str or ') =>' in as_str - first_item = body[0] - if first_item.type in ('FunctionDeclaration', 'ClassDeclaration'): - return True - elif as_str.startswith('function') or as_str.startswith('class'): - return attempt_variable_declaration(as_str) - elif first_item.type == 'VariableDeclaration': - init = first_item.declarations[0].init - if not init: + if is_function: + return True + + is_function = 'new Function(' in as_str + if is_function: + return True + + is_class = as_str.startswith('class ') + if is_class: + return True + + is_class = 'class {' in as_str or 'class{' in as_str + if is_class: + return True + + is_class = '= class' in as_str or '=class' in as_str + if is_class: + return True + + return False + else: + try: + parsed = esprima.parseScript(as_str) + except ParseError: + try: + parsed = esprima.parseModule(as_str) + except ParseError: + if as_str.startswith('function') is False: + return False + else: + return attempt_variable_declaration(as_str) + + body = parsed.body + if not body: return False - if init.type in ('FunctionExpression', 'ArrowFunctionExpression', - 'ClassExpression'): + + first_item = body[0] + if first_item.type in ('FunctionDeclaration', 'ClassDeclaration'): return True - elif init.type == 'NewExpression': - callee = init.callee - if not callee: + elif as_str.startswith('function') or as_str.startswith('class'): + return attempt_variable_declaration(as_str) + elif first_item.type == 'VariableDeclaration': + init = first_item.declarations[0].init + if not init: return False - if callee.name == 'Function': + if init.type in ('FunctionExpression', 'ArrowFunctionExpression', + 'ClassExpression'): return True + elif init.type == 'NewExpression': + callee = init.callee + if not callee: + return False + if callee.name == 'Function': + return True return False -def get_js_literal(item) -> str: +def get_js_literal(item, careful_validation = False) -> str: """Convert the value of ``item`` into a JavaScript literal string. + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + :returns: The JavaScript literal string. :rtype: :class:`str ` """ as_str = '' - if checkers.is_iterable(item, forbid_literals = (str, bytes, dict, UserDict)): + if not isinstance(item, (str, bytes, dict, UserDict)) and hasattr(item, '__iter__'): subitems = [get_js_literal(x) for x in item] as_str += '[' subitem_counter = 0 @@ -211,11 +358,11 @@ def get_js_literal(item) -> str: if subitem_counter < len(subitems): as_str += ',\n' as_str += ']' - elif checkers.is_string(item): + elif isinstance(item, str): if (item.startswith('[') or item.startswith('Date')) and item != 'Date': as_str += f"""{item}""" elif item.startswith('{') and item.endswith('}'): - if is_js_object(item): + if is_js_object(item, careful_validation = careful_validation): as_str += f"""{item}""" elif "'" in item: item = item.replace("'", "\\'") @@ -227,7 +374,7 @@ def get_js_literal(item) -> str: elif item.startswith == 'HCP: REPLACE-WITH-': item_str = item.replace('HCP: REPLACE-WITH-', '') as_str += f"""{item_str}""" - elif not is_js_function_or_class(item): + elif not is_js_function_or_class(item, careful_validation = careful_validation): as_str += f"""'{item}'""" else: as_str += f"""{item}""" @@ -243,7 +390,9 @@ def get_js_literal(item) -> str: return as_str -def assemble_js_literal(as_dict, keys_as_strings = False) -> Optional[str]: +def assemble_js_literal(as_dict, + keys_as_strings = False, + careful_validation = False) -> Optional[str]: """Convert ``as_dict`` into a JavaScript object literal string. :param as_dict: A :class:`dict ` representation of the JavaScript object. @@ -254,10 +403,24 @@ def assemble_js_literal(as_dict, keys_as_strings = False) -> Optional[str]: to ``False``. :type keys_as_strings: :class:`bool ` + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + :returns: The JavaScript object literal representation of ``as_dict``. :rtype: :class:`str ` or :obj:`None ` """ - as_dict = validators.dict(as_dict, allow_empty = True) + if careful_validation: + as_dict = validators.dict(as_dict, allow_empty = True) + if not as_dict: return None @@ -279,7 +442,7 @@ def assemble_js_literal(as_dict, keys_as_strings = False) -> Optional[str]: else: as_str += f""" {key}: """ - as_str += get_js_literal(item) + as_str += get_js_literal(item, careful_validation = careful_validation) if current_key < keys: as_str += ',\n' diff --git a/highcharts_core/metaclasses.py b/highcharts_core/metaclasses.py index 43418704..6f317d30 100644 --- a/highcharts_core/metaclasses.py +++ b/highcharts_core/metaclasses.py @@ -14,6 +14,12 @@ 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 @@ -81,15 +87,26 @@ def _process_required_modules(self, scripts = None, include_extension = False) - """ if not scripts: scripts = [] - - properties = [x[1:] for x in self.__dict__ - if x.startswith('_') and hasattr(self, x[1:])] + + 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 = getattr(self, property_name, None) + property_value = properties[property_name] if property_value is None: continue - if checkers.is_iterable(property_value, forbid_literals = (str, bytes, dict)): + 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'): @@ -121,7 +138,7 @@ def _process_required_modules(self, scripts = None, include_extension = False) - else: final_scripts.append(f'{script}.js') return final_scripts - + return scripts def get_required_modules(self, include_extension = False) -> List[str]: @@ -197,7 +214,12 @@ def trim_iterable(untrimmed, :rtype: iterable """ - if not checkers.is_iterable(untrimmed, forbid_literals = (str, bytes, dict)): + if HAS_NUMPY and isinstance(untrimmed, np.ndarray): + return untrimmed + + if isinstance(untrimmed, + (str, bytes, dict, UserDict)) or not hasattr(untrimmed, + '__iter__'): return untrimmed trimmed = [] @@ -219,7 +241,9 @@ def trim_iterable(untrimmed, trimmed.append(HighchartsMeta.trim_dict(item, to_json = to_json, context = context)) - elif checkers.is_iterable(item, forbid_literals = (str, bytes, dict)): + elif not isinstance(item, + (str, bytes, dict, UserDict)) and hasattr(item, + '__iter__'): if item: trimmed.append(HighchartsMeta.trim_iterable(item, to_json = to_json, @@ -259,6 +283,18 @@ def trim_dict(untrimmed: dict, # 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) + 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: continue @@ -285,7 +321,9 @@ def trim_dict(untrimmed: dict, if trimmed_value: as_dict[key] = trimmed_value # iterable -> array - elif checkers.is_iterable(value, forbid_literals = (str, bytes, dict)): + elif not isinstance(value, + (str, bytes, dict, UserDict)) and hasattr(value, + '__iter__'): trimmed_value = HighchartsMeta.trim_iterable(value, to_json = to_json, context = context) @@ -477,7 +515,8 @@ def to_json(self, def to_js_literal(self, filename = None, - encoding = 'utf-8') -> Optional[str]: + encoding = 'utf-8', + careful_validation = False) -> Optional[str]: """Return the object represented as a :class:`str ` containing the JavaScript object literal. @@ -489,6 +528,18 @@ def to_js_literal(self, to ``'utf-8'``. :type encoding: :class:`str ` + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + :rtype: :class:`str ` or :obj:`None ` """ if filename: @@ -498,11 +549,14 @@ def to_js_literal(self, as_dict = {} for key in untrimmed: item = untrimmed[key] - serialized = serialize_to_js_literal(item, encoding = encoding) + 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) + as_str = assemble_js_literal(as_dict, + careful_validation = careful_validation) if filename: with open(filename, 'w', encoding = encoding) as file_: @@ -1065,7 +1119,8 @@ def to_json(self, def to_js_literal(self, filename = None, - encoding = 'utf-8') -> Optional[str]: + encoding = 'utf-8', + careful_validation = False) -> Optional[str]: """Return the object represented as a :class:`str ` containing the JavaScript object literal. @@ -1077,6 +1132,18 @@ def to_js_literal(self, to ``'utf-8'``. :type encoding: :class:`str ` + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + :rtype: :class:`str ` or :obj:`None ` """ if filename: @@ -1086,11 +1153,15 @@ def to_js_literal(self, as_dict = {} for key in untrimmed: item = untrimmed[key] - serialized = serialize_to_js_literal(item, encoding = encoding) + 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) + 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_: diff --git a/highcharts_core/options/data.py b/highcharts_core/options/data.py index 26ea1f43..31836403 100644 --- a/highcharts_core/options/data.py +++ b/highcharts_core/options/data.py @@ -780,9 +780,9 @@ def from_pandas(cls, """Create a :class:`Data` instance from a Pandas :class:`DataFrame ` from which to create the + :param as_df: The :class:`DataFrame ` from which to create the :class:`Data` instance. - :type as_df: :class:`DataFrame ` + :type as_df: :class:`DataFrame ` :param represent_as: The format to which ``as_df`` should be serialized. Accepts ``'csv'`` or ``'html'``. Defaults to ``'csv'``. diff --git a/highcharts_core/options/series/arcdiagram.py b/highcharts_core/options/series/arcdiagram.py index c81cb4d6..73ac23b9 100644 --- a/highcharts_core/options/series/arcdiagram.py +++ b/highcharts_core/options/series/arcdiagram.py @@ -4,11 +4,11 @@ from validator_collection import validators from highcharts_core import errors -from highcharts_core.decorators import class_sensitive +from highcharts_core.decorators import class_sensitive, validate_types from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.arcdiagram import ArcDiagramData +from highcharts_core.options.series.data.arcdiagram import ArcDiagramData, ArcDiagramDataCollection from highcharts_core.options.plot_options.arcdiagram import ArcDiagramOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray from highcharts_core.utility_classes.nodes import NodeOptions @@ -35,20 +35,41 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return ArcDiagramDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return ArcDiagramData + @property - def data(self) -> Optional[List[ArcDiagramData]]: + def data(self) -> Optional[List[ArcDiagramData] | ArcDiagramDataCollection]: """The collection of data points for the series. Defaults to :obj:`None `. :rtype: :class:`list ` of :class:`ArcDiagramData` or - :obj:`None ` + :class:`ArcDiagramDataCollection ` + or :obj:`None ` """ return self._data @data.setter - @class_sensitive(ArcDiagramData, force_iterable = True) def data(self, value): - self._data = value + if not is_ndarray(value) and not value: + self._data = None + else: + self._data = ArcDiagramData.from_array(value) @property def link_weight(self) -> Optional[int | float | Decimal]: diff --git a/highcharts_core/options/series/area.py b/highcharts_core/options/series/area.py index 692bbfd4..59f3ab1d 100644 --- a/highcharts_core/options/series/area.py +++ b/highcharts_core/options/series/area.py @@ -1,10 +1,11 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.cartesian import CartesianData -from highcharts_core.options.series.data.range import RangeData +from highcharts_core.decorators import validate_types +from highcharts_core.options.series.data.cartesian import CartesianData, CartesianDataCollection +from highcharts_core.options.series.data.range import RangeData, RangeDataCollection from highcharts_core.options.plot_options.area import AreaOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class AreaSeries(SeriesBase, AreaOptions): @@ -19,8 +20,26 @@ class AreaSeries(SeriesBase, AreaOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return CartesianDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return CartesianData + @property - def data(self) -> Optional[List[CartesianData]]: + def data(self) -> Optional[List[CartesianData] | CartesianDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -83,13 +102,14 @@ def data(self) -> Optional[List[CartesianData]]: A one-dimensional collection of :class:`CartesianData` objects. :rtype: :class:`list ` of :class:`CartesianData` or + :class:`CartesianDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = CartesianData.from_array(value) @@ -197,8 +217,26 @@ class AreaRangeSeries(AreaSeries): """ + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return RangeDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return RangeData + @property - def data(self) -> Optional[List[RangeData]]: + def data(self) -> Optional[List[RangeData] | RangeDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -267,13 +305,14 @@ def data(self) -> Optional[List[RangeData]]: A one-dimensional collection of :class:`RangeData` objects. :rtype: :class:`list ` of :class:`RangeData` or + :class:`RangeDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = RangeData.from_array(value) diff --git a/highcharts_core/options/series/bar.py b/highcharts_core/options/series/bar.py index f6d1ae4c..d0f6275a 100644 --- a/highcharts_core/options/series/bar.py +++ b/highcharts_core/options/series/bar.py @@ -1,13 +1,27 @@ from typing import Optional, List from highcharts_core import constants +from highcharts_core.decorators import validate_types from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.cartesian import CartesianData, Cartesian3DData -from highcharts_core.options.series.data.bar import BarData, WaterfallData, WindBarbData, XRangeData -from highcharts_core.options.series.data.range import RangeData -from highcharts_core.options.plot_options.bar import BaseBarOptions, BarOptions, WaterfallOptions, \ - WindBarbOptions, XRangeOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.options.series.data.cartesian import (CartesianData, + Cartesian3DData, + CartesianDataCollection, + Cartesian3DDataCollection) +from highcharts_core.options.series.data.bar import (BarData, + BarDataCollection, + WaterfallData, + WaterfallDataCollection, + WindBarbData, + WindBarbDataCollection, + XRangeData, + XRangeDataCollection) +from highcharts_core.options.series.data.range import RangeData, RangeDataCollection +from highcharts_core.options.plot_options.bar import (BaseBarOptions, + BarOptions, + WaterfallOptions, + WindBarbOptions, + XRangeOptions) +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class BaseBarSeries(SeriesBase, BaseBarOptions): @@ -16,8 +30,26 @@ class BaseBarSeries(SeriesBase, BaseBarOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return BarDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return BarData + @property - def data(self) -> Optional[List[BarData]]: + def data(self) -> Optional[List[BarData] | BarDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -80,13 +112,14 @@ def data(self) -> Optional[List[BarData]]: A one-dimensional collection of :class:`BarData` objects. :rtype: :class:`list ` of :class:`BarData` or + :class:`BarDataCollection` :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = BarData.from_array(value) @@ -365,8 +398,26 @@ class ColumnPyramidSeries(ColumnSeries): :align: center """ + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return CartesianDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return CartesianData + @property - def data(self) -> Optional[List[CartesianData]]: + def data(self) -> Optional[List[CartesianData] | CartesianDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -429,13 +480,14 @@ def data(self) -> Optional[List[CartesianData]]: A one-dimensional collection of :class:`CartesianData` objects. :rtype: :class:`list ` of :class:`CartesianData` or + :class:`CartesianDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = CartesianData.from_array(value) @@ -466,8 +518,26 @@ class ColumnRangeSeries(ColumnSeries): :align: center """ + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return RangeDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return RangeData + @property - def data(self) -> Optional[List[RangeData]]: + def data(self) -> Optional[List[RangeData] | RangeDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -530,13 +600,14 @@ def data(self) -> Optional[List[RangeData]]: A one-dimensional collection of :class:`RangeData` objects. :rtype: :class:`list ` of :class:`RangeData` or + :class:`RangeDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = RangeData.from_array(value) @@ -583,8 +654,27 @@ class VariwideSeries(BaseBarSeries): :align: center """ + + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return Cartesian3DDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return Cartesian3DData + @property - def data(self) -> Optional[List[Cartesian3DData]]: + def data(self) -> Optional[List[Cartesian3DData] | Cartesian3DDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -652,13 +742,14 @@ def data(self) -> Optional[List[Cartesian3DData]]: A one-dimensional collection of :class:`Cartesian3DData` objects. :rtype: :class:`list ` of :class:`Cartesian3DData` or + :class:`Cartesian3DDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = Cartesian3DData.from_array(value) @@ -695,8 +786,26 @@ class WaterfallSeries(ColumnSeries, WaterfallOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return WaterfallDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return WaterfallData + @property - def data(self) -> Optional[List[WaterfallData]]: + def data(self) -> Optional[List[WaterfallData] | WaterfallDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -759,13 +868,14 @@ def data(self) -> Optional[List[WaterfallData]]: A one-dimensional collection of :class:`WaterfallData` objects. :rtype: :class:`list ` of :class:`WaterfallData` or + :class:`WaterfallDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = WaterfallData.from_array(value) @@ -898,8 +1008,26 @@ class WindBarbSeries(BarSeries, WindBarbOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return WindBarbDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return WindBarbData + @property - def data(self) -> Optional[List[WindBarbData]]: + def data(self) -> Optional[List[WindBarbData] | WindBarbDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -964,13 +1092,14 @@ def data(self) -> Optional[List[WindBarbData]]: A one-dimensional collection of :class:`WindBarbData` objects. :rtype: :class:`list ` of :class:`WindBarbData` or + :class:`WindBarbDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = WindBarbData.from_array(value) @@ -1111,8 +1240,26 @@ class XRangeSeries(BaseBarSeries, XRangeOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return XRangeData + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return XRangeDataCollection + @property - def data(self) -> Optional[List[XRangeData]]: + def data(self) -> Optional[List[XRangeData] | XRangeDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -1121,13 +1268,14 @@ def data(self) -> Optional[List[XRangeData]]: :class:`dict ` instances that can be coerced to :class:`XRangeData`. :rtype: :class:`list ` of :class:`XRangeData` or + :class:`XRangeDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = XRangeData.from_array(value) diff --git a/highcharts_core/options/series/base.py b/highcharts_core/options/series/base.py index 38740c65..5add8b8e 100644 --- a/highcharts_core/options/series/base.py +++ b/highcharts_core/options/series/base.py @@ -1,5 +1,6 @@ from typing import Optional, List from decimal import Decimal +from collections import UserDict try: import orjson as json @@ -11,13 +12,18 @@ import simplejson as json except ImportError: import json +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False from validator_collection import validators, checkers from highcharts_core import errors, utility_functions, constants -from highcharts_core.decorators import class_sensitive from highcharts_core.options.plot_options.series import SeriesOptions from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection class SeriesBase(SeriesOptions): @@ -77,6 +83,116 @@ def __str__(self): return f'{self.__class__.__name__}({kwargs_as_str})' + def __getattr__(self, name): + """Facilitates the retrieval of properties from the series and its underlying data. + + The logic is: + + 1. If the attribute exists on the series object, then return it. + 2. If ``.data`` is empty, then return :obj:`None `. + 3. If ``.data`` contains a + :class:`DataPointCollection `, + then return the attribute from the collection. + 4. Since ``.data`` contains a list of data points, return an iterable + containing the attribute from each data point. If NumPy is available, + return this iterable as a NumPy :class:`ndarray `. + + :param name: The name of the attribute to retrieve. + :type name: :class:`str ` + + :returns: The value of the attribute. + + :raises AttributeError: If ``name`` is not a valid attribute of the data point + class or the instance. + """ + try: + return super().__getattribute__(name) + except AttributeError as error: + if name in ['__iter__', '__next__', 'requires_js_object']: + raise error + pass + + if not self.data: + raise AttributeError(name) + + if isinstance(self.data, DataPointCollection): + return getattr(self.data, name) + + results = [getattr(x, name) for x in self.data] + + if HAS_NUMPY: + results = np.asarray(results) + + return results + + def __setattr__(self, name, value): + """Updates the series attribute, or descendent attributes on the ``.data`` + properties. + """ + try: + super().__setattr__(name, value) + return + except AttributeError: + pass + + collection_cls = self._data_collection_class() + data_point_cls = self._data_point_class() + + if not utility_functions.is_ndarray(self.data) and not self.data: + if HAS_NUMPY: + collection = collection_cls() + setattr(collection, name, value) + self.data = collection + elif checkers.is_iterable(value, forbid_literals = (str, + bytes, + dict, + UserDict)): + collection = collection_cls() + setattr(collection, name, value) + self.data = collection + else: + data_point = data_point_cls(name = value) + self._data = [data_point] + elif not self.data: + collection = collection_cls() + setattr(collection, name, value) + self.data = collection + elif checkers.is_type(self.data, 'DataPointCollection'): + setattr(self.data, name, value) + else: + if not checkers.is_iterable(value, forbid_literals = (str, + bytes, + dict, + UserDict)): + value = [value for x in self.data] + + if len(self.data) > len(value): + value = value + [None for x in range(len(self.data) - len(value))] + elif len(self.data) < len(value): + self.data = self.data + [data_point_cls() + for x in range(len(value) - len(self.data))] + + for index in range(len(self.data)): + setattr(self.data[index], name, value[index]) + + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return DataPointCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return DataBase + @property def _dot_path(self) -> Optional[str]: """The dot-notation path to the options key for the current class. @@ -86,19 +202,23 @@ def _dot_path(self) -> Optional[str]: return f'series.{self.type}' @property - def data(self) -> Optional[List[DataBase]]: + def data(self) -> Optional[List[DataBase] | DataPointCollection]: """The collection of data points for the series. Defaults to :obj:`None `. - :rtype: :class:`DataBase` or :obj:`None ` + :rtype: :class:`DataBase` or + :class:`DataPointCollection ` + or :obj:`None ` """ return self._data @data.setter - @class_sensitive(DataBase, force_iterable = True) def data(self, value): - self._data = value - + if not utility_functions.is_ndarray(value) and not value: + self._data = None + else: + self._data = self._data_point_class().from_array(value) + @property def id(self) -> Optional[str]: """An id for the series. Defaults to :obj:`None `. @@ -351,9 +471,60 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed + def load_from_array(self, value): + """Update the :meth:`.data ` + property with data loaded from an iterable in ``value``. + + :param value: The value that should contain the data which will be converted into data + point instances. + + .. note:: + + If ``value`` is not an iterable, it will be converted into an iterable to be + further de-serialized correctly. + + :type value: iterable + + """ + data_point_cls = self._data_point_class() + + self.data = data_point_cls.from_array(value) + + @classmethod + def from_array(cls, value, series_kwargs = None): + """Create one instance of the series with ``data`` populated from ``value``. + + :param value: The value that should contain the data which will be converted into data + point instances. + + .. note:: + + If ``value`` is not an iterable, it will be converted into an iterable to be + further de-serialized correctly. + + :type value: iterable + + :param series_kwargs: Optional keyword arguments to apply when instanting the + series. Defaults to :obj:`None `. + :type series_kwargs: :class:`dict ` or :obj:`None ` + + :returns: An instance of the series type with ``data`` populated from the value. + :rtype: :class:`SeriesBase ` + descendent + """ + series_kwargs = validators.dict(series_kwargs, allow_empty = True) or {} + + data_point_cls = cls._data_point_class() + data_points = data_point_cls.from_array(value) + + series_kwargs['data'] = data_points + series = cls(**series_kwargs) + + return series + def load_from_csv(self, as_string_or_file, - property_column_map, + property_column_map = None, has_header_row = True, delimiter = ',', null_text = 'None', @@ -361,7 +532,217 @@ def load_from_csv(self, line_terminator = '\r\n', wrap_all_strings = False, double_wrapper_character_when_nested = False, - escape_character = "\\"): + escape_character = "\\", + series_in_rows = False, + series_index = True, + **kwargs): + """Replace the existing + :meth:`.data ` property + with a new value populated from data in a CSV string or file. + + .. note:: + + For an example + :class:`LineSeries `, the + minimum code required would be: + + .. code-block:: python + + my_series = LineSeries() + + # EXAMPLE 1. Minimal code - will attempt to update the line series + # taking x-values from the first column, and y-values from + # the second column. If there are too many columns in the CSV, + # will throw an error. + + my_series = my_series.from_csv('some-csv-file.csv') + + # EXAMPLE 2. More precise code - will attempt to update the line series + # mapping columns in the CSV file to properties on the series + # instance. + + my_series = my_series.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': 3, + 'id': 'id' + }) + + # EXAMPLE 3. More precise code - will update the line series + # using a specific series generated from the CSV file. + + my_series = my_series.from_csv('some-csv-file.csv', series_index = 2) + + As the example above shows, data is loaded into the ``my_series`` instance + from the CSV file with a filename ``some-csv-file.csv``. As shown in + EXAMPLE 1, unless otherwise specified, the :meth:`.x ` + values for each data point will be taken from the first (index 0) column + in the CSV file, while the :meth:`.y ` values will be + taken from the second column. + + If the CSV has more than 2 columns, then this will throw an + :exc:`HighchartsCSVDeserializationError` because the function is not certain + which columns to use to update the series. If this happens, you can either: + + #. As shown in EXAMPLE 2, precisely specify which columns to use by + providing a ``property_column_map`` argument. In EXAMPLE 2, the + :meth:`.x ` values for each data point will be taken + from the first (index 0) column in the CSV file. The + :meth:`.y ` values will be taken from the fourth + (index 3) column in the CSV file. And the + :meth:`.id ` values will be taken from a column whose + header row is labeled ``'id'`` (regardless of its index). + #. Supply a ``series_index`` argument, which indicates which of the series + generated from the CSV file should be used to update the instance. + + :param as_string_or_file: The CSV data to use to pouplate data. Accepts either + the raw CSV data as a :class:`str ` or a path to a file in the + runtime environment that contains the CSV data. + + .. tip:: + + Unwrapped empty column values are automatically interpreted as null + (:obj:`None `). + + :type as_string_or_file: :class:`str ` or Path-like + + :param property_column_map: An optional :class:`dict ` used to + indicate which data point property should be set to which CSV column. The keys + in the :class:`dict ` should correspond to properties in the data + point class, while the value can either be a numerical index (starting with 0) + or a :class:`str ` indicating the label for the CSV column. + Defaults to :obj:`None `. + + .. warning:: + + If the ``property_column_map`` uses :class:`str ` values, the CSV + file *must* have a header row (this is expected, by default). If there is no + header row and a :class:`str ` value is found, a + :exc:`HighchartsCSVDeserializationError` will be raised. + + :type property_column_map: :class:`dict ` or :obj:`None ` + + :param has_header_row: If ``True``, indicates that the first row of + ``as_string_or_file`` contains column labels, rather than actual data. Defaults + to ``True``. + :type has_header_row: :class:`bool ` + + :param delimiter: The delimiter used between columns. Defaults to ``,``. + :type delimiter: :class:`str ` + + :param wrapper_character: The string used to wrap string values when + wrapping is applied. Defaults to ``'``. + :type wrapper_character: :class:`str ` + + :param null_text: The string used to indicate an empty value if empty + values are wrapped. Defaults to `None`. + :type null_text: :class:`str ` + + :param line_terminator: The string used to indicate the end of a line/record in + the CSV data. Defaults to ``'\\r\\n'``. + :type line_terminator: :class:`str ` + + :param line_terminator: The string used to indicate the end of a line/record in + the CSV data. Defaults to ``'\\r\\n'``. + + .. note:: + + The Python :mod:`csv ` currently ignores the ``line_terminator`` + parameter and always applies ``'\\r\\n'``, by design. The Python docs say this + may change in the future, so for future backwards compatibility we are + including it here. + + :type line_terminator: :class:`str ` + + :param wrap_all_strings: If ``True``, indicates that the CSV file has all string + data values wrapped in quotation marks. Defaults to ``False``. + + .. warning:: + + If set to ``True``, the :mod:`csv ` module will try to coerce any + value that is *not* wrapped in quotation marks to a + :class:`float `. This can cause unexpected behavior, and + typically we recommend leaving this as ``False`` and then re-casting values + after they have been parsed. + + :type wrap_all_strings: :class:`bool ` + + :param double_wrapper_character_when_nested: If ``True``, quote character is + doubled when appearing within a string value. If ``False``, the + ``escape_character`` is used to prefix quotation marks. Defaults to ``False``. + :type double_wrapper_character_when_nested: :class:`bool ` + + :param escape_character: A one-character string that indicates the character used + to escape quotation marks if they appear within a string value that is already + wrapped in quotation marks. Defaults to ``\\`` (which is Python for ``'\'``, + which is Python's native escape character). + :type escape_character: :class:`str ` + + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param series_index: if :obj:`None `, will raise a + :exc:`HighchartsCSVDeserializationError ` + if the CSV data contains more than one series and no ``property_column_map`` + is provided. Otherwise, will update the instance with the series found + in the CSV at the ``series_index`` value. Defaults to + :obj:`None `. + :type series_index: :class:`int ` or :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + + :raises HighchartsCSVDeserializationError: if ``property_column_map`` references + CSV columns by their label, but the CSV data does not contain a header row + + """ + cls = self.__class__ + new_instance = cls.from_csv( + as_string_or_file, + property_column_map = property_column_map, + has_header_row = has_header_row, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = wrap_all_strings, + double_wrapper_character_when_nested = double_wrapper_character_when_nested, + escape_character = escape_character, + series_in_rows = series_in_rows, + series_index = series_index, + **kwargs + ) + + if series_index is None and isinstance(new_instance, list): + raise errors.HighchartsCSVDeserializationError( + f'Expected data for a single series, but got {len(new_instance)} when ' + f'loading from CSV. Please either modify the structure of your CSV ' + f'or provide more targeted instructions using the property_column_map ' + f'argument.' + ) + elif isinstance(new_instance, list): + new_instance = new_instance[series_index] + + self.data = new_instance.data + + @classmethod + def _from_csv_multi_map(cls, + as_string_or_file, + property_column_map = None, + has_header_row = True, + series_kwargs = None, + delimiter = ',', + null_text = 'None', + wrapper_character = "'", + line_terminator = '\r\n', + wrap_all_strings = False, + double_wrapper_character_when_nested = False, + escape_character = "\\", + series_in_rows = False, + **kwargs): """Replace the existing :meth:`.data ` property with a new value populated from data in a CSV string or file. @@ -406,7 +787,8 @@ def load_from_csv(self, data point property should be set to which CSV column. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value can either be a numerical index (starting with 0) or a - :class:`str ` indicating the label for the CSV column. + :class:`str ` indicating the label for the CSV column. Defaults to + :obj:`None `. .. warning:: @@ -415,13 +797,24 @@ def load_from_csv(self, header row and a :class:`str ` value is found, a :exc:`HighchartsCSVDeserializationError` will be raised. - :type property_column_map: :class:`dict ` + :type property_column_map: :class:`dict ` or :obj:`None ` :param has_header_row: If ``True``, indicates that the first row of ``as_string_or_file`` contains column labels, rather than actual data. Defaults to ``True``. :type has_header_row: :class:`bool ` + :param series_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the series instance. Defaults + to :obj:`None `. + + .. warning:: + + If ``series_kwargs`` contains a ``data`` key, its value will be *overwritten*. + The ``data`` value will be created from the CSV file instead. + + :type series_kwargs: :class:`dict ` + :param delimiter: The delimiter used between columns. Defaults to ``,``. :type delimiter: :class:`str ` @@ -473,6 +866,15 @@ def load_from_csv(self, which is Python's native escape character). :type escape_character: :class:`str ` + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + :raises HighchartsCSVDeserializationError: if ``property_column_map`` references CSV columns by their label, but the CSV data does not contain a header row @@ -482,7 +884,8 @@ def load_from_csv(self, except AttributeError: pass - property_column_map = validators.dict(property_column_map, allow_empty = False) + property_column_map = validators.dict(property_column_map, + allow_empty = True) or {} cleaned_column_map = {} for key in property_column_map: map_value = property_column_map.get(key, None) @@ -527,41 +930,115 @@ def load_from_csv(self, escape_character = "\\" ) + fixed_values = {} + iterable_values = {} + number_of_series = 1 + mismatched_series = {} + names = [] for key in cleaned_column_map: map_value = cleaned_column_map[key] - if map_value not in columns: - if isinstance(map_value, str): + + is_iterable = not isinstance(map_value, (str, bytes, dict, UserDict)) and \ + hasattr(map_value, '__iter__') + + if is_iterable: + for item in map_value: + if item not in columns: + raise errors.HighchartsCSVDeserializationError( + f'property_column_map is looking for a column labeled ' + f'"{item}", but no corresponding column was found.' + ) + + implied_series = len(map_value) + if number_of_series == 1 and implied_series > number_of_series: + number_of_series = implied_series + elif implied_series != number_of_series: + mismatched_series[key] = implied_series + + iterable_values[key] = map_value + if key == 'y': + name_list = [x if isinstance(x, str) else columns[x] + for x in map_value] + names.extend(name_list) + else: + if isinstance(map_value, str) and map_value not in columns: raise errors.HighchartsCSVDeserializationError( f'property_column_map is looking for a column labeled ' f'"{map_value}", but no corresponding column was found.' ) - else: + elif map_value not in columns and checkers.is_integer( + map_value, + coerce_value = True + ) and int(map_value) > len(columns): raise errors.HighchartsCSVDeserializationError( - f'property_column_map is looking for a column indexed ' + f'property_column_map is looking for a column at index ' f'{map_value}, but no corresponding column was found.' ) - data_point_dicts = [] - for record in csv_records: - data_point_dict = {} - for key in cleaned_column_map: - map_value = cleaned_column_map[key] - value = record.get(map_value, None) - if value and isinstance(value, str) and ',' in value: - test_value = value.replace(delimiter, '') - if checkers.is_numeric(test_value): - value = test_value - - data_point_dict[key] = value - - data_point_dicts.append(data_point_dict) - - self.data = data_point_dicts + fixed_values[key] = map_value + if key == 'y': + if isinstance(map_value, str): + names.append(map_value) + else: + names.append(columns[map_value]) + + if mismatched_series: + raise errors.HighchartsCSVDeserializationError( + f'Unable to create series from CSV. The property map implied ' + f'multiple series were needed, but properties had mismatched ' + f'number of values:\n{mismatched_series}' + ) + + collections = [] + for index in range(number_of_series): + collection_cls = cls._data_collection_class() + collection_instance = collection_cls() + for key in iterable_values: + iterable_value = iterable_values[key][index] + prop_array = [x.get(iterable_value, None) for x in csv_records] + for i, value in enumerate(prop_array): + if value and isinstance(value, str) and ',' in value: + test_value = value.replace(',', '') + if checkers.is_numeric(test_value): + value = test_value + prop_array[i] = value + + setattr(collection_instance, key, prop_array) + for key in fixed_values: + fixed_value = fixed_values[key] + prop_array = [x.get(fixed_value, None) for x in csv_records] + for i, value in enumerate(prop_array): + if value and isinstance(value, str) and ',' in value: + test_value = value.replace(',', '') + if checkers.is_numeric(test_value): + value = test_value + prop_array[i] = value + + setattr(collection_instance, key, prop_array) + getattr(collection_instance, key, None) + + collections.append(collection_instance) + + series_list = [] + for index in range(number_of_series): + series_kwargs['data'] = collections[index] + series_instance = cls(**series_kwargs) + try: + series_instance.name = names[index] + except IndexError: + pass + for key in kwargs: + if key not in series_kwargs and key not in cleaned_column_map: + setattr(series_instance, key, kwargs[key]) + + series_list.append(series_instance) + + return series_list @classmethod def from_csv(cls, as_string_or_file, - property_column_map, + property_column_map = None, has_header_row = True, series_kwargs = None, delimiter = ',', @@ -570,9 +1047,12 @@ def from_csv(cls, line_terminator = '\r\n', wrap_all_strings = False, double_wrapper_character_when_nested = False, - escape_character = "\\"): - """Create a new :term:`series` instance with a - :meth:`.data ` property + escape_character = "\\", + series_in_rows = False, + series_index = None, + **kwargs): + """Create one or more new :term:`series` instances with + :meth:`.data ` populated from data in a CSV string or file. .. note:: @@ -583,6 +1063,15 @@ def from_csv(cls, .. code-block:: python + # Create one or more LineSeries instances from the CSV file "some-csv-file.csv". + + # EXAMPLE 1. The minimum code to produce one series for each + # column in the CSV file (excluding the first column): + + my_series = LineSeries.from_csv('some-csv-file.csv') + + # EXAMPLE 2. Produces ONE series with more precise configuration: + my_series = LineSeries.from_csv('some-csv-file.csv', property_column_map = { 'x': 0, @@ -590,6 +1079,16 @@ def from_csv(cls, 'id': 'id' }) + # EXAMPLE 3. Produces THREE series instances with + # more precise configuration: + + my_series = LineSeries.from_csv('some-csv-file.csv', + property_column_map = { + 'x': 0, + 'y': [3, 5, 8], + 'id': 'id' + }) + As the example above shows, data is loaded into the ``my_series`` instance from the CSV file with a filename ``some-csv-file.csv``. The :meth:`x ` @@ -614,16 +1113,36 @@ def from_csv(cls, data point property should be set to which CSV column. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value can either be a numerical index (starting with 0) or a - :class:`str ` indicating the label for the CSV column. - - .. warning:: - - If the ``property_column_map`` uses :class:`str ` values, the CSV - file *must* have a header row (this is expected, by default). If there is no - header row and a :class:`str ` value is found, a - :exc:`HighchartsCSVDeserializationError` will be raised. - - :type property_column_map: :class:`dict ` + :class:`str ` indicating the label for the CSV column. Defaults to + :obj:`None `. + + .. note:: + + If any of the values in ``property_column_map`` contain an iterable, then + one series will be produced for each item in the iterable. For example, + the following: + + .. code-block:: python + + { + 'x': 0, + 'y': [3, 5, 8] + } + + will return *three* series, each of which will have its + :meth:`.x ` value populated from the first column + (index 0), and whose :meth:`.y ` + values will be populated from the fourth, sixth, and ninth columns (indices + 3, 5, and 8), respectively. + + .. warning:: + + If the ``property_column_map`` uses :class:`str ` values, the CSV + file *must* have a header row (this is expected, by default). If there is no + header row and a :class:`str ` value is found, a + :exc:`HighchartsCSVDeserializationError` will be raised. + + :type property_column_map: :class:`dict ` or :obj:`None ` :param has_header_row: If ``True``, indicates that the first row of ``as_string_or_file`` contains column labels, rather than actual data. Defaults @@ -692,114 +1211,847 @@ def from_csv(cls, which is Python's native escape character). :type escape_character: :class:`str ` + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to ``False``. + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param series_index: If supplied, return the series that Highcharts for Python + generated from the CSV at the ``series_index`` position. Defaults to + :obj:`None `, which returns all series generated from the CSV. + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + :returns: A :term:`series` instance (descended from - :class:`SeriesBase `) with its + :class:`SeriesBase `) OR + :class:`list ` of series instances with its :meth:`.data ` property - populated from the CSV data in ``as_string_or_file``. + populated from the data in ``df``. :rtype: :class:`list ` of series instances (descended from - :class:`SeriesBase `) + :class:`SeriesBase `) or + :class:`SeriesBase `-descendent :raises HighchartsCSVDeserializationError: if ``property_column_map`` references CSV columns by their label, but the CSV data does not contain a header row """ series_kwargs = validators.dict(series_kwargs, allow_empty = True) or {} + if series_in_rows: + return cls.from_csv_in_rows( + as_string_or_file, + has_header_row = has_header_row, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = wrap_all_strings, + double_wrapper_character_when_nested = double_wrapper_character_when_nested, + escape_character = escape_character, + series_index = series_index, + **kwargs + ) - instance = cls(**series_kwargs) - instance.load_from_csv(as_string_or_file, - property_column_map, - has_header_row = has_header_row, - delimiter = delimiter, - null_text = null_text, - wrapper_character = wrapper_character, - line_terminator = line_terminator, - wrap_all_strings = wrap_all_strings, - double_wrapper_character_when_nested = double_wrapper_character_when_nested, - escape_character = escape_character) + # SCENARIO 1: Has Property Map + if property_column_map: + series_list = cls._from_csv_multi_map( + as_string_or_file, + property_column_map = property_column_map, + has_header_row = has_header_row, + series_kwargs = series_kwargs, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = wrap_all_strings, + double_wrapper_character_when_nested = double_wrapper_character_when_nested, + escape_character = escape_character, + **kwargs + ) + if len(series_list) == 1: + return series_list[0] + + return series_list + + # SCENARIO 2: Properties in KWARGS + collection_cls = cls._data_collection_class() + data_point_cls = cls._data_point_class() + props_from_array = data_point_cls._get_props_from_array() + if not props_from_array: + props_from_array = ['x', 'y'] + + property_map = {} + for prop in props_from_array: + if prop in kwargs: + property_map[prop] = kwargs[prop] + + if property_map: + series_list = cls._from_csv_multi_map( + as_string_or_file, + property_column_map = property_map, + has_header_row = has_header_row, + series_kwargs = series_kwargs, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = wrap_all_strings, + double_wrapper_character_when_nested = double_wrapper_character_when_nested, + escape_character = escape_character, + **kwargs + ) + for index in range(len(series_list)): + for key in kwargs: + if key not in props_from_array and key not in series_kwargs: + setattr(series_list[index], key, kwargs[key]) + + if len(series_list) == 1: + return series_list[0] - return instance + if series_index is not None: + return series_list[index] - def load_from_pandas(self, - df, - property_map): - """Replace the contents of the + return series_list + + # SCENARIO 3: No Explicit Properties + if not checkers.is_on_filesystem(as_string_or_file): + as_str = as_string_or_file + columns, csv_records = utility_functions.parse_csv( + as_str, + has_header_row = has_header_row, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = False, + double_wrapper_character_when_nested = False, + escape_character = "\\" + ) + else: + with open(as_string_or_file, 'r', newline = '') as file_: + columns, csv_records = utility_functions.parse_csv( + file_, + has_header_row = has_header_row, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = False, + double_wrapper_character_when_nested = False, + escape_character = "\\" + ) + + try: + series_idx = kwargs.get('index', columns[0]) + except IndexError: + series_idx = kwargs.get('index', 0) + + column_count = len(columns) + if not columns: + column_count = len(csv_records[0]) + + supported_dimensions = collection_cls._get_supported_dimensions() + + # SCENARIO 3a: Single Series, Data Frame Columns align exactly to Data Point Properties + if column_count in supported_dimensions: + property_map = {} + props_from_array = data_point_cls._get_props_from_array(length = column_count) + if not props_from_array: + props_from_array = ['x', 'y'] + + property_map[props_from_array[0]] = [x.get(series_idx, None) + for x in csv_records] + + for index, prop in enumerate(props_from_array[1:]): + if series_idx is not None: + prop_array = [x.get(columns[index + 1], index + 1) + for x in csv_records] + else: + prop_array = [x.get(columns[index], index) + for x in csv_records] + for i, value in enumerate(prop_array): + if value and isinstance(value, str) and ',' in value: + test_value = value.replace(',', '') + if checkers.is_numeric(test_value): + value = test_value + prop_array[i] = value + + property_map[prop] = prop_array + + collection = collection_cls() + for key in property_map: + setattr(collection, key, property_map[key]) + + series_kwargs['data'] = collection + series_instance = cls(**series_kwargs) + for key in kwargs: + if key not in series_kwargs and key not in property_map: + setattr(series_instance, key, kwargs[key]) + + return series_instance + + # SCENARIO 3b: Multiple Series, Data Frame Columns correspond to multiples of Data Point Properties + reversed_dimensions = sorted(supported_dimensions, reverse = True) + + columns_per_series = None + if reversed_dimensions: + for dimension in reversed_dimensions: + if series_idx is not None and dimension > 1 and column_count % (dimension - 1) == 0: + if dimension > 2 and props_from_array[-1] == 'name': + columns_per_series = dimension - 2 + else: + columns_per_series = dimension - 1 + break + if dimension > 1 and column_count % dimension == 0: + columns_per_series = dimension + break + elif dimension == 1: + columns_per_series = 1 + + if not columns_per_series: + raise errors.HighchartsCSVDeserializationError( + f'Could not determine how to deserialize CSV with {column_count}' + f' columns into a {collection_cls.__name__} instance. Please supply ' + f'more precise instructions using property_column_map or ' + f'by explicitly specificying data property kwargs.' + ) + + series_count = column_count // columns_per_series + if columns_per_series == 1 and series_idx: + series_count -= 1 + + series_list = [] + for index in range(series_count): + start = 1 + (len(series_list) * columns_per_series) + + property_map = {} + if series_idx is not None: + expected_length = columns_per_series + 1 + else: + expected_length = columns_per_series + + props_from_array = data_point_cls._get_props_from_array(length = expected_length) + if not props_from_array: + props_from_array = ['x', 'y'] + + property_map[props_from_array[0]] = [x.get(series_idx, None) + for x in csv_records] + + has_implicit_series_name = 'name' not in kwargs and 'name' not in series_kwargs + if has_implicit_series_name: + try: + series_name = columns[start] + except (IndexError, TypeError): + series_name = None + else: + series_name = series_kwargs.get('name', None) or kwargs.get('name', None) + + props_from_array = props_from_array[1:] + for idx, prop in enumerate(props_from_array): + index = start + idx + + prop_array = [x.get(columns[index], idx) for x in csv_records] + + property_map[prop] = prop_array + + collection = collection_cls() + for key in property_map: + try: + setattr(collection, key, property_map[key]) + except ValueError as error: + if key not in ['x', 'name'] and 'name' not in property_map: + setattr(collection, 'name', property_map[key]) + else: + raise error + + series_kwargs['data'] = collection + series_instance = cls(**series_kwargs) + for key in kwargs: + if key not in series_kwargs and key not in property_map: + setattr(series_instance, key, kwargs[key]) + + if 'name' not in series_kwargs and 'name' not in kwargs: + series_instance.name = series_name + + series_list.append(series_instance) + + if series_index is not None: + return series_list[series_index] + + return series_list + + @classmethod + def from_csv_in_rows(cls, + as_string_or_file, + has_header_row = True, + series_kwargs = None, + delimiter = ',', + null_text = 'None', + wrapper_character = "'", + line_terminator = '\r\n', + wrap_all_strings = False, + double_wrapper_character_when_nested = False, + escape_character = "\\", + **kwargs): + """Create a new :term:`series` instance with a + :meth:`.data ` property + populated from data in a CSV string or file. + + .. note:: + + For an example + :class:`LineSeries `, the + minimum code required would be: + + .. code-block:: python + + my_series = LineSeries.from_csv_in_rows('some-csv-file.csv') + + :param as_string_or_file: The CSV data to use to pouplate data. Accepts either + the raw CSV data as a :class:`str ` or a path to a file in the + runtime environment that contains the CSV data. + + .. tip:: + + Unwrapped empty column values are automatically interpreted as null + (:obj:`None `). + + :type as_string_or_file: :class:`str ` or Path-like + + :param has_header_row: If ``True``, indicates that the first row of + ``as_string_or_file`` contains column labels, rather than actual data. Defaults + to ``True``. + :type has_header_row: :class:`bool ` + + :param series_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the series instance. Defaults + to :obj:`None `. + + .. warning:: + + If ``series_kwargs`` contains a ``data`` key, its value will be *overwritten*. + The ``data`` value will be created from the CSV file instead. + + :type series_kwargs: :class:`dict ` + + :param delimiter: The delimiter used between columns. Defaults to ``,``. + :type delimiter: :class:`str ` + + :param wrapper_character: The string used to wrap string values when + wrapping is applied. Defaults to ``'``. + :type wrapper_character: :class:`str ` + + :param null_text: The string used to indicate an empty value if empty + values are wrapped. Defaults to `None`. + :type null_text: :class:`str ` + + :param line_terminator: The string used to indicate the end of a line/record in + the CSV data. Defaults to ``'\\r\\n'``. + :type line_terminator: :class:`str ` + + :param line_terminator: The string used to indicate the end of a line/record in + the CSV data. Defaults to ``'\\r\\n'``. + + .. note:: + + The Python :mod:`csv ` currently ignores the ``line_terminator`` + parameter and always applies ``'\\r\\n'``, by design. The Python docs say this + may change in the future, so for future backwards compatibility we are + including it here. + + :type line_terminator: :class:`str ` + + :param wrap_all_strings: If ``True``, indicates that the CSV file has all string + data values wrapped in quotation marks. Defaults to ``False``. + + .. warning:: + + If set to ``True``, the :mod:`csv ` module will try to coerce any + value that is *not* wrapped in quotation marks to a + :class:`float `. This can cause unexpected behavior, and + typically we recommend leaving this as ``False`` and then re-casting values + after they have been parsed. + + :type wrap_all_strings: :class:`bool ` + + :param double_wrapper_character_when_nested: If ``True``, quote character is + doubled when appearing within a string value. If ``False``, the + ``escape_character`` is used to prefix quotation marks. Defaults to ``False``. + :type double_wrapper_character_when_nested: :class:`bool ` + + :param escape_character: A one-character string that indicates the character used + to escape quotation marks if they appear within a string value that is already + wrapped in quotation marks. Defaults to ``\\\\`` (which is Python for ``'\\'``, + which is Python's native escape character). + :type escape_character: :class:`str ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + + :returns: A :term:`series` instance (descended from + :class:`SeriesBase `) OR + :class:`list ` of series instances with its + :meth:`.data ` property + populated from the data in ``df``. + :rtype: :class:`list ` of series instances (descended from + :class:`SeriesBase `) or + :class:`SeriesBase `-descendent + + :raises HighchartsCSVDeserializationError: if ``property_column_map`` references + CSV columns by their label, but the CSV data does not contain a header row + + """ + series_kwargs = validators.dict(series_kwargs, allow_empty = True) or {} + + if not checkers.is_on_filesystem(as_string_or_file): + as_str = as_string_or_file + columns, csv_records = utility_functions.parse_csv( + as_str, + has_header_row = has_header_row, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = False, + double_wrapper_character_when_nested = False, + escape_character = "\\" + ) + else: + with open(as_string_or_file, 'r', newline = '') as file_: + columns, csv_records = utility_functions.parse_csv( + file_, + has_header_row = has_header_row, + delimiter = delimiter, + null_text = null_text, + wrapper_character = wrapper_character, + line_terminator = line_terminator, + wrap_all_strings = False, + double_wrapper_character_when_nested = False, + escape_character = "\\" + ) + + collection_cls = cls._data_collection_class() + supported_dimensions = collection_cls._get_supported_dimensions() + if 2 not in supported_dimensions: + raise errors.HighchartsPandasDeserializationError( + f'Unable to create a collection of {cls.__name__} instances ' + f'from CSV using a 2-dimensional array because {cls.__name__} does ' + f'not support 2-dimensional arrays as inputs. Please use a ' + f'different series type, or transpose the CSV to a columnar structure ' + f'and supply a column_property_map for greater precision.' + ) + data_properties = collection_cls._get_props_from_array() + + if columns: + x_values = columns[1:] + else: + x_values = [x for x in range(len(csv_records[0].keys()) - 1)] + + name_key = list(csv_records[0].keys())[0] + name_values = [row[name_key] for row in csv_records] + + series_count = len(csv_records) + series_list = [] + + for row in range(series_count): + series_name = name_values[row] + y_values = [x for x in list(csv_records[row].values())[1:]] + for i, value in enumerate(y_values): + if value and isinstance(value, str) and ',' in value: + test_value = value.replace(',', '') + if checkers.is_numeric(test_value): + value = test_value + y_values[i] = value + + as_array = zip(x_values, y_values) + collection = collection_cls.from_array(as_array) + series_instance_kwargs = series_kwargs.copy() + series_instance_kwargs['data'] = collection + series_instance_kwargs['name'] = series_name + series_instance = cls(**series_instance_kwargs) + for key in kwargs: + if key not in series_instance_kwargs and key not in data_properties: + setattr(series_instance, key, kwargs[key]) + + series_list.append(series_instance) + + return series_list + + def load_from_pandas(self, + df, + property_map = None, + series_in_rows = False, + series_index = None): + """Replace the contents of the :meth:`.data ` property with data points populated from a `pandas `_ - :class:`DataFrame `. + :class:`DataFrame `. - :param df: The :class:`DataFrame ` from which data should be + :param df: The :class:`DataFrame ` from which data should be loaded. - :type df: :class:`DataFrame ` + :type df: :class:`DataFrame ` :param property_map: A :class:`dict ` used to indicate which data point property should be set to which column in ``df``. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value should indicate the label for the - :class:`DataFrame ` column. - :type property_map: :class:`dict ` + :class:`DataFrame ` column. Defaults to + :obj:`None `. + + :type property_map: :class:`dict ` or :obj:`None ` + + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param series_index: If supplied, return the series that Highcharts for Python + generated from ``df`` at the ``series_index`` value. Defaults to + :obj:`None `, which returns all series generated from ``df``. + + .. warning:: + + If :obj:`None ` and Highcharts for Python generates multiple + series, then a :exc:`HighchartsPandasDeserializationError` will be raised. + + :type series_index: :class:`int `, or :obj:`None ` + + :raises HighchartsPandasDeserializationError: if ``property_map`` references + a column that does not exist in the data frame + :raises HighchartsPandasDeserializationError: if ``series_index`` is + :obj:`None `, and it is ambiguous which series generated from + the dataframe should be used + :raises HighchartsDependencyError: if `pandas `_ is + not available in the runtime environment + """ + cls = self.__class__ + new_instance = cls.from_pandas(df, + property_map = property_map, + series_in_rows = series_in_rows) + if series_index is None and isinstance(new_instance, list): + raise errors.HighchartsPandasDeserializationError( + f'Expected data for a single series, but got {len(new_instance)} when ' + f'loading from df. Please either modify the structure of df ' + f'or provide more targeted instructions using the property_map ' + f'argument.' + ) + elif isinstance(new_instance, list): + new_instance = new_instance[series_index] + + self.data = new_instance.data + + @classmethod + def _from_pandas_multi_map(cls, + df, + property_map, + series_kwargs = None, + **kwargs): + """Create one or more :term:`series` instances whose + :meth:`.data ` properties + are populated from a `pandas `_ + :class:`DataFrame `, when ``property_map`` suggests there are + multiple series. + + :param df: The :class:`DataFrame ` from which data should be + loaded. + :type df: :class:`DataFrame ` + + :param property_map: A :class:`dict ` used to indicate which + data point property should be set to which column in ``df``. The keys in the + :class:`dict ` should correspond to properties in the data point + class, while the value should indicate the label for the + :class:`DataFrame ` column. Defaults to :obj:`None ` + :type property_map: :class:`dict ` or :obj:`None ` + + :param series_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the series instance. Defaults + to :obj:`None `. + + .. warning:: + + If ``series_kwargs`` contains a ``data`` key, its value will be *overwritten*. + The ``data`` value will be created from ``df`` instead. + + :type series_kwargs: :class:`dict ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + + :returns: A :term:`series` instance (descended from + :class:`SeriesBase `) OR + :class:`list ` of series instances with its + :meth:`.data ` property + populated from the data in ``df``. + :rtype: :class:`list ` of series instances (descended from + :class:`SeriesBase `) or + :class:`SeriesBase `-descendent :raises HighchartsPandasDeserializationError: if ``property_map`` references a column that does not exist in the data frame :raises HighchartsDependencyError: if `pandas `_ is not available in the runtime environment """ + series_kwargs = validators.dict(series_kwargs, allow_empty = True) or {} + + fixed_values = {} + iterable_values = {} + number_of_series = 1 + mismatched_series = {} + names = [] + for key in property_map: + map_value = property_map[key] + + is_iterable = not isinstance(map_value, (str, bytes, dict, UserDict)) and \ + hasattr(map_value, '__iter__') + + if is_iterable: + for item in map_value: + if item not in df.columns.values: + raise errors.HighchartsPandasDeserializationError( + f'Unable to find a column labeled "{item}" in df.' + ) + + implied_series = len(map_value) + if number_of_series == 1 and implied_series > number_of_series: + number_of_series = implied_series + elif implied_series != number_of_series: + mismatched_series[key] = implied_series + + iterable_values[key] = map_value + if key == 'y': + names.extend(map_value) + else: + if map_value not in df.columns.values: + if map_value != df.index.name: + raise errors.HighchartsPandasDeserializationError( + f'Unable to find a column labeled "{map_value}" in df.' + ) + + fixed_values[key] = map_value + if key == 'y': + names.append(map_value) + + if mismatched_series: + raise errors.HighchartsPandasDeserializationError( + f'Unable to create series from df. The property map implied ' + f'multiple series were needed, but properties had mismatched ' + f'number of values:\n{mismatched_series}' + ) + + collections = [] + for index in range(number_of_series): + collection_cls = cls._data_collection_class() + collection_instance = collection_cls() + for key in iterable_values: + iterable_value = iterable_values[key][index] + prop_array = df[iterable_value].values + setattr(collection_instance, key, prop_array) + for key in fixed_values: + fixed_value = fixed_values[key] + try: + prop_array = df[fixed_value].values + except KeyError: + prop_array = df.index.values + setattr(collection_instance, key, prop_array) + collections.append(collection_instance) + + series_list = [] + for index in range(number_of_series): + series_kwargs['data'] = collections[index] + series_instance = cls(**series_kwargs) + try: + series_instance.name = names[index] + except IndexError: + pass + for key in kwargs: + if key not in series_kwargs and property_map: + setattr(series_instance, key, kwargs[key]) + + series_list.append(series_instance) + + return series_list + + @classmethod + def from_pandas_in_rows(cls, + df, + series_kwargs = None, + series_index = None, + **kwargs): + """Create a collection of :term:`series` instances, one for each + row in ``df``. + + :param df: The :class:`DataFrame ` from which data + should be loaded. + :type df: :class:`DataFrame ` + + :param series_kwargs: An optional :class:`dict ` containing keyword + arguments that should be used when instantiating the series instance. Defaults + to :obj:`None `. + + .. warning:: + + If ``series_kwargs`` contains a ``data`` key, its value will be + *overwritten*. The ``data`` value will be created from ``df`` instead. + + :type series_kwargs: :class:`dict ` + + :param series_index: If supplied, return the series that Highcharts for Python + generated from ``df`` at the ``series_index`` value. Defaults to + :obj:`None `, which returns all series generated from ``df``. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + + :returns: Collection of :term:`series` instances corresponding, with one series + per row in ``df``, and where: + + * the series x-values are populated from the column labels in ``df`` + * the series name is set to the row label from ``df`` + * the series y-values are populated from the values within that row in ``df`` + + :rtype: :class:`list ` of + :class:`SeriesBase `-descendent + instances + + """ try: - from pandas import DataFrame, isna + from pandas import DataFrame except ImportError: raise errors.HighchartsDependencyError('pandas is not available in the ' 'runtime environment. Please install ' 'using "pip install pandas"') - if not checkers.is_type(df, ('DataFrame', 'Series')): - raise errors.HighchartsValueError(f'df is expected to be a pandas DataFrame ' - f'or Series. Was: {df.__class__.__name__}') + if not checkers.is_type(df, ('DataFrame')): + raise errors.HighchartsValueError(f'df is expected to be a Pandas DataFrame.' + f'Was: {df.__class__.__name__}') + + series_kwargs = validators.dict(series_kwargs, allow_empty = True) or {} - if not property_map: - raise errors.HighchartsValueError('property_map cannot be None or empty') + collection_cls = cls._data_collection_class() + supported_dimensions = collection_cls._get_supported_dimensions() + if 2 not in supported_dimensions: + raise errors.HighchartsPandasDeserializationError( + f'Unable to create a collection of {cls.__name__} instances ' + f'from df using a 2-dimensional array because {cls.__name__} does ' + f'not support 2-dimensional arrays as inputs. Please use a ' + f'different series type, or transpose df to a columnar structure ' + f'and supply a property_map for greater precision.' + ) + data_properties = collection_cls._get_props_from_array() + + x_values = df.columns.values + name_values = df.index.values + + series_count = len(df) + series_list = [] + + for row in range(series_count): + series_name = name_values[row] + y_values = df.iloc[[row]].values + y_values = y_values.reshape(x_values.shape) - property_map = validators.dict(property_map) - for key in property_map: - map_value = property_map[key] - if map_value not in df.columns: - raise errors.HighchartsPandasDeserializationError( - f'Unable to find a column labeled "{map_value}" in df.' - ) - narrower_df = df[[property_map[key] for key in property_map]] + as_array = np.column_stack((x_values, y_values)) + collection = collection_cls.from_array(as_array) + series_instance_kwargs = series_kwargs.copy() + series_instance_kwargs['data'] = collection + series_instance_kwargs['name'] = series_name + series_instance = cls(**series_instance_kwargs) + for key in kwargs: + if key not in series_instance_kwargs and key not in data_properties: + setattr(series_instance, key, kwargs[key]) - df_as_dicts = narrower_df.to_dict(orient = 'records') + series_list.append(series_instance) - records_as_dicts = [] - for record in df_as_dicts: - record_as_dict = {} - for key in property_map: - map_value = property_map[key] - record_as_dict[key] = record.get(map_value, None) - if isna(record_as_dict[key]): - record_as_dict[key] = constants.EnforcedNull - records_as_dicts.append(record_as_dict) - - self.data = records_as_dicts + if series_index is not None: + return series_list[series_index] + + return series_list @classmethod def from_pandas(cls, df, - property_map, - series_kwargs = None): - """Create a :term:`series` instance whose - :meth:`.data ` property - is populated from a `pandas `_ - :class:`DataFrame `. + property_map = None, + series_kwargs = None, + series_in_rows = False, + series_index = None, + **kwargs): + """Create one or more :term:`series` instances whose + :meth:`.data ` properties + are populated from a `pandas `_ + :class:`DataFrame `. + + .. code-block:: python + + # Given a Pandas DataFrame instance named "df" + from highcharts_core.chart import Chart + from highcharts_core.options.series.area import LineSeries + + # Creating a Series from the DataFrame + + ## EXAMPLE 1. Minimum code required. Creates one or more series. + + my_series = LineSeries.from_pandas(df) - :param df: The :class:`DataFrame ` from which data should be + ## EXAMPLE 2. More precise configuration. Creates ONE series. + + my_series = LineSeries.from_pandas(df, series_index = 2) + + ## EXAMPLE 3. More precise configuration. Creates ONE series. + + my_series = LineSeries.from_pandas(df, + property_map = { + 'x': 'date', + 'y': 'value', + 'id': 'id' + }) + + ## EXAMPLE 4. More precise configuration. Creates THREE series. + + my_series = LineSeries.from_pandas(df, + property_map = { + 'x': 'date', + 'y': ['value1', 'value2', 'value3'], + 'id': 'id' + }) + + :param df: The :class:`DataFrame ` from which data should be loaded. - :type df: :class:`DataFrame ` + :type df: :class:`DataFrame ` :param property_map: A :class:`dict ` used to indicate which data point property should be set to which column in ``df``. The keys in the :class:`dict ` should correspond to properties in the data point class, while the value should indicate the label for the - :class:`DataFrame ` column. - :type property_map: :class:`dict ` + :class:`DataFrame ` column. Defaults to + :obj:`None `. + + .. note:: + + If any of the values in ``property_map`` contain an iterable, then + one series will be produced for each item in the iterable. For example, + the following: + + .. code-block:: python + + { + 'x': 'timestamp', + 'y': ['value1', 'value2', 'value3'] + } + + will return *three* series, each of which will have its + :meth:`.x ` value populated from the column + labeled ``'timestamp'``, and whose :meth:`.y ` + values will be populated from the columns labeled ``'value1'``, + ``'value2'``, and ``'value3'``, respectively. + + :type property_map: :class:`dict ` or :obj:`None ` :param series_kwargs: An optional :class:`dict ` containing keyword arguments that should be used when instantiating the series instance. Defaults @@ -812,12 +2064,30 @@ def from_pandas(cls, :type series_kwargs: :class:`dict ` + :param series_in_rows: if ``True``, will attempt a streamlined cartesian series + with x-values taken from column names, y-values taken from row values, and + the series name taken from the row index. Defaults to ``False``. + :obj:`False `. + :type series_in_rows: :class:`bool ` + + :param series_index: If supplied, return the series that Highcharts for Python + generated from ``df`` at the ``series_index`` value. Defaults to + :obj:`None `, which returns all series generated from ``df``. + + :type series_index: :class:`int `, slice, or + :obj:`None ` + + :param **kwargs: Remaining keyword arguments will be attempted on the resulting + :term:`series` instance and the data points it contains. + :returns: A :term:`series` instance (descended from - :class:`SeriesBase `) with its + :class:`SeriesBase `) OR + :class:`list ` of series instances with its :meth:`.data ` property populated from the data in ``df``. :rtype: :class:`list ` of series instances (descended from - :class:`SeriesBase `) + :class:`SeriesBase `) or + :class:`SeriesBase `-descendent :raises HighchartsPandasDeserializationError: if ``property_map`` references a column that does not exist in the data frame @@ -825,11 +2095,156 @@ def from_pandas(cls, not available in the runtime environment """ series_kwargs = validators.dict(series_kwargs, allow_empty = True) or {} + + # SCENARIO 0: Series in Rows + if series_in_rows: + return cls.from_pandas_in_rows(df, + series_kwargs, + series_index = series_index, + **kwargs) + + # SCENARIO 1: Has Property Map + if property_map: + series_list = cls._from_pandas_multi_map(df, + property_map, + series_kwargs, + **kwargs) + if len(series_list) == 1: + return series_list[0] + + if series_index is not None: + return series_list[series_index] + + return series_list + + # SCENARIO 2: Properties in KWARGS + collection_cls = cls._data_collection_class() + data_point_cls = cls._data_point_class() + props_from_array = data_point_cls._get_props_from_array() + if not props_from_array: + props_from_array = ['x', 'y'] + + property_map = {} + for prop in props_from_array: + if prop in kwargs: + property_map[prop] = kwargs[prop] + + if property_map: + series_list = cls._from_pandas_multi_map(df, + property_map, + series_kwargs) + for index in range(len(series_list)): + for key in kwargs: + if key not in props_from_array and key not in series_kwargs: + setattr(series_list[index], key, kwargs[key]) + + if len(series_list) == 1: + return series_list[0] - instance = cls(**series_kwargs) - instance.load_from_pandas(df, property_map) + if series_index is not None: + return series_list[series_index] - return instance + return series_list + + # SCENARIO 3: No Explicit Properties + series_idx = kwargs.get('index', df.index) + column_count = len(df.columns) + supported_dimensions = collection_cls._get_supported_dimensions() + + # SCENARIO 3a: Single Series, Data Frame Columns align exactly to Data Point Properties + if column_count in supported_dimensions: + property_map = {} + props_from_array = data_point_cls._get_props_from_array(length = column_count) + if not props_from_array: + props_from_array = ['x', 'y'] + property_map[props_from_array[0]] = series_idx + + for index, prop in enumerate(props_from_array[1:]): + prop_value = df.iloc[:, index + 1].values + property_map[prop] = prop_value + + collection = collection_cls() + for key in property_map: + setattr(collection, key, property_map[key]) + + series_kwargs['data'] = collection + series_instance = cls(**series_kwargs) + for key in kwargs: + if key not in series_kwargs and key not in property_map: + setattr(series_instance, key, kwargs[key]) + + return series_instance + + # SCENARIO 3b: Multiple Series, Data Frame Columns correspond to multiples of Data Point Properties + reversed_dimensions = sorted(supported_dimensions, reverse = True) + columns_per_series = None + if reversed_dimensions: + for dimension in reversed_dimensions: + if series_idx is not None and dimension > 1 and column_count % (dimension - 1) == 0: + if dimension > 2 and props_from_array[-1] == 'name': + columns_per_series = dimension - 2 + else: + columns_per_series = dimension - 1 + break + elif dimension > 1 and column_count % dimension == 0: + columns_per_series = dimension + break + elif dimension == 1: + columns_per_series = 1 + if not columns_per_series: + raise errors.HighchartsPandasDeserializationError( + f'Could not determine how to deserialize data frame with {column_count}' + f' columns into a {collection_cls.__name__} instance. Please supply ' + f'more precise instructions using property_map or ' + f'by explicitly specificying data property kwargs.' + ) + + series_count = column_count // columns_per_series + series_list = [] + for index in range(series_count): + start = len(series_list) * columns_per_series + + property_map = {} + if series_idx is not None: + expected_length = columns_per_series + 1 + else: + expected_length = columns_per_series + props_from_array = data_point_cls._get_props_from_array(length = expected_length) + if not props_from_array: + props_from_array = ['x', 'y'] + + property_map[props_from_array[0]] = series_idx + + has_implicit_series_name = 'name' not in kwargs and 'name' not in series_kwargs + if has_implicit_series_name: + series_name = df.columns[start] + else: + series_name = series_kwargs.get('name', None) or kwargs.get('name', None) + + for index, prop in enumerate(props_from_array[1:]): + index = start + index + prop_value = df.iloc[:, index].values + property_map[prop] = prop_value + + collection = collection_cls() + for key in property_map: + setattr(collection, key, property_map[key]) + + series_kwargs['data'] = collection + series_kwargs['name'] = series_name + series_instance = cls(**series_kwargs) + for key in kwargs: + if key not in series_kwargs and key not in property_map: + setattr(series_instance, key, kwargs[key]) + + series_list.append(series_instance) + + if series_index is not None: + return series_list[index] + + + + return series_list def load_from_pyspark(self, df, @@ -943,3 +2358,165 @@ def from_pyspark(cls, instance.load_from_pyspark(df, property_map) return instance + + def to_chart(self, chart_kwargs = None, options_kwargs = None): + """Create a :class:`Chart ` instance containing the + series instance. + + :param chart_kwargs: Optional keyword arguments to use when constructing the + :class:`Chart ` instance. Defaults to + :obj:`None `. + :type chart_kwargs: :class:`dict ` + + :param options_kwargs: Optional keyword arguments to use when constructing the + chart's :class:`HighchartsOptions ` + object. Defaults to :obj:`None `. + + .. warning:: + + If your ``chart_kwargs`` contains an ``options`` key, its value + will be overwritten if you supply ``options_kwargs``. + + :type options_kwargs: :class:`dict ` + + :returns: A :class:`Chart ` instance containing the + series instance. + :rtype: :class:`Chart ` + """ + from highcharts_core.chart import Chart + + chart_kwargs = validators.dict(chart_kwargs, allow_empty = True) or {} + + as_chart = Chart(**chart_kwargs) + if options_kwargs: + as_chart.options = options_kwargs + + as_chart.add_series(self) + + return as_chart + + def display(self, + global_options = None, + container = None, + retries = 5, + interval = 1000, + chart_kwargs = None, + options_kwargs = None): + """Display the series in `Jupyter Labs `_ or + `Jupyter Notebooks `_. + + :param global_options: The :term:`shared options` to use when rendering the chart. + Defaults to :obj:`None ` + :type global_options: :class:`SharedOptions ` + or :obj:`None ` + + :param container: The ID to apply to the HTML container when rendered in Jupyter Labs. Defaults to + :obj:`None `, which applies the :meth:`.container ` + property if set, and ``'highcharts_target_div'`` if not set. + + .. note:: + + Highcharts for Python will append a 6-character random string to the value of ``container`` + to ensure uniqueness of the chart's container when rendering in a Jupyter Notebook/Labs context. The + :class:`Chart ` instance will retain the mapping between container and the + random string so long as the instance exists, thus allowing you to easily update the rendered chart by + calling the :meth:`.display() ` method again. + + If you wish to create a new chart from the instance that does not update the existing chart, then you can do + so by specifying a new ``container`` value. + + :type container: :class:`str ` or :obj:`None ` + + :param retries: The number of times to retry rendering the chart. Used to avoid race conditions with the + Highcharts script. Defaults to 5. + :type retries: :class:`int ` + + :param interval: The number of milliseconds to wait between retrying rendering the chart. Defaults to 1000 (1 + seocnd). + :type interval: :class:`int ` + + :param chart_kwargs: Optional keyword arguments to use when constructing the + :class:`Chart ` instance. Defaults to + :obj:`None `. + :type chart_kwargs: :class:`dict ` + + :param options_kwargs: Optional keyword arguments to use when constructing the + chart's :class:`HighchartsOptions ` + object. Defaults to :obj:`None `. + + .. warning:: + + If your ``chart_kwargs`` contains an ``options`` key, its value + will be overwritten if you supply ``options_kwargs``. + + :type options_kwargs: :class:`dict ` + + :raises HighchartsDependencyError: if + `ipython `_ is not available in the + runtime environment + """ + as_chart = self.to_chart(chart_kwargs = chart_kwargs, + options_kwargs = options_kwargs) + as_chart.display(global_options = global_options, + container = container, + retries = retries, + interval = interval) + + def convert_to(self, series_type): + """Creates a new series of ``series_type`` from the current series. + + :param series_type: The series type that should be returned. + :type series_type: :class:`str ` or + :class:`SeriesBase `-descended + + .. warning:: + + This operation is *not* guaranteed to work converting between all series + types. This is because some series types have different properties, different + logic / functionality for their properties, and may have entirely different + data requirements. + + In general, this method is expected to be *lossy* in nature, meaning that when + the series can be converted "close enough" the series will be converted. + However, if the target ``series_type`` does not support certain properties set + on the original instance, then those settings will *not* be propagated to the + new series. + + In certain cases, this method may raise an + :exc:`HighchartsSeriesConversionError ` + if the method is unable to convert (even losing some data) the original into + ``series_type``. + + :returns: A new series of ``series_type``, maintaining relevant properties and + data from the original instance. + :rtype: ``series_type`` + :class:`SeriesBase ` descendant + + :raises HighchartsSeriesConversionError: if unable to convert (even after losing + some data) the original instance into an instance of ``series_type``. + :raises HighchartsValueError: if ``series_type`` is not a recognized series type + + """ + from highcharts_core.options.series.series_generator import SERIES_CLASSES + if isinstance(series_type, str): + series_type = series_type.lower() + if series_type not in SERIES_CLASSES: + raise errors.HighchartsValueError(f'series_type expects a valid Highcharts ' + f'series type. Received: {series_type}') + series_type_name = series_type + series_type = SERIES_CLASSES.get(series_type) + elif not issubclass(series_type, SeriesBase): + raise errors.HighchartsValueError(f'series_type expects a valid Highcharts ' + f'series type. Received: {series_type}') + else: + series_type_name = series_type.__name__ + + as_js_literal = self.to_js_literal() + try: + target = series_type.from_js_literal(as_js_literal) + except (ValueError, TypeError): + raise errors.HighchartsSeriesConversionError(f'Unable to convert ' + f'{self.__class__.__name__} instance ' + f'to {series_type_name}') + + return target \ No newline at end of file diff --git a/highcharts_core/options/series/boxplot.py b/highcharts_core/options/series/boxplot.py index d12406f7..7051bf9b 100644 --- a/highcharts_core/options/series/boxplot.py +++ b/highcharts_core/options/series/boxplot.py @@ -1,10 +1,10 @@ from typing import Optional, List from highcharts_core.options.series.bar import BarSeries -from highcharts_core.options.series.data.boxplot import BoxPlotData -from highcharts_core.options.series.data.range import RangeData +from highcharts_core.options.series.data.boxplot import BoxPlotData, BoxPlotDataCollection +from highcharts_core.options.series.data.range import RangeData, RangeDataCollection from highcharts_core.options.plot_options.boxplot import BoxPlotOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class BoxPlotSeries(BarSeries, BoxPlotOptions): @@ -28,8 +28,26 @@ class BoxPlotSeries(BarSeries, BoxPlotOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return BoxPlotDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return BoxPlotData + @property - def data(self) -> Optional[List[BoxPlotData]]: + def data(self) -> Optional[List[BoxPlotData] | BoxPlotDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -104,13 +122,14 @@ def data(self) -> Optional[List[BoxPlotData]]: A one-dimensional collection of :class:`BoxPlotData` objects. :rtype: :class:`list ` of :class:`BoxPlotData` or + :class:`BoxPlotDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = BoxPlotData.from_array(value) @@ -248,8 +267,26 @@ class ErrorBarSeries(BoxPlotSeries): """ + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return RangeDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return RangeData + @property - def data(self) -> Optional[List[RangeData]]: + def data(self) -> Optional[List[RangeData] | RangeDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -318,13 +355,14 @@ def data(self) -> Optional[List[RangeData]]: A one-dimensional collection of :class:`RangeData` objects. :rtype: :class:`list ` of :class:`RangeData` or + :class:`RangeDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = RangeData.from_array(value) diff --git a/highcharts_core/options/series/bubble.py b/highcharts_core/options/series/bubble.py index ed080931..3abb1ded 100644 --- a/highcharts_core/options/series/bubble.py +++ b/highcharts_core/options/series/bubble.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.cartesian import Cartesian3DData +from highcharts_core.options.series.data.cartesian import Cartesian3DData, Cartesian3DDataCollection from highcharts_core.options.plot_options.bubble import BubbleOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class BubbleSeries(SeriesBase, BubbleOptions): @@ -23,8 +23,26 @@ class BubbleSeries(SeriesBase, BubbleOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return Cartesian3DDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return Cartesian3DData + @property - def data(self) -> Optional[List[Cartesian3DData]]: + def data(self) -> Optional[List[Cartesian3DData] | Cartesian3DDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -92,13 +110,14 @@ def data(self) -> Optional[List[Cartesian3DData]]: A one-dimensional collection of :class:`Cartesian3DData` objects. :rtype: :class:`list ` of :class:`Cartesian3DData` or + :class:`Cartesian3DDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = Cartesian3DData.from_array(value) diff --git a/highcharts_core/options/series/bullet.py b/highcharts_core/options/series/bullet.py index 2cbd225b..4f9a5b3c 100644 --- a/highcharts_core/options/series/bullet.py +++ b/highcharts_core/options/series/bullet.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.bar import BarSeries -from highcharts_core.options.series.data.bullet import BulletData +from highcharts_core.options.series.data.bullet import BulletData, BulletDataCollection from highcharts_core.options.plot_options.bullet import BulletOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class BulletSeries(BarSeries, BulletOptions): @@ -22,8 +22,26 @@ class BulletSeries(BarSeries, BulletOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return BulletDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return BulletData + @property - def data(self) -> Optional[List[BulletData]]: + def data(self) -> Optional[List[BulletData] | BulletDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -92,13 +110,14 @@ def data(self) -> Optional[List[BulletData]]: A one-dimensional collection of :class:`BulletData` objects. :rtype: :class:`list ` of :class:`BulletData` or + :class:`BulletDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = BulletData.from_array(value) diff --git a/highcharts_core/options/series/data/arcdiagram.py b/highcharts_core/options/series/data/arcdiagram.py index 064902d3..16dfdbd5 100644 --- a/highcharts_core/options/series/data/arcdiagram.py +++ b/highcharts_core/options/series/data/arcdiagram.py @@ -3,9 +3,15 @@ from collections import UserDict from validator_collection import validators, checkers +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False from highcharts_core import utility_functions, constants, errors from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.utility_classes.gradients import Gradient from highcharts_core.utility_classes.patterns import Pattern @@ -86,7 +92,7 @@ def weight(self, value): self._weight = validators.numeric(value, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -126,11 +132,35 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return ArcDiagramDataCollection.from_ndarray(value) + + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 3] + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ return ['from_', @@ -190,3 +220,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class ArcDiagramDataCollection(DataPointCollection): + """Collection of data points. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return ArcDiagramData \ No newline at end of file diff --git a/highcharts_core/options/series/data/bar.py b/highcharts_core/options/series/data/bar.py index 8c5b7bb3..5935c304 100644 --- a/highcharts_core/options/series/data/bar.py +++ b/highcharts_core/options/series/data/bar.py @@ -4,10 +4,15 @@ import datetime from validator_collection import validators, checkers +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive -from highcharts_core.options.series.data.cartesian import CartesianData +from highcharts_core.options.series.data.cartesian import CartesianData, CartesianDataCollection from highcharts_core.utility_classes.gradients import Gradient from highcharts_core.utility_classes.patterns import Pattern from highcharts_core.utility_classes.partial_fill import PartialFillOptions @@ -116,6 +121,16 @@ def point_width(self) -> Optional[int | float | Decimal]: def point_width(self, value): self._point_width = validators.numeric(value, allow_empty = True) + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return BarDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -174,6 +189,28 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class BarDataCollection(CartesianDataCollection): + """A collection of :class:`BarData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return BarData + + class WaterfallData(CartesianData): """Variant of :class:`CartesianData` which is used for data points in a waterfall chart.""" @@ -228,6 +265,16 @@ def is_sum(self, value): else: self._is_sum = bool(value) + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return WaterfallDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -282,6 +329,28 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class WaterfallDataCollection(CartesianDataCollection): + """A collection of :class:`BarData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return WaterfallData + + class WindBarbData(CartesianData): """Variant of :class:`CartesianData` which is used for data points in a windbarb chart.""" @@ -321,7 +390,15 @@ def value(self, value_): self._value = validators.numeric(value_, allow_empty = True) @classmethod - def from_array(cls, value): + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 3, 4] + + @classmethod + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -376,14 +453,35 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return WindBarbDataCollection.from_ndarray(value) + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['x', 'value', 'direction', 'y', 'name'] + prop_list = { + None: ['x', 'value', 'direction', 'y', 'name'], + 4: ['x', 'value', 'direction', 'y'], + 3: ['x', 'value', 'direction'] + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -487,6 +585,28 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class WindBarbDataCollection(CartesianDataCollection): + """A collection of :class:`BarData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return WindBarbData + + class XRangeData(CartesianData): """Variant of :class:`CartesianData` which is used for data points in an X-Range series.""" @@ -581,7 +701,7 @@ def x2(self, value): self._x2 = value @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -609,6 +729,16 @@ def from_array(cls, value): return collection + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return XRangeDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -663,3 +793,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: untrimmed[key] = parent_as_dict[key] return untrimmed + + +class XRangeDataCollection(CartesianDataCollection): + """A collection of :class:`XRangeData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return XRangeData diff --git a/highcharts_core/options/series/data/base.py b/highcharts_core/options/series/data/base.py index 6fd178f2..22813de1 100644 --- a/highcharts_core/options/series/data/base.py +++ b/highcharts_core/options/series/data/base.py @@ -1,11 +1,18 @@ from typing import Optional, List, Dict from decimal import Decimal +from collections import UserDict from validator_collection import validators, checkers +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False from highcharts_core import constants, errors, utility_functions -from highcharts_core.decorators import class_sensitive +from highcharts_core.decorators import class_sensitive, validate_types from highcharts_core.metaclasses import HighchartsMeta, JavaScriptDict +from highcharts_core.js_literal_functions import serialize_to_js_literal, assemble_js_literal, get_js_literal from highcharts_core.utility_classes.gradients import Gradient from highcharts_core.utility_classes.patterns import Pattern from highcharts_core.utility_classes.events import PointEvents @@ -304,14 +311,55 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed - def _get_props_from_array(self) -> List[str]: + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 2, 3] + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return [] + return cls._get_props_from_array_helper({}, length) + + @staticmethod + def _get_props_from_array_helper(prop_list, length = None) -> List[str]: + """Helper which adjusts the prop list to account for name. + + :param prop_list: List of properties + :type prop_list: :class:`list ` of :class:`str ` + + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + + :rtype: :class:`list ` of :class:`str ` + + """ + try: + return prop_list[length] + except KeyError as error: + try: + last_key = list(prop_list.keys())[-1] + except IndexError: + return prop_list.get(None, []) + if length == (last_key + 1) and prop_list[None][-1] == 'name': + return prop_list[None] + + raise error @property def requires_js_object(self) -> bool: @@ -336,8 +384,104 @@ def requires_js_object(self) -> bool: return False + def populate_from_array(self, value): + """Update the data point's properties with values provided by an array (iterable). + + This method is used to parse data that is input to **Highcharts for Python** + without property names, in an array-organized + structure as described in the `Highcharts JS `__ + documentation. + + .. seealso:: + + The specific structure of the expected array is highly dependent on the type + of data point that the series needs, which itself is dependent on the series + type itself. + + Please review the detailed :ref:`series documentation ` + for series type-specific details of relevant array structures. + + .. note:: + + An example of how this works for a simple + :class:`LineSeries ` (which + uses + :class:`CartesianData ` + data points) would be: + + .. code-block:: python + + my_data_point = CartesianData() + + # A simple array of numerical values which correspond to the Y value of the + # data point + my_data_point.populate_from_array([0, 0]) + my_data_point.populate_from_array([1, 5]) + my_data_point.populate_from_array([2, 3]) + my_data_point.populate_from_array([3, 5]) + + :param value: The value that should contain the data which will be converted into + data point property values. + + .. note:: + + If ``value`` is not an iterable, it will be converted into an iterable to be + further de-serialized correctly. + + :type value: iterable + + """ + if HAS_NUMPY: + is_ndarray = isinstance(value, np.ndarray) + else: + is_ndarray = False + + if not is_ndarray and not checkers.is_iterable(value, + forbid_literals = ( + str, + bytes, + dict, + UserDict + )): + value = [value] + + try: + properties = self._get_props_from_array(len(value)) + except KeyError: + full_properties = self._get_props_from_array() + if len(full_properties) == len(value): + properties = full_properties + else: + properties = [] + + if len(value) == 0: + value = [None for x in properties] + + if len(value) < len(properties): + value = value[:len(properties)] + + processed_x = False + processed_name = False + for index, prop in enumerate(properties): + if hasattr(value[index], 'item'): + item = value[index].item() + else: + item = value[index] + if HAS_NUMPY and not checkers.is_string(item) and np.isnan(item): + item = None + setattr(self, prop, item) + if prop == 'name' and item is not None: + processed_name = True + if prop == 'x': + processed_x = True + + if processed_x and not processed_name: + if not self.name and checkers.is_string(self.x): + self.name = self.x + self.x = None + @classmethod - def from_array(cls, value): + def from_list(cls, value): """Creates a collection of data point instances, parsing the contents of ``value`` as an array (iterable). This method is specifically used to parse data that is input to **Highcharts for Python** without property names, in an array-organized @@ -424,7 +568,8 @@ def from_array(cls, value): """ if not value: return [] - elif checkers.is_string(value): + + if checkers.is_string(value): try: value = validators.json(value) except (ValueError, TypeError): @@ -440,6 +585,22 @@ def from_array(cls, value): as_obj = cls.from_dict(item) elif item is None or isinstance(item, constants.EnforcedNullType): as_obj = cls() + elif checkers.is_iterable(item, forbid_literals = (str, + bytes, + dict, + UserDict)): + try: + array_props = cls._get_props_from_array(len(item)) + except KeyError: + raise errors.HighchartsValueError(f'each data point supplied must either ' + f'be a DataBase Data Point or be ' + f'coercable to one. Could not coerce: ' + f'{item}') + kwargs = {} + for index, prop in enumerate(array_props): + kwargs[prop] = item[index] + + as_obj = cls(**kwargs) else: raise errors.HighchartsValueError(f'each data point supplied must either ' f'be a DataBase Data Point or be ' @@ -449,6 +610,103 @@ def from_array(cls, value): return collection + @classmethod + def from_array(cls, value): + """Creates a collection of data point instances, parsing the contents of ``value`` + as an array (iterable). This method is specifically used to parse data that is + input to **Highcharts for Python** without property names, in an array-organized + structure as described in the `Highcharts JS `__ + documentation. + + .. seealso:: + + The specific structure of the expected array is highly dependent on the type + of data point that the series needs, which itself is dependent on the series + type itself. + + Please review the detailed :ref:`series documentation ` + for series type-specific details of relevant array structures. + + .. note:: + + An example of how this works for a simple + :class:`LineSeries ` (which + uses + :class:`CartesianData ` + data points) would be: + + .. code-block:: python + + my_series = LineSeries() + + # A simple array of numerical values which correspond to the Y value of the + # data point + my_series.data = [0, 5, 3, 5] + + # An array containing 2-member arrays (corresponding to the X and Y values + # of the data point) + my_series.data = [ + [0, 0], + [1, 5], + [2, 3], + [3, 5] + ] + + # An array of dict with named values + my_series.data = [ + { + 'x': 0, + 'y': 0, + 'name': 'Point1', + 'color': '#00FF00' + }, + { + 'x': 1, + 'y': 5, + 'name': 'Point2', + 'color': '#CCC' + }, + { + 'x': 2, + 'y': 3, + 'name': 'Point3', + 'color': '#999' + }, + { + 'x': 3, + 'y': 5, + 'name': 'Point4', + 'color': '#000' + } + ] + + :param value: The value that should contain the data which will be converted into + data point instances. + + .. note:: + + If ``value`` is not an iterable, it will be converted into an iterable to be + further de-serialized correctly. + + :type value: iterable + + :returns: Collection of :term:`data point` instances (descended from + :class:`DataBase `) + :rtype: :class:`list ` of + :class:`DataBase ` + descendant instances or + :class:`CartesianDataCollection ` + """ + if not utility_functions.is_ndarray(value) and not value: + return [] + elif utility_functions.is_ndarray(value): + return cls.from_ndarray(value) + + if checkers.is_type(value, 'DataPointCollection'): + return value + + return cls.from_list(value) + def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion of @@ -468,7 +726,85 @@ def to_array(self, force_object = False) -> List | Dict: :rtype: :class:`list ` of values or :class:`dict ` """ if self.requires_js_object or force_object: - return self._to_untrimmed_dict() + return self._to_untrimmed_dict(in_cls = self.__class__.__name__) + + props = self._get_props_from_array() + + if props and props[-1] == 'name': + props = props[:-1] return [getattr(self, x, constants.EnforcedNull) - for x in self._get_props_from_array()] + for x in props] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + from highcharts_core.options.series.data.collections import DataPointCollection + + return DataPointCollection.from_ndarray(value) + + def to_js_literal(self, + filename = None, + encoding = 'utf-8', + careful_validation = False) -> Optional[str]: + """Return the object represented as a :class:`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 ` + :type filename: Path-like + + :param encoding: The character encoding to apply to the resulting object. Defaults + to ``'utf-8'``. + :type encoding: :class:`str ` + + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + + :rtype: :class:`str ` or :obj:`None ` + """ + if filename: + filename = validators.path(filename) + + untrimmed = self.to_array() + if isinstance(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) + else: + serialized = serialize_to_js_literal(untrimmed, + careful_validation = careful_validation) + if isinstance(serialized, list): + as_str = ','.join([get_js_literal(x, careful_validation = careful_validation) + for x in serialized]) + as_str = f'[{as_str}]' + else: + as_str = serialized + + if filename: + with open(filename, 'w', encoding = encoding) as file_: + file_.write(as_str) + + return as_str diff --git a/highcharts_core/options/series/data/boxplot.py b/highcharts_core/options/series/data/boxplot.py index 55bf1591..b8d7177a 100644 --- a/highcharts_core/options/series/data/boxplot.py +++ b/highcharts_core/options/series/data/boxplot.py @@ -5,6 +5,7 @@ from highcharts_core import constants, errors from highcharts_core.options.series.data.cartesian import CartesianData +from highcharts_core.options.series.data.collections import DataPointCollection class BoxPlotData(CartesianData): @@ -234,7 +235,7 @@ def whisker_dash_style(self, value): self._whisker_dash_style = value @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -290,14 +291,43 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 5, 6] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return BoxPlotDataCollection.from_ndarray(value) + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['x', 'low', 'q1', 'median', 'q3', 'high', 'name'] + prop_list = { + None: ['x', 'low', 'q1', 'median', 'q3', 'high', 'name'], + 6: ['x', 'low', 'q1', 'median', 'q3', 'high'], + 5: ['low', 'q1', 'median', 'q3', 'high'], + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -421,3 +451,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: untrimmed[key] = parent_as_dict[key] return untrimmed + + +class BoxPlotDataCollection(DataPointCollection): + """A collection of :class:`BoxPlotData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return BoxPlotData diff --git a/highcharts_core/options/series/data/bullet.py b/highcharts_core/options/series/data/bullet.py index 01981a1a..2193b7e8 100644 --- a/highcharts_core/options/series/data/bullet.py +++ b/highcharts_core/options/series/data/bullet.py @@ -6,6 +6,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.bar import BarData +from highcharts_core.options.series.data.cartesian import CartesianDataCollection from highcharts_core.options.plot_options.bullet import TargetOptions @@ -49,7 +50,7 @@ def target_options(self, value): self._target_options = value @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -99,14 +100,35 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return BulletDataCollection.from_ndarray(value) + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['x', 'y', 'target', 'name'] + prop_list = { + None: ['x', 'y', 'target', 'name'], + 3: ['x', 'y', 'target'], + 2: ['y', 'target'], + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -207,3 +229,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: untrimmed[key] = parent_as_dict[key] return untrimmed + + +class BulletDataCollection(CartesianDataCollection): + """A collection of :class:`BulletData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return BulletData diff --git a/highcharts_core/options/series/data/cartesian.py b/highcharts_core/options/series/data/cartesian.py index b5b2c666..f2191dae 100644 --- a/highcharts_core/options/series/data/cartesian.py +++ b/highcharts_core/options/series/data/cartesian.py @@ -4,10 +4,17 @@ import datetime from validator_collection import validators, checkers +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False + from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel from highcharts_core.utility_classes.markers import Marker @@ -147,7 +154,7 @@ def y(self, value): self._y = validators.numeric(value) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -189,14 +196,43 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 2] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return CartesianDataCollection.from_ndarray(value) + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['x', 'y', 'name'] + prop_list = { + None: ['x', 'y', 'name'], + 1: ['y'], + 2: ['x', 'y'] + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -299,6 +335,28 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class CartesianDataCollection(DataPointCollection): + """A collection of :class:`CartesianData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return CartesianData + + class Cartesian3DData(CartesianData): """Variant of :class:`CartesianData` which supports three dimensions (an x, y, and z-axis).""" @@ -327,7 +385,7 @@ def z(self, value): self._z = validators.numeric(value) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -379,14 +437,43 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 2, 3] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return Cartesian3DDataCollection.from_ndarray(value) + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['x', 'y', 'z', 'name'] + prop_list = { + None: ['x', 'y', 'z', 'name'], + 3: ['x', 'y', 'z'], + 2: ['y', 'z'] + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -497,6 +584,28 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class Cartesian3DDataCollection(DataPointCollection): + """A collection of :class:`Cartesian3DData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return Cartesian3DData + + class CartesianValueData(CartesianData): """Variant of :class:`CartesianData` which supports three values (an ``x``, ``y``, and ``value``).""" @@ -538,7 +647,7 @@ def value(self, value_): self._value = validators.numeric(value_) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -590,14 +699,43 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 2, 3] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return CartesianValueDataCollection.from_ndarray(value) + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['x', 'y', 'value', 'name'] + prop_list = { + None: ['x', 'y', 'value', 'name'], + 3: ['x', 'y', 'value'], + 2: ['y', 'value'], + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -710,3 +848,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class CartesianValueDataCollection(CartesianDataCollection): + """A collection of :class:`Cartesian3DData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return CartesianValueData \ No newline at end of file diff --git a/highcharts_core/options/series/data/collections.py b/highcharts_core/options/series/data/collections.py new file mode 100644 index 00000000..78651477 --- /dev/null +++ b/highcharts_core/options/series/data/collections.py @@ -0,0 +1,882 @@ +from typing import Optional, List +from collections import UserDict + +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False + +from validator_collection import checkers, validators, errors as validator_errors + +from highcharts_core import constants, errors, utility_functions +from highcharts_core.decorators import validate_types +from highcharts_core.metaclasses import HighchartsMeta +from highcharts_core.js_literal_functions import serialize_to_js_literal, assemble_js_literal +from highcharts_core.options.series.data.base import DataBase + + +class DataPointCollection(HighchartsMeta): + """Collection of data points. + + This class stores numerical values that Highcharts can interpret + from a primitive array in a :class:`numpy.ndarray ` + (in the + :meth:`.ndarray ` + property) and non-numerical data point properties as Highcharts for Python + :class:`DataBase `-descended + objects (in the + :meth:`.data_points ` + property). + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + def __init__(self, **kwargs): + self._array = None + self._ndarray = None + self._data_points = None + + self.array = kwargs.get('array', None) + self.ndarray = kwargs.get('ndarray', None) + self.data_points = kwargs.get('data_points', None) + + def __getattr__(self, name): + """Facilitates the retrieval of a 1D array of values from the collection. + + The basic logic is as follows: + + 1. This method is automatically called when an attribute is not found in the + instance. + 2. It checks to see whether the attribute is a valid property of the data point + class. + 3. If it is, and NumPy is installed, it assembles the array and returns the + dimension indicated by the attribute name. If NumPy is not installed, it + returns a simple list with values as per the attribute name. + 4. If ``name`` is not a valid property of the data point class, then it + calls the ``super().__getattribute__()`` method to handle the attribute. + + :param name: The name of the attribute to retrieve. + :type name: :class:`str ` + + :returns: The value of the attribute. + + :raises AttributeError: If ``name`` is not a valid attribute of the data point + class or the instance. + """ + data_point_properties = self._get_props_from_array() + data_point_class = self._get_data_point_class() + + if name in ['_array', + 'array', + '_ndarray', + 'ndarray', + '_data_points', + 'data_points']: + return super().__getattr__(name) + + if name in data_point_properties and ( + self.ndarray is not None or self.array is not None + ): + if HAS_NUMPY and self.ndarray is not None and name in self.ndarray: + return self.ndarray[name] + + position = data_point_properties.index(name) + + try: + return [x[position] for x in self.array] + except (TypeError, IndexError): + raise AttributeError(name) + + data_points = self._assemble_data_points() + as_list = [getattr(x, name, None) for x in data_points] + + if HAS_NUMPY: + return np.asarray(as_list) + + return as_list + + def __setattr__(self, name, value): + """Updates the collected data values if ``name`` is a valid property of the + data point. + + The basic logic is as follows: + + 1. Check if ``name`` is a valid property of the data point class. + 2. If it is not, then call the ``super().__setattr__()`` method to handle + the attribute. End the method. + 3. If it is, then check whether the call requires merging into existing + data (as opposed to wholesale overwrite). + 4. If merging is required, check whether ``value`` is of the same length + as other existing data. If it is shorter, then pad it with empty values. + If it is longer, then raise an error. + 5. If NumPy is supported, then convert ``value`` to a NumPy array. Otherwise, + leave it as is. + 6. If NumPy is supported and an array is present, replace the corresponding + slice with the new value.Otherwise, reconstitute the resulting array with + new values. + 7. If no array is supported, then set the corresponding property on the data + points. + + """ + if name.startswith('_'): + super().__setattr__(name, value) + return + elif name in ['array', 'ndarray', 'data_points']: + super().__setattr__(name, value) + return + + data_point_properties = self._get_props_from_array() + + try: + has_ndarray = self.ndarray is not None + has_array = self.array is not None + has_data_points = self.data_points is not None + except AttributeError: + has_ndarray = False + has_array = False + has_data_points = False + + if name in data_point_properties and has_ndarray and name != 'name': + index = data_point_properties.index(name) + is_arraylike = utility_functions.is_arraylike(value) + + array_dict = self.ndarray.copy() + + # if value is not an array + if not is_arraylike: + value = np.full((self.ndarray_length, 1), value) + + extend_ndarray = len(value) > self.ndarray_length + extend_value = len(value) < self.ndarray_length + + # if value has more members (values) than the existing ndarray + if extend_ndarray: + for key in self.ndarray: + if key == name: + continue + array_dict[key] = utility_functions.lengthen_array(array_dict[key], + members = len(value)) + array_dict[name] = value + # if value has fewer members (values) than the existing ndarray + elif extend_value: + value = utility_functions.lengthen_array(value, + members = self.ndarray_length) + array_dict[name] = value + + self._ndarray = array_dict + elif name in data_point_properties and has_array and name != 'name': + index = data_point_properties.index(name) + is_arraylike = utility_functions.is_arraylike(value) + + # if value is not an array + if not is_arraylike: + value = [value for x in range(len(self.array))] + + if len(value) > len(self.array): + self.array.extend([[] for x in range(len(value) - len(self.array))]) + elif len(value) < len(self.array): + value.extend([None for x in range(len(self.array) - len(value))]) + + array = [] + for row_index, inner_array in enumerate(self.array): + revised_array = [x for x in inner_array] + revised_array = utility_functions.extend_columns(revised_array, + index + 1) + row_value = value[row_index] + if utility_functions.is_iterable(row_value): + revised_array[index] = row_value[index] + else: + revised_array[index] = row_value + + array.append(revised_array) + + self.array = array + elif name in data_point_properties and has_data_points: + is_arraylike = utility_functions.is_arraylike(value) + if not is_arraylike: + value = np.full((len(self.data_points), 1), value) + + if len(self.data_points) < len(value): + missing = len(value) - len(self.data_points) + for i in range(missing): + data_point_cls = self._get_data_point_class() + empty_data_point = data_point_cls() + self.data_points.append(empty_data_point) + + if len(value) < len(self.data_points): + value = utility_functions.lengthen_array(value, + members = len(self.data_points)) + + for i in range(len(self.data_points)): + if hasattr(value[i], 'item'): + checked_value = value[i].item() + else: + checked_value = value[i] + try: + setattr(self.data_points[i], name, checked_value) + except validator_errors.CannotCoerceError as error: + if isinstance(checked_value, str) and ',' in checked_value: + checked_value = checked_value.replace(',', '') + setattr(self.data_points[i], name, checked_value) + elif checkers.is_numeric(checked_value): + checked_value = str(checked_value) + setattr(self.data_points[i], name, checked_value) + else: + raise error + + elif name in data_point_properties and name == 'name': + index = data_point_properties.index(name) + + is_iterable = not isinstance(value, + (str, bytes, dict, UserDict)) and hasattr(value, + '__iter__') + if is_iterable: + as_list = [] + for i in range(len(value)): + if HAS_NUMPY: + if name != 'name' and data_point_properties[-1] == 'name': + inner_list = [np.nan for x in data_point_properties[:-1]] + else: + inner_list = [np.nan for x in data_point_properties] + else: + if name != 'name' and data_point_properties[-1] == 'name': + inner_list = [None for x in data_point_properties[:-1]] + else: + inner_list = [None for x in data_point_properties] + if index < len(inner_list): + inner_list[index] = value[i] + as_list.append(inner_list) + else: + if name != 'name' and data_point_properties[-1] == 'name': + as_list = [None for x in data_point_properties[:-1]] + else: + as_list = [None for x in data_point_properties] + as_list[index] = value + + if HAS_NUMPY: + self.ndarray = as_list + else: + self.array = as_list + elif utility_functions.is_arraylike(value): + if not has_data_points: + data_point_cls = self._get_data_point_class() + data_points = [data_point_cls() for x in value] + for index in range(len(data_points)): + try: + setattr(data_points[index], name, value[index]) + except validator_errors.CannotCoerceError: + if isinstance(value[index], str) and ',' in value[index]: + coerced_value = value[index].replace(',', '') + setattr(data_points[index], name, coerced_value) + elif checkers.is_numeric(value[index]) or ( + HAS_NUMPY and isinstance(value[index], np.number) + ): + coerced_value = str(value[index]) + setattr(data_points[index], name, coerced_value) + else: + raise errors.HighchartsValueError( + f'Unable to set {name} to {value[index]}. ' + f'If using a helper method, this is likely ' + f'due to mismatched columns. Please review ' + f'your input data.') + super().__setattr__('data_points', [x for x in data_points]) + elif len(value) <= len(self.data_points): + for index in range(len(value)): + setattr(self.data_points[index], name, value[index]) + else: + cut_off = len(self.data_points) + data_point_cls = self._get_data_point_class() + for index in range(cut_off): + setattr(self.data_points[index], name, value[index]) + for index in range(len(value[cut_off:])): + data_point = data_point_cls() + setattr(data_point, name, value[index]) + self.data_points.append(data_point) + elif name == 'name': + if not has_data_points: + data_point_cls = self._get_data_point_class() + if has_ndarray: + length = self.ndarray_length + elif has_array: + length = len(self.array) + else: + length = 1 + + data_points = [data_point_cls() for x in range(length)] + + for index in range(len(data_points)): + setattr(data_points[index], name, value) + + super().__setattr__('data_points', [x for x in data_points]) + else: + for index in range(len(value)): + setattr(self.data_points[index], name, value[index]) + else: + super().__setattr__(name, value) + + def __len__(self): + """Returns the number of data points in the collection. + + :rtype: :class:`int ` + """ + if self.ndarray is not None: + result = self.ndarray_length + elif self.array: + result = len(self.array) + elif self.data_points: + result = len(self.data_points) + else: + result = 0 + + return result + + def __iter__(self): + self._current_index = 0 + return iter(self.to_array(force_object = True)) + + def __next__(self): + if self._current_index < len(self): + x = self.to_array(force_object = True)[self._current_index] + self._current_index += 1 + return x + raise StopIteration + + def __bool__(self): + return len(self) > 0 + + @property + def array(self) -> Optional[List]: + """Primitive collection of values for data points in the collection. Used if + `NumPy `__ is not available. Defaults to + :obj:`None `. + + .. note:: + + If `NumPy `__ is availalbe, will instead behave as + an alias for + :meth:`.ndarray ` + + :rtype: :class:`list ` or :obj:`None ` + """ + return self._array + + @array.setter + def array(self, value): + if not value: + self._array = None + elif utility_functions.is_iterable(value): + self._array = [x for x in value] + else: + raise errors.HighchartsValueError(f'.array requires an iterable value. ' + f'Received: {value}') + + @property + def data_points(self) -> Optional[List[DataBase]]: + """The collection of data points for the series. Defaults to + :obj:`None `. + + :rtype: :class:`list ` of :class:`DataBase` or + :obj:`None ` + """ + return self._data_points + + @data_points.setter + def data_points(self, value): + if not value: + self._data_points = None + else: + validated = validate_types(value, + types = self._get_data_point_class(), + force_iterable = True) + if not checkers.is_iterable(validated, forbid_literals = (str, + bytes, + dict, + UserDict)): + validated = [validated] + + super().__setattr__('_data_points', validated) + + @property + def ndarray(self): + """A :class:`dict ` whose keys correspond to data point properties, + and whose values are :class:`numpy.ndarray ` instances that + contain the data point collection's values. + + :rtype: :class:`dict ` or :obj:`None ` + """ + return self._ndarray + + @ndarray.setter + def ndarray(self, value): + def raise_unsupported_dimension_error(length): + supported_dimensions = self._get_supported_dimensions() + + supported_as_str = ', '.join([str(x) for x in supported_dimensions[:-1]]) + supported_as_str += f', or {str(supported_dimensions[-1])}' + + raise errors.HighchartsValueError(f'{self.__name__} supports arrays with ' + f'{supported_as_str} dimensions. Received' + f' a value with: {length}') + + is_iterable = not isinstance(value, + (str, bytes, dict, UserDict)) and hasattr(value, + '__iter__') + if value is None: + self._ndarray = None + as_array = False + elif HAS_NUMPY and not isinstance(value, np.ndarray) and is_iterable: + length = len(value[0]) + for item in value: + if len(item) not in self._get_supported_dimensions(): + raise_unsupported_dimension_error(len(item)) + props_from_array = self._get_props_from_array(length = length) + as_dict = {} + for index, prop in enumerate(props_from_array): + prop_value = [x[index] for x in value] + as_dict[prop] = utility_functions.to_ndarray(prop_value) + + as_array = utility_functions.to_ndarray(value) + else: + as_array = value + + if HAS_NUMPY and isinstance(as_array, np.ndarray): + dimensions = as_array.ndim + supported_dimensions = self._get_supported_dimensions() + if dimensions not in supported_dimensions: + dimensions = as_array.ndim + 1 + if dimensions not in supported_dimensions: + raise_unsupported_dimension_error(dimensions) + props_from_array = self._get_props_from_array(length = dimensions) + if props_from_array and props_from_array[-1] != 'name': + props_from_array.append('name') + as_dict = {} + for index, prop in enumerate(props_from_array): + try: + as_dict[prop] = as_array[:, index] + except IndexError as error: + if index == len(props_from_array) - 1 and prop == 'name': + pass + else: + raise error + self._ndarray = as_dict + elif value is not None: + raise errors.HighchartsValueError(f'.ndarray expects a numpy.ndarray ' + f'or an iterable that can easily be ' + f'coerced to one. Received: ' + f'{value.__class__.__name__}') + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return DataBase + + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """The number of dimensions supported by the collection. + + :rtype: :class:`list ` of :class:`int ` + """ + dimensions = cls._get_data_point_class()._get_supported_dimensions() + last_dimension = dimensions[-1] + data_point_properties = cls._get_props_from_array() + if 'name' not in data_point_properties or len(data_point_properties) > last_dimension: + dimensions.append(last_dimension + 1) + + return dimensions + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: + """Returns a list of the property names that can be set using the + :meth:`.from_array() ` + method. + + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + + :rtype: :class:`list ` of :class:`str ` + """ + data_point_cls = cls._get_data_point_class() + + return data_point_cls._get_props_from_array(length) + + @classmethod + def from_array(cls, value): + """Creates a + :class:`DataPointCollection ` + instance from an array of values. + + :param value: The value that should contain the data which will be converted into + data point instances. + :type value: iterable or :class:`numpy.ndarray ` + + :returns: A single-object collection of data points. + :rtype: :class:`DataPointCollection ` + or :obj:`None ` + + :raises HighchartsDependencyError: if `NumPy `__ is not installed + """ + if HAS_NUMPY and isinstance(value, np.ndarray) and value.dtype != np.dtype('O'): + return cls.from_ndarray(value) + elif HAS_NUMPY and isinstance(value, np.ndarray): + as_list = value.tolist() + else: + as_list = value + + data_points = cls._get_data_point_class().from_array(as_list) + + return cls(data_points = data_points) + + @classmethod + def from_ndarray(cls, value): + """Creates a + :class:`DataPointCollection ` + instance from an array of values. + + :param value: The value that should contain the data which will be converted into + data point instances. + :type value: :class:`numpy.ndarray ` + + :returns: A single-object collection of data points. + :rtype: :class:`DataPointCollection ` + or :obj:`None ` + + :raises HighchartsDependencyError: if `NumPy `__ is not installed + """ + if not HAS_NUMPY: + raise errors.HighchartsDependencyError('DataPointCollection requires NumPy ' + 'be installed. The runtime ' + 'environment does not currently have ' + 'NumPy installed. Please use the data ' + 'point pattern instead, or install NumPy' + ' using "pip install numpy" or similar.') + + if not isinstance(value, np.ndarray): + raise errors.HighchartsValueError(f'Expected a NumPy ndarray instance, but ' + f'received: {value.__class__.__name__}') + + if value.dtype == np.dtype('O'): + return cls.from_array(value.tolist()) + + return cls(ndarray = value) + + @property + def requires_js_object(self) -> bool: + """Indicates whether or not the data point *must* be serialized to a JS literal + object or whether it can be serialized to a primitive array. + + :returns: ``True`` if the data point *must* be serialized to a JS literal object. + ``False`` if it can be serialized to an array. + :rtype: :class:`bool ` + """ + if not self.data_points: + return False + + from_array_props = [utility_functions.to_camelCase(x) + for x in self._get_props_from_array()] + + data_points_as_dict = [x.to_dict() for x in self.data_points] + for data_point in data_points_as_dict: + for prop in from_array_props: + if prop in data_point: + del data_point[prop] + + data_points = sum([1 for x in data_points_as_dict if x]) + + if data_points: + return True + + return False + + @property + def ndarray_length(self) -> int: + """The length of the array stored in + :meth:`.ndarray `. + + :rtype: :class:`int ` + """ + if not self.ndarray: + return 0 + + return len(self.ndarray[list(self.ndarray.keys())[0]]) + + def _assemble_data_points(self): + """Assemble a collection of + :class:`DataBase `-descended + objects from the provided data. The algorithm should be as follows: + + 1. Take any data points provided in the + :meth:`.data_points ` property. + + 2. If the + :meth:`.ndarray ` + is empty, return the data points as-is. + + 3. Strip the data points of properties from the + :meth:`._get_props_from_array() ` + method. + + 4. Populate the data points with property values from + :meth:`.ndarray `. + + 5. Return the re-hydrated data points. + + :rtype: :class:`list ` of + :class:`DataBase ` + instances. + + """ + if self.data_points is not None: + data_points = [x for x in self.data_points] + else: + data_points = [] + + if self.ndarray is None and not self.array: + return data_points + + for index, data_point in enumerate(data_points): + for prop in self._get_props_from_array(): + if getattr(data_point, prop) is not None: + setattr(data_points[index], prop, None) + + if HAS_NUMPY and self.ndarray is not None: + if len(data_points) < self.ndarray_length: + missing = self.ndarray_length - len(data_points) + for i in range(missing): + data_points.append(self._get_data_point_class()()) + + for index in range(self.ndarray_length): + inner_list = [self.ndarray[key][index] for key in self.ndarray] + data_points[index].populate_from_array(inner_list) + else: + if len(data_points) < len(self.array): + missing = len(self.array) - len(data_points) + for i in range(missing): + data_points.append(self._get_data_point_class()()) + + for index in range(len(self.array)): + array = self.array[index] + data_points[index].populate_from_array(array) + + return data_points + + def _assemble_ndarray(self): + """Assemble a :class:`numpy.ndarray ` from the contents + of + :meth:`.data_points `. + + .. warning:: + + This method will *ignore* properties that Highcharts (JS) cannot support in a + primitive nested array structure. + + :returns: A :class:`numpy.ndarray ` of the data points. + :rtype: :class:`numpy.ndarray ` + + """ + if not self.data_points: + return np.ndarray.empty() + + props = self._get_props_from_array() + if props and props[-1] == 'name': + props = props[:-1] + + as_list = [[getattr(data_point, x, constants.EnforcedNull) + for x in props] + for data_point in self.data_points] + + return utility_functions.to_ndarray(as_list) + + def to_array(self, force_object = False, force_ndarray = False) -> List: + """Generate the array representation of the data points (the inversion + of + :meth:`.from_array() `). + + .. warning:: + + If any data points *cannot* be serialized to a JavaScript array, + this method will instead return the untrimmed :class:`dict ` + representation of the data points as a fallback. + + :param force_object: if ``True``, forces the return of the instance's + untrimmed :class:`dict ` representation. Defaults to ``False``. + + .. warning:: + + Values in + :meth:`.ndarray ` + are *ignored* within this operation in favor of data points stored in + :meth:`.data_points `. + + However, if there are no data points in + :meth:`.data_points ` + then data point objects will be assembled based on + :meth:`.ndarray `. + + :type force_object: :class:`bool ` + + :param force_ndarray: if ``True``, forces the return of the instance's + data points as a :class:`numpy.ndarray `. Defaults to + ``False``. + + .. warning:: + + Properties of any + :meth:`.data_points ` + are *ignored* within this operation if + :meth:`.ndarray ` + is populated. + + However, if + :meth:`.ndarray ` + is not populated, then a :class:`numpy.ndarray ` will + be assembled from values in + :meth:`.data_points ` + (ignoring properties that Highcharts (JS) cannot interpret as a primitive array). + + :type force_ndarray: :class:`bool ` + + :raises HighchartsValueError: if both `force_object` and `force_ndarray` are + ``True`` + + :returns: The array representation of the data point collection. + :rtype: :class:`list ` + """ + if force_object and force_ndarray: + raise errors.HighchartsValueError('force_object and force_ndarray cannot ' + 'both be True') + + if self.ndarray is None and not self.array and not self.data_points: + return [] + + if force_object and self.data_points and not self.array: + return [x for x in self.data_points] + elif force_object and self.ndarray is not None: + return [x for x in self._assemble_data_points()] + elif force_object and self.array is not None: + return [x for x in self._assemble_data_points()] + + if force_ndarray and not HAS_NUMPY: + raise errors.HighchartsDependencyError('Cannot force ndarray if NumPy is ' + 'not available in the runtime ' + 'environment. Please install using ' + '"pip install numpy" or similar.') + elif force_ndarray and self.ndarray is not None: + return utility_functions.from_ndarray(self.ndarray) + elif force_ndarray and self.data_points: + as_ndarray = self._assemble_ndarray() + return utility_functions.from_ndarray(as_ndarray) + + if self.ndarray is not None and not self.requires_js_object: + as_list = [] + columns = [] + for key in self.ndarray: + value = self.ndarray[key] + if utility_functions.is_ndarray(value): + columns.append(utility_functions.from_ndarray(value)) + else: + columns.append(value) + as_list = [list(x) for x in zip(*columns)] + + return as_list + elif self.array is not None and not self.requires_js_object: + return [x for x in self.array] + + if not self.array and self.data_points: + return [x for x in self.data_points] + + return [x for x in self._assemble_data_points()] + + @classmethod + 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 ` object. + + :param as_dict: The HighCharts JS compatible :class:`dict ` + representation of the object. + :type as_dict: :class:`dict ` + + :returns: The keyword arguments that would be used to initialize an instance. + :rtype: :class:`dict ` + + """ + kwargs = { + 'array': as_dict.get('array', None), + 'ndarray': as_dict.get('ndarray', None), + 'data_points': as_dict.get('dataPoints', None), + } + + return kwargs + + def _to_untrimmed_dict(self, in_cls = None) -> dict: + untrimmed = { + 'array': self.array, + 'ndarray': self.ndarray, + 'dataPoints': self.data_points, + } + + return untrimmed + + def to_js_literal(self, + filename = None, + encoding = 'utf-8', + careful_validation = False) -> Optional[str]: + """Return the object represented as a :class:`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 ` + :type filename: Path-like + + :param encoding: The character encoding to apply to the resulting object. Defaults + to ``'utf-8'``. + :type encoding: :class:`str ` + + :param careful_validation: if ``True``, will carefully validate JavaScript values + along the way using the + `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 ` + + :rtype: :class:`str ` or :obj:`None ` + """ + if filename: + filename = validators.path(filename) + + untrimmed = self.to_array() + is_ndarray = all([isinstance(x, list) for x in untrimmed]) + if not is_ndarray: + as_str = '[' + as_str += ','.join([x.to_js_literal(encoding = encoding, + careful_validation = careful_validation) + for x in untrimmed]) + as_str += ']' + else: + serialized = serialize_to_js_literal(untrimmed, + encoding = encoding, + careful_validation = careful_validation) + as_str = serialized + + if filename: + with open(filename, 'w', encoding = encoding) as file_: + file_.write(as_str) + + return as_str diff --git a/highcharts_core/options/series/data/connections.py b/highcharts_core/options/series/data/connections.py index c22e3035..628b7aaa 100644 --- a/highcharts_core/options/series/data/connections.py +++ b/highcharts_core/options/series/data/connections.py @@ -6,6 +6,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel @@ -47,7 +48,7 @@ def to(self, value): self._to = validators.string(value, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -177,7 +178,7 @@ def drag_drop(self, value): self._drag_drop = value @classmethod - def from_array(cls, value): + def from_list(cls, value): """Generator method which produces a collection of :class:`ConnectionData` instances derived from ``value``. Generally consumed by the setter methods in series-type specific data classes. @@ -214,15 +215,43 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 2] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return ConnectionDataCollection.from_ndarray(value) + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['from_', - 'to'] + prop_list = { + None: ['from_', 'to'], + 2: ['from_', 'to'] + } + + return cls._get_props_from_array_helper(prop_list, length) @classmethod def _get_kwargs_from_dict(cls, as_dict): @@ -273,6 +302,16 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class ConnectionDataCollection(DataPointCollection): + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return ConnectionData + + class WeightedConnectionData(ConnectionData): """Variant of :class:`ConnectionData` that also applies a ``weight`` to the connection.""" @@ -297,7 +336,7 @@ def weight(self, value): self._weight = validators.numeric(value, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): """Generator method which produces a collection of :class:`ConnectionData` instances derived from ``value``. Generally consumed by the setter methods in series-type specific data classes. @@ -331,14 +370,40 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return WeightedConnectionDataCollection.from_ndarray(value) + + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1] + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return [] + prop_list = {} + + return cls._get_props_from_array_helper(prop_list, length) @classmethod def _get_kwargs_from_dict(cls, as_dict): @@ -404,6 +469,16 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class WeightedConnectionDataCollection(DataPointCollection): + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return WeightedConnectionData + + class OutgoingWeightedConnectionData(WeightedConnectionData): """Variant of :class:`WeightedConnectionData` that supports the ``outoging`` flag.""" @@ -431,7 +506,7 @@ def outgoing(self, value): self._outgoing = bool(value) @classmethod - def from_array(cls, value): + def from_list(cls, value): """Generator method which produces a collection of :class:`OutgoingWeightedConnectionData` instances derived from ``value``. Generally consumed by the setter methods in series-type specific data classes. @@ -466,6 +541,16 @@ def from_array(cls, value): return collection + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return OutgoingWeightedConnectionDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -516,3 +601,13 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: untrimmed[key] = parent_as_dict[key] return untrimmed + + +class OutgoingWeightedConnectionDataCollection(DataPointCollection): + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return OutgoingWeightedConnectionData diff --git a/highcharts_core/options/series/data/pie.py b/highcharts_core/options/series/data/pie.py index 8966312c..de83bd1a 100644 --- a/highcharts_core/options/series/data/pie.py +++ b/highcharts_core/options/series/data/pie.py @@ -5,7 +5,8 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive -from highcharts_core.options.series.data.single_point import SinglePointData +from highcharts_core.options.series.data.single_point import SinglePointData, SinglePointDataCollection +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel @@ -83,6 +84,38 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return PieDataCollection.from_ndarray(value) + + +class PieDataCollection(SinglePointDataCollection): + """A collection of :class:`PieData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return PieData + class VariablePieData(PieData): """Variant of :class:`PieData` suited for :class:`VariablePieSeries`.""" @@ -113,7 +146,7 @@ def z(self, value): self._z = validators.numeric(value) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -145,6 +178,16 @@ def from_array(cls, value): return collection + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return VariablePieDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -192,3 +235,26 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: untrimmed[key] = parent_as_dict[key] return untrimmed + + +class VariablePieDataCollection(DataPointCollection): + """A collection of :class:`VariablePieData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return VariablePieData + diff --git a/highcharts_core/options/series/data/range.py b/highcharts_core/options/series/data/range.py index 2f7e8b8e..6303cb44 100644 --- a/highcharts_core/options/series/data/range.py +++ b/highcharts_core/options/series/data/range.py @@ -8,7 +8,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.base import DataBase -from highcharts_core.options.series.data.cartesian import CartesianData +from highcharts_core.options.series.data.cartesian import CartesianData, CartesianDataCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel from highcharts_core.utility_classes.markers import Marker @@ -159,7 +159,7 @@ def x(self, value): self._x = value @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -216,14 +216,43 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return RangeDataCollection.from_ndarray(value) + + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 2, 3] + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['x', 'low', 'high', 'name'] + prop_list = { + None: ['x', 'low', 'high', 'name'], + 3: ['x', 'low', 'high'], + 2: ['low', 'high'], + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -333,6 +362,28 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class RangeDataCollection(CartesianDataCollection): + """A collection of :class:`RangeData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return RangeData + + class ConnectedRangeData(CartesianData): """Variant of :class:`CartesianData` which extends the class with connector attributes.""" @@ -389,6 +440,16 @@ def low_color(self, value): from highcharts_core import utility_functions self._low_color = utility_functions.validate_color(value) + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return ConnectedRangeDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -443,3 +504,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: untrimmed[key] = parent_as_dict[key] return untrimmed + + +class ConnectedRangeDataCollection(CartesianDataCollection): + """A collection of :class:`ConnectedRangeData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return ConnectedRangeData diff --git a/highcharts_core/options/series/data/single_point.py b/highcharts_core/options/series/data/single_point.py index 5253ed11..2128f0f4 100644 --- a/highcharts_core/options/series/data/single_point.py +++ b/highcharts_core/options/series/data/single_point.py @@ -7,6 +7,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel @@ -161,7 +162,7 @@ def y(self, value): self._y = validators.numeric(value) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -197,14 +198,43 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return SinglePointDataCollection.from_ndarray(value) + + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 2] + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['y', 'name'] + prop_list = { + None: ['y', 'name'], + 2: ['y', 'name'], + 1: ['y'], + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -304,6 +334,16 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class SinglePointDataCollection(DataPointCollection): + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return SinglePointData + + class SingleValueData(SinglePointBase): """Data point that features a single and ``value`` value.""" @@ -331,7 +371,7 @@ def value(self, value_): self._value = validators.numeric(value_) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -361,7 +401,26 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return SingleValueDataCollection.from_ndarray(value) + + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1] + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. @@ -459,6 +518,16 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class SingleValueDataCollection(DataPointCollection): + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return SingleValueData + + class SingleXData(SinglePointBase): """Data point that features a single labeled ``x`` value.""" @@ -486,7 +555,7 @@ def x(self, value): self._x = validators.numeric(value) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -516,11 +585,35 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return SingleXDataCollection.from_ndarray(value) + + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1] + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ return ['x'] @@ -615,6 +708,16 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class SingleXDataCollection(DataPointCollection): + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return SingleXData + + class LabeledSingleXData(SingleXData): """Data point that features a single labeled ``x`` value.""" @@ -638,7 +741,7 @@ def label(self, value): self._label = validators.string(value, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -668,6 +771,16 @@ def from_array(cls, value): return collection + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return LabeledSingleXDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -733,6 +846,16 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: return untrimmed +class LabeledSingleXDataCollection(DataPointCollection): + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return LabeledSingleXData + + class ConnectedSingleXData(SingleXData): """Variant of :class:`SingleXData` which supports a connector.""" @@ -773,7 +896,7 @@ def connector_width(self, value): self._connector_width = validators.numeric(value, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -803,6 +926,16 @@ def from_array(cls, value): return collection + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return ConnectedSingleXDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -868,3 +1001,15 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class ConnectedSingleXDataCollection(DataPointCollection): + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return ConnectedSingleXData + + diff --git a/highcharts_core/options/series/data/sunburst.py b/highcharts_core/options/series/data/sunburst.py index 1ee63df9..3b04739e 100644 --- a/highcharts_core/options/series/data/sunburst.py +++ b/highcharts_core/options/series/data/sunburst.py @@ -5,6 +5,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.treemap import TreemapData +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.utility_classes.markers import Marker @@ -54,7 +55,7 @@ def sliced(self, value): self._sliced = bool(value) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -83,6 +84,16 @@ def from_array(cls, value): return collection + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return SunburstDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -148,3 +159,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class SunburstDataCollection(DataPointCollection): + """A collection of :class:`SunburstData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return SunburstData diff --git a/highcharts_core/options/series/data/treegraph.py b/highcharts_core/options/series/data/treegraph.py index fe4d8d43..c3b11d87 100644 --- a/highcharts_core/options/series/data/treegraph.py +++ b/highcharts_core/options/series/data/treegraph.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List from decimal import Decimal from validator_collection import validators, checkers @@ -6,6 +6,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel from highcharts_core.utility_classes.buttons import CollapseButtonConfiguration @@ -130,7 +131,7 @@ def parent(self, value): self._parent = validators.string(value, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -161,6 +162,24 @@ def from_array(cls, value): return collection + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return TreegraphDataCollection.from_ndarray(value) + + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1] + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -221,3 +240,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class TreegraphDataCollection(DataPointCollection): + """A collection of :class:`TreegraphData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return TreegraphData \ No newline at end of file diff --git a/highcharts_core/options/series/data/treemap.py b/highcharts_core/options/series/data/treemap.py index 4d4dd9c9..9dc1b859 100644 --- a/highcharts_core/options/series/data/treemap.py +++ b/highcharts_core/options/series/data/treemap.py @@ -6,6 +6,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel @@ -124,7 +125,7 @@ def value(self, value_): self._value = validators.numeric(value_) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -153,6 +154,16 @@ def from_array(cls, value): return collection + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return TreemapDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -212,3 +223,26 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class TreemapDataCollection(DataPointCollection): + """A collection of :class:`TreemapData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return TreemapData + diff --git a/highcharts_core/options/series/data/vector.py b/highcharts_core/options/series/data/vector.py index 27f13531..3e5f147f 100644 --- a/highcharts_core/options/series/data/vector.py +++ b/highcharts_core/options/series/data/vector.py @@ -5,6 +5,7 @@ from highcharts_core import constants, errors from highcharts_core.options.series.data.cartesian import CartesianData +from highcharts_core.options.series.data.collections import DataPointCollection class VectorData(CartesianData): @@ -51,7 +52,7 @@ def length(self, value): self._length = validators.numeric(value, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -93,14 +94,43 @@ def from_array(cls, value): return collection - def _get_props_from_array(self) -> List[str]: + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1, 3, 4] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return VectorDataCollection.from_ndarray(value) + + @classmethod + def _get_props_from_array(cls, length = None) -> List[str]: """Returns a list of the property names that can be set using the :meth:`.from_array() ` method. + :param length: The length of the array, which may determine the properties to + parse. Defaults to :obj:`None `, which returns the full list of + properties. + :type length: :class:`int ` or :obj:`None ` + :rtype: :class:`list ` of :class:`str ` """ - return ['x', 'y', 'length', 'direction', 'name'] + prop_list = { + None: ['x', 'y', 'length', 'direction', 'name'], + 4: ['x', 'y', 'length', 'direction'], + 3: ['y', 'length', 'direction'], + } + return cls._get_props_from_array_helper(prop_list, length) def to_array(self, force_object = False) -> List | Dict: """Generate the array representation of the data point (the inversion @@ -215,3 +245,27 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class VectorDataCollection(DataPointCollection): + """A collection of :class:`VectorData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return VectorData + + diff --git a/highcharts_core/options/series/data/venn.py b/highcharts_core/options/series/data/venn.py index 46977883..1780973b 100644 --- a/highcharts_core/options/series/data/venn.py +++ b/highcharts_core/options/series/data/venn.py @@ -6,6 +6,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel @@ -113,7 +114,7 @@ def value(self, value_): self._value = validators.numeric(value_, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -141,6 +142,24 @@ def from_array(cls, value): return collection + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return VennDataCollection.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -201,3 +220,25 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class VennDataCollection(DataPointCollection): + """A collection of :class:`VennData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return VennData diff --git a/highcharts_core/options/series/data/wordcloud.py b/highcharts_core/options/series/data/wordcloud.py index 4d38810a..5b023f26 100644 --- a/highcharts_core/options/series/data/wordcloud.py +++ b/highcharts_core/options/series/data/wordcloud.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, List from decimal import Decimal from validator_collection import validators, checkers @@ -6,6 +6,7 @@ from highcharts_core import constants, errors from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.data.base import DataBase +from highcharts_core.options.series.data.collections import DataPointCollection from highcharts_core.options.plot_options.drag_drop import DragDropOptions from highcharts_core.utility_classes.data_labels import DataLabel @@ -76,7 +77,7 @@ def weight(self, value): self._weight = validators.numeric(value, allow_empty = True) @classmethod - def from_array(cls, value): + def from_list(cls, value): if not value: return [] elif checkers.is_string(value): @@ -104,6 +105,24 @@ def from_array(cls, value): return collection + @classmethod + def _get_supported_dimensions(cls) -> List[int]: + """Returns a list of the supported dimensions for the data point. + + :rtype: :class:`list ` of :class:`int ` + """ + return [1] + + @classmethod + def from_ndarray(cls, value): + """Creates a collection of data points from a `NumPy `__ + :class:`ndarray ` instance. + + :returns: A collection of data point values. + :rtype: :class:`DataPointCollection ` + """ + return WordcloudData.from_ndarray(value) + @classmethod def _get_kwargs_from_dict(cls, as_dict): """Convenience method which returns the keyword arguments used to initialize the @@ -161,3 +180,27 @@ def _to_untrimmed_dict(self, in_cls = None) -> dict: } return untrimmed + + +class WordcloudDataCollection(DataPointCollection): + """A collection of :class:`WordcloudData` objects. + + .. note:: + + When serializing to JS literals, if possible, the collection is serialized to a primitive + array to boost performance within Python *and* JavaScript. However, this may not always be + possible if data points have non-array-compliant properties configured (e.g. adjusting their + style, names, identifiers, etc.). If serializing to a primitive array is not possible, the + results are serialized as JS literal objects. + + """ + + @classmethod + def _get_data_point_class(cls): + """The Python class to use as the underlying data point within the Collection. + + :rtype: class object + """ + return WordcloudData + + diff --git a/highcharts_core/options/series/dependencywheel.py b/highcharts_core/options/series/dependencywheel.py index 0b824e58..bdda7783 100644 --- a/highcharts_core/options/series/dependencywheel.py +++ b/highcharts_core/options/series/dependencywheel.py @@ -2,9 +2,9 @@ from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.connections import WeightedConnectionData +from highcharts_core.options.series.data.connections import WeightedConnectionData, WeightedConnectionDataCollection from highcharts_core.options.plot_options.dependencywheel import DependencyWheelOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray from highcharts_core.utility_classes.nodes import DependencyWheelNodeOptions @@ -27,8 +27,26 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return WeightedConnectionDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return WeightedConnectionData + @property - def data(self) -> Optional[List[WeightedConnectionData]]: + def data(self) -> Optional[List[WeightedConnectionData] | WeightedConnectionDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -44,13 +62,14 @@ def data(self) -> Optional[List[WeightedConnectionData]]: :class:`WeightedConnectionData` instances. :rtype: :class:`list ` of :class:`WeightedConnectionData` or + :class:`WeightedConnectionDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = WeightedConnectionData.from_array(value) diff --git a/highcharts_core/options/series/dumbbell.py b/highcharts_core/options/series/dumbbell.py index 3178aa82..28dceb7b 100644 --- a/highcharts_core/options/series/dumbbell.py +++ b/highcharts_core/options/series/dumbbell.py @@ -2,10 +2,10 @@ from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.range import ConnectedRangeData +from highcharts_core.options.series.data.range import ConnectedRangeData, ConnectedRangeDataCollection from highcharts_core.options.plot_options.dumbbell import LollipopOptions, DumbbellOptions from highcharts_core.options.plot_options.drag_drop import HighLowDragDropOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class DumbbellSeries(SeriesBase, DumbbellOptions): @@ -23,8 +23,26 @@ class DumbbellSeries(SeriesBase, DumbbellOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return ConnectedRangeDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return ConnectedRangeData + @property - def data(self) -> Optional[List[ConnectedRangeData]]: + def data(self) -> Optional[List[ConnectedRangeData] | ConnectedRangeDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -93,13 +111,14 @@ def data(self) -> Optional[List[ConnectedRangeData]]: A one-dimensional collection of :class:`ConnectedRangeData` objects. :rtype: :class:`list ` of :class:`ConnectedRangeData` or + :class:`ConnectedRangeDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = ConnectedRangeData.from_array(value) diff --git a/highcharts_core/options/series/funnel.py b/highcharts_core/options/series/funnel.py index a5d15f07..f54d31a9 100644 --- a/highcharts_core/options/series/funnel.py +++ b/highcharts_core/options/series/funnel.py @@ -2,8 +2,8 @@ from highcharts_core.options.series.pie import PieSeries from highcharts_core.options.plot_options.funnel import FunnelOptions, Funnel3DOptions -from highcharts_core.options.series.data.single_point import SinglePointData -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.options.series.data.single_point import SinglePointData, SinglePointDataCollection +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class FunnelSeries(PieSeries, FunnelOptions): @@ -25,8 +25,26 @@ class FunnelSeries(PieSeries, FunnelOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return SinglePointDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return SinglePointData + @property - def data(self) -> Optional[List[SinglePointData]]: + def data(self) -> Optional[List[SinglePointData] | SinglePointDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -50,13 +68,14 @@ def data(self) -> Optional[List[SinglePointData]]: A one-dimensional collection of :class:`SinglePointData` objects. :rtype: :class:`list ` of :class:`SinglePointData` or + :class:`SinglePointDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = SinglePointData.from_array(value) @@ -189,8 +208,26 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return SinglePointDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return SinglePointData + @property - def data(self) -> Optional[List[SinglePointData]]: + def data(self) -> Optional[List[SinglePointData] | SinglePointDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -214,13 +251,14 @@ def data(self) -> Optional[List[SinglePointData]]: A one-dimensional collection of :class:`SinglePointData` objects. :rtype: :class:`list ` of :class:`SinglePointData` or + :class:`SinglePointDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = SinglePointData.from_array(value) diff --git a/highcharts_core/options/series/gauge.py b/highcharts_core/options/series/gauge.py index b7d1bbb1..970d0dfd 100644 --- a/highcharts_core/options/series/gauge.py +++ b/highcharts_core/options/series/gauge.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.single_point import SinglePointData +from highcharts_core.options.series.data.single_point import SinglePointData, SinglePointDataCollection from highcharts_core.options.plot_options.gauge import GaugeOptions, SolidGaugeOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class GaugeSeries(SeriesBase, GaugeOptions): @@ -21,8 +21,26 @@ class GaugeSeries(SeriesBase, GaugeOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return SinglePointDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return SinglePointData + @property - def data(self) -> Optional[List[SinglePointData]]: + def data(self) -> Optional[List[SinglePointData] | SinglePointDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -46,13 +64,14 @@ def data(self) -> Optional[List[SinglePointData]]: A one-dimensional collection of :class:`SinglePointData` objects. :rtype: :class:`list ` of :class:`SinglePointData` or + :class:`SinglePointDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = SinglePointData.from_array(value) @@ -163,8 +182,26 @@ class SolidGaugeSeries(SeriesBase, SolidGaugeOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return SinglePointDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return SinglePointData + @property - def data(self) -> Optional[List[SinglePointData]]: + def data(self) -> Optional[List[SinglePointData] | SinglePointDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -188,13 +225,14 @@ def data(self) -> Optional[List[SinglePointData]]: A one-dimensional collection of :class:`SinglePointData` objects. :rtype: :class:`list ` of :class:`SinglePointData` or + :class:`SinglePointDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = SinglePointData.from_array(value) diff --git a/highcharts_core/options/series/heatmap.py b/highcharts_core/options/series/heatmap.py index 7c78f2fa..db7c7bba 100644 --- a/highcharts_core/options/series/heatmap.py +++ b/highcharts_core/options/series/heatmap.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.cartesian import CartesianValueData +from highcharts_core.options.series.data.cartesian import CartesianValueData, CartesianValueDataCollection from highcharts_core.options.plot_options.heatmap import HeatmapOptions, TilemapOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class HeatmapSeries(SeriesBase, HeatmapOptions): @@ -25,8 +25,26 @@ class HeatmapSeries(SeriesBase, HeatmapOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return CartesianValueDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return CartesianValueData + @property - def data(self) -> Optional[List[CartesianValueData]]: + def data(self) -> Optional[List[CartesianValueData] | CartesianValueDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -94,13 +112,14 @@ def data(self) -> Optional[List[CartesianValueData]]: A one-dimensional collection of :class:`CartesianValueData` objects. :rtype: :class:`list ` of :class:`CartesianValueData` or + :class:`CartesianValueDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = CartesianValueData.from_array(value) diff --git a/highcharts_core/options/series/item.py b/highcharts_core/options/series/item.py index ce1400db..23965c35 100644 --- a/highcharts_core/options/series/item.py +++ b/highcharts_core/options/series/item.py @@ -2,8 +2,8 @@ from highcharts_core.options.series.pie import PieSeries from highcharts_core.options.plot_options.item import ItemOptions -from highcharts_core.options.series.data.single_point import SinglePointData -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.options.series.data.single_point import SinglePointData, SinglePointDataCollection +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class ItemSeries(PieSeries, ItemOptions): @@ -42,8 +42,26 @@ class ItemSeries(PieSeries, ItemOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return SinglePointDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return SinglePointData + @property - def data(self) -> Optional[List[SinglePointData]]: + def data(self) -> Optional[List[SinglePointData] | SinglePointDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -67,13 +85,14 @@ def data(self) -> Optional[List[SinglePointData]]: A one-dimensional collection of :class:`SinglePointData` objects. :rtype: :class:`list ` of :class:`SinglePointData` or + :class:`SinglePointDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = SinglePointData.from_array(value) diff --git a/highcharts_core/options/series/networkgraph.py b/highcharts_core/options/series/networkgraph.py index a982f94d..3e13d037 100644 --- a/highcharts_core/options/series/networkgraph.py +++ b/highcharts_core/options/series/networkgraph.py @@ -2,9 +2,9 @@ from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.connections import ConnectionData +from highcharts_core.options.series.data.connections import ConnectionData, ConnectionDataCollection from highcharts_core.options.plot_options.networkgraph import NetworkGraphOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class NetworkGraphSeries(SeriesBase, NetworkGraphOptions): @@ -22,8 +22,26 @@ class NetworkGraphSeries(SeriesBase, NetworkGraphOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return ConnectionDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return ConnectionData + @property - def data(self) -> Optional[List[ConnectionData]]: + def data(self) -> Optional[List[ConnectionData] | ConnectionDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -37,14 +55,17 @@ def data(self) -> Optional[List[ConnectionData]]: instances coercable to a :class:`ConnectionData` instance. :rtype: :class:`list ` of :class:`ConnectionData` or + :class:`ConnectionDataCollection` or :obj:`None ` """ return self._data @data.setter - @class_sensitive(ConnectionData, force_iterable = True) def data(self, value): - self._data = value + if not is_ndarray(value) and not value: + self._data = None + else: + self._data = ConnectionData.from_array(value) @classmethod def _get_kwargs_from_dict(cls, as_dict): diff --git a/highcharts_core/options/series/organization.py b/highcharts_core/options/series/organization.py index ae3ef9e9..0a3a87e5 100644 --- a/highcharts_core/options/series/organization.py +++ b/highcharts_core/options/series/organization.py @@ -2,10 +2,11 @@ from highcharts_core.decorators import class_sensitive from highcharts_core.options.series.bar import BarSeries -from highcharts_core.options.series.data.connections import OutgoingWeightedConnectionData +from highcharts_core.options.series.data.connections import (OutgoingWeightedConnectionData, + OutgoingWeightedConnectionDataCollection) from highcharts_core.options.plot_options.organization import OrganizationOptions from highcharts_core.utility_classes.nodes import OrganizationNodeOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class OrganizationSeries(BarSeries, OrganizationOptions): @@ -37,8 +38,26 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return OutgoingWeightedConnectionDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return OutgoingWeightedConnectionData + @property - def data(self) -> Optional[List[OutgoingWeightedConnectionData]]: + def data(self) -> Optional[List[OutgoingWeightedConnectionData] | OutgoingWeightedConnectionDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -98,16 +117,18 @@ def data(self) -> Optional[List[OutgoingWeightedConnectionData]]: .. tab:: Object Collection - A one-dimensional collection of :class:`OrganizationData` objects. + A one-dimensional collection of :class:`OutgoingWeightedConnectionData` + objects. - :rtype: :class:`list ` of :class:`OrganizationData` or + :rtype: :class:`list ` of :class:`OutgoingWeightedConnectionData` or + :class:`OutgoingWeightedConnectionData` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = OutgoingWeightedConnectionData.from_array(value) diff --git a/highcharts_core/options/series/packedbubble.py b/highcharts_core/options/series/packedbubble.py index 23660c90..084a2d55 100644 --- a/highcharts_core/options/series/packedbubble.py +++ b/highcharts_core/options/series/packedbubble.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.networkgraph import NetworkGraphSeries -from highcharts_core.options.series.data.single_point import SingleValueData +from highcharts_core.options.series.data.single_point import SingleValueData, SingleValueDataCollection from highcharts_core.options.plot_options.packedbubble import PackedBubbleOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class PackedBubbleSeries(NetworkGraphSeries, PackedBubbleOptions): @@ -32,8 +32,26 @@ class PackedBubbleSeries(NetworkGraphSeries, PackedBubbleOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return SingleValueDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return SingleValueData + @property - def data(self) -> Optional[List[SingleValueData]]: + def data(self) -> Optional[List[SingleValueData] | SingleValueDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -58,13 +76,14 @@ def data(self) -> Optional[List[SingleValueData]]: A one-dimensional collection of :class:`SingleValueData` objects. :rtype: :class:`list ` of :class:`SingleValueData` or + :class:`SingleValueDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = SingleValueData.from_array(value) diff --git a/highcharts_core/options/series/pictorial.py b/highcharts_core/options/series/pictorial.py index 18ce437a..95389982 100644 --- a/highcharts_core/options/series/pictorial.py +++ b/highcharts_core/options/series/pictorial.py @@ -4,9 +4,9 @@ from validator_collection import validators from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.cartesian import CartesianData +from highcharts_core.options.series.data.cartesian import CartesianData, CartesianDataCollection from highcharts_core.options.plot_options.pictorial import PictorialOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray from highcharts_core.metaclasses import HighchartsMeta from highcharts_core.decorators import class_sensitive from highcharts_core.utility_classes.ast import AttributeObject @@ -88,8 +88,26 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return CartesianDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return CartesianData + @property - def data(self) -> Optional[List[CartesianData]]: + def data(self) -> Optional[List[CartesianData] | CartesianDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -152,13 +170,14 @@ def data(self) -> Optional[List[CartesianData]]: A one-dimensional collection of :class:`CartesianData` objects. :rtype: :class:`list ` of :class:`CartesianData` or + :class:`CartesianDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = CartesianData.from_array(value) diff --git a/highcharts_core/options/series/pie.py b/highcharts_core/options/series/pie.py index f5e0005d..5a5cd9dc 100644 --- a/highcharts_core/options/series/pie.py +++ b/highcharts_core/options/series/pie.py @@ -1,9 +1,12 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.pie import PieData, VariablePieData +from highcharts_core.options.series.data.pie import (PieData, + PieDataCollection, + VariablePieData, + VariablePieDataCollection) from highcharts_core.options.plot_options.pie import PieOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class PieSeries(SeriesBase, PieOptions): @@ -31,8 +34,26 @@ class PieSeries(SeriesBase, PieOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return PieDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return PieData + @property - def data(self) -> Optional[List[PieData]]: + def data(self) -> Optional[List[PieData] | PieDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -56,13 +77,14 @@ def data(self) -> Optional[List[PieData]]: A one-dimensional collection of :class:`PieData` objects. :rtype: :class:`list ` of :class:`PieData` or + :class:`PieDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = PieData.from_array(value) @@ -197,8 +219,26 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return VariablePieDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return VariablePieData + @property - def data(self) -> Optional[List[VariablePieData]]: + def data(self) -> Optional[List[VariablePieData] | VariablePieDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -227,13 +267,14 @@ def data(self) -> Optional[List[VariablePieData]]: A one-dimensional collection of :class:`VariablePieData` objects. :rtype: :class:`list ` of :class:`VariablePieData` or + :class:`VariablePieDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = VariablePieData.from_array(value) diff --git a/highcharts_core/options/series/polygon.py b/highcharts_core/options/series/polygon.py index 2746600f..ce7f7bf3 100644 --- a/highcharts_core/options/series/polygon.py +++ b/highcharts_core/options/series/polygon.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.cartesian import CartesianData +from highcharts_core.options.series.data.cartesian import CartesianData, CartesianDataCollection from highcharts_core.options.plot_options.polygon import PolygonOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class PolygonSeries(SeriesBase, PolygonOptions): @@ -23,8 +23,26 @@ class PolygonSeries(SeriesBase, PolygonOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return CartesianDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return CartesianData + @property - def data(self) -> Optional[List[CartesianData]]: + def data(self) -> Optional[List[CartesianData] | CartesianDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -87,13 +105,14 @@ def data(self) -> Optional[List[CartesianData]]: A one-dimensional collection of :class:`CartesianData` objects. :rtype: :class:`list ` of :class:`CartesianData` or + :class:`CartesianDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = CartesianData.from_array(value) diff --git a/highcharts_core/options/series/pyramid.py b/highcharts_core/options/series/pyramid.py index b5b47c07..acaeae32 100644 --- a/highcharts_core/options/series/pyramid.py +++ b/highcharts_core/options/series/pyramid.py @@ -2,8 +2,8 @@ from highcharts_core.options.series.funnel import FunnelSeries from highcharts_core.options.series.pie import PieSeries from highcharts_core.options.plot_options.pyramid import PyramidOptions, Pyramid3DOptions -from highcharts_core.options.series.data.single_point import SinglePointData -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.options.series.data.single_point import SinglePointData, SinglePointDataCollection +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class PyramidSeries(FunnelSeries, PyramidOptions): @@ -39,8 +39,26 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return SinglePointDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return SinglePointData + @property - def data(self) -> Optional[List[SinglePointData]]: + def data(self) -> Optional[List[SinglePointData] | SinglePointDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -64,13 +82,14 @@ def data(self) -> Optional[List[SinglePointData]]: A one-dimensional collection of :class:`SinglePointData` objects. :rtype: :class:`list ` of :class:`SinglePointData` or + :class:`SinglePointDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = SinglePointData.from_array(value) diff --git a/highcharts_core/options/series/sankey.py b/highcharts_core/options/series/sankey.py index a505dfa7..c1cf3a58 100644 --- a/highcharts_core/options/series/sankey.py +++ b/highcharts_core/options/series/sankey.py @@ -1,9 +1,10 @@ from typing import Optional, List from highcharts_core.options.series.dependencywheel import DependencyWheelSeries -from highcharts_core.options.series.data.connections import OutgoingWeightedConnectionData +from highcharts_core.options.series.data.connections import (OutgoingWeightedConnectionData, + OutgoingWeightedConnectionDataCollection) from highcharts_core.options.plot_options.sankey import SankeyOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class SankeySeries(DependencyWheelSeries, SankeyOptions): @@ -37,8 +38,26 @@ class SankeySeries(DependencyWheelSeries, SankeyOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return OutgoingWeightedConnectionDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return OutgoingWeightedConnectionData + @property - def data(self) -> Optional[List[OutgoingWeightedConnectionData]]: + def data(self) -> Optional[List[OutgoingWeightedConnectionData] | OutgoingWeightedConnectionDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -54,13 +73,14 @@ def data(self) -> Optional[List[OutgoingWeightedConnectionData]]: :class:`OutgoingWeightedConnectionData` instances. :rtype: :class:`list ` of :class:`OutgoingWeightedConnectionData` or + :class:`OutgoingWeightedConnectionDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = OutgoingWeightedConnectionData.from_array(value) diff --git a/highcharts_core/options/series/scatter.py b/highcharts_core/options/series/scatter.py index de792fe4..fbe32867 100644 --- a/highcharts_core/options/series/scatter.py +++ b/highcharts_core/options/series/scatter.py @@ -1,9 +1,12 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.cartesian import CartesianData, Cartesian3DData +from highcharts_core.options.series.data.cartesian import (CartesianData, + CartesianDataCollection, + Cartesian3DData, + Cartesian3DDataCollection) from highcharts_core.options.plot_options.scatter import ScatterOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class ScatterSeries(SeriesBase, ScatterOptions): @@ -21,8 +24,26 @@ class ScatterSeries(SeriesBase, ScatterOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return CartesianDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return CartesianData + @property - def data(self) -> Optional[List[CartesianData]]: + def data(self) -> Optional[List[CartesianData] | CartesianDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -85,13 +106,14 @@ def data(self) -> Optional[List[CartesianData]]: A one-dimensional collection of :class:`CartesianData` objects. :rtype: :class:`list ` of :class:`CartesianData` or + :class:`CartesianDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = CartesianData.from_array(value) @@ -196,8 +218,26 @@ class Scatter3DSeries(ScatterSeries): """ + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return Cartesian3DDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return Cartesian3DData + @property - def data(self) -> Optional[List[Cartesian3DData]]: + def data(self) -> Optional[List[Cartesian3DData] | Cartesian3DDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -260,13 +300,14 @@ def data(self) -> Optional[List[Cartesian3DData]]: A one-dimensional collection of :class:`Cartesian3DData` objects. :rtype: :class:`list ` of :class:`Cartesian3DData` or + :class:`Cartesian3DDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = Cartesian3DData.from_array(value) diff --git a/highcharts_core/options/series/spline.py b/highcharts_core/options/series/spline.py index 20a2d805..a847423b 100644 --- a/highcharts_core/options/series/spline.py +++ b/highcharts_core/options/series/spline.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.cartesian import CartesianData +from highcharts_core.options.series.data.cartesian import CartesianData, CartesianDataCollection from highcharts_core.options.plot_options.spline import SplineOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class SplineSeries(SeriesBase, SplineOptions): @@ -21,8 +21,26 @@ class SplineSeries(SeriesBase, SplineOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return CartesianDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return CartesianData + @property - def data(self) -> Optional[List[CartesianData]]: + def data(self) -> Optional[List[CartesianData] | CartesianDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -85,13 +103,14 @@ def data(self) -> Optional[List[CartesianData]]: A one-dimensional collection of :class:`CartesianData` objects. :rtype: :class:`list ` of :class:`CartesianData` or + :class:`CartesianDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = CartesianData.from_array(value) diff --git a/highcharts_core/options/series/sunburst.py b/highcharts_core/options/series/sunburst.py index 7ccc90f8..0d6b57ac 100644 --- a/highcharts_core/options/series/sunburst.py +++ b/highcharts_core/options/series/sunburst.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.sunburst import SunburstData +from highcharts_core.options.series.data.sunburst import SunburstData, SunburstDataCollection from highcharts_core.options.plot_options.sunburst import SunburstOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class SunburstSeries(SeriesBase, SunburstOptions): @@ -22,8 +22,26 @@ class SunburstSeries(SeriesBase, SunburstOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return SunburstDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return SunburstData + @property - def data(self) -> Optional[List[SunburstData]]: + def data(self) -> Optional[List[SunburstData] | SunburstDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -38,13 +56,14 @@ def data(self) -> Optional[List[SunburstData]]: :class:`dict ` instances coercable to :class:`SunburstData` :rtype: :class:`list ` of :class:`SunburstData` or + :class:`SunburstDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = SunburstData.from_array(value) diff --git a/highcharts_core/options/series/timeline.py b/highcharts_core/options/series/timeline.py index 127f4077..e09301f1 100644 --- a/highcharts_core/options/series/timeline.py +++ b/highcharts_core/options/series/timeline.py @@ -1,9 +1,10 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.single_point import LabeledSingleXData +from highcharts_core.options.series.data.single_point import (LabeledSingleXData, + LabeledSingleXDataCollection) from highcharts_core.options.plot_options.timeline import TimelineOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class TimelineSeries(SeriesBase, TimelineOptions): @@ -36,8 +37,26 @@ class TimelineSeries(SeriesBase, TimelineOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return LabeledSingleXDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return LabeledSingleXData + @property - def data(self) -> Optional[List[LabeledSingleXData]]: + def data(self) -> Optional[List[LabeledSingleXData] | LabeledSingleXDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -51,13 +70,14 @@ def data(self) -> Optional[List[LabeledSingleXData]]: A one-dimensional collection of :class:`LabeledSingleXData` objects. :rtype: :class:`list ` of :class:`LabeledSingleXData` or + :class:`LabeledSingleXDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = LabeledSingleXData.from_array(value) diff --git a/highcharts_core/options/series/treegraph.py b/highcharts_core/options/series/treegraph.py index bed5d0d0..c61686bf 100644 --- a/highcharts_core/options/series/treegraph.py +++ b/highcharts_core/options/series/treegraph.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.treegraph import TreegraphData +from highcharts_core.options.series.data.treegraph import TreegraphData, TreegraphDataCollection from highcharts_core.options.plot_options.treegraph import TreegraphOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class TreegraphSeries(SeriesBase, TreegraphOptions): @@ -21,8 +21,26 @@ class TreegraphSeries(SeriesBase, TreegraphOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return TreegraphDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return TreegraphData + @property - def data(self) -> Optional[List[TreegraphData]]: + def data(self) -> Optional[List[TreegraphData] | TreegraphDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -51,13 +69,14 @@ def data(self) -> Optional[List[TreegraphData]]: :class:`dict ` instances coercable to :class:`TreegraphData` :rtype: :class:`list ` of :class:`TreegraphData` or + :class:`TreegraphDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = TreegraphData.from_array(value) diff --git a/highcharts_core/options/series/treemap.py b/highcharts_core/options/series/treemap.py index 8c25a8f2..6d01bde8 100644 --- a/highcharts_core/options/series/treemap.py +++ b/highcharts_core/options/series/treemap.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.treemap import TreemapData +from highcharts_core.options.series.data.treemap import TreemapData, TreemapDataCollection from highcharts_core.options.plot_options.treemap import TreemapOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class TreemapSeries(SeriesBase, TreemapOptions): @@ -21,8 +21,26 @@ class TreemapSeries(SeriesBase, TreemapOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return TreemapDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return TreemapData + @property - def data(self) -> Optional[List[TreemapData]]: + def data(self) -> Optional[List[TreemapData] | TreemapDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -37,13 +55,14 @@ def data(self) -> Optional[List[TreemapData]]: :class:`dict ` instances coercable to :class:`TreemapData` :rtype: :class:`list ` of :class:`TreemapData` or + :class:`TreemapDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = TreemapData.from_array(value) diff --git a/highcharts_core/options/series/vector.py b/highcharts_core/options/series/vector.py index 3f0de082..2e297d60 100644 --- a/highcharts_core/options/series/vector.py +++ b/highcharts_core/options/series/vector.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.vector import VectorData +from highcharts_core.options.series.data.vector import VectorData, VectorDataCollection from highcharts_core.options.plot_options.vector import VectorOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class VectorSeries(SeriesBase, VectorOptions): @@ -21,8 +21,26 @@ class VectorSeries(SeriesBase, VectorOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return VectorDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return VectorData + @property - def data(self) -> Optional[List[VectorData]]: + def data(self) -> Optional[List[VectorData] | VectorDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -53,13 +71,14 @@ def data(self) -> Optional[List[VectorData]]: A one-dimensional collection of :class:`VectorData` objects. :rtype: :class:`list ` of :class:`VectorData` or + :class:`VectorDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = VectorData.from_array(value) diff --git a/highcharts_core/options/series/venn.py b/highcharts_core/options/series/venn.py index 5af38c5d..47c2912b 100644 --- a/highcharts_core/options/series/venn.py +++ b/highcharts_core/options/series/venn.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.venn import VennData +from highcharts_core.options.series.data.venn import VennData, VennDataCollection from highcharts_core.options.plot_options.venn import VennOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class VennSeries(SeriesBase, VennOptions): @@ -34,8 +34,26 @@ class VennSeries(SeriesBase, VennOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return VennDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return VennData + @property - def data(self) -> Optional[List[VennData]]: + def data(self) -> Optional[List[VennData] | VennDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -50,13 +68,14 @@ def data(self) -> Optional[List[VennData]]: coercable to :class:`VennData`. :rtype: :class:`list ` of :class:`VennData` or + :class:`VennDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = VennData.from_array(value) diff --git a/highcharts_core/options/series/wordcloud.py b/highcharts_core/options/series/wordcloud.py index 233e08c5..46166d8a 100644 --- a/highcharts_core/options/series/wordcloud.py +++ b/highcharts_core/options/series/wordcloud.py @@ -1,9 +1,9 @@ from typing import Optional, List from highcharts_core.options.series.base import SeriesBase -from highcharts_core.options.series.data.wordcloud import WordcloudData +from highcharts_core.options.series.data.wordcloud import WordcloudData, WordcloudDataCollection from highcharts_core.options.plot_options.wordcloud import WordcloudOptions -from highcharts_core.utility_functions import mro__to_untrimmed_dict +from highcharts_core.utility_functions import mro__to_untrimmed_dict, is_ndarray class WordcloudSeries(SeriesBase, WordcloudOptions): @@ -21,8 +21,26 @@ class WordcloudSeries(SeriesBase, WordcloudOptions): def __init__(self, **kwargs): super().__init__(**kwargs) + @classmethod + def _data_collection_class(cls): + """Returns the class object used for the data collection. + + :rtype: :class:`DataPointCollection ` + descendent + """ + return WordcloudDataCollection + + @classmethod + def _data_point_class(cls): + """Returns the class object used for individual data points. + + :rtype: :class:`DataBase ` + descendent + """ + return WordcloudData + @property - def data(self) -> Optional[List[WordcloudData]]: + def data(self) -> Optional[List[WordcloudData] | WordcloudDataCollection]: """Collection of data that represents the series. Defaults to :obj:`None `. @@ -37,13 +55,14 @@ def data(self) -> Optional[List[WordcloudData]]: coercable to :class:`WordcloudData`. :rtype: :class:`list ` of :class:`WordcloudData` or + :class:`WordcloudDataCollection` or :obj:`None ` """ return self._data @data.setter def data(self, value): - if not value: + if not is_ndarray(value) and not value: self._data = None else: self._data = WordcloudData.from_array(value) diff --git a/highcharts_core/utility_classes/javascript_functions.py b/highcharts_core/utility_classes/javascript_functions.py index b3262b40..c7a89ede 100644 --- a/highcharts_core/utility_classes/javascript_functions.py +++ b/highcharts_core/utility_classes/javascript_functions.py @@ -4,7 +4,7 @@ import esprima from esprima.error_handler import Error as ParseError -from highcharts_core import errors +from highcharts_core import errors, ai from highcharts_core.decorators import validate_types from highcharts_core.metaclasses import HighchartsMeta @@ -77,8 +77,18 @@ def arguments(self, value): if not value: self._arguments = None else: - self._arguments = [validators.variable_name(x) - for x in validators.iterable(value)] + 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]: @@ -132,7 +142,8 @@ def to_json(self, encoding = 'utf-8'): def to_js_literal(self, filename = None, - encoding = 'utf-8') -> str: + encoding = 'utf-8', + careful_validation = False) -> str: if filename: filename = validators.path(filename) @@ -192,10 +203,19 @@ def _convert_from_js_ast(cls, property_definition, original_str): function_body = original_str[body_start:body_end] + arguments = [] if property_definition.type in ['MethodDefinition', 'Property']: - arguments = [x.name for x in property_definition.value.params] + 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: - arguments = [x.name for x in property_definition.params] + 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, @@ -243,6 +263,98 @@ def from_js_literal(cls, return cls._convert_from_js_ast(property_definition, updated_str) + @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 `__ and `Anthropic `__, + 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 ` + + :param api_key: The API key used to authenticate against the + generative AI provider. Defaults to + :obj:`None `, which then tries to find the API + key in the appropriate environment variable: + + * ``OPENAI_API_KEY`` if using an + `OpenAI `__ provided model + * ``ANTHROPIC_API_KEY`` if using an + `Anthropic `__ provided model + + :type api_key: :class:`str ` or :obj:`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 ` + + :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, @@ -484,7 +596,8 @@ def from_js_literal(cls, def to_js_literal(self, filename = None, - encoding = 'utf-8') -> str: + encoding = 'utf-8', + careful_validation = False) -> str: if filename: filename = validators.path(filename) diff --git a/highcharts_core/utility_functions.py b/highcharts_core/utility_functions.py index b42dddf0..29270404 100644 --- a/highcharts_core/utility_functions.py +++ b/highcharts_core/utility_functions.py @@ -2,10 +2,17 @@ import csv import string import random +import typing +from collections import UserDict -from validator_collection import validators +from validator_collection import validators, checkers +try: + import numpy as np + HAS_NUMPY = True +except ImportError: + HAS_NUMPY = False -from highcharts_core import errors +from highcharts_core import errors, constants def get_random_string(length = 6): @@ -183,7 +190,10 @@ def to_camelCase(snake_case): :returns: A ``camelCase`` representation of ``snake_case``. :rtype: :class:`str ` """ - snake_case = validators.string(snake_case) + if not snake_case: + raise errors.HighchartsValueError(f'snake_case cannot be empty') + + snake_case = str(snake_case) if '_' not in snake_case: return snake_case @@ -358,7 +368,7 @@ def parse_csv(csv_data, return columns, records_as_dicts -def jupyter_add_script(url, is_last = False): +def jupyter_add_script(url, is_last = False, use_require = False): """Generates the JavaScript code Promise which adds a