diff --git a/CHANGES.rst b/CHANGES.rst index 2dffaec..f473642 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,4 +1,15 @@ +Release 1.10.0 +========================================= + +* **ENHANCEMENT:** Added `User-Agent` and `Referer` support to the `ExportServer` class to accommodate + new security measures on Highsoft's public Export Server instance. +* **BUGFIX:** Adjusted handling of NumPy `datetime64` values to serialize them to Unix epoch-based milliseconds, + rather than the default nanoseconds (closes #204). +* **DOCS:** Added examples of adjusting `datetime64` values to epoch-based milliseconds (courtesy of @ThomasGL). + +---- + Release 1.9.4 ========================================= diff --git a/README.rst b/README.rst index 3aabb1d..625af5b 100644 --- a/README.rst +++ b/README.rst @@ -329,6 +329,44 @@ Hello World, and Basic Usage href = 'https://www.highchartspython.com') my_chart.options.credits = my_credits + # EXAMPLE 3. + # Pandas with time series + import pandas as pd + import datetime as dt + import numpy as np + df = pd.DataFrame([ + {"ref_date": dt.date(2024, 1, 1), "data": 1}, + {"ref_date": dt.date(2024, 1, 2), "data": 5}, + {"ref_date": dt.date(2024, 1, 3), "data": None}, + {"ref_date": dt.date(2024, 1, 4), "data": 4}, + {"ref_date": dt.date(2024, 1, 5), "data": None}, + ]) + + df['ref_date'] = pd.to_datetime(df['ref_date']) + df.set_index('ref_date', inplace=True) + + df.index = (df.index.astype(np.int64) / 10**6).astype(np.int64) + # Correcting nanoseconds to epoch, which is crucial for javascript rendering, + # See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now + # for more information on this behaviour + + from highcharts_core.chart import Chart + chart = Chart.from_pandas( + df=df.reset_index(), + series_type='line', + property_map={ + 'x': df.index.name, + 'y': df.columns.to_list() + } + ) + + chart.options.x_axis = { + 'type': 'datetime' + } + + chart.display() + + 5. Generate the JavaScript Code for Your Chart ================================================= diff --git a/docs/tutorials/pandas.rst b/docs/tutorials/pandas.rst index 0af4d9e..8fd31a2 100644 --- a/docs/tutorials/pandas.rst +++ b/docs/tutorials/pandas.rst @@ -412,6 +412,56 @@ What we did here is we added a ``series_index`` argument, which tells **Highchar 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. +Working with Time Series +====================================== + +Normally, in the context of Pandas one would reference their Pandas DataFrame with the time series at the index. +However, JavaScript (and the Highcharts JS library) renders time in relationship to the Unix epoch of January 1, 1970. + +.. seealso:: + + To see how this behaves, check the example under + `Date.now() - JavaScript | MDN `__ + 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. + +While Highcharts for Python will automatically convert NumPy `datetime64 ` values into their +appropriate integers, you may want to do this conversion yourself. A demonstration is given below: + + .. code-block:: python + + import pandas as pd + import datetime as dt + import numpy as np + df = pd.DataFrame([ + {"ref_date": dt.date(2024, 1, 1), "data": 1}, + {"ref_date": dt.date(2024, 1, 2), "data": 5}, + {"ref_date": dt.date(2024, 1, 3), "data": None}, + {"ref_date": dt.date(2024, 1, 4), "data": 4}, + {"ref_date": dt.date(2024, 1, 5), "data": None}, + ]) + + df['ref_date'] = pd.to_datetime(df['ref_date']) + df.set_index('ref_date', inplace=True) + + df.index = (df.index.astype(np.int64) / 10**6).astype(np.int64) + # This line is the important one! It converts the datetime64 values into their epoch-based millisecond equivalents. + + from highcharts_core.chart import Chart + chart = Chart.from_pandas( + df=df.reset_index(), + series_type='line', + property_map={ + 'x': df.index.name, + 'y': df.columns.to_list() + } + ) + + chart.options.x_axis = { + 'type': 'datetime' + } + + chart.display() + ------------------------ ********************************************************************** @@ -467,6 +517,7 @@ Filtering Series Created from Rows 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. + -------------------------- *********************************************************** @@ -495,4 +546,5 @@ the ``series_index`` argument tells it to only use the 10th series generated. 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 + :exc:`HighchartsPandasDeserializationError ` + diff --git a/highcharts_core/__version__.py b/highcharts_core/__version__.py index 6c08d42..52af183 100644 --- a/highcharts_core/__version__.py +++ b/highcharts_core/__version__.py @@ -1 +1 @@ -__version__ = '1.9.4' +__version__ = '1.10.0' diff --git a/highcharts_core/headless_export.py b/highcharts_core/headless_export.py index 042ad1b..44ba137 100644 --- a/highcharts_core/headless_export.py +++ b/highcharts_core/headless_export.py @@ -11,6 +11,7 @@ import requests from validator_collection import validators, checkers +from highcharts_core import __version__ as highcharts_version from highcharts_core import errors, constants from highcharts_core.decorators import class_sensitive from highcharts_core.metaclasses import HighchartsMeta @@ -57,6 +58,9 @@ def __init__(self, **kwargs): self._files = None self._css = None self._js = None + + self._referer = None + self._user_agent = None self.protocol = kwargs.get('protocol', os.getenv('HIGHCHARTS_EXPORT_SERVER_PROTOCOL', @@ -87,6 +91,11 @@ def __init__(self, **kwargs): js = kwargs.get('js', None) resources = kwargs.get('resources', None) + self.referer = kwargs.get('referer', + os.getenv('HIGHCHARTS_EXPORT_SERVER_REFERER', 'https://www.highcharts.com')) + self.user_agent = kwargs.get('user_agent', + os.getenv('HIGHCHARTS_EXPORT_SERVER_USER_AGENT', None)) + if resources: self.resources = kwargs.get('resources', None) else: @@ -96,6 +105,44 @@ def __init__(self, **kwargs): super().__init__(**kwargs) + @property + def referer(self) -> Optional[str]: + """The referer to use when making requests to the export server. Defaults to the + ``HIGHCHARTS_EXPORT_SERVER_REFERER`` environment variable if present, otherwise defaults to + ``'https://www.highcharts.com'``. + + :rtype: :class:`str ` or :obj:`None ` + """ + return self._referer + + @referer.setter + def referer(self, value): + value = validators.url(value, allow_empty = True) + if not value: + value = 'https://www.highcharts.com' + + self._referer = value + + @property + def user_agent(self) -> Optional[str]: + """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 + ``Highcharts Core for Python / v.. + + :rtype: :class:`str ` or :obj:`None ` + """ + if self._user_agent: + return self._user_agent + + return f'Highcharts Core for Python / v.{highcharts_version.__version__}' + + @user_agent.setter + def user_agent(self, value): + value = validators.string(value, allow_empty = True) + if not value: + value = None + + self._user_agent = value + @property def protocol(self) -> Optional[str]: """The protocol over which the Highcharts for Python library should communicate @@ -926,7 +973,11 @@ def request_chart(self, result = requests.post(self.url, data = as_json.encode('utf-8'), - headers = { 'Content-Type': 'application/json' }, + headers = { + 'Content-Type': 'application/json', + 'Referer': self.referer, + 'User-Agent': self.user_agent, + }, auth = basic_auth, timeout = timeout) diff --git a/highcharts_core/options/series/data/cartesian.py b/highcharts_core/options/series/data/cartesian.py index f2191da..a32b138 100644 --- a/highcharts_core/options/series/data/cartesian.py +++ b/highcharts_core/options/series/data/cartesian.py @@ -128,6 +128,8 @@ def x(self, value): value = validators.datetime(value) elif checkers.is_date(value): value = validators.date(value) + elif HAS_NUMPY and hasattr(value, 'dtype') and value.dtype.char == 'M': + value = (value.astype(np.int64) / 10**6).astype(np.int64) elif checkers.is_numeric(value): value = validators.numeric(value) else: diff --git a/highcharts_core/utility_functions.py b/highcharts_core/utility_functions.py index 93c1b79..07d293a 100644 --- a/highcharts_core/utility_functions.py +++ b/highcharts_core/utility_functions.py @@ -688,8 +688,10 @@ def from_ndarray(as_ndarray, force_enforced_null = False): else: nan_replacement = None - if as_ndarray.dtype.char not in ['O', 'U']: + if as_ndarray.dtype.char not in ['O', 'U', 'M']: stripped = np.where(np.isnan(as_ndarray), nan_replacement, as_ndarray) + elif as_ndarray.dtype.char == 'M': + stripped = (as_ndarray.astype(np.int64) / 10**6).astype(np.int64) else: prelim_stripped = as_ndarray.tolist() stripped = []