Skip to content

Commit 4dbbb51

Browse files
authored
Merge pull request #206 from highcharts-for-python/develop
PR for v.1.10.0
2 parents e5c0a57 + 2aef265 commit 4dbbb51

File tree

7 files changed

+160
-4
lines changed

7 files changed

+160
-4
lines changed

CHANGES.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11

2+
Release 1.10.0
3+
=========================================
4+
5+
* **ENHANCEMENT:** Added `User-Agent` and `Referer` support to the `ExportServer` class to accommodate
6+
new security measures on Highsoft's public Export Server instance.
7+
* **BUGFIX:** Adjusted handling of NumPy `datetime64` values to serialize them to Unix epoch-based milliseconds,
8+
rather than the default nanoseconds (closes #204).
9+
* **DOCS:** Added examples of adjusting `datetime64` values to epoch-based milliseconds (courtesy of @ThomasGL).
10+
11+
----
12+
213
Release 1.9.4
314
=========================================
415

README.rst

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,44 @@ Hello World, and Basic Usage
329329
href = 'https://www.highchartspython.com')
330330
my_chart.options.credits = my_credits
331331
332+
# EXAMPLE 3.
333+
# Pandas with time series
334+
import pandas as pd
335+
import datetime as dt
336+
import numpy as np
337+
df = pd.DataFrame([
338+
{"ref_date": dt.date(2024, 1, 1), "data": 1},
339+
{"ref_date": dt.date(2024, 1, 2), "data": 5},
340+
{"ref_date": dt.date(2024, 1, 3), "data": None},
341+
{"ref_date": dt.date(2024, 1, 4), "data": 4},
342+
{"ref_date": dt.date(2024, 1, 5), "data": None},
343+
])
344+
345+
df['ref_date'] = pd.to_datetime(df['ref_date'])
346+
df.set_index('ref_date', inplace=True)
347+
348+
df.index = (df.index.astype(np.int64) / 10**6).astype(np.int64)
349+
# Correcting nanoseconds to epoch, which is crucial for javascript rendering,
350+
# See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now
351+
# for more information on this behaviour
352+
353+
from highcharts_core.chart import Chart
354+
chart = Chart.from_pandas(
355+
df=df.reset_index(),
356+
series_type='line',
357+
property_map={
358+
'x': df.index.name,
359+
'y': df.columns.to_list()
360+
}
361+
)
362+
363+
chart.options.x_axis = {
364+
'type': 'datetime'
365+
}
366+
367+
chart.display()
368+
369+
332370
5. Generate the JavaScript Code for Your Chart
333371
=================================================
334372

docs/tutorials/pandas.rst

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,56 @@ What we did here is we added a ``series_index`` argument, which tells **Highchar
412412
include the series found at that index in the resulting chart. In this case, we supplied a :func:`slice <python:slice>`
413413
object, which operates just like ``list_of_series[7:10]``. The result only returns those series between index 7 and 10.
414414

415+
Working with Time Series
416+
======================================
417+
418+
Normally, in the context of Pandas one would reference their Pandas DataFrame with the time series at the index.
419+
However, JavaScript (and the Highcharts JS library) renders time in relationship to the Unix epoch of January 1, 1970.
420+
421+
.. seealso::
422+
423+
To see how this behaves, check the example under
424+
`Date.now() - JavaScript | MDN <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now>`__
425+
and try playing with your browser console with a command like ``Date.now();``. You should see a very large integer representing the number of nanoseconds elapsed since the first of January. 1970.
426+
427+
While Highcharts for Python will automatically convert NumPy `datetime64 <numpy:numpy.datetime64>` values into their
428+
appropriate integers, you may want to do this conversion yourself. A demonstration is given below:
429+
430+
.. code-block:: python
431+
432+
import pandas as pd
433+
import datetime as dt
434+
import numpy as np
435+
df = pd.DataFrame([
436+
{"ref_date": dt.date(2024, 1, 1), "data": 1},
437+
{"ref_date": dt.date(2024, 1, 2), "data": 5},
438+
{"ref_date": dt.date(2024, 1, 3), "data": None},
439+
{"ref_date": dt.date(2024, 1, 4), "data": 4},
440+
{"ref_date": dt.date(2024, 1, 5), "data": None},
441+
])
442+
443+
df['ref_date'] = pd.to_datetime(df['ref_date'])
444+
df.set_index('ref_date', inplace=True)
445+
446+
df.index = (df.index.astype(np.int64) / 10**6).astype(np.int64)
447+
# This line is the important one! It converts the datetime64 values into their epoch-based millisecond equivalents.
448+
449+
from highcharts_core.chart import Chart
450+
chart = Chart.from_pandas(
451+
df=df.reset_index(),
452+
series_type='line',
453+
property_map={
454+
'x': df.index.name,
455+
'y': df.columns.to_list()
456+
}
457+
)
458+
459+
chart.options.x_axis = {
460+
'type': 'datetime'
461+
}
462+
463+
chart.display()
464+
415465
------------------------
416466

417467
**********************************************************************
@@ -467,6 +517,7 @@ Filtering Series Created from Rows
467517
my_series = LineSeries.from_pandas_in_rows(df, series_index = slice(0, 5))
468518
469519
This will return the first five series in the list of 57.
520+
470521
--------------------------
471522

