diff --git a/CHANGELOG.md b/CHANGELOG.md index d0986ae5797..77441b70677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! - Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution! - Fix serialization of box/lasso select data for figures with multiple subplots [[#5710](https://github.com/plotly/plotly.py/pull/5710)], with thanks to @flpi-sun for the contribution! +- Stop the code generator from writing a duplicate optional `FigureWidget` import block into both `plotly.graph_objs` and `plotly.graph_objects`; `plotly.graph_objects.FigureWidget` is now re-exported from `plotly.graph_objs` like every other class, with no change in behaviour [[#5849](https://github.com/plotly/plotly.py/issues/5849)] ## [7.1.0] - 2026-09-15 diff --git a/codegen/__init__.py b/codegen/__init__.py index b299fa36045..dea51a88664 100644 --- a/codegen/__init__.py +++ b/codegen/__init__.py @@ -307,6 +307,11 @@ def __getattr__(import_name): "..graph_objs." + rel_path.split(".")[-1] for rel_path in datatype_rel_class_imports[()] ] + # FigureWidget is owned by graph_objs, which handles the optional + # ipywidgets import and the missing_* fallback. Re-export it from + # graph_objs like every other class instead of duplicating the optional + # import block in graph_objects. + graph_objects_rel_classes.append("..graph_objs.FigureWidget") graph_objects_rel_modules = [ "..graph_objs." + rel_module.split(".")[-1] for rel_module in datatype_rel_module_imports[()] @@ -315,7 +320,6 @@ def __getattr__(import_name): graph_objects_init_source = build_from_imports_py( graph_objects_rel_modules, graph_objects_rel_classes, - init_extra=optional_figure_widget_import, ) graph_objects_path = opath.join(outdir, "graph_objects", "__init__.py") os.makedirs(opath.join(outdir, "graph_objects"), exist_ok=True) diff --git a/plotly/graph_objects/__init__.py b/plotly/graph_objects/__init__.py index 670e4b8c6a1..919f804085d 100644 --- a/plotly/graph_objects/__init__.py +++ b/plotly/graph_objects/__init__.py @@ -77,6 +77,7 @@ from ..graph_objs import YBins from ..graph_objs import Trace from ..graph_objs import Histogram2dcontour + from ..graph_objs import FigureWidget from ..graph_objs import waterfall from ..graph_objs import volume from ..graph_objs import violin @@ -255,42 +256,6 @@ "..graph_objs.YBins", "..graph_objs.Trace", "..graph_objs.Histogram2dcontour", + "..graph_objs.FigureWidget", ], ) - - -if sys.version_info < (3, 7) or TYPE_CHECKING: - try: - import ipywidgets as _ipywidgets - from packaging.version import Version as _Version - - if _Version(_ipywidgets.__version__) >= _Version("7.0.0"): - from ..graph_objs._figurewidget import FigureWidget - else: - raise ImportError() - except Exception: - from ..missing_anywidget import FigureWidget -else: - __all__.append("FigureWidget") - orig_getattr = __getattr__ - - def __getattr__(import_name): - if import_name == "FigureWidget": - try: - import ipywidgets - from packaging.version import Version - - if Version(ipywidgets.__version__) >= Version("7.0.0"): - from ..graph_objs._figurewidget import FigureWidget - - return FigureWidget - else: - raise ImportError() - except Exception: - from ..missing_anywidget import FigureWidget - - return FigureWidget - else: - raise ImportError() - - return orig_getattr(import_name) diff --git a/tests/test_core/test_figure_widget_backend/test_figurewidget_reexport.py b/tests/test_core/test_figure_widget_backend/test_figurewidget_reexport.py new file mode 100644 index 00000000000..1c801081c94 --- /dev/null +++ b/tests/test_core/test_figure_widget_backend/test_figurewidget_reexport.py @@ -0,0 +1,77 @@ +# ruff: noqa: F401 F403 F405 +""" +Regression test for plotly/plotly.py#5849. + +The optional ``FigureWidget`` import block (the ``try: import ipywidgets`` / +``missing_*`` fallback) must live only in ``plotly/graph_objs/__init__.py``. +``plotly/graph_objects/__init__.py`` must re-export ``FigureWidget`` from +``graph_objs`` like every other class, instead of duplicating that block. + +These tests fail on unmodified master, where the block is emitted into both +generated files. +""" + +import os +import os.path as opath +import subprocess +import sys + +import plotly +import plotly.graph_objects as go +import plotly.graph_objs as go2 +from plotly.missing_anywidget import FigureWidget as FigureWidgetMissingAnywidget + + +def test_figurewidget_is_reexported_from_graph_objs(): + # (a) graph_objects.FigureWidget is the same object as + # graph_objs.FigureWidget: it is re-exported, not imported separately. + assert go.FigureWidget is go2.FigureWidget + + +def test_figurewidget_fallback_when_ipywidgets_missing(): + # (b) With the ipywidgets import forced to fail, graph_objects.FigureWidget + # still resolves to the missing_* fallback owned by graph_objs. This runs + # in a subprocess so it holds whether or not ipywidgets is installed. + script = ( + "import sys\n" + "sys.modules['ipywidgets'] = None\n" + "import plotly.graph_objects as go\n" + "from plotly.missing_anywidget import FigureWidget as Missing\n" + "assert go.FigureWidget is Missing, " + "f'expected missing_* fallback, got {go.FigureWidget!r}'\n" + "print('ok')\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"forced-failure fallback check failed:\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "ok" in result.stdout + + +def test_no_duplicate_ipywidgets_block_in_graph_objects(): + # (c) The optional ipywidgets import block must not be duplicated in + # graph_objects/__init__.py; it belongs only in graph_objs/__init__.py. + plotly_root = opath.dirname(plotly.__file__) + go_init = opath.join(plotly_root, "graph_objects", "__init__.py") + with open(go_init) as f: + go_source = f.read() + assert "import ipywidgets" not in go_source, ( + "graph_objects/__init__.py still contains a duplicated " + "optional ipywidgets import block" + ) + assert "missing_anywidget" not in go_source, ( + "graph_objects/__init__.py still references the missing_* fallback " + "directly instead of re-exporting FigureWidget from graph_objs" + ) + + # The block must still be present in graph_objs/__init__.py. + go2_init = opath.join(plotly_root, "graph_objs", "__init__.py") + with open(go2_init) as f: + go2_source = f.read() + assert "import ipywidgets" in go2_source + assert "missing_anywidget" in go2_source