Skip to content

Commit c33bf59

Browse files
[feat] Add CCv2 support (#40)
### TL;DR Added support for Streamlit's Custom Component v2 API to the Bokeh component. ### What changed? - Added version detection to use Custom Component v2 API when Streamlit >= 1.51.0 is available - Implemented v2 component with proper theme handling and responsive sizing - Created utility functions for loading Bokeh scripts and managing component state
1 parent beb0be1 commit c33bf59

File tree

7 files changed

+838
-48
lines changed

7 files changed

+838
-48
lines changed

e2e_playwright/bokeh_chart_basics.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import numpy as np
16+
import pandas as pd
1517
import streamlit as st
1618
from bokeh.plotting import figure
1719
from chart_types import CHART_TYPES
18-
import numpy as np
19-
import pandas as pd
20+
2021
from streamlit_bokeh import streamlit_bokeh
2122

2223
np.random.seed(0)
@@ -188,9 +189,9 @@ def lorenz(xyz, t):
188189
line_width=1.5,
189190
)
190191
elif chart == "linear_cmap":
191-
from numpy.random import standard_normal
192192
from bokeh.transform import linear_cmap
193193
from bokeh.util.hex import hexbin
194+
from numpy.random import standard_normal
194195

195196
x = standard_normal(50000)
196197
y = standard_normal(50000)
@@ -279,6 +280,6 @@ def lorenz(xyz, t):
279280
p.legend.location = "top_left"
280281
p.legend.orientation = "horizontal"
281282

282-
streamlit_bokeh(p, use_container_width=False)
283+
streamlit_bokeh(p, use_container_width=False, key="chart_1")
283284

284-
streamlit_bokeh(p, use_container_width=True)
285+
streamlit_bokeh(p, use_container_width=True, key="chart_2")

streamlit_bokeh/__init__.py

Lines changed: 88 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,53 +12,86 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import importlib.metadata
1516
import json
1617
import os
18+
import re
1719
from typing import TYPE_CHECKING
18-
import streamlit.components.v1 as components
19-
import importlib.metadata
20+
2021
import bokeh
22+
import streamlit as st
2123
from bokeh.embed import json_item
2224

25+
if TYPE_CHECKING:
26+
from bokeh.plotting.figure import Figure
27+
28+
2329
# Create a _RELEASE constant. We'll set this to False while we're developing
2430
# the component, and True when we're ready to package and distribute it.
2531
# (This is, of course, optional - there are innumerable ways to manage your
2632
# release process.)
2733
_DEV = os.environ.get("DEV", False)
2834
_RELEASE = not _DEV
2935

