From 17095f73d3264bd453e6e4e8e83c405e2d9adfc9 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:46:25 -0500 Subject: [PATCH 1/3] WIP recommendations (working) --- plotly/basedatatypes.py | 27 +++++++++ plotly/recommendations.py | 124 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 plotly/recommendations.py diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 1384e08d543..a7d19477e9e 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -29,6 +29,20 @@ Undefined = object() +def _emit_recommendations(obj, context): + """ + Emit recommendation warnings when recommendations mode is enabled. + context is one of "figure", "trace", "layout". + Lazy import to avoid circular imports. + """ + try: + from plotly.recommendations import run_recommendations + + run_recommendations(obj, context) + except Exception: + pass + + def _len_dict_item(item): """ Because a parsed dict path is a tuple containings strings or integers, to @@ -664,6 +678,9 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. ) raise type_err + # Recommendations mode: optional warnings when enabled + _emit_recommendations(self, "figure") + # Magic Methods # ------------- def __reduce__(self): @@ -2279,6 +2296,10 @@ def add_traces( # Update messages self._send_addTraces_msg(new_traces_data) + # Recommendations mode: run trace checkers on newly added traces + for trace in data: + _emit_recommendations(trace, "trace") + return self # Subplots @@ -4381,6 +4402,12 @@ def __init__(self, plotly_name, **kwargs): # ### Backing property for backward compatible _validator property ## self.__validators = None + # Recommendations mode: optional warnings for top-level graph objects + if isinstance(self, BaseTraceType): + _emit_recommendations(self, "trace") + elif isinstance(self, BaseLayoutType): + _emit_recommendations(self, "layout") + # @property # def _validate(self): # fig = self.figure diff --git a/plotly/recommendations.py b/plotly/recommendations.py new file mode 100644 index 00000000000..e2787fc9103 --- /dev/null +++ b/plotly/recommendations.py @@ -0,0 +1,124 @@ +""" +Recommendations mode: optional warnings when constructing graph_objects (Figure, +trace types, Layout) if arguments don't match certain criteria. Frame is not +included for now. + +Enable/disable globally: + - Environment: set PLOTLY_RECOMMENDATIONS=1 (or "true", "yes") to enable. + - In code: plotly.recommendations.config.enabled = True + +Example: + import plotly.recommendations + import plotly.graph_objects as go + + plotly.recommendations.config.enabled = True + + go.Figure(data=[go.Scatter(y=[1, 2, 3])]) # may emit recommendation warnings +""" + +import os +import warnings + +# ----------------------------------------------------------------------------- +# Global enable/disable +# ----------------------------------------------------------------------------- + +def _env_enabled(): + v = os.environ.get("PLOTLY_RECOMMENDATIONS", "").strip().lower() + return v in ("1", "true", "yes", "on") + + +class _RecommendationsConfig: + """ + Global config for recommendations mode. + When enabled, recommendation checkers run after Figure/trace/Layout + construction and may emit warnings. + """ + + def __init__(self): + self._enabled = _env_enabled() + + @property + def enabled(self): + return self._enabled + + @enabled.setter + def enabled(self, value): + self._enabled = bool(value) + +# Singleton used by the rest of the package +config = _RecommendationsConfig() + + +# ----------------------------------------------------------------------------- +# Recommendation checkers (extensible list) +# ----------------------------------------------------------------------------- + + +def _check_scatter_xy_length(obj, context): + """Warn if a scatter trace has x or y with length >= 10.""" + if context != "trace": + return + if getattr(obj, "plotly_name", None) != "scatter": + return + for prop in ("x", "y"): + try: + val = obj[prop] + if val is not None and hasattr(val, "__len__") and len(val) >= 10: + warnings.warn( + "Scatter trace '%s' has length %d (recommended < 10)." + % (prop, len(val)), + UserWarning, + stacklevel=2, + ) + except (KeyError, TypeError): + pass + + +def _recommendation_checkers(): + """ + Return the list of (context_filter, checker_func) to run. + checker_func(obj, context) may call warnings.warn(). + context is one of "figure", "trace", "layout". + context_filter: set of contexts this checker applies to, or None for all. + """ + return [ + ({"trace"}, _check_scatter_xy_length), + ] + + +def run_recommendations(obj, context): + """ + Run all recommendation checkers for the given object and context. + Called internally after Figure/trace/Layout construction when + recommendations mode is enabled. + + Parameters + ---------- + obj : BaseFigure | BasePlotlyType + The constructed figure, trace, or layout. + context : str + One of "figure", "trace", "layout". + """ + if not config.enabled: + return + checkers = _recommendation_checkers() + # For figures, run trace checkers on each trace (traces have props set by then) + if context == "figure" and hasattr(obj, "data"): + for trace in obj.data: + for ctx_filter, checker in checkers: + if ctx_filter is not None and "trace" not in ctx_filter: + continue + try: + checker(trace, "trace") + except Exception: + pass + # Run checkers for this context + for ctx_filter, checker in checkers: + if ctx_filter is not None and context not in ctx_filter: + continue + try: + checker(obj, context) + except Exception: + # Don't let a recommender break construction + pass From bfe083737eb8fae8c57a6bca5c6a0578f3abf263 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:31:02 -0500 Subject: [PATCH 2/3] revise recommendations.py --- plotly/recommendations.py | 150 ++++++++++++++++++++++++++++---------- 1 file changed, 111 insertions(+), 39 deletions(-) diff --git a/plotly/recommendations.py b/plotly/recommendations.py index e2787fc9103..b4b4fe68384 100644 --- a/plotly/recommendations.py +++ b/plotly/recommendations.py @@ -16,6 +16,8 @@ go.Figure(data=[go.Scatter(y=[1, 2, 3])]) # may emit recommendation warnings """ +from functools import partial +import inspect import os import warnings @@ -51,39 +53,102 @@ def enabled(self, value): # ----------------------------------------------------------------------------- -# Recommendation checkers (extensible list) +# Property resolution (single place for "does this property exist") # ----------------------------------------------------------------------------- -def _check_scatter_xy_length(obj, context): - """Warn if a scatter trace has x or y with length >= 10.""" - if context != "trace": - return - if getattr(obj, "plotly_name", None) != "scatter": - return - for prop in ("x", "y"): +def _targets(obj, context, prefix): + """Yield each object (layout or trace) that this prefix applies to.""" + if context == "figure": + if prefix == "layout" and hasattr(obj, "layout"): + yield obj.layout + if hasattr(obj, "data"): + for t in obj.data: + if prefix == "trace" or getattr(t, "plotly_name", None) == prefix: + yield t + elif (context == "trace" or context == "layout") and ( + prefix == "trace" or prefix == "layout" or getattr(obj, "plotly_name", None) == prefix + ): + yield obj + + +def _get_stacklevel(): + """Find the first frame outside the plotly package so the warning points at user code.""" + plotly_dir = os.path.abspath(os.path.dirname(__file__)) + stack = inspect.stack() + for i, frame in enumerate(stack): try: - val = obj[prop] - if val is not None and hasattr(val, "__len__") and len(val) >= 10: - warnings.warn( - "Scatter trace '%s' has length %d (recommended < 10)." - % (prop, len(val)), - UserWarning, - stacklevel=2, - ) - except (KeyError, TypeError): - pass + frame_path = os.path.abspath(frame.filename) + except (AttributeError, TypeError): + frame_path = getattr(frame, "filename", "") or "" + if not frame_path.startswith(plotly_dir): + break + else: + i = len(stack) - 1 + return i + + +def _is_empty(obj): + """ + Quick check if the object has no values assigned yet (e.g. just constructed). + Figure: no traces. Trace/layout: no properties in _props. + """ + if hasattr(obj, "_data"): + return len(obj._data) == 0 + if hasattr(obj, "_props"): + props = obj._props + return props is None or len(props) == 0 + return False + + +def _get_value(whole_obj, path, context): + """ + Resolve a full path (e.g. "scatter.x", "layout.title.text") from the whole + object (figure, or current trace/layout). Returns a list of values, one per + applicable target; missing properties yield None. + """ + prefix, suffix = path.split(".", 1) if "." in path else (path, "") + result = [] + for target in _targets(whole_obj, context, prefix): + if not suffix: + result.append(target) + continue + try: + v = target + for p in suffix.split("."): + v = v[p] + result.append(v) + except (KeyError, TypeError, AttributeError): + result.append(None) + return result + + +# ----------------------------------------------------------------------------- +# Recommendation checkers (extensible list) +# ----------------------------------------------------------------------------- + + +def max_length(input_list, max_length): + """ + Check if input_list has length >= max_length (e.g. recommended to keep shorter). + Returns a string describing the issue, or None if no issue was found. + """ + if input_list is not None and hasattr(input_list, "__len__") and len(input_list) >= max_length: + return f"has length {len(input_list)} (recommended <= {max_length})." + return None def _recommendation_checkers(): """ - Return the list of (context_filter, checker_func) to run. - checker_func(obj, context) may call warnings.warn(). - context is one of "figure", "trace", "layout". - context_filter: set of contexts this checker applies to, or None for all. + Return the list of (path_def, checker_func). + + path_def: a dot-separated path string (e.g. "scatter.x") or list of same. + checker_func: called with one value per path (or None if missing). Returns + an issue string to warn about, or None. """ return [ - ({"trace"}, _check_scatter_xy_length), + ("scatter.x", partial(max_length, max_length=1000)), + ("scatter.y", partial(max_length, max_length=1000)), ] @@ -93,6 +158,10 @@ def run_recommendations(obj, context): Called internally after Figure/trace/Layout construction when recommendations mode is enabled. + Property resolution is done here: for each checker we resolve the + path(s) on the applicable object(s), pass the values (or None) to the + checker, and catch exceptions so one checker cannot break others. + Parameters ---------- obj : BaseFigure | BasePlotlyType @@ -100,25 +169,28 @@ def run_recommendations(obj, context): context : str One of "figure", "trace", "layout". """ - if not config.enabled: + + if (not config.enabled) or _is_empty(obj): return + checkers = _recommendation_checkers() - # For figures, run trace checkers on each trace (traces have props set by then) - if context == "figure" and hasattr(obj, "data"): - for trace in obj.data: - for ctx_filter, checker in checkers: - if ctx_filter is not None and "trace" not in ctx_filter: - continue - try: - checker(trace, "trace") - except Exception: - pass - # Run checkers for this context - for ctx_filter, checker in checkers: - if ctx_filter is not None and context not in ctx_filter: + stacklevel = 0 + + for path_def, checker in checkers: + if not path_def: continue + paths = [path_def] if isinstance(path_def, str) else path_def + value_lists = [_get_value(obj, p, context) for p in paths] try: - checker(obj, context) + for value_tuple in zip(*value_lists): + issue = checker(*value_tuple) + if issue: + stacklevel = stacklevel or _get_stacklevel() + warnings.warn( + f"{path_def}: {issue}", + UserWarning, + stacklevel=stacklevel, + ) except Exception: - # Don't let a recommender break construction pass + From 83cf0cc8a4e79ff6d45a84ae084b2928d54150b5 Mon Sep 17 00:00:00 2001 From: Emily KL <4672118+emilykl@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:41:49 -0500 Subject: [PATCH 3/3] add error mode --- plotly/basedatatypes.py | 11 +++++++-- plotly/recommendations.py | 49 ++++++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index a7d19477e9e..c4945fd17db 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -35,10 +35,11 @@ def _emit_recommendations(obj, context): context is one of "figure", "trace", "layout". Lazy import to avoid circular imports. """ + from plotly.recommendations import run_recommendations, RecommendationError try: - from plotly.recommendations import run_recommendations - run_recommendations(obj, context) + except RecommendationError: + raise except Exception: pass @@ -5224,6 +5225,12 @@ def update(self, dict1=None, overwrite=False, **kwargs): BaseFigure._perform_update(self, dict1, overwrite=overwrite) BaseFigure._perform_update(self, kwargs, overwrite=overwrite) + # Recommendations mode: run after update so Express (trace.update(patch)) is covered + if isinstance(self, BaseTraceType): + _emit_recommendations(self, "trace") + elif isinstance(self, BaseLayoutType): + _emit_recommendations(self, "layout") + return self def pop(self, key, *args): diff --git a/plotly/recommendations.py b/plotly/recommendations.py index b4b4fe68384..6a48f8cfc7c 100644 --- a/plotly/recommendations.py +++ b/plotly/recommendations.py @@ -7,6 +7,10 @@ - Environment: set PLOTLY_RECOMMENDATIONS=1 (or "true", "yes") to enable. - In code: plotly.recommendations.config.enabled = True +Mode: plotly.recommendations.config.mode can be "warn" (default) or "error". + - "warn": emit a UserWarning when a recommendation is violated. + - "error": raise RecommendationError when a recommendation is violated. + Example: import plotly.recommendations import plotly.graph_objects as go @@ -21,24 +25,39 @@ import os import warnings +_VALID_MODES = ("warn", "error") + + # ----------------------------------------------------------------------------- -# Global enable/disable +# Exception and config # ----------------------------------------------------------------------------- + +class RecommendationError(ValueError): + """Raised when a recommendation is violated and config.mode is 'error'.""" + + def _env_enabled(): v = os.environ.get("PLOTLY_RECOMMENDATIONS", "").strip().lower() return v in ("1", "true", "yes", "on") +def _env_mode(): + v = os.environ.get("PLOTLY_RECOMMENDATIONS_MODE", "").strip().lower() + return v if v in _VALID_MODES else "warn" + + + class _RecommendationsConfig: """ Global config for recommendations mode. When enabled, recommendation checkers run after Figure/trace/Layout - construction and may emit warnings. + construction. Use config.mode to choose "warn" (emit warnings) or "error" (raise). """ def __init__(self): self._enabled = _env_enabled() + self._mode = _env_mode() @property def enabled(self): @@ -48,6 +67,19 @@ def enabled(self): def enabled(self, value): self._enabled = bool(value) + @property + def mode(self): + return self._mode + + @mode.setter + def mode(self, value): + if value not in _VALID_MODES: + raise ValueError( + "config.mode must be one of %s, got %r" % (_VALID_MODES, value) + ) + self._mode = value + + # Singleton used by the rest of the package config = _RecommendationsConfig() @@ -133,7 +165,7 @@ def max_length(input_list, max_length): Check if input_list has length >= max_length (e.g. recommended to keep shorter). Returns a string describing the issue, or None if no issue was found. """ - if input_list is not None and hasattr(input_list, "__len__") and len(input_list) >= max_length: + if input_list is not None and hasattr(input_list, "__len__") and len(input_list) > max_length: return f"has length {len(input_list)} (recommended <= {max_length})." return None @@ -185,12 +217,13 @@ def run_recommendations(obj, context): for value_tuple in zip(*value_lists): issue = checker(*value_tuple) if issue: + msg = f"{path_def}: {issue}" + if config.mode == "error": + raise RecommendationError(msg) stacklevel = stacklevel or _get_stacklevel() - warnings.warn( - f"{path_def}: {issue}", - UserWarning, - stacklevel=stacklevel, - ) + warnings.warn(msg, UserWarning, stacklevel=stacklevel) + except RecommendationError: + raise except Exception: pass