solara-ui 1.40.0__py2.py3-none-any.whl → 1.42.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. solara/__init__.py +1 -1
  2. solara/__main__.py +30 -11
  3. solara/_stores.py +185 -0
  4. solara/components/component_vue.py +26 -2
  5. solara/components/echarts.py +5 -2
  6. solara/components/echarts.vue +22 -5
  7. solara/components/file_drop.py +20 -0
  8. solara/components/input.py +16 -0
  9. solara/components/markdown.py +22 -13
  10. solara/components/spinner-solara.vue +2 -2
  11. solara/components/spinner.py +17 -2
  12. solara/hooks/use_reactive.py +8 -1
  13. solara/reactive.py +9 -3
  14. solara/server/assets/style.css +2 -0
  15. solara/server/kernel.py +2 -1
  16. solara/server/qt.py +113 -0
  17. solara/server/settings.py +1 -0
  18. solara/server/starlette.py +2 -2
  19. solara/server/static/main-vuetify.js +10 -0
  20. solara/server/static/solara_bootstrap.py +1 -1
  21. solara/server/templates/loader-solara.html +1 -1
  22. solara/server/templates/solara.html.j2 +6 -1
  23. solara/settings.py +14 -0
  24. solara/template/portal/pyproject.toml +1 -1
  25. solara/test/pytest_plugin.py +3 -0
  26. solara/toestand.py +139 -16
  27. solara/util.py +22 -0
  28. solara/website/components/markdown.py +45 -1
  29. solara/website/components/sidebar.py +3 -1
  30. solara/website/pages/__init__.py +13 -7
  31. solara/website/pages/changelog/changelog.md +9 -0
  32. solara/website/pages/documentation/advanced/content/20-understanding/40-routing.md +17 -1
  33. solara/website/pages/documentation/api/cross_filter/cross_filter_dataframe.py +4 -5
  34. solara/website/pages/documentation/api/cross_filter/cross_filter_report.py +3 -5
  35. solara/website/pages/documentation/api/cross_filter/cross_filter_select.py +3 -5
  36. solara/website/pages/documentation/api/cross_filter/cross_filter_slider.py +3 -5
  37. solara/website/pages/documentation/api/hooks/use_cross_filter.py +3 -5
  38. solara/website/pages/documentation/api/hooks/use_exception.py +9 -11
  39. solara/website/pages/documentation/api/hooks/use_previous.py +6 -9
  40. solara/website/pages/documentation/api/hooks/use_state_or_update.py +23 -26
  41. solara/website/pages/documentation/api/hooks/use_thread.py +11 -13
  42. solara/website/pages/documentation/api/utilities/on_kernel_start.py +17 -0
  43. solara/website/pages/documentation/components/input/input.py +22 -0
  44. solara/website/pages/documentation/components/viz/echarts.py +3 -1
  45. solara/website/pages/documentation/examples/__init__.py +13 -21
  46. solara/website/pages/documentation/examples/ai/chatbot.py +1 -1
  47. solara/website/pages/documentation/examples/general/pokemon_search.py +3 -3
  48. solara/website/pages/documentation/examples/general/vue_component.py +1 -1
  49. solara/website/pages/documentation/examples/libraries/altair.py +1 -0
  50. solara/website/pages/documentation/examples/libraries/bqplot.py +1 -1
  51. solara/website/pages/documentation/examples/libraries/ipyleaflet.py +1 -1
  52. solara/website/pages/documentation/examples/libraries/ipyleaflet_advanced.py +1 -1
  53. solara/website/pages/documentation/examples/utilities/countdown_timer.py +18 -20
  54. solara/website/pages/documentation/examples/visualization/annotator.py +1 -3
  55. solara/website/pages/documentation/examples/visualization/linked_views.py +3 -6
  56. solara/website/pages/documentation/getting_started/content/00-quickstart.md +19 -1
  57. solara/website/pages/documentation/getting_started/content/01-introduction.md +1 -1
  58. solara/website/pages/roadmap/roadmap.md +3 -0
  59. solara/widgets/vue/navigator.vue +46 -16
  60. solara/widgets/vue/vegalite.vue +18 -0
  61. {solara_ui-1.40.0.dist-info → solara_ui-1.42.0.dist-info}/METADATA +8 -5
  62. {solara_ui-1.40.0.dist-info → solara_ui-1.42.0.dist-info}/RECORD +66 -64
  63. {solara_ui-1.40.0.dist-info → solara_ui-1.42.0.dist-info}/WHEEL +1 -1
  64. {solara_ui-1.40.0.data → solara_ui-1.42.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
  65. {solara_ui-1.40.0.data → solara_ui-1.42.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
  66. {solara_ui-1.40.0.dist-info → solara_ui-1.42.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,12 +8,15 @@ class SpinnerSolaraWidget(ipyvue.VueTemplate):
8
8
  template_file = (__file__, "spinner-solara.vue")
9
9
 
10
10
  size = traitlets.Unicode("64px").tag(sync=True)
11
+ color_back = traitlets.Unicode("#FFCF64").tag(sync=True)
12
+ color_front = traitlets.Unicode("#FF8C3E").tag(sync=True)
11
13
 
12
14
 
13
15
  @solara.component
14
- def SpinnerSolara(size="64px"):
16
+ def SpinnerSolara(size="64px", color_back="#FFCF64", color_front="#FF8C3E"):
15
17
  """Spinner component with the Solara logo to indicate the app is busy.
16
18
 
19
+ ## Examples
17
20
  ### Basic example
18
21
 
19
22
  ```solara
@@ -24,7 +27,19 @@ def SpinnerSolara(size="64px"):
24
27
  solara.SpinnerSolara(size="100px")
25
28
  ```
26
29
 
30
+ ## Changing the colors
31
+ ```solara
32
+ import solara
33
+
34
+ @solara.component
35
+ def Page():
36
+ solara.SpinnerSolara(size="100px", color_back="Grey", color_front="Lime")
37
+ ```
38
+
39
+
27
40
  ## Arguments
28
41
  * `size`: Size of the spinner.
42
+ * `color_back`: Color of the spinner in the background.
43
+ * `color_front`: Color of the spinner in the foreground.
29
44
  """
30
- return SpinnerSolaraWidget.element(size=size)
45
+ return SpinnerSolaraWidget.element(size=size, color_back=color_back, color_front=color_front)
@@ -1,4 +1,4 @@
1
- from typing import Callable, Optional, TypeVar, Union
1
+ from typing import Any, Callable, Optional, TypeVar, Union
2
2
 
3
3
  import solara
4
4
 
@@ -8,6 +8,7 @@ T = TypeVar("T")
8
8
  def use_reactive(
9
9
  value: Union[T, solara.Reactive[T]],
10
10
  on_change: Optional[Callable[[T], None]] = None,
11
+ equals: Callable[[Any, Any], bool] = solara.util.equals_extra,
11
12
  ) -> solara.Reactive[T]:
12
13
  """Creates a reactive variable with the a local component scope.
13
14
 
@@ -44,6 +45,12 @@ def use_reactive(
44
45
  * on_change (Optional[Callable[[T], None]]): An optional callback function
45
46
  that will be called when the reactive variable's value changes.
46
47
 
48
+ * equals: A function that returns True if two values are considered equal, and False otherwise.
49
+ The default function is `solara.util.equals`, which performs a deep comparison of the two values
50
+ and is more forgiving than the default `==` operator.
51
+ You can provide a custom function if you need to define a different notion of equality.
52
+
53
+
47
54
  Returns:
48
55
  solara.Reactive[T]: A reactive variable with the specified initial value
49
56
  or the provided reactive variable.
solara/reactive.py CHANGED
@@ -1,13 +1,14 @@
1
- from typing import TypeVar
1
+ from typing import Any, Callable, TypeVar
2
2
 
3
3
  from solara.toestand import Reactive
4
+ import solara.util
4
5
 
5
6
  __all__ = ["reactive", "Reactive"]
6
7
 
7
8
  T = TypeVar("T")
8
9
 
9
10
 
10
- def reactive(value: T) -> Reactive[T]:
11
+ def reactive(value: T, equals: Callable[[Any, Any], bool] = solara.util.equals_extra) -> Reactive[T]:
11
12
  """Creates a new Reactive object with the given initial value.
12
13
 
13
14
  Reactive objects are mostly used to manage global or application-wide state in
@@ -35,6 +36,11 @@ def reactive(value: T) -> Reactive[T]:
35
36
 
36
37
  Args:
37
38
  value (T): The initial value of the reactive variable.
39
+ equals: A function that returns True if two values are considered equal, and False otherwise.
40
+ The default function is `solara.util.equals`, which performs a deep comparison of the two values
41
+ and is more forgiving than the default `==` operator.
42
+ You can provide a custom function if you need to define a different notion of equality.
43
+
38
44
 
39
45
  Returns:
40
46
  Reactive[T]: A new Reactive object with the specified initial value.
@@ -90,4 +96,4 @@ def reactive(value: T) -> Reactive[T]:
90
96
  Whenever the counter value changes, `CounterDisplay` automatically updates to display the new value.
91
97
 
92
98
  """
93
- return Reactive(value)
99
+ return Reactive(value, equals=equals)
@@ -1,6 +1,7 @@
1
1
  html {
2
2
  /* override vuetify's css reset ress.css */
3
3
  overflow-y: auto;
4
+ scroll-behavior: smooth;
4
5
  }
5
6
 
6
7
  .jupyter-widgets code {
@@ -124,6 +125,7 @@ div.highlight {
124
125
 
125
126
  .solara-autorouter-content {
126
127
  height: 100%;
128
+ max-width: 100%;
127
129
  }
128
130
 
129
131
  /* originally from index.css */
solara/server/kernel.py CHANGED
@@ -2,6 +2,7 @@ import json
2
2
  import logging
3
3
  import pdb
4
4
  import queue
5
+ import re
5
6
  import struct
6
7
  import warnings
7
8
  from binascii import b2a_base64
@@ -73,7 +74,7 @@ def json_dumps(data):
73
74
  )
74
75
 
75
76
 
76
- ipykernel_version = tuple(map(int, ipykernel.__version__.split(".")))
77
+ ipykernel_version = tuple(map(int, re.split(r"\D+", ipykernel.__version__)[:3]))
77
78
  if ipykernel_version >= (6, 18, 0):
78
79
  import comm.base_comm
79
80
 
solara/server/qt.py ADDED
@@ -0,0 +1,113 @@
1
+ import sys
2
+ from typing import List
3
+ import webbrowser
4
+ from qtpy.QtWidgets import QApplication
5
+ from qtpy.QtWebEngineWidgets import QWebEngineView
6
+ from qtpy.QtWebChannel import QWebChannel
7
+ from qtpy import QtCore, QtGui
8
+ import signal
9
+ from pathlib import Path
10
+
11
+ HERE = Path(__file__).parent
12
+
13
+
14
+ # setUrlRequestInterceptor, navigationRequested and acceptNavigationRequest
15
+ # all trigger the websocket to disconnect, so we need to block cross origin
16
+ # requests on the frontend/browser side by intercepting clicks on links
17
+
18
+ cross_origin_block_js = """
19
+ var script = document.createElement('script');
20
+ script.src = 'qrc:///qtwebchannel/qwebchannel.js';
21
+ document.head.appendChild(script);
22
+ script.onload = function() {
23
+ new QWebChannel(qt.webChannelTransport, function(channel) {
24
+ let py_callback = channel.objects.py_callback;
25
+
26
+ document.addEventListener('click', function(event) {
27
+ let target = event.target;
28
+ while (target && target.tagName !== 'A') {
29
+ target = target.parentNode;
30
+ }
31
+
32
+ if (target && target.tagName === 'A') {
33
+ const linkOrigin = new URL(target.href).origin;
34
+ const currentOrigin = window.location.origin;
35
+
36
+ if (linkOrigin !== currentOrigin) {
37
+ event.preventDefault();
38
+ console.log("Blocked cross-origin navigation to:", target.href);
39
+ py_callback.open_link(target.href); // Call Python method
40
+ }
41
+ }
42
+ }, true);
43
+ });
44
+ };
45
+ """
46
+
47
+
48
+ class PyCallback(QtCore.QObject):
49
+ @QtCore.Slot(str)
50
+ def open_link(self, url):
51
+ webbrowser.open(url)
52
+
53
+
54
+ class QWebEngineViewWithPopup(QWebEngineView):
55
+ # keep a strong reference to all windows
56
+ windows: List = []
57
+
58
+ def __init__(self):
59
+ super().__init__()
60
+ self.page().newWindowRequested.connect(self.handle_new_window_request)
61
+
62
+ # Set up WebChannel and py_callback object
63
+ self.py_callback = PyCallback()
64
+ self.channel = QWebChannel()
65
+ self.channel.registerObject("py_callback", self.py_callback)
66
+ self.page().setWebChannel(self.channel)
67
+
68
+ self.loadFinished.connect(self._inject_javascript)
69
+
70
+ def _inject_javascript(self, ok):
71
+ self.page().runJavaScript(cross_origin_block_js)
72
+
73
+ def handle_new_window_request(self, info):
74
+ webview = QWebEngineViewWithPopup()
75
+ geometry = info.requestedGeometry()
76
+ webview.resize(geometry.width(), geometry.height())
77
+ webview.setUrl(info.requestedUrl())
78
+ webview.show()
79
+ QWebEngineViewWithPopup.windows.append(webview)
80
+ return webview
81
+
82
+
83
+ def run_qt(url):
84
+ app = QApplication(["Solara App"])
85
+ web = QWebEngineViewWithPopup()
86
+ web.setUrl(QtCore.QUrl(url))
87
+ web.resize(1024, 1024)
88
+ web.show()
89
+
90
+ app_name = "Solara"
91
+ app.setApplicationDisplayName(app_name)
92
+ app.setApplicationName(app_name)
93
+ web.setWindowTitle(app_name)
94
+ app.setWindowIcon(QtGui.QIcon(str(HERE.parent / "website/public/logo.svg")))
95
+ if sys.platform.startswith("darwin"):
96
+ # Set app name, if PyObjC is installed
97
+ # Python 2 has PyObjC preinstalled
98
+ # Python 3: pip3 install pyobjc-framework-Cocoa
99
+ try:
100
+ from Foundation import NSBundle
101
+
102
+ bundle = NSBundle.mainBundle()
103
+ if bundle:
104
+ app_info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
105
+ if app_info is not None:
106
+ app_info["CFBundleName"] = app_name
107
+ app_info["CFBundleDisplayName"] = app_name
108
+ except ModuleNotFoundError:
109
+ pass
110
+
111
+ # without this, ctrl-c does not work in the terminal
112
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
113
+ app.exec_()
solara/server/settings.py CHANGED
@@ -37,6 +37,7 @@ class ThemeVariant(str, Enum):
37
37
  class ThemeSettings(BaseSettings):
38
38
  variant: ThemeVariant = ThemeVariant.light
39
39
  loader: str = "solara"
40
+ show_banner: bool = True
40
41
 
41
42
  class Config:
42
43
  env_prefix = "solara_theme_"
@@ -86,8 +86,8 @@ prefix = ""
86
86
  # Since starlette seems to accept really large values for http, lets do the same for websockets
87
87
  # An arbitrarily large value we settled on for now is 32kb
88
88
  # If we don't do this, users with many cookies will fail to get a websocket connection.
89
- ws_version = tuple(map(int, websockets.__version__.split(".")))
90
- if ws_version[0] >= 13:
89
+ ws_major_version = int(websockets.__version__.split(".")[0])
90
+ if ws_major_version >= 13:
91
91
  websockets.legacy.http.MAX_LINE_LENGTH = int(os.environ.get("WEBSOCKETS_MAX_LINE_LENGTH", str(1024 * 32))) # type: ignore
92
92
  else:
93
93
  websockets.legacy.http.MAX_LINE = 1024 * 32 # type: ignore
@@ -138,15 +138,25 @@ async function solaraInit(mountId, appName) {
138
138
  });
139
139
 
140
140
  window.addEventListener('solara.router', function (event) {
141
+ app.$data.urlHasChanged = true;
141
142
  if(kernel.status == 'busy') {
142
143
  app.$data.loadingPage = true;
143
144
  }
144
145
  });
145
146
  kernel.statusChanged.connect(() => {
147
+ // When navigation is triggered from the front-end, kernel.status becoming busy and
148
+ // solara.router event happen in a different order than when navigating through Python, so
149
+ // if the URL has changed when the kernel becomes busy, we set loadingPage to true
150
+ if (kernel.status == 'busy' && app.$data.urlHasChanged) {
151
+ app.$data.loadingPage = true;
152
+ }
146
153
  // the first idle after a loadingPage == true (a router event)
147
154
  // will be used as indicator that the page is loaded
148
155
  if (app.$data.loadingPage && kernel.status == 'idle') {
149
156
  app.$data.loadingPage = false;
157
+ app.$data.urlHasChanged = false;
158
+ const event = new Event('solara.pageReady');
159
+ window.dispatchEvent(event);
150
160
  }
151
161
  });
152
162
 
@@ -119,7 +119,7 @@ async def main():
119
119
  ]
120
120
  for dep in requirements:
121
121
  await micropip.install(dep, keep_going=True)
122
- await micropip.install("/wheels/solara-1.40.0-py2.py3-none-any.whl", keep_going=True)
122
+ await micropip.install("/wheels/solara-1.42.0-py2.py3-none-any.whl", keep_going=True)
123
123
  import solara
124
124
 
125
125
  el = solara.Warning("lala")
@@ -32,7 +32,7 @@
32
32
  <jupyter-widget-mount-point mount-id="solara-main">
33
33
  A widget with mount-id="solara-main" should go here
34
34
  </jupyter-widget-mount-point>
35
- <div style="position: absolute; right: 0px; bottom: 0px; padding: 10px;">
35
+ <div v-if="showBanner" style="position: absolute; right: 0px; bottom: 0px; padding: 10px;">
36
36
  <b>This website runs on <a href="https://solara.dev">Solara</a></b>
37
37
  </div>
38
38
  </div>
@@ -172,7 +172,7 @@
172
172
  </script>
173
173
  {% endraw -%}
174
174
 
175
- <body data-base-url="{{root_path}}/static/">
175
+ <body data-base-url="{{root_path}}/jupyter/">
176
176
  {% if perform_check %}
177
177
  <iframe src="https://solara.dev/static/public/success.html?system=solara&check=html&version={{solara_version}}" style="display: none"></iframe>
178
178
  {% endif %}
@@ -296,6 +296,10 @@
296
296
  this.forceUpdateTrigger += 1;
297
297
  original_$forceUpdate();
298
298
  });
299
+ // in case we are showing a popop (e.g. using ipypopout), hide the banner
300
+ if(searchParams.has('modelid')) {
301
+ this.showBanner = false;
302
+ }
299
303
  },
300
304
  mounted() {
301
305
  document.querySelector('#app').removeAttribute("style");
@@ -413,6 +417,7 @@
413
417
  loadingPage: false,
414
418
  _lastBusyTimer: null,
415
419
  kernelBusyLong: false,
420
+ showBanner: theme.show_banner,
416
421
  // outputMessages: [{ name: 'stderr', text: 'lala' }],
417
422
  }
418
423
  }
solara/settings.py CHANGED
@@ -61,9 +61,23 @@ class MainSettings(BaseSettings):
61
61
  env_file = ".env"
62
62
 
63
63
 
64
+ class Storage(BaseSettings):
65
+ mutation_detection: Optional[bool] = None # True/False, or None to auto determine
66
+ factory: str = "solara.toestand.default_storage"
67
+
68
+ def get_factory(self):
69
+ return solara.util.import_item(self.factory)
70
+
71
+ class Config:
72
+ env_prefix = "solara_storage_"
73
+ case_sensitive = False
74
+ env_file = ".env"
75
+
76
+
64
77
  assets: Assets = Assets()
65
78
  cache: Cache = Cache()
66
79
  main = MainSettings()
80
+ storage = Storage()
67
81
 
68
82
  if main.check_hooks not in ["off", "warn", "raise"]:
69
83
  raise ValueError(f"Invalid value for check_hooks: {main.check_hooks}, expected one of ['off', 'warn', 'raise']")
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling >=0.25"]
2
+ requires = ["hatchling==1.26.3"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
@@ -515,6 +515,8 @@ def create_runner_solara(solara_server, solara_app, page_session: "playwright.sy
515
515
 
516
516
  def run(f: Callable, locals={}):
517
517
  nonlocal count
518
+ from IPython.display import clear_output
519
+
518
520
  path = Path(f.__code__.co_filename)
519
521
  cwd = str(path.parent)
520
522
  current_dir = os.getcwd()
@@ -523,6 +525,7 @@ def create_runner_solara(solara_server, solara_app, page_session: "playwright.sy
523
525
 
524
526
  sys.path.append(cwd)
525
527
  try:
528
+ clear_output()
526
529
  f()
527
530
  finally:
528
531
  os.chdir(current_dir)
solara/toestand.py CHANGED
@@ -1,9 +1,13 @@
1
1
  import contextlib
2
2
  import dataclasses
3
+ import inspect
3
4
  import logging
5
+ import os
4
6
  import sys
5
7
  import threading
8
+ from types import FrameType
6
9
  import warnings
10
+ import copy
7
11
  from abc import ABC, abstractmethod
8
12
  from collections import defaultdict
9
13
  from operator import getitem
@@ -24,9 +28,10 @@ from typing import (
24
28
 
25
29
  import react_ipywidgets as react
26
30
  import reacton.core
27
- from reacton.utils import equals
31
+ from solara.util import equals_extra
28
32
 
29
33
  import solara
34
+ import solara.settings
30
35
  from solara import _using_solara_server
31
36
 
32
37
  T = TypeVar("T")
@@ -61,7 +66,7 @@ def use_sync_external_store(subscribe: Callable[[Callable[[], None]], Callable[[
61
66
 
62
67
  def on_store_change(_ignore_new_state=None):
63
68
  new_state = get_snapshot()
64
- if not equals(new_state, prev_state.current):
69
+ if not equals_extra(new_state, prev_state.current):
65
70
  prev_state.current = new_state
66
71
  force_update()
67
72
 
@@ -87,8 +92,9 @@ def merge_state(d1: S, **kwargs) -> S:
87
92
 
88
93
 
89
94
  class ValueBase(Generic[T]):
90
- def __init__(self, merge: Callable = merge_state):
95
+ def __init__(self, merge: Callable = merge_state, equals=equals_extra):
91
96
  self.merge = merge
97
+ self.equals = equals
92
98
  self.listeners: Dict[str, Set[Tuple[Callable[[T], None], Optional[ContextManager]]]] = defaultdict(set)
93
99
  self.listeners2: Dict[str, Set[Tuple[Callable[[T, T], None], Optional[ContextManager]]]] = defaultdict(set)
94
100
 
@@ -199,8 +205,8 @@ class KernelStore(ValueBase[S], ABC):
199
205
  _type_counter: Dict[Any, int] = defaultdict(int)
200
206
  scope_lock = threading.RLock()
201
207
 
202
- def __init__(self, key=None):
203
- super().__init__()
208
+ def __init__(self, key=None, equals: Callable[[Any, Any], bool] = equals_extra):
209
+ super().__init__(equals=equals)
204
210
  self.storage_key = key
205
211
  self._global_dict = {}
206
212
  # since a set can trigger events, which can trigger new updates, we need a recursive lock
@@ -250,7 +256,7 @@ class KernelStore(ValueBase[S], ABC):
250
256
  def set(self, value: S):
251
257
  scope_dict, scope_id = self._get_dict()
252
258
  old = self.get()
253
- if equals(old, value):
259
+ if self.equals(old, value):
254
260
  return
255
261
  scope_dict[self.storage_key] = value
256
262
 
@@ -268,23 +274,114 @@ class KernelStore(ValueBase[S], ABC):
268
274
  def initial_value(self) -> S:
269
275
  pass
270
276
 
277
+ def _check_mutation(self):
278
+ pass
279
+
280
+
281
+ def _is_internal_module(file_name: str):
282
+ file_name_parts = file_name.split(os.sep)
283
+ if len(file_name_parts) < 2:
284
+ return False
285
+ return (
286
+ file_name_parts[-2:] == ["solara", "toestand.py"]
287
+ or file_name_parts[-2:] == ["solara", "reactive.py"]
288
+ or file_name_parts[-2:] == ["solara", "use_reactive.py"]
289
+ or file_name_parts[-2:] == ["reacton", "core.py"]
290
+ # If we use SomeClass[K](...) we go via the typing module, so we need to skip that as well
291
+ or (file_name_parts[-2].startswith("python") and file_name_parts[-1] == "typing.py")
292
+ )
293
+
294
+
295
+ def _find_outside_solara_frame() -> Optional[FrameType]:
296
+ # the module where the call stack origined from
297
+ current_frame: Optional[FrameType] = None
298
+ module_frame = None
299
+
300
+ # _getframe is not guaranteed to exist in all Python implementations,
301
+ # but is much faster than the inspect module
302
+ if hasattr(sys, "_getframe"):
303
+ current_frame = sys._getframe(1)
304
+ else:
305
+ current_frame = inspect.currentframe()
306
+
307
+ while current_frame is not None:
308
+ file_name = current_frame.f_code.co_filename
309
+ # Skip most common cases, i.e. toestand.py, reactive.py, use_reactive.py, Reacton's core.py, and the typing module
310
+ if not _is_internal_module(file_name):
311
+ module_frame = current_frame
312
+ break
313
+ current_frame = current_frame.f_back
314
+
315
+ return module_frame
316
+
271
317
 
272
318
  class KernelStoreValue(KernelStore[S]):
273
319
  default_value: S
320
+ _traceback: Optional[inspect.Traceback]
321
+ _default_value_copy: Optional[S]
274
322
 
275
- def __init__(self, default_value: S, key=None):
323
+ def __init__(self, default_value: S, key=None, equals: Callable[[Any, Any], bool] = equals_extra, unwrap=lambda x: x):
276
324
  self.default_value = default_value
325
+ self._unwrap = unwrap
326
+ self.equals = equals
327
+ self._mutation_detection = solara.settings.storage.mutation_detection
328
+ if self._mutation_detection:
329
+ frame = _find_outside_solara_frame()
330
+ if frame is not None:
331
+ self._traceback = inspect.getframeinfo(frame)
332
+ else:
333
+ self._traceback = None
334
+ self._default_value_copy = copy.deepcopy(default_value)
335
+ if not self.equals(self._unwrap(self.default_value), self._unwrap(self._default_value_copy)):
336
+ msg = """The equals function for this reactive value returned False when comparing a deepcopy to itself.
337
+
338
+ This reactive variable will not be able to detect mutations correctly, and is therefore disabled.
339
+
340
+ To avoid this warning, and to ensure that mutation detection works correctly, please provide a better equals function to the reactive variable.
341
+ A good choice for dataframes and numpy arrays might be solara.util.equals_pickle, which will also attempt to compare the pickled values of the objects.
342
+
343
+ Example:
344
+ df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
345
+ reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
346
+ """
347
+ tb = self._traceback
348
+ if tb:
349
+ if tb.code_context:
350
+ code = tb.code_context[0]
351
+ else:
352
+ code = "<No code context available>"
353
+ msg += "This warning was triggered from:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
354
+ warnings.warn(msg)
355
+ self._mutation_detection = False
277
356
  cls = type(default_value)
278
357
  if key is None:
279
358
  with KernelStoreValue.scope_lock:
280
359
  index = self._type_counter[cls]
281
360
  self._type_counter[cls] += 1
282
361
  key = cls.__module__ + ":" + cls.__name__ + ":" + str(index)
283
- super().__init__(key=key)
362
+ super().__init__(key=key, equals=equals)
284
363
 
285
364
  def initial_value(self) -> S:
365
+ self._check_mutation()
286
366
  return self.default_value
287
367
 
368
+ def _check_mutation(self):
369
+ if not self._mutation_detection:
370
+ return
371
+ initial = self._unwrap(self._default_value_copy)
372
+ current = self._unwrap(self.default_value)
373
+ if not self.equals(initial, current):
374
+ tb = self._traceback
375
+ if tb:
376
+ if tb.code_context:
377
+ code = tb.code_context[0].strip()
378
+ else:
379
+ code = "No code context available"
380
+ msg = f"Reactive variable was initialized at {tb.filename}:{tb.lineno} with {initial!r}, but was mutated to {current!r}.\n" f"{code}"
381
+ else:
382
+ msg = f"Reactive variable was initialized with a value of {initial!r}, but was mutated to {current!r} (unable to report the location in the source code)."
383
+ raise ValueError(msg)
384
+
288
385
 
289
386
  def _create_key_callable(f: Callable[[], S]):
290
387
  try:
@@ -302,22 +399,48 @@ def _create_key_callable(f: Callable[[], S]):
302
399
 
303
400
 
304
401
  class KernelStoreFactory(KernelStore[S]):
305
- def __init__(self, factory: Callable[[], S], key=None):
402
+ def __init__(self, factory: Callable[[], S], key=None, equals: Callable[[Any, Any], bool] = equals_extra):
306
403
  self.factory = factory
307
404
  key = key or _create_key_callable(factory)
308
- super().__init__(key=key)
405
+ super().__init__(key=key, equals=equals)
309
406
 
310
407
  def initial_value(self) -> S:
311
408
  return self.factory()
312
409
 
313
410
 
411
+ def mutation_detection_storage(default_value: S, key=None, equals=None) -> ValueBase[S]:
412
+ from solara.util import equals_pickle as default_equals
413
+ from ._stores import MutateDetectorStore, StoreValue, _PublicValueNotSet
414
+
415
+ kernel_store = KernelStoreValue[StoreValue[S]](
416
+ StoreValue[S](private=default_value, public=_PublicValueNotSet(), get_traceback=None, set_value=None, set_traceback=None),
417
+ key=key,
418
+ unwrap=lambda x: x.private,
419
+ )
420
+ return MutateDetectorStore[S](kernel_store, equals=equals or default_equals)
421
+
422
+
423
+ def default_storage(default_value: S, key=None, equals=None) -> ValueBase[S]:
424
+ # in solara v2 we will also do this when mutation_detection is None
425
+ # and we do not run on production mode
426
+ if solara.settings.storage.mutation_detection is True:
427
+ return mutation_detection_storage(default_value, key=key, equals=equals)
428
+ else:
429
+ return KernelStoreValue[S](default_value, key=key, equals=equals or equals_extra)
430
+
431
+
432
+ def _call_storage_factory(default_value: S, key=None, equals=None) -> ValueBase[S]:
433
+ factory = solara.settings.storage.get_factory()
434
+ return factory(default_value, key=key, equals=equals)
435
+
436
+
314
437
  class Reactive(ValueBase[S]):
315
438
  _storage: ValueBase[S]
316
439
 
317
- def __init__(self, default_value: Union[S, ValueBase[S]], key=None):
440
+ def __init__(self, default_value: Union[S, ValueBase[S]], key=None, equals=None):
318
441
  super().__init__()
319
442
  if not isinstance(default_value, ValueBase):
320
- self._storage = KernelStoreValue(default_value, key=key)
443
+ self._storage = _call_storage_factory(default_value, key=key, equals=equals)
321
444
  else:
322
445
  self._storage = default_value
323
446
  self.__post__init__()
@@ -505,11 +628,11 @@ def computed(
505
628
 
506
629
 
507
630
  class ReactiveField(Reactive[T]):
508
- def __init__(self, field: "FieldBase"):
631
+ def __init__(self, field: "FieldBase", equals: Callable[[Any, Any], bool] = equals_extra):
509
632
  # super().__init__() # type: ignore
510
633
  # We skip the Reactive constructor, because we do not need it, but we do
511
634
  # want to be an instanceof for use in use_reactive
512
- ValueBase.__init__(self)
635
+ ValueBase.__init__(self, equals=equals)
513
636
  self._field = field
514
637
  field = field
515
638
  while not isinstance(field, ValueBase):
@@ -536,7 +659,7 @@ class ReactiveField(Reactive[T]):
536
659
  except KeyError:
537
660
  return # same
538
661
  old_value = self._field.get(old)
539
- if not equals(new_value, old_value):
662
+ if not self.equals(new_value, old_value):
540
663
  listener(new_value)
541
664
 
542
665
  return self._root.subscribe_change(on_change, scope=scope)
@@ -550,7 +673,7 @@ class ReactiveField(Reactive[T]):
550
673
  except KeyError:
551
674
  return # see subscribe
552
675
  old_value = self._field.get(old)
553
- if not equals(new_value, old_value):
676
+ if not self.equals(new_value, old_value):
554
677
  listener(new_value, old_value)
555
678
 
556
679
  return self._root.subscribe_change(on_change, scope=scope)