diff --git a/.gitignore b/.gitignore index a0cf18ba..7de50eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -134,4 +134,7 @@ dmypy.json tests/input_files/headless_export/output/ # VSCode Settings -.vscode/ \ No newline at end of file +.vscode/ + +# test_stbox +.test_stbox/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 7e2a5ceb..1360656d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,12 @@ job: - python: "3.11" dist: bionic env: TOXENV=py311 + - python: "3.12" + dist: bionic + env: TOXENV=py312 + - python: "3.13" + dist: bionic + env: TOXENV=py313 - python: "3.10" dist: focal env: TOXENV=coverage diff --git a/CHANGES.rst b/CHANGES.rst index 3e5c9e21..309de36e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,24 @@ +Release 1.11.0 +========================================= + +* **ENHANCEMENT:** Align the API to **Highcharts (JS) v.12.6**. In particular, this includes: + + * Added ``Credits.events`` property. + * Added ``Boost.chunk_size`` property. + * Added ``Exporting.local`` property. + * Added non-Cartesian series zoom module. + * Added ``Tooltip.show_delay`` and ``CrosshairOptions.show_delay`` properties. + * Added ``Legend.max_width`` support. + * Added multiple new properties to Treegraph and Treemap series types, including: + ``headers``, ``group_padding``, ``node_size_by``, ``traverse_to_leaf``, and ``zoom_enabled``. + * Added ``Tooltip.fixed`` and ``Tooltip.position`` support. + +* **TESTS:** Added unit tests to confirm ``Chart.module_url`` support for local path. +* **ENHANCEMENT:** Updated dependencies and requirements to more-recent versions to address security patches. +* **ENHANCEMENT:** Major performance optimization to data point serialization. (courtesy of @dcherman) +* **BUGFIX:** Fixed import error associated with ``requests.auth.HTTPBasicAuth``. Closes #221 + +---- Release 1.10.3 ========================================= diff --git a/docs/_contributors.rst b/docs/_contributors.rst index ee47cc67..39bbf55f 100644 --- a/docs/_contributors.rst +++ b/docs/_contributors.rst @@ -5,4 +5,5 @@ * karlacio (`@karlacio `__) * Max Dugan Knight (`@maxduganknight `__) * Julien Bacquart (`@JulienBacquart `__) -* Thomas Glezer (`@ThomasGL `__) \ No newline at end of file +* Thomas Glezer (`@ThomasGL `__) +* dherman (`@dcherman `__) \ No newline at end of file diff --git a/docs/_dependencies.rst b/docs/_dependencies.rst index fe223586..437050c6 100644 --- a/docs/_dependencies.rst +++ b/docs/_dependencies.rst @@ -32,7 +32,7 @@ not work properly if your rendering layer does not leverage Highcharts Core (JS). * `esprima-python `_ v.4.0 or higher - * `requests `_ v.2.31 or higher + * `requests `_ v.2.32 or higher * `validator-collection `_ v.1.5 or higher @@ -76,7 +76,7 @@ $ pip install highcharts-core[dev] - * `pytest `_ v.7.1 or higher + * `pytest `_ v.9.0 or higher * `pytest-cov `_ v.3.0 or higher * `pytest-xdist `_ v.2.5 or higher * `python-dotenv `_ v. 0.21 or higher diff --git a/docs/index.rst b/docs/index.rst index 63f368a7..0f80b339 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -37,7 +37,7 @@ Highcharts Core for Python .. sidebar:: Version Compatibility - **Latest Highcharts (JS) version supported:** v.11.4.0 + **Latest Highcharts (JS) version supported:** v.12.6.0 **Highcharts Core for Python** is designed to be compatible with: diff --git a/highcharts_core/__version__.py b/highcharts_core/__version__.py index bc8f1165..f84c53b0 100644 --- a/highcharts_core/__version__.py +++ b/highcharts_core/__version__.py @@ -1 +1 @@ -__version__ = "1.10.3" +__version__ = "1.11.0" diff --git a/highcharts_core/chart.py b/highcharts_core/chart.py index 507ae155..5fe8fb3d 100644 --- a/highcharts_core/chart.py +++ b/highcharts_core/chart.py @@ -452,7 +452,7 @@ def callback(self, value): @property def module_url(self) -> str: - """The URL from which Highcharts modules should be downloaded when + """The URL or local path from which Highcharts modules should be downloaded when generating the ``', - '', - '' - ], None), - ("""{ + False, + [ + '', + '', + '', + ], + None, + ), + ( + """{ "chart": { "type": "column" }, @@ -275,16 +299,20 @@ def test_get_required_modules(json_str, expected_modules, error): } }] }""", - True, - """\n\n""", None), -]) + True, + """\n\n""", + None, + ), + ], +) def test_get_script_tags(options_str, as_str, expected, error): from highcharts_core.options import HighchartsOptions + options = HighchartsOptions.from_json(options_str) chart = cls.from_options(options) if not error: - result = chart.get_script_tags(as_str = as_str) + result = chart.get_script_tags(as_str=as_str) if isinstance(expected, list): assert isinstance(result, list) is True assert len(result) == len(expected) @@ -296,114 +324,112 @@ def test_get_script_tags(options_str, as_str, expected, error): assert result is None or len(result) == 0 else: with pytest.raises(error): - result = chart.get_script_tags(as_str = as_str) - + result = chart.get_script_tags(as_str=as_str) -@pytest.mark.parametrize('kwargs, error', [ - ({}, None), - ({ - 'container': 'my-container-name', - 'module_url': 'https://mycustomurl.com/', - 'options': { - 'title': { - 'text': 'My Chart' - } - } - }, None), -]) + +@pytest.mark.parametrize( + "kwargs, error", + [ + ({}, None), + ( + { + "container": "my-container-name", + "module_url": "https://mycustomurl.com/", + "options": {"title": {"text": "My Chart"}}, + }, + None, + ), + ], +) def test__repr__(kwargs, error): obj = cls(**kwargs) if not error: result = repr(obj) - if 'options' in kwargs: - assert 'options = ' in result + if "options" in kwargs: + assert "options = " in result else: with pytest.raises(error): result = repr(obj) -@pytest.mark.parametrize('kwargs, error', [ - ({}, None), - ({ - 'container': 'my-container-name', - 'module_url': 'https://mycustomurl.com/', - 'options': { - 'title': { - 'text': 'My Chart' - } - } - }, None), -]) +@pytest.mark.parametrize( + "kwargs, error", + [ + ({}, None), + ( + { + "container": "my-container-name", + "module_url": "https://mycustomurl.com/", + "options": {"title": {"text": "My Chart"}}, + }, + None, + ), + ], +) def test__str__(kwargs, error): obj = cls(**kwargs) if not error: result = str(obj) print(result) - if 'options' in kwargs: - assert 'options = ' in result + if "options" in kwargs: + assert "options = " in result else: with pytest.raises(error): result = str(obj) - -@pytest.mark.parametrize('kwargs, expected_series, expected_data_points, error', [ - ({}, 0, [], None), - ({ - 'series': [ +@pytest.mark.parametrize( + "kwargs, expected_series, expected_data_points, error", + [ + ({}, 0, [], None), + ({"series": [{"data": [[1, 2], [3, 4]], "type": "line"}]}, 1, [(0, 2)], None), + ({"series": {"data": [[1, 2], [3, 4]], "type": "line"}}, 1, [(0, 2)], None), + ({"data": [[1, 2], [3, 4]], "series_type": "line"}, 1, [(0, 2)], None), + ( { - 'data': [[1, 2], [3, 4]], - 'type': 'line' - } - ] - }, 1, [(0, 2)], None), - ({ - 'series': { - 'data': [[1, 2], [3, 4]], - 'type': 'line' - } - }, 1, [(0, 2)], None), - - ({ - 'data': [[1, 2], [3, 4]], - 'series_type': 'line' - }, 1, [(0, 2)], None), - - ({ - 'data': [[1, 2], [3, 4]], - }, 1, [(0, 2)], errors.HighchartsValueError), - -]) -def test_issue90_one_shot_creation(kwargs, expected_series, expected_data_points, error): + "data": [[1, 2], [3, 4]], + }, + 1, + [(0, 2)], + errors.HighchartsValueError, + ), + ], +) +def test_issue90_one_shot_creation( + kwargs, expected_series, expected_data_points, error +): if not error: result = cls(**kwargs) assert result is not None if kwargs: - assert getattr(result, 'options') is not None - assert getattr(result.options, 'series') is not None + assert getattr(result, "options") is not None + assert getattr(result.options, "series") is not None assert len(result.options.series) == expected_series for item in expected_data_points: assert len(result.options.series[item[0]].data) == item[1] else: with pytest.raises(error): result = cls(**kwargs) - -@pytest.mark.parametrize('filename, error', [ - ('test-data-files/nst-est2019-01.csv', None), -]) + +@pytest.mark.parametrize( + "filename, error", + [ + ("test-data-files/nst-est2019-01.csv", None), + ], +) def test_from_pandas_in_rows(run_pandas_tests, input_files, filename, error): if not run_pandas_tests: return import pandas - + input_file = check_input_file(input_files, filename) - df = pandas.read_csv(input_file, header = 0, thousands = ',') - df.index = df['Geographic Area'] - df = df.drop(columns = ['Geographic Area']) + df = pandas.read_csv(input_file, header=0, thousands=",") + df.index = df["Geographic Area"] + df = df.drop(columns=["Geographic Area"]) print(df) - + if not error: result = cls.from_pandas_in_rows(df) assert result is not None @@ -418,108 +444,92 @@ def test_from_pandas_in_rows(run_pandas_tests, input_files, filename, error): def prep_df(df): - df.index = df['Geographic Area'] - df = df.drop(columns = ['Geographic Area']) - + df.index = df["Geographic Area"] + df = df.drop(columns=["Geographic Area"]) + return df def reduce_to_two_columns(df): - df = df[['Geographic Area', '2010']] - + df = df[["Geographic Area", "2010"]] + return df -@pytest.mark.parametrize('filename, kwargs, pre_test_df_func, expected_series, expected_data_points, error', [ - # SCENARIO 0: Series in Rows - ('test-data-files/nst-est2019-01.csv', - { - 'series_in_rows': True - }, - prep_df, - 57, - 10, - None), - - # SCENARIO 1a: Has Property Map, Single Series - ('test-data-files/nst-est2019-01.csv', - { - 'property_map': { - 'name': 'Geographic Area', - }, - 'series_in_rows': False - }, - None, - 1, - 57, - None), - - # SCENARIO 1b: Has Property Map, Multiple Series - ('test-data-files/nst-est2019-01.csv', - { - 'property_map': { - 'x': ['Geographic Area', '2010'] - }, - 'series_in_rows': False - }, - None, - 2, - 57, - None), - - # SCENARIO 2a: Single Property in KWARGS - ('test-data-files/nst-est2019-01.csv', - { - 'x': 'Geographic Area', - 'y': '2010' - }, - None, - 1, - 57, - None), - - # SCENARIO 3a: Exact Match on Column Count - ('test-data-files/nst-est2019-01.csv', - {}, - reduce_to_two_columns, - 1, - 57, - None), - - # SCENARIO 3b: Multiple Series, Multipled Columns - ('test-data-files/nst-est2019-01.csv', - {}, - prep_df, - 10, - 57, - None), - - # SCENARIO 4: Mismatched Columns - # NOTE: On SeriesBase, this will actually return one series per column. - # This is because SeriesBase supports 1D arrays. - ('test-data-files/nst-est2019-01.csv', - {}, - None, - 11, - 57, - TypeError), - -]) -def test_from_pandas(run_pandas_tests, - input_files, - filename, - kwargs, - pre_test_df_func, - expected_series, - expected_data_points, - error): +@pytest.mark.parametrize( + "filename, kwargs, pre_test_df_func, expected_series, expected_data_points, error", + [ + # SCENARIO 0: Series in Rows + ( + "test-data-files/nst-est2019-01.csv", + {"series_in_rows": True}, + prep_df, + 57, + 10, + None, + ), + # SCENARIO 1a: Has Property Map, Single Series + ( + "test-data-files/nst-est2019-01.csv", + { + "property_map": { + "name": "Geographic Area", + }, + "series_in_rows": False, + }, + None, + 1, + 57, + None, + ), + # SCENARIO 1b: Has Property Map, Multiple Series + ( + "test-data-files/nst-est2019-01.csv", + { + "property_map": {"x": ["Geographic Area", "2010"]}, + "series_in_rows": False, + }, + None, + 2, + 57, + None, + ), + # SCENARIO 2a: Single Property in KWARGS + ( + "test-data-files/nst-est2019-01.csv", + {"x": "Geographic Area", "y": "2010"}, + None, + 1, + 57, + None, + ), + # SCENARIO 3a: Exact Match on Column Count + ("test-data-files/nst-est2019-01.csv", {}, reduce_to_two_columns, 1, 57, None), + # SCENARIO 3b: Multiple Series, Multipled Columns + ("test-data-files/nst-est2019-01.csv", {}, prep_df, 10, 57, None), + # SCENARIO 4: Mismatched Columns + # NOTE: On SeriesBase, this will actually return one series per column. + # This is because SeriesBase supports 1D arrays. + ("test-data-files/nst-est2019-01.csv", {}, None, 11, 57, TypeError), + ], +) +def test_from_pandas( + run_pandas_tests, + input_files, + filename, + kwargs, + pre_test_df_func, + expected_series, + expected_data_points, + error, +): if not run_pandas_tests: return import pandas input_file = check_input_file(input_files, filename) - df = pandas.read_csv(input_file, header = 0, thousands = ',') + df = pandas.read_csv(input_file, header=0, thousands=",") if pre_test_df_func: df = pre_test_df_func(df) print(df) @@ -537,14 +547,18 @@ def test_from_pandas(run_pandas_tests, result = cls.from_pandas(df, **kwargs) -@pytest.mark.parametrize('filename, expected_series, expected_data_points, error', [ - ('test-data-files/nst-est2019-01.csv', 57, 10, None), -]) -def test_from_csv_in_rows(input_files, filename, expected_series, expected_data_points, error): +@pytest.mark.parametrize( + "filename, expected_series, expected_data_points, error", + [ + ("test-data-files/nst-est2019-01.csv", 57, 10, None), + ], +) +def test_from_csv_in_rows( + input_files, filename, expected_series, expected_data_points, error +): input_file = check_input_file(input_files, filename) if not error: - result = cls.from_csv_in_rows(input_file, - wrapper_character = '"') + result = cls.from_csv_in_rows(input_file, wrapper_character='"') assert result is not None assert isinstance(result, cls) assert result.options is not None @@ -559,129 +573,113 @@ def test_from_csv_in_rows(input_files, filename, expected_series, expected_data_ result = cls.from_pandas_in_rows(input_file) -@pytest.mark.parametrize('filename, property_map, kwargs, expected_series, expected_data_points, error', [ - ('test-data-files/nst-est2019-01.csv', - {}, - { - 'wrapper_character': '"' - }, - 10, - 57, - None), - ('test-data-files/nst-est2019-01.csv', - { - 'name': 'Geographic Area', - 'x': 'Geographic Area', - 'y': '2010' - }, - { - 'wrapper_character': '"' - }, - 1, - 57, - None), - - # SCENARIO 0: Series in Rows - ('test-data-files/nst-est2019-01.csv', - {}, - { - 'wrapper_character': '"', - 'series_in_rows': True - }, - 57, - 10, - None), - - # SCENARIO 1a: Has Property Map, Single Series - ('test-data-files/nst-est2019-01.csv', - { - 'name': 'Geographic Area' - }, - { - 'wrapper_character': '"', - 'series_in_rows': False - }, - 1, - 57, - None), - - ('test-data-files/nst-est2019-01.csv', - { - 'x': 'Geographic Area', - 'y': '2010' - }, - { - 'wrapper_character': '"' - }, - 1, - 57, - None), - - # SCENARIO 1b: Has Property Map, Multiple Series - ('test-data-files/nst-est2019-01.csv', - { - 'x': ['Geographic Area', '2010'] - }, - { - 'series_in_rows': False, - 'wrapper_character': '"' - }, - 2, - 57, - None), - - # SCENARIO 2a: Single Property in KWARGS - ('test-data-files/nst-est2019-01.csv', - {}, - { - 'wrapper_character': '"', - 'x': 'Geographic Area', - 'y': '2010' - }, - 1, - 57, - None), - - # SCENARIO 3a: Exact Match on Column Count - ('test-data-files/nst-est2019-01-reduced-to-two.csv', - {}, - { - 'wrapper_character': '"' - }, - 1, - 57, - None), - - # SCENARIO 3b: Multiple Series, Multipled Columns - ('test-data-files/nst-est2019-01-removed-column.csv', - {}, - { - 'wrapper_character': '"' - }, - 9, - 57, - None), - - # SCENARIO 4: Mismatched Columns - # NOTE: On SeriesBase, this will actually return one series per column. - # This is because SeriesBase supports 1D arrays. - ('test-data-files/nst-est2019-01.csv', - {}, - { - 'wrapper_character': '"' - }, - 10, - 57, - None), - -]) -def test_from_csv(input_files, filename, property_map, kwargs, expected_series, expected_data_points, error): +@pytest.mark.parametrize( + "filename, property_map, kwargs, expected_series, expected_data_points, error", + [ + ( + "test-data-files/nst-est2019-01.csv", + {}, + {"wrapper_character": '"'}, + 10, + 57, + None, + ), + ( + "test-data-files/nst-est2019-01.csv", + {"name": "Geographic Area", "x": "Geographic Area", "y": "2010"}, + {"wrapper_character": '"'}, + 1, + 57, + None, + ), + # SCENARIO 0: Series in Rows + ( + "test-data-files/nst-est2019-01.csv", + {}, + {"wrapper_character": '"', "series_in_rows": True}, + 57, + 10, + None, + ), + # SCENARIO 1a: Has Property Map, Single Series + ( + "test-data-files/nst-est2019-01.csv", + {"name": "Geographic Area"}, + {"wrapper_character": '"', "series_in_rows": False}, + 1, + 57, + None, + ), + ( + "test-data-files/nst-est2019-01.csv", + {"x": "Geographic Area", "y": "2010"}, + {"wrapper_character": '"'}, + 1, + 57, + None, + ), + # SCENARIO 1b: Has Property Map, Multiple Series + ( + "test-data-files/nst-est2019-01.csv", + {"x": ["Geographic Area", "2010"]}, + {"series_in_rows": False, "wrapper_character": '"'}, + 2, + 57, + None, + ), + # SCENARIO 2a: Single Property in KWARGS + ( + "test-data-files/nst-est2019-01.csv", + {}, + {"wrapper_character": '"', "x": "Geographic Area", "y": "2010"}, + 1, + 57, + None, + ), + # SCENARIO 3a: Exact Match on Column Count + ( + "test-data-files/nst-est2019-01-reduced-to-two.csv", + {}, + {"wrapper_character": '"'}, + 1, + 57, + None, + ), + # SCENARIO 3b: Multiple Series, Multipled Columns + ( + "test-data-files/nst-est2019-01-removed-column.csv", + {}, + {"wrapper_character": '"'}, + 9, + 57, + None, + ), + # SCENARIO 4: Mismatched Columns + # NOTE: On SeriesBase, this will actually return one series per column. + # This is because SeriesBase supports 1D arrays. + ( + "test-data-files/nst-est2019-01.csv", + {}, + {"wrapper_character": '"'}, + 10, + 57, + None, + ), + ], +) +def test_from_csv( + input_files, + filename, + property_map, + kwargs, + expected_series, + expected_data_points, + error, +): input_file = check_input_file(input_files, filename) - + if not error: - result = cls.from_csv(input_file, - property_column_map = property_map, - **kwargs) + result = cls.from_csv(input_file, property_column_map=property_map, **kwargs) assert result is not None assert isinstance(result, cls) assert result.options is not None @@ -693,48 +691,64 @@ def test_from_csv(input_files, filename, property_map, kwargs, expected_series, assert len(series.data) == expected_data_points else: with pytest.raises(error): - result = cls.from_csv(input_file, - property_column_map = property_map, - **kwargs) - - -@pytest.mark.parametrize('value, expected_shape, has_ndarray, has_data_points, error', [ - (np.asarray([ - [0.0, 15.0], - [10.0, -50.0], - [20.0, -56.5], - [30.0, -46.5], - [40.0, -22.1], - [50.0, -2.5], - [60.0, -27.7], - [70.0, -55.7], - [80.0, -76.5] - ]) if HAS_NUMPY else [ - [0.0, 15.0], - [10.0, -50.0], - [20.0, -56.5], - [30.0, -46.5], - [40.0, -22.1], - [50.0, -2.5], - [60.0, -27.7], - [70.0, -55.7], - [80.0, -76.5] - ], (9, 2), True, False, None), - ([ - { - 'id': 'some-value' - }, - { - 'id': 'some other value' - }, - ], (2, 2), False, True, None), - - ('Not an Array', None, True, False, ValueError), -]) + result = cls.from_csv( + input_file, property_column_map=property_map, **kwargs + ) + + +@pytest.mark.parametrize( + "value, expected_shape, has_ndarray, has_data_points, error", + [ + ( + np.asarray( + [ + [0.0, 15.0], + [10.0, -50.0], + [20.0, -56.5], + [30.0, -46.5], + [40.0, -22.1], + [50.0, -2.5], + [60.0, -27.7], + [70.0, -55.7], + [80.0, -76.5], + ] + ) + if HAS_NUMPY + else [ + [0.0, 15.0], + [10.0, -50.0], + [20.0, -56.5], + [30.0, -46.5], + [40.0, -22.1], + [50.0, -2.5], + [60.0, -27.7], + [70.0, -55.7], + [80.0, -76.5], + ], + (9, 2), + True, + False, + None, + ), + ( + [ + {"id": "some-value"}, + {"id": "some other value"}, + ], + (2, 2), + False, + True, + None, + ), + ("Not an Array", None, True, False, ValueError), + ], +) def test_from_array(value, expected_shape, has_ndarray, has_data_points, error): if has_ndarray is False and has_data_points is False: - raise AssertionError('Test is invalid. has_ndarray or has_data_points must be ' - 'True. Both were supplied as False.') + raise AssertionError( + "Test is invalid. has_ndarray or has_data_points must be " + "True. Both were supplied as False." + ) if not error: result = cls.from_array(value) assert result is not None @@ -762,3 +776,26 @@ def test_from_array(value, expected_shape, has_ndarray, has_data_points, error): else: with pytest.raises(error): result = cls.from_array(value) + + +@pytest.mark.parametrize( + "module_url, error", + [ + (None, None), + ("https://mycustomurl.com/", None), + ("../some/relative/path", None), + ], +) +def test_chart_module_url(module_url, error): + if not error: + result = cls(module_url=module_url) + assert result is not None + if module_url is not None: + assert result.module_url == module_url + if module_url != "https://code.highcharts.com/": + assert result.module_url != "https://code.highcharts.com/" + else: + assert result.module_url == "https://code.highcharts.com/" + else: + with pytest.raises(error): + result = cls(module_url=module_url) diff --git a/tox.ini b/tox.ini index f91ce5a3..f21edfff 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{310,311},no_numpy{310,311},docs,coverage +envlist = py{310,311,312,313},no_numpy{310,311,312,313},docs,coverage minversion = 4.4 [testenv] @@ -13,7 +13,7 @@ commands = [testenv:py] description = - py{310,311}: Run unit tests against {envname} + py{310,311,312,313}: Run unit tests against {envname} commands = {[testenv]commands}