solara-ui 1.41.0__py2.py3-none-any.whl → 1.43.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 (83) hide show
  1. solara/__init__.py +1 -1
  2. solara/__main__.py +17 -6
  3. solara/_stores.py +189 -0
  4. solara/components/__init__.py +18 -1
  5. solara/components/component_vue.py +23 -0
  6. solara/components/datatable.py +4 -4
  7. solara/components/echarts.py +5 -2
  8. solara/components/echarts.vue +22 -5
  9. solara/components/file_drop.py +20 -0
  10. solara/components/input.py +21 -1
  11. solara/components/markdown.py +62 -17
  12. solara/components/misc.py +2 -2
  13. solara/components/spinner-solara.vue +2 -2
  14. solara/components/spinner.py +17 -2
  15. solara/hooks/use_reactive.py +8 -1
  16. solara/lab/components/__init__.py +1 -0
  17. solara/lab/components/chat.py +3 -3
  18. solara/lab/components/input_time.py +133 -0
  19. solara/lab/hooks/dataframe.py +1 -0
  20. solara/lab/utils/dataframe.py +11 -1
  21. solara/reactive.py +9 -3
  22. solara/server/app.py +63 -30
  23. solara/server/flask.py +12 -2
  24. solara/server/jupyter/server_extension.py +1 -0
  25. solara/server/kernel.py +52 -4
  26. solara/server/kernel_context.py +66 -7
  27. solara/server/patch.py +25 -29
  28. solara/server/qt.py +1 -1
  29. solara/server/server.py +15 -5
  30. solara/server/settings.py +11 -0
  31. solara/server/shell.py +19 -1
  32. solara/server/starlette.py +39 -11
  33. solara/server/static/solara_bootstrap.py +1 -1
  34. solara/settings.py +17 -0
  35. solara/tasks.py +18 -8
  36. solara/template/portal/pyproject.toml +1 -1
  37. solara/test/pytest_plugin.py +4 -0
  38. solara/toestand.py +170 -16
  39. solara/util.py +40 -0
  40. solara/website/components/docs.py +4 -0
  41. solara/website/components/markdown.py +60 -2
  42. solara/website/pages/changelog/changelog.md +17 -0
  43. solara/website/pages/documentation/advanced/content/20-understanding/50-solara-server.md +10 -0
  44. solara/website/pages/documentation/api/cross_filter/cross_filter_dataframe.py +4 -5
  45. solara/website/pages/documentation/api/cross_filter/cross_filter_report.py +3 -5
  46. solara/website/pages/documentation/api/cross_filter/cross_filter_select.py +3 -5
  47. solara/website/pages/documentation/api/cross_filter/cross_filter_slider.py +3 -5
  48. solara/website/pages/documentation/api/hooks/use_cross_filter.py +3 -5
  49. solara/website/pages/documentation/api/hooks/use_exception.py +9 -11
  50. solara/website/pages/documentation/api/hooks/use_previous.py +6 -9
  51. solara/website/pages/documentation/api/hooks/use_state_or_update.py +23 -26
  52. solara/website/pages/documentation/api/hooks/use_thread.py +11 -13
  53. solara/website/pages/documentation/api/routing/route.py +10 -12
  54. solara/website/pages/documentation/api/routing/use_route.py +26 -30
  55. solara/website/pages/documentation/api/utilities/on_kernel_start.py +17 -0
  56. solara/website/pages/documentation/components/advanced/link.py +6 -8
  57. solara/website/pages/documentation/components/advanced/meta.py +6 -9
  58. solara/website/pages/documentation/components/advanced/style.py +7 -9
  59. solara/website/pages/documentation/components/input/file_browser.py +12 -14
  60. solara/website/pages/documentation/components/input/input.py +22 -0
  61. solara/website/pages/documentation/components/lab/input_time.py +15 -0
  62. solara/website/pages/documentation/components/layout/columns_responsive.py +37 -39
  63. solara/website/pages/documentation/components/layout/gridfixed.py +4 -6
  64. solara/website/pages/documentation/components/output/html.py +1 -3
  65. solara/website/pages/documentation/components/page/head.py +4 -7
  66. solara/website/pages/documentation/components/viz/echarts.py +3 -1
  67. solara/website/pages/documentation/examples/__init__.py +9 -0
  68. solara/website/pages/documentation/examples/ai/chatbot.py +60 -44
  69. solara/website/pages/documentation/examples/general/live_update.py +1 -0
  70. solara/website/pages/documentation/examples/general/pokemon_search.py +3 -3
  71. solara/website/pages/documentation/examples/visualization/linked_views.py +0 -3
  72. solara/website/pages/documentation/faq/content/99-faq.md +9 -0
  73. solara/website/pages/documentation/getting_started/content/00-quickstart.md +2 -2
  74. solara/website/pages/documentation/getting_started/content/01-introduction.md +1 -1
  75. solara/website/pages/documentation/getting_started/content/04-tutorials/_jupyter_dashboard_1.ipynb +23 -19
  76. solara/website/pages/documentation/getting_started/content/07-deploying/10-self-hosted.md +2 -2
  77. solara/website/pages/roadmap/roadmap.md +6 -0
  78. {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/METADATA +9 -6
  79. {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/RECORD +83 -80
  80. {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/WHEEL +1 -1
  81. {solara_ui-1.41.0.data → solara_ui-1.43.0.data}/data/etc/jupyter/jupyter_notebook_config.d/solara.json +0 -0
  82. {solara_ui-1.41.0.data → solara_ui-1.43.0.data}/data/etc/jupyter/jupyter_server_config.d/solara.json +0 -0
  83. {solara_ui-1.41.0.dist-info → solara_ui-1.43.0.dist-info}/licenses/LICENSE +0 -0
solara/server/shell.py CHANGED
@@ -1,3 +1,4 @@
1
+ import atexit
1
2
  import io
2
3
  import sys
3
4
  from binascii import b2a_base64
@@ -58,9 +59,12 @@ class SolaraDisplayPublisher(DisplayPublisher):
58
59
  self,
59
60
  data,
60
61
  metadata=None,
62
+ source=None,
63
+ *, # Enforce keyword-only arguments to match DisplayPublisher.publish
61
64
  transient=None,
62
65
  update=False,
63
- ):
66
+ **kwargs, # Make sure we're compatible with DisplayPublisher.publish
67
+ ) -> None:
64
68
  """Publish a display-data message
65
69
 
66
70
  Parameters
@@ -180,10 +184,24 @@ class SolaraInteractiveShell(InteractiveShell):
180
184
  history_manager = Any() # type: ignore
181
185
  display_pub: SolaraDisplayPublisher
182
186
 
187
+ def __init__(self, *args, **kwargs):
188
+ super().__init__(*args, **kwargs)
189
+ atexit.unregister(self.atexit_operations)
190
+
191
+ if self.magics_manager:
192
+ magic = self.magics_manager.registry["ScriptMagics"]
193
+ atexit.unregister(magic.kill_bg_processes)
194
+
183
195
  def set_parent(self, parent):
184
196
  """Tell the children about the parent message."""
185
197
  self.display_pub.set_parent(parent)
186
198
 
199
+ def init_sys_modules(self):
200
+ pass # don't create a __main__, it will cause a mem leak
201
+
202
+ def init_prefilter(self):
203
+ pass # avoid consuming memory
204
+
187
205
  def init_history(self):
188
206
  self.history_manager = Mock() # type: ignore
189
207
 
@@ -15,6 +15,7 @@ import anyio
15
15
  import starlette.websockets
16
16
  import uvicorn.server
17
17
  import websockets.legacy.http
18
+ import websockets.exceptions
18
19
 
19
20
  from solara.server.utils import path_is_child_of
20
21
 
@@ -86,8 +87,8 @@ prefix = ""
86
87
  # Since starlette seems to accept really large values for http, lets do the same for websockets
87
88
  # An arbitrarily large value we settled on for now is 32kb
88
89
  # 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:
90
+ ws_major_version = int(websockets.__version__.split(".")[0])
91
+ if ws_major_version >= 13:
91
92
  websockets.legacy.http.MAX_LINE_LENGTH = int(os.environ.get("WEBSOCKETS_MAX_LINE_LENGTH", str(1024 * 32))) # type: ignore
92
93
  else:
93
94
  websockets.legacy.http.MAX_LINE = 1024 * 32 # type: ignore
@@ -121,37 +122,55 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
121
122
  while len(self.to_send) > 0:
122
123
  first = self.to_send.pop(0)
123
124
  if isinstance(first, bytes):
124
- await self.ws.send_bytes(first)
125
+ await self._send_bytes_exc(first)
125
126
  else:
126
- await self.ws.send_text(first)
127
+ await self._send_text_exc(first)
128
+
129
+ async def _send_bytes_exc(self, data: bytes):
130
+ # make sures we catch the starlette/websockets specific exception
131
+ # and re-raise it as a websocket.WebSocketDisconnect
132
+ try:
133
+ await self.ws.send_bytes(data)
134
+ except (websockets.exceptions.ConnectionClosed, starlette.websockets.WebSocketDisconnect, RuntimeError) as e:
135
+ # starlette throws a RuntimeError once you call send after the connection is closed
136
+ raise websocket.WebSocketDisconnect() from e
137
+
138
+ async def _send_text_exc(self, data: str):
139
+ # make sures we catch the starlette/websockets specific exception
140
+ # and re-raise it as a websocket.WebSocketDisconnect
141
+ try:
142
+ await self.ws.send_text(data)
143
+ except (websockets.exceptions.ConnectionClosed, starlette.websockets.WebSocketDisconnect, RuntimeError) as e:
144
+ # starlette throws a RuntimeError once you call send after the connection is closed
145
+ raise websocket.WebSocketDisconnect() from e
127
146
 
128
147
  def close(self):
129
148
  if self.portal is None:
130
149
  asyncio.ensure_future(self.ws.close())
131
150
  else:
132
- self.portal.call(self.ws.close) # type: ignore
151
+ self.portal.call(self.ws.close)
133
152
 
134
153
  def send_text(self, data: str) -> None:
135
154
  if self.portal is None:
136
- task = self.event_loop.create_task(self.ws.send_text(data))
155
+ task = self.event_loop.create_task(self._send_text_exc(data))
137
156
  self.tasks.add(task)
138
157
  task.add_done_callback(self.tasks.discard)
139
158
  else:
140
159
  if settings.main.experimental_performance:
141
160
  self.to_send.append(data)
142
161
  else:
143
- self.portal.call(self.ws.send_bytes, data) # type: ignore
162
+ self.portal.call(self._send_text_exc, data)
144
163
 
145
164
  def send_bytes(self, data: bytes) -> None:
146
165
  if self.portal is None:
147
- task = self.event_loop.create_task(self.ws.send_bytes(data))
166
+ task = self.event_loop.create_task(self._send_bytes_exc(data))
148
167
  self.tasks.add(task)
149
168
  task.add_done_callback(self.tasks.discard)
150
169
  else:
151
170
  if settings.main.experimental_performance:
152
171
  self.to_send.append(data)
153
172
  else:
154
- self.portal.call(self.ws.send_bytes, data) # type: ignore
173
+ self.portal.call(self._send_bytes_exc, data)
155
174
 
156
175
  async def receive(self):
157
176
  if self.portal is None:
@@ -159,9 +178,9 @@ class WebsocketWrapper(websocket.WebsocketWrapper):
159
178
  else:
160
179
  if hasattr(self.portal, "start_task_soon"):
161
180
  # version 3+
162
- fut = self.portal.start_task_soon(self.ws.receive) # type: ignore
181
+ fut = self.portal.start_task_soon(self.ws.receive)
163
182
  else:
164
- fut = self.portal.spawn_task(self.ws.receive) # type: ignore
183
+ fut = self.portal.spawn_task(self.ws.receive)
165
184
 
166
185
  message = await asyncio.wrap_future(fut)
167
186
  if "text" in message:
@@ -444,6 +463,7 @@ See also https://solara.dev/documentation/getting_started/deploying/self-hosted
444
463
  session_id = request.cookies.get(server.COOKIE_KEY_SESSION_ID) or str(uuid4())
445
464
  samesite = "lax"
446
465
  secure = False
466
+ httponly = settings.session.http_only
447
467
  # we want samesite, so we can set a cookie when embedded in an iframe, such as on huggingface
448
468
  # however, samesite=none requires Secure https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
449
469
  # when hosted on the localhost domain we can always set the Secure flag
@@ -469,6 +489,7 @@ Also check out the following Solara documentation:
469
489
  expires="Fri, 01 Jan 2038 00:00:00 GMT",
470
490
  samesite=samesite, # type: ignore
471
491
  secure=secure, # type: ignore
492
+ httponly=httponly, # type: ignore
472
493
  ) # type: ignore
473
494
  return response
474
495
 
@@ -549,12 +570,19 @@ class StaticCdn(StaticFilesOptionalAuth):
549
570
 
550
571
 
551
572
  def on_startup():
573
+ appmod.ensure_apps_initialized()
552
574
  # TODO: configure and set max number of threads
553
575
  # see https://github.com/encode/starlette/issues/1724
554
576
  telemetry.server_start()
555
577
 
556
578
 
557
579
  def on_shutdown():
580
+ # shutdown all kernels
581
+ for context in list(kernel_context.contexts.values()):
582
+ try:
583
+ context.close()
584
+ except: # noqa
585
+ logger.exception("error closing kernel on shutdown")
558
586
  telemetry.server_stop()
559
587
 
560
588
 
@@ -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.41.0-py2.py3-none-any.whl", keep_going=True)
122
+ await micropip.install("/wheels/solara-1.43.0-py2.py3-none-any.whl", keep_going=True)
123
123
  import solara
124
124
 
125
125
  el = solara.Warning("lala")
solara/settings.py CHANGED
@@ -54,6 +54,9 @@ class Assets(BaseSettings):
54
54
 
55
55
  class MainSettings(BaseSettings):
56
56
  check_hooks: str = "warn"
57
+ allow_reactive_boolean: bool = True
58
+ # TODO: also change default_container in solara/components/__init__.py
59
+ default_container: Optional[str] = "Column"
57
60
 
58
61
  class Config:
59
62
  env_prefix = "solara_"
@@ -61,9 +64,23 @@ class MainSettings(BaseSettings):
61
64
  env_file = ".env"
62
65
 
63
66
 
67
+ class Storage(BaseSettings):
68
+ mutation_detection: Optional[bool] = None # True/False, or None to auto determine
69
+ factory: str = "solara.toestand.default_storage"
70
+
71
+ def get_factory(self):
72
+ return solara.util.import_item(self.factory)
73
+
74
+ class Config:
75
+ env_prefix = "solara_storage_"
76
+ case_sensitive = False
77
+ env_file = ".env"
78
+
79
+
64
80
  assets: Assets = Assets()
65
81
  cache: Cache = Cache()
66
82
  main = MainSettings()
83
+ storage = Storage()
67
84
 
68
85
  if main.check_hooks not in ["off", "warn", "raise"]:
69
86
  raise ValueError(f"Invalid value for check_hooks: {main.check_hooks}, expected one of ['off', 'warn', 'raise']")
solara/tasks.py CHANGED
@@ -1,3 +1,4 @@
1
+ import sys
1
2
  import abc
2
3
  import asyncio
3
4
  import dataclasses
@@ -27,6 +28,12 @@ from solara.toestand import Singleton
27
28
 
28
29
  from .toestand import Ref as ref
29
30
 
31
+ if sys.version_info >= (3, 8):
32
+ from typing import Literal
33
+ else:
34
+ from typing_extensions import Literal
35
+
36
+
30
37
  R = TypeVar("R")
31
38
  T = TypeVar("T")
32
39
  P = typing_extensions.ParamSpec("P")
@@ -686,24 +693,27 @@ def task(
686
693
  return wrapper(f)
687
694
 
688
695
 
696
+ # Quotes around Task[...] are needed in Python <= 3.9, since ParamSpec doesn't properly support non-type arguments
697
+ # i.e. [] is taken as a value instead of a type
698
+ # See https://github.com/python/typing_extensions/issues/126 and related issues
689
699
  @overload
690
700
  def use_task(
691
701
  f: None = None,
692
702
  *,
693
- dependencies: None = ...,
703
+ dependencies: Literal[None] = ...,
694
704
  raise_error=...,
695
705
  prefer_threaded=...,
696
- ) -> Callable[[Callable[P, R]], Task[P, R]]: ...
706
+ ) -> Callable[[Callable[[], R]], "Task[[], R]"]: ...
697
707
 
698
708
 
699
709
  @overload
700
710
  def use_task(
701
- f: Callable[P, R],
711
+ f: Callable[[], R],
702
712
  *,
703
- dependencies: None = ...,
713
+ dependencies: Literal[None] = ...,
704
714
  raise_error=...,
705
715
  prefer_threaded=...,
706
- ) -> Task[P, R]: ...
716
+ ) -> "Task[[], R]": ...
707
717
 
708
718
 
709
719
  @overload
@@ -727,12 +737,12 @@ def use_task(
727
737
 
728
738
 
729
739
  def use_task(
730
- f: Union[None, Callable[P, R]] = None,
740
+ f: Union[None, Callable[[], R]] = None,
731
741
  *,
732
742
  dependencies: Union[None, List] = [],
733
743
  raise_error=True,
734
744
  prefer_threaded=True,
735
- ) -> Union[Callable[[Callable[P, R]], Task[P, R]], Task[P, R]]:
745
+ ) -> Union[Callable[[Callable[[], R]], "Task[[], R]"], "Task[[], R]"]:
736
746
  """A hook that runs a function or coroutine function as a task and returns the result.
737
747
 
738
748
  Allows you to run code in the background, with the UI available to the user. This is useful for long running tasks,
@@ -811,7 +821,7 @@ def use_task(
811
821
  """
812
822
 
813
823
  def wrapper(f):
814
- def create_task() -> Task[P, R]:
824
+ def create_task() -> "Task[[], R]":
815
825
  return task(f, prefer_threaded=prefer_threaded)
816
826
 
817
827
  task_instance = solara.use_memo(create_task, dependencies=[])
@@ -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]
@@ -150,6 +150,7 @@ def solara_app(solara_server):
150
150
  solara.server.app.apps["__default__"].close()
151
151
  if isinstance(app, str):
152
152
  app = solara.server.app.AppScript(app)
153
+ app.init()
153
154
  used_app = app
154
155
  solara.server.app.apps["__default__"] = app
155
156
  try:
@@ -515,6 +516,8 @@ def create_runner_solara(solara_server, solara_app, page_session: "playwright.sy
515
516
 
516
517
  def run(f: Callable, locals={}):
517
518
  nonlocal count
519
+ from IPython.display import clear_output
520
+
518
521
  path = Path(f.__code__.co_filename)
519
522
  cwd = str(path.parent)
520
523
  current_dir = os.getcwd()
@@ -523,6 +526,7 @@ def create_runner_solara(solara_server, solara_app, page_session: "playwright.sy
523
526
 
524
527
  sys.path.append(cwd)
525
528
  try:
529
+ clear_output()
526
530
  f()
527
531
  finally:
528
532
  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,11 +92,43 @@ 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
 
101
+ # make sure all boolean operations give type errors
102
+ if not solara.settings.main.allow_reactive_boolean:
103
+
104
+ def __bool__(self):
105
+ raise TypeError("Reactive vars are not allowed in boolean expressions, did you mean to use .value?")
106
+
107
+ def __eq__(self, other):
108
+ raise TypeError(f"'==' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
109
+
110
+ def __ne__(self, other):
111
+ raise TypeError(f"'!=' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
112
+
113
+ # If we explicitly define __eq__, we need to explicitly define __hash__ as well
114
+ # Otherwise our class is marked unhashable
115
+ __hash__ = object.__hash__
116
+
117
+ def __lt__(self, other):
118
+ raise TypeError(f"'<' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
119
+
120
+ def __le__(self, other):
121
+ raise TypeError(f"'<=' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
122
+
123
+ def __gt__(self, other):
124
+ raise TypeError(f"'>' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
125
+
126
+ def __ge__(self, other):
127
+ raise TypeError(f"'>=' not supported between a Reactive and {other.__class__.__name__}, did you mean to use .value?")
128
+
129
+ def __len__(self):
130
+ raise TypeError("'len(...)' is not supported for a Reactive, did you mean to use .value?")
131
+
95
132
  @property
96
133
  def lock(self):
97
134
  raise NotImplementedError
@@ -199,8 +236,8 @@ class KernelStore(ValueBase[S], ABC):
199
236
  _type_counter: Dict[Any, int] = defaultdict(int)
200
237
  scope_lock = threading.RLock()
201
238
 
202
- def __init__(self, key=None):
203
- super().__init__()
239
+ def __init__(self, key=None, equals: Callable[[Any, Any], bool] = equals_extra):
240
+ super().__init__(equals=equals)
204
241
  self.storage_key = key
205
242
  self._global_dict = {}
206
243
  # since a set can trigger events, which can trigger new updates, we need a recursive lock
@@ -250,7 +287,7 @@ class KernelStore(ValueBase[S], ABC):
250
287
  def set(self, value: S):
251
288
  scope_dict, scope_id = self._get_dict()
252
289
  old = self.get()
253
- if equals(old, value):
290
+ if self.equals(old, value):
254
291
  return
255
292
  scope_dict[self.storage_key] = value
256
293
 
@@ -268,23 +305,114 @@ class KernelStore(ValueBase[S], ABC):
268
305
  def initial_value(self) -> S:
269
306
  pass
270
307
 
308
+ def _check_mutation(self):
309
+ pass
310
+
311
+
312
+ def _is_internal_module(file_name: str):
313
+ file_name_parts = file_name.split(os.sep)
314
+ if len(file_name_parts) < 2:
315
+ return False
316
+ return (
317
+ file_name_parts[-2:] == ["solara", "toestand.py"]
318
+ or file_name_parts[-2:] == ["solara", "reactive.py"]
319
+ or file_name_parts[-2:] == ["solara", "use_reactive.py"]
320
+ or file_name_parts[-2:] == ["reacton", "core.py"]
321
+ # If we use SomeClass[K](...) we go via the typing module, so we need to skip that as well
322
+ or (file_name_parts[-2].startswith("python") and file_name_parts[-1] == "typing.py")
323
+ )
324
+
325
+
326
+ def _find_outside_solara_frame() -> Optional[FrameType]:
327
+ # the module where the call stack origined from
328
+ current_frame: Optional[FrameType] = None
329
+ module_frame = None
330
+
331
+ # _getframe is not guaranteed to exist in all Python implementations,
332
+ # but is much faster than the inspect module
333
+ if hasattr(sys, "_getframe"):
334
+ current_frame = sys._getframe(1)
335
+ else:
336
+ current_frame = inspect.currentframe()
337
+
338
+ while current_frame is not None:
339
+ file_name = current_frame.f_code.co_filename
340
+ # Skip most common cases, i.e. toestand.py, reactive.py, use_reactive.py, Reacton's core.py, and the typing module
341
+ if not _is_internal_module(file_name):
342
+ module_frame = current_frame
343
+ break
344
+ current_frame = current_frame.f_back
345
+
346
+ return module_frame
347
+
271
348
 
272
349
  class KernelStoreValue(KernelStore[S]):
273
350
  default_value: S
351
+ _traceback: Optional[inspect.Traceback]
352
+ _default_value_copy: Optional[S]
274
353
 
275
- def __init__(self, default_value: S, key=None):
354
+ def __init__(self, default_value: S, key=None, equals: Callable[[Any, Any], bool] = equals_extra, unwrap=lambda x: x):
276
355
  self.default_value = default_value
356
+ self._unwrap = unwrap
357
+ self.equals = equals
358
+ self._mutation_detection = solara.settings.storage.mutation_detection
359
+ if self._mutation_detection:
360
+ frame = _find_outside_solara_frame()
361
+ if frame is not None:
362
+ self._traceback = inspect.getframeinfo(frame)
363
+ else:
364
+ self._traceback = None
365
+ self._default_value_copy = copy.deepcopy(default_value)
366
+ if not self.equals(self._unwrap(self.default_value), self._unwrap(self._default_value_copy)):
367
+ msg = """The equals function for this reactive value returned False when comparing a deepcopy to itself.
368
+
369
+ This reactive variable will not be able to detect mutations correctly, and is therefore disabled.
370
+
371
+ To avoid this warning, and to ensure that mutation detection works correctly, please provide a better equals function to the reactive variable.
372
+ 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.
373
+
374
+ Example:
375
+ df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
376
+ reactive_df = solara.reactive(df, equals=solara.util.equals_pickle)
377
+ """
378
+ tb = self._traceback
379
+ if tb:
380
+ if tb.code_context:
381
+ code = tb.code_context[0]
382
+ else:
383
+ code = "<No code context available>"
384
+ msg += "This warning was triggered from:\n" f"{tb.filename}:{tb.lineno}\n" f"{code}"
385
+ warnings.warn(msg)
386
+ self._mutation_detection = False
277
387
  cls = type(default_value)
278
388
  if key is None:
279
389
  with KernelStoreValue.scope_lock:
280
390
  index = self._type_counter[cls]
281
391
  self._type_counter[cls] += 1
282
392
  key = cls.__module__ + ":" + cls.__name__ + ":" + str(index)
283
- super().__init__(key=key)
393
+ super().__init__(key=key, equals=equals)
284
394
 
285
395
  def initial_value(self) -> S:
396
+ self._check_mutation()
286
397
  return self.default_value
287
398
 
399
+ def _check_mutation(self):
400
+ if not self._mutation_detection:
401
+ return
402
+ initial = self._unwrap(self._default_value_copy)
403
+ current = self._unwrap(self.default_value)
404
+ if not self.equals(initial, current):
405
+ tb = self._traceback
406
+ if tb:
407
+ if tb.code_context:
408
+ code = tb.code_context[0].strip()
409
+ else:
410
+ code = "No code context available"
411
+ msg = f"Reactive variable was initialized at {tb.filename}:{tb.lineno} with {initial!r}, but was mutated to {current!r}.\n" f"{code}"
412
+ else:
413
+ 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)."
414
+ raise ValueError(msg)
415
+
288
416
 
289
417
  def _create_key_callable(f: Callable[[], S]):
290
418
  try:
@@ -302,22 +430,48 @@ def _create_key_callable(f: Callable[[], S]):
302
430
 
303
431
 
304
432
  class KernelStoreFactory(KernelStore[S]):
305
- def __init__(self, factory: Callable[[], S], key=None):
433
+ def __init__(self, factory: Callable[[], S], key=None, equals: Callable[[Any, Any], bool] = equals_extra):
306
434
  self.factory = factory
307
435
  key = key or _create_key_callable(factory)
308
- super().__init__(key=key)
436
+ super().__init__(key=key, equals=equals)
309
437
 
310
438
  def initial_value(self) -> S:
311
439
  return self.factory()
312
440
 
313
441
 
442
+ def mutation_detection_storage(default_value: S, key=None, equals=None) -> ValueBase[S]:
443
+ from solara.util import equals_pickle as default_equals
444
+ from ._stores import MutateDetectorStore, StoreValue, _PublicValueNotSet, _SetValueNotSet
445
+
446
+ kernel_store = KernelStoreValue[StoreValue[S]](
447
+ StoreValue[S](private=default_value, public=_PublicValueNotSet(), get_traceback=None, set_value=_SetValueNotSet(), set_traceback=None),
448
+ key=key,
449
+ unwrap=lambda x: x.private,
450
+ )
451
+ return MutateDetectorStore[S](kernel_store, equals=equals or default_equals)
452
+
453
+
454
+ def default_storage(default_value: S, key=None, equals=None) -> ValueBase[S]:
455
+ # in solara v2 we will also do this when mutation_detection is None
456
+ # and we do not run on production mode
457
+ if solara.settings.storage.mutation_detection is True:
458
+ return mutation_detection_storage(default_value, key=key, equals=equals)
459
+ else:
460
+ return KernelStoreValue[S](default_value, key=key, equals=equals or equals_extra)
461
+
462
+
463
+ def _call_storage_factory(default_value: S, key=None, equals=None) -> ValueBase[S]:
464
+ factory = solara.settings.storage.get_factory()
465
+ return factory(default_value, key=key, equals=equals)
466
+
467
+
314
468
  class Reactive(ValueBase[S]):
315
469
  _storage: ValueBase[S]
316
470
 
317
- def __init__(self, default_value: Union[S, ValueBase[S]], key=None):
471
+ def __init__(self, default_value: Union[S, ValueBase[S]], key=None, equals=None):
318
472
  super().__init__()
319
473
  if not isinstance(default_value, ValueBase):
320
- self._storage = KernelStoreValue(default_value, key=key)
474
+ self._storage = _call_storage_factory(default_value, key=key, equals=equals)
321
475
  else:
322
476
  self._storage = default_value
323
477
  self.__post__init__()
@@ -505,11 +659,11 @@ def computed(
505
659
 
506
660
 
507
661
  class ReactiveField(Reactive[T]):
508
- def __init__(self, field: "FieldBase"):
662
+ def __init__(self, field: "FieldBase", equals: Callable[[Any, Any], bool] = equals_extra):
509
663
  # super().__init__() # type: ignore
510
664
  # We skip the Reactive constructor, because we do not need it, but we do
511
665
  # want to be an instanceof for use in use_reactive
512
- ValueBase.__init__(self)
666
+ ValueBase.__init__(self, equals=equals)
513
667
  self._field = field
514
668
  field = field
515
669
  while not isinstance(field, ValueBase):
@@ -536,7 +690,7 @@ class ReactiveField(Reactive[T]):
536
690
  except KeyError:
537
691
  return # same
538
692
  old_value = self._field.get(old)
539
- if not equals(new_value, old_value):
693
+ if not self.equals(new_value, old_value):
540
694
  listener(new_value)
541
695
 
542
696
  return self._root.subscribe_change(on_change, scope=scope)
@@ -550,7 +704,7 @@ class ReactiveField(Reactive[T]):
550
704
  except KeyError:
551
705
  return # see subscribe
552
706
  old_value = self._field.get(old)
553
- if not equals(new_value, old_value):
707
+ if not self.equals(new_value, old_value):
554
708
  listener(new_value, old_value)
555
709
 
556
710
  return self._root.subscribe_change(on_change, scope=scope)
solara/util.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import base64
2
2
  import contextlib
3
+ import functools
3
4
  import gzip
4
5
  import hashlib
5
6
  import json
@@ -31,6 +32,28 @@ except RuntimeError:
31
32
  has_threads = False
32
33
 
33
34
 
35
+ from reacton.utils import equals as equals_extra
36
+
37
+
38
+ def equals_pickle(a, b):
39
+ """Compare two values for equality.
40
+
41
+ Avoid false negative, e.g. when comparing dataframes, we want to compare
42
+ the data, not the object identity.
43
+
44
+ """
45
+ if equals_extra(a, b):
46
+ return True
47
+ import pickle
48
+
49
+ try:
50
+ if pickle.dumps(a) == pickle.dumps(b):
51
+ return True
52
+ except Exception:
53
+ pass
54
+ return False
55
+
56
+
34
57
  def github_url(file):
35
58
  rel_path = os.path.relpath(file, Path(solara.__file__).parent.parent)
36
59
  github_url = solara.github_url + f"/blob/{solara.git_branch}/" + rel_path
@@ -306,3 +329,20 @@ def is_running_in_vscode():
306
329
 
307
330
  def is_running_in_voila():
308
331
  return os.environ.get("SERVER_SOFTWARE", "").startswith("voila")
332
+
333
+
334
+ def once(f):
335
+ called = False
336
+ return_value = None
337
+
338
+ @functools.wraps(f)
339
+ def wrapper():
340
+ nonlocal called
341
+ nonlocal return_value
342
+ if called:
343
+ return return_value
344
+ called = True
345
+ return_value = f()
346
+ return return_value
347
+
348
+ return wrapper