diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 10e3b1416..4720d924f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -179,7 +179,7 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos, windows] - python: ["3.7", "3.13"] + python: ["3.7", "3.14"] exclude: - os: windows python: 3.7 @@ -368,7 +368,7 @@ jobs: # osx should work fine (and we test that locally often) os: [ubuntu, windows] # just 1 version, it's heavy - python-version: [3.9] + python-version: [3.9, 3.14] ipywidgets_major: ["7", "8"] include: - ipywidgets_major: "7" @@ -593,7 +593,7 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos, windows] - python: [3.7, 3.12] + python: [3.7, 3.14] ipywidgets: ["7.7", "8.0"] exclude: - os: windows diff --git a/packages/solara-enterprise/solara_enterprise/ssg.py b/packages/solara-enterprise/solara_enterprise/ssg.py index f53d0f96e..f557dcddd 100644 --- a/packages/solara-enterprise/solara_enterprise/ssg.py +++ b/packages/solara-enterprise/solara_enterprise/ssg.py @@ -2,6 +2,7 @@ import logging import threading import time +import sys import typing import urllib import weakref @@ -65,26 +66,49 @@ def _worker_with_cleanup(*args, **kwargs): class CleanupThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor): - def _adjust_thread_count(self): - # copy of the original code with _worker replaced - # if idle threads are available, don't spin new threads - if self._idle_semaphore.acquire(timeout=0): - return - - # When the executor gets lost, the weakref callback will wake up - # the worker threads. - def weakref_cb(_, q=self._work_queue): - q.put(None) - - num_threads = len(self._threads) - if num_threads < self._max_workers: - thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads) - t = threading.Thread( - name=thread_name, target=_worker_with_cleanup, args=(weakref.ref(self, weakref_cb), self._work_queue, self._initializer, self._initargs) - ) - t.start() - self._threads.add(t) # type: ignore - concurrent.futures.thread._threads_queues[t] = self._work_queue # type: ignore + if sys.version_info < (3, 14): + + def _adjust_thread_count(self): + # copy of the original code with _worker replaced + # if idle threads are available, don't spin new threads + if self._idle_semaphore.acquire(timeout=0): + return + + # When the executor gets lost, the weakref callback will wake up + # the worker threads. + def weakref_cb(_, q=self._work_queue): + q.put(None) + + num_threads = len(self._threads) + if num_threads < self._max_workers: + thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads) + t = threading.Thread( + name=thread_name, target=_worker_with_cleanup, args=(weakref.ref(self, weakref_cb), self._work_queue, self._initializer, self._initargs) + ) + t.start() + self._threads.add(t) # type: ignore + concurrent.futures.thread._threads_queues[t] = self._work_queue # type: ignore + else: + + def _adjust_thread_count(self): + # if idle threads are available, don't spin new threads + if self._idle_semaphore.acquire(timeout=0): + return + + # When the executor gets lost, the weakref callback will wake up + # the worker threads. + def weakref_cb(_, q=self._work_queue): + q.put(None) + + num_threads = len(self._threads) + if num_threads < self._max_workers: + thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads) + t = threading.Thread( + name=thread_name, target=_worker_with_cleanup, args=(weakref.ref(self, weakref_cb), self._create_worker_context(), self._work_queue) + ) + t.start() + self._threads.add(t) + concurrent.futures.thread._threads_queues[t] = self._work_queue # type: ignore def ssg_crawl(base_url: str): diff --git a/packages/solara-meta/pyproject.toml b/packages/solara-meta/pyproject.toml index 38a2c0c9d..c44c6c542 100644 --- a/packages/solara-meta/pyproject.toml +++ b/packages/solara-meta/pyproject.toml @@ -38,7 +38,8 @@ assets = [ documentation = [ "bqplot", - "altair", + "altair ; python_version < '3.14'", + "altair >= 6.0.0 ; python_version >= '3.14'", "folium", "ipycanvas", "ipyleaflet", @@ -90,6 +91,9 @@ dev = [ "numpy<2", ] +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build.targets.wheel] include = ["LICENSE"] diff --git a/solara/components/figure_altair.py b/solara/components/figure_altair.py index 2fcb50cc6..8e7ecd8d6 100644 --- a/solara/components/figure_altair.py +++ b/solara/components/figure_altair.py @@ -27,12 +27,27 @@ def FigureAltair( bundle = chart._repr_mimebundle_()[0] key4 = "application/vnd.vegalite.v4+json" key5 = "application/vnd.vegalite.v5+json" - if key4 not in bundle and key5 not in bundle: - raise KeyError(f"{key4} and {key5} not in mimebundle:\n\n{bundle}") - spec = bundle.get(key5, bundle.get(key4)) - return solara.widgets.VegaLite.element( - spec=spec, on_click=on_click, listen_to_click=on_click is not None, on_hover=on_hover, listen_to_hover=on_hover is not None - ) + key6 = "application/vnd.vegalite.v6.json" + if key6 in bundle: + version = 6 + spec = bundle.get(key6) + elif key5 in bundle: + version = 5 + spec = bundle.get(key5) + elif key4 in bundle: + version = 4 + spec = bundle.get(key4) + else: + raise KeyError(f"{key4} and {key5} and {key6} not in mimebundle:\n\n{bundle}") + + return solara.widgets.VegaLite.element( + version=version, + spec=spec, + on_click=on_click, + listen_to_click=on_click is not None, + on_hover=on_hover, + listen_to_hover=on_hover is not None, + ) # alias for backward compatibility diff --git a/solara/server/kernel_context.py b/solara/server/kernel_context.py index 4e6faa493..4ef5013ec 100644 --- a/solara/server/kernel_context.py +++ b/solara/server/kernel_context.py @@ -41,6 +41,18 @@ class Local(threading.local): local = Local() +def _get_event_loop(): + # not 100% why this is needed for Python 3.14 (maybe also lower?) + # but during the tests, even though we have pytest-tornasync installed, there is not + # event loop available. + current_loop = asyncio._get_running_loop() + if current_loop is not None: + return current_loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + class PageStatus(enum.Enum): CONNECTED = "connected" DISCONNECTED = "disconnected" @@ -77,7 +89,7 @@ class VirtualKernelContext: closed_event: threading.Event = dataclasses.field(default_factory=threading.Event) _on_close_callbacks: List[Callable[[], None]] = dataclasses.field(default_factory=list) lock: threading.RLock = dataclasses.field(default_factory=threading.RLock) - event_loop: asyncio.AbstractEventLoop = dataclasses.field(default_factory=asyncio.get_event_loop) + event_loop: asyncio.AbstractEventLoop = dataclasses.field(default_factory=_get_event_loop) def __post_init__(self): with self: diff --git a/solara/widgets/vue/vegalite.vue b/solara/widgets/vue/vegalite.vue index dcf3d3c54..1b6c325ff 100644 --- a/solara/widgets/vue/vegalite.vue +++ b/solara/widgets/vue/vegalite.vue @@ -74,9 +74,9 @@ module.exports = { require.config({ map: { '*': { - 'vega': `${this.getCdn()}/vega@5/build/vega.min.js`, - 'vega-lite': `${this.getCdn()}/vega-lite@5/build/vega-lite.min.js`, - 'vega-embed': `${this.getCdn()}/vega-embed@6/build/vega-embed.min.js`, + 'vega': `${this.getCdn()}/vega@${this.version}/build/vega.min.js`, + 'vega-lite': `${this.getCdn()}/vega-lite@${this.version}/build/vega-lite.min.js`, + 'vega-embed': `${this.getCdn()}/vega-embed@${this.version}/build/vega-embed.min.js`, } } }) diff --git a/solara/widgets/widgets.py b/solara/widgets/widgets.py index 17e32e7bc..c8cea735c 100644 --- a/solara/widgets/widgets.py +++ b/solara/widgets/widgets.py @@ -22,6 +22,7 @@ class VegaLite(v.VuetifyTemplate): on_click = traitlets.traitlets.Callable(None, allow_none=True) on_hover = traitlets.traitlets.Callable(None, allow_none=True) cdn = traitlets.Unicode(None, allow_none=True).tag(sync=True) + version = traitlets.Int(6).tag(sync=True) def vue_altair_click(self, *args): if self.on_click is not None: diff --git a/tests/unit/toestand_test.py b/tests/unit/toestand_test.py index bec245168..5c9c0eb53 100644 --- a/tests/unit/toestand_test.py +++ b/tests/unit/toestand_test.py @@ -1,5 +1,5 @@ +import concurrent import dataclasses -import threading import unittest.mock from pathlib import Path from typing import Callable, Dict, List, Optional, Set, TypeVar, cast @@ -879,28 +879,24 @@ class DataFrame: def test_thread_local(): - def test1(): - assert solara.lab.thread_local.reactive_used is None + with concurrent.futures.ThreadPoolExecutor(1) as w1, concurrent.futures.ThreadPoolExecutor(1) as w2: - t = threading.Thread(target=test1) - t.start() - t.join() + def test1(): + assert solara.toestand.thread_local.reactive_used is None - def test2(): - myset: Set[solara.lab.ValueBase] = set() - solara.lab.local.reactive_used = myset - assert solara.lab.thread_local.reactive_used is myset + w1.submit(test1).result() - t = threading.Thread(target=test2) - t.start() - t.join() + def test2(): + myset: Set[solara.toestand.ValueBase] = set() + solara.toestand.thread_local.reactive_used = myset + assert solara.toestand.thread_local.reactive_used is myset - def test3(): - assert solara.lab.thread_local.reactive_used is None + w1.submit(test2).result() - t = threading.Thread(target=test3) - t.start() - t.join() + def test3(): + assert solara.toestand.thread_local.reactive_used is None + + w2.submit(test3).result() def test_reactive_auto_subscribe(kernel_context):