30-
# Declare a Streamlit component. `declare_component` returns a function
31-
# that is used to create instances of the component. We're naming this
32-
# function "_component_func", with an underscore prefix, because we don't want
33-
# to expose it directly to users. Instead, we will create a custom wrapper
34-
# function, below, that will serve as our component's public API.
35-
36-
# It's worth noting that this call to `declare_component` is the
37-
# *only thing* you need to do to create the binding between Streamlit and
38-
# your component frontend. Everything else we do in this file is simply a
39-
# best practice.
40-
41-
if not _RELEASE:
42-
_component_func = components.declare_component(
43-
# We give the component a simple, descriptive name ("streamlit_bokeh"
44-
# does not fit this bill, so please choose something better for your
45-
# own component :)
46-
"streamlit_bokeh",
47-
# Pass `url` here to tell Streamlit that the component will be served
48-
# by the local dev server that you run via `npm run start`.
49-
# (This is useful while your component is in development.)
50-
url="http://localhost:3001",
36+
37+
def _version_ge(a: str, b: str) -> bool:
38+
"""
39+
Return True if version string a is greater than or equal to b.
40+
41+
The comparison extracts up to three numeric components from each version
42+
string (major, minor, patch) and compares them as integer tuples.
43+
Non-numeric suffixes (for example, 'rc1', 'dev') are ignored.
44+
45+
Parameters
46+
----------
47+
a : str
48+
The left-hand version string.
49+
b : str
50+
The right-hand version string to compare against.
51+
52+
Returns
53+
-------
54+
bool
55+
True if a >= b, otherwise False.
56+
"""
57+
58+
def parse(v: str) -> tuple[int, int, int]:
59+
nums = [int(x) for x in re.findall(r"\d+", v)[:3]]
60+
while len(nums) < 3:
61+
nums.append(0)
62+
return nums[0], nums[1], nums[2]
63+
64+
return parse(a) >= parse(b)
65+
66+
67+
_STREAMLIT_VERSION = importlib.metadata.version("streamlit")
68+
69+
# If streamlit version is >= 1.51.0 use Custom Component v2 API, otherwise use
70+
# Custom Component v1 API
71+
# _IS_USING_CCV2 = _version_ge(_STREAMLIT_VERSION, "1.51.0")
72+
# Temporarily setting this to False, will be updated in next PR.
73+
_IS_USING_CCV2 = False
74+
75+
# Version-gated component registration
76+
if _IS_USING_CCV2:
77+
_component_func = st.components.v2.component(
78+
"streamlit-bokeh.streamlit_bokeh",
79+
js="v2/index-*.mjs",
80+
html="<div class='stBokehContainer'></div>",
5181
)
5282
else:
53-
# When we're distributing a production version of the component, we'll
54-
# replace the `url` param with `path`, and point it to the component's
55-
# build directory:
56-
parent_dir = os.path.dirname(os.path.abspath(__file__))
57-
build_dir = os.path.join(parent_dir, "frontend/build")
58-
_component_func = components.declare_component("streamlit_bokeh", path=build_dir)
83+
if not _RELEASE:
84+
_component_func = st.components.v1.declare_component(
85+
"streamlit_bokeh",
86+
url="http://localhost:3001",
87+
)
88+
else:
89+
parent_dir = os.path.dirname(os.path.abspath(__file__))
90+
build_dir = os.path.join(parent_dir, "frontend/build")
91+
_component_func = st.components.v1.declare_component(
92+
"streamlit_bokeh", path=build_dir
93+
)
5994

60-
if TYPE_CHECKING:
61-
from bokeh.plotting.figure import Figure
6295

6396
__version__ = importlib.metadata.version("streamlit_bokeh")
6497
REQUIRED_BOKEH_VERSION = "3.8.0"
@@ -112,14 +145,28 @@ def streamlit_bokeh(
112145
f"{REQUIRED_BOKEH_VERSION}` to install the correct version."
113146
)
114147

115-
# Call through to our private component function. Arguments we pass here
116-
# will be sent to the frontend, where they'll be available in an "args"
117-
# dictionary.
118-
_component_func(
119-
figure=json.dumps(json_item(figure)),
120-
use_container_width=use_container_width,
121-
bokeh_theme=theme,
122-
key=key,
123-
)
148+
if _IS_USING_CCV2:
149+
# Call through to our private component function.
150+
_component_func(
151+
key=key,
152+
data={
153+
"figure": json.dumps(json_item(figure)),
154+
"bokeh_theme": theme,
155+
"use_container_width": use_container_width,
156+
},
157+
isolate_styles=False,
158+
)
159+
160+
return None
161+
else:
162+
# Call through to our private component function. Arguments we pass here
163+
# will be sent to the frontend, where they'll be available in an "args"
164+
# dictionary.
165+
_component_func(
166+
figure=json.dumps(json_item(figure)),
167+
use_container_width=use_container_width,
168+
bokeh_theme=theme,
169+
key=key,
170+
)
124171

125-
return None
172+
return None
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Copyright (c) Snowflake Inc. (2025)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { beforeEach, describe, expect, test } from "vitest"
18+
19+
import {
20+
getChartDataGenerator,
21+
getChartDimensions,
22+
setChartThemeGenerator,
23+
} from "./index"
24+
import { MinimalStreamlitTheme } from "./streamlit-theme"
25+
26+
describe("getChartDataGenerator", () => {
27+
let getChartData: (figure: string) => {
28+
data: object | null
29+
hasChanged: boolean
30+
}
31+
32+
beforeEach(() => {
33+
getChartData = getChartDataGenerator()
34+
})
35+
36+
test("should return parsed data and hasChanged true on first call", () => {
37+
const figure = JSON.stringify({ key: "value" })
38+
const result = getChartData(figure)
39+
40+
expect(result).toEqual({ data: { key: "value" }, hasChanged: true })
41+
})
42+
43+
test("should return hasChanged false for the same figure", () => {
44+
const figure = JSON.stringify({ key: "value" })
45+
getChartData(figure)
46+
const result = getChartData(figure)
47+
48+
expect(result).toEqual({ data: { key: "value" }, hasChanged: false })
49+
})
50+
51+
test("should return hasChanged true for a different figure", () => {
52+
getChartData(JSON.stringify({ key: "value" }))
53+
const newFigure = JSON.stringify({ key: "newValue" })
54+
const result = getChartData(newFigure)
55+
56+
expect(result).toEqual({ data: { key: "newValue" }, hasChanged: true })
57+
})
58+
})
59+
60+
// Unit tests for setChartThemeGenerator
61+
describe("setChartThemeGenerator", () => {
62+
let setChartTheme: (
63+
newTheme: string,
64+
newAppTheme: MinimalStreamlitTheme
65+
) => boolean
66+
67+
beforeEach(() => {
68+
setChartTheme = setChartThemeGenerator()
69+
})
70+
71+
test("should apply the theme when theme changes", () => {
72+
const newTheme = "dark"
73+
const newAppTheme: MinimalStreamlitTheme = {
74+
textColor: "white",
75+
backgroundColor: "black",
76+
secondaryBackgroundColor: "gray",
77+
font: "Source Pro",
78+
}
79+
const result = setChartTheme(newTheme, newAppTheme)
80+
const { use_theme: useTheme } =
81+
global.window.Bokeh.require("core/properties")
82+
83+
expect(result).toBe(true)
84+
expect(useTheme).toHaveBeenCalled()
85+
})
86+
87+
test("should not reapply the theme if it's the same", () => {
88+
const newTheme = "dark"
89+
const newAppTheme: MinimalStreamlitTheme = {
90+
textColor: "white",
91+
backgroundColor: "black",
92+
secondaryBackgroundColor: "gray",
93+
font: "Source Pro",
94+
}
95+
setChartTheme(newTheme, newAppTheme)
96+
const result = setChartTheme(newTheme, newAppTheme)
97+
98+
expect(result).toBe(false)
99+
})
100+
101+
test("should apply Streamlit theme when appropriate", () => {
102+
const newTheme = "streamlit"
103+
const newAppTheme: MinimalStreamlitTheme = {
104+
textColor: "white",
105+
backgroundColor: "black",
106+
secondaryBackgroundColor: "gray",
107+
font: "Source Pro",
108+
}
109+
const result = setChartTheme(newTheme, newAppTheme)
110+
111+
expect(result).toBe(true)
112+
})
113+
})
114+
115+
describe("getChartDimensions", () => {
116+
test("should return default dimensions when no width/height attributes are provided", () => {
117+
const plot = { attributes: {} }
118+
const result = getChartDimensions(plot, false, document.documentElement)
119+
expect(result).toEqual({ width: 400, height: 350 })
120+
})
121+
122+
test("should return provided dimensions when width/height attributes are set", () => {
123+
const plot = { attributes: { width: 800, height: 400 } }
124+
const result = getChartDimensions(plot, false, document.documentElement)
125+
expect(result).toEqual({ width: 800, height: 400 })
126+
})
127+
128+
test("should calculate new dimensions based on container width", () => {
129+
Object.defineProperty(document.documentElement, "clientWidth", {
130+
configurable: true,
131+
writable: true,
132+
value: 1200, // Set the desired value
133+
})
134+
135+
const plot = { attributes: { width: 800, height: 400 } }
136+
const result = getChartDimensions(plot, true, document.documentElement)
137+
expect(result.width).toBe(1200)
138+
expect(result.height).toBeCloseTo(600)
139+
})
140+
})

0 commit comments

Comments
 (0)