472523
***********************************************************
@@ -495,4 +546,5 @@ the ``series_index`` argument tells it to only use the 10th series generated.
495546
the arguments supplied lead to an unambiguous *single* series. If they are ambiguous - meaning they
496547
lead to multiple series generated from the :class:`DataFrame <pandas:pandas.DataFrame>` - then
497548
the method will throw a
498-
:exc:`HighchartsPandasDeserializationError <highcharts_core.errors.HighchartsPandasDeserializationError>`
549+
:exc:`HighchartsPandasDeserializationError <highcharts_core.errors.HighchartsPandasDeserializationError>`
550+

highcharts_core/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.9.4'
1+
__version__ = '1.10.0'

highcharts_core/headless_export.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import requests
1212
from validator_collection import validators, checkers
1313

14+
from highcharts_core import __version__ as highcharts_version
1415
from highcharts_core import errors, constants
1516
from highcharts_core.decorators import class_sensitive
1617
from highcharts_core.metaclasses import HighchartsMeta
@@ -57,6 +58,9 @@ def __init__(self, **kwargs):
5758
self._files = None
5859
self._css = None
5960
self._js = None
61+
62+
self._referer = None
63+
self._user_agent = None
6064

6165
self.protocol = kwargs.get('protocol',
6266
os.getenv('HIGHCHARTS_EXPORT_SERVER_PROTOCOL',
@@ -87,6 +91,11 @@ def __init__(self, **kwargs):
8791
js = kwargs.get('js', None)
8892
resources = kwargs.get('resources', None)
8993

94+
self.referer = kwargs.get('referer',
95+
os.getenv('HIGHCHARTS_EXPORT_SERVER_REFERER', 'https://www.highcharts.com'))
96+
self.user_agent = kwargs.get('user_agent',
97+
os.getenv('HIGHCHARTS_EXPORT_SERVER_USER_AGENT', None))
98+
9099
if resources:
91100
self.resources = kwargs.get('resources', None)
92101
else:
@@ -96,6 +105,44 @@ def __init__(self, **kwargs):
96105

97106
super().__init__(**kwargs)
98107

108+
@property
109+
def referer(self) -> Optional[str]:
110+
"""The referer to use when making requests to the export server. Defaults to the
111+
``HIGHCHARTS_EXPORT_SERVER_REFERER`` environment variable if present, otherwise defaults to
112+
``'https://www.highcharts.com'``.
113+
114+
:rtype: :class:`str <python:str>` or :obj:`None <python:None>`
115+
"""
116+
return self._referer
117+
118+
@referer.setter
119+
def referer(self, value):
120+
value = validators.url(value, allow_empty = True)
121+
if not value:
122+
value = 'https://www.highcharts.com'
123+
124+
self._referer = value
125+
126+
@property
127+
def user_agent(self) -> Optional[str]:
128+
"""The user agent to use when making requests to the export server. Defaults to the ``HIGHCHARTS_EXPORT_SERVER_USER_AGENT`` environment variable if present, otherwise defaults to
129+
``Highcharts Core for Python / v.<VERSION NUMBER>.
130+
131+
:rtype: :class:`str <python:str>` or :obj:`None <python:None>`
132+
"""
133+
if self._user_agent:
134+
return self._user_agent
135+
136+
return f'Highcharts Core for Python / v.{highcharts_version.__version__}'
137+
138+
@user_agent.setter
139+
def user_agent(self, value):
140+
value = validators.string(value, allow_empty = True)
141+
if not value:
142+
value = None
143+
144+
self._user_agent = value
145+
99146
@property
100147
def protocol(self) -> Optional[str]:
101148
"""The protocol over which the Highcharts for Python library should communicate
@@ -926,7 +973,11 @@ def request_chart(self,
926973

927974
result = requests.post(self.url,
928975
data = as_json.encode('utf-8'),
929-
headers = { 'Content-Type': 'application/json' },
976+
headers = {
977+
'Content-Type': 'application/json',
978+
'Referer': self.referer,
979+
'User-Agent': self.user_agent,
980+
},
930981
auth = basic_auth,
931982
timeout = timeout)
932983

highcharts_core/options/series/data/cartesian.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ def x(self, value):
128128
value = validators.datetime(value)
129129
elif checkers.is_date(value):
130130
value = validators.date(value)
131+
elif HAS_NUMPY and hasattr(value, 'dtype') and value.dtype.char == 'M':
132+
value = (value.astype(np.int64) / 10**6).astype(np.int64)
131133
elif checkers.is_numeric(value):
132134
value = validators.numeric(value)
133135
else:

highcharts_core/utility_functions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,8 +688,10 @@ def from_ndarray(as_ndarray, force_enforced_null = False):
688688
else:
689689
nan_replacement = None
690690

691-
if as_ndarray.dtype.char not in ['O', 'U']:
691+
if as_ndarray.dtype.char not in ['O', 'U', 'M']:
692692
stripped = np.where(np.isnan(as_ndarray), nan_replacement, as_ndarray)
693+
elif as_ndarray.dtype.char == 'M':
694+
stripped = (as_ndarray.astype(np.int64) / 10**6).astype(np.int64)
693695
else:
694696
prelim_stripped = as_ndarray.tolist()
695697
stripped = []

0 commit comments

Comments
 (0)