Skip to content

Commit aef2ed4

Browse files
[feat] Add v2 cookiecutter support (#113)
### TL;DR Added a v2 component template with Cookiecutter support, providing both React and pure TypeScript options for Streamlit component development. This has the full implementation for the React example, and a stub placeholder for the template without React. ### What changed? - Created a new v2 component template structure with Cookiecutter integration - Added support for two frontend frameworks: "React + TypeScript" and "Pure TypeScript" - Fixed code formatting in existing e2e test files - Updated template.json with more appropriate default values
1 parent 22feac7 commit aef2ed4

File tree

36 files changed

+1040
-42
lines changed

36 files changed

+1040
-42
lines changed

cookiecutter/v1/{{ cookiecutter.package_name }}/e2e/test_template.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
from pathlib import Path
22

33
import pytest
4-
5-
from playwright.sync_api import Page, expect
6-
74
from e2e_utils import StreamlitRunner
5+
from playwright.sync_api import Page, expect
86

97
ROOT_DIRECTORY = Path(__file__).parent.parent.absolute()
108
BASIC_EXAMPLE_FILE = ROOT_DIRECTORY / "my_component" / "example.py"
119

10+
1211
@pytest.fixture(autouse=True, scope="module")
1312
def streamlit_app():
1413
with StreamlitRunner(BASIC_EXAMPLE_FILE) as runner:
@@ -23,15 +22,11 @@ def go_to_app(page: Page, streamlit_app: StreamlitRunner):
2322

2423

2524
def test_should_render_template(page: Page):
26-
frame_0 = page.frame_locator(
27-
'iframe[title="my_component\\.my_component"]'
28-
).nth(0)
29-
frame_1 = page.frame_locator(
30-
'iframe[title="my_component\\.my_component"]'
31-
).nth(1)
25+
frame_0 = page.frame_locator('iframe[title="my_component\\.my_component"]').nth(0)
26+
frame_1 = page.frame_locator('iframe[title="my_component\\.my_component"]').nth(1)
3227

33-
st_markdown_0 = page.get_by_role('paragraph').nth(0)
34-
st_markdown_1 = page.get_by_role('paragraph').nth(1)
28+
st_markdown_0 = page.get_by_role("paragraph").nth(0)
29+
st_markdown_1 = page.get_by_role("paragraph").nth(1)
3530

3631
expect(st_markdown_0).to_contain_text("You've clicked 0 times!")
3732

@@ -66,7 +61,7 @@ def test_should_change_iframe_height(page: Page):
6661
locator = page.locator('iframe[title="my_component\\.my_component"]').nth(1)
6762

6863
page.wait_for_timeout(1000)
69-
init_frame_height = locator.bounding_box()['height']
64+
init_frame_height = locator.bounding_box()["height"]
7065
assert init_frame_height != 0
7166

7267
page.get_by_label("Enter a name").click()
@@ -77,13 +72,13 @@ def test_should_change_iframe_height(page: Page):
7772
expect(frame.get_by_text("Streamlit Streamlit Streamlit")).to_be_visible()
7873

7974
page.wait_for_timeout(1000)
80-
frame_height = locator.bounding_box()['height']
75+
frame_height = locator.bounding_box()["height"]
8176
assert frame_height > init_frame_height
8277

8378
page.set_viewport_size({"width": 150, "height": 150})
8479

8580
expect(frame.get_by_text("Streamlit Streamlit Streamlit")).not_to_be_in_viewport()
8681

8782
page.wait_for_timeout(1000)
88-
frame_height_after_viewport_change = locator.bounding_box()['height']
83+
frame_height_after_viewport_change = locator.bounding_box()["height"]
8984
assert frame_height_after_viewport_change > frame_height

cookiecutter/v2/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Streamlit template for CookieCutter
2+
3+
Template for creating a streamlit component
4+
5+
## Usage
6+
7+
### Run Cookiecutter via uvx
8+
9+
```
10+
uvx cookiecutter https://github.com/streamlit/component-template.git --directory=cookiecutter/v2
11+
```
12+
13+
Follow the prompts to generate your project.
14+
15+
More info:
16+
17+
- Cookiecutter: `https://pypi.org/project/cookiecutter/`
18+
- uv tools: `https://docs.astral.sh/uv/guides/tools/`

cookiecutter/v2/cookiecutter.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"author_name": "John Smith",
3+
"author_email": "[email protected]",
4+
"project_name": "Streamlit Component X",
5+
"package_name": "{{ cookiecutter.project_name.lower().replace(' ', '-') }}",
6+
"import_name": "{{ cookiecutter.package_name.lower().replace('-', '_') }}",
7+
"description": "Streamlit component that allows you to do X",
8+
"open_source_license": [
9+
"MIT license",
10+
"BSD license",
11+
"ISC license",
12+
"Apache Software License 2.0",
13+
"GNU General Public License v3",
14+
"Not open source"
15+
],
16+
"framework": [
17+
"React + Typescript",
18+
"Pure Typescript"
19+
]
20+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import shutil
2+
from pathlib import Path
3+
4+
project_dir = Path("{{ cookiecutter.import_name }}").absolute()
5+
6+
framework = "{{ cookiecutter.framework }}"
7+
if framework == "React + Typescript":
8+
shutil.move(str(project_dir / "frontend-react"), str(project_dir / "frontend"))
9+
shutil.rmtree(str(project_dir / "frontend-reactless"))
10+
elif framework == "Pure Typescript":
11+
shutil.move(str(project_dir / "frontend-reactless"), str(project_dir / "frontend"))
12+
shutil.rmtree(str(project_dir / "frontend-react"))
13+
else:
14+
raise Exception(f"Unsupported option: {framework!r}")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
YOUR LICENSE HERE
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
recursive-include {{ cookiecutter.import_name }}/frontend/build *
2+
include pyproject.toml
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# {{ cookiecutter.package_name }}
2+
3+
{{ cookiecutter.description }}
4+
5+
## Installation instructions
6+
7+
```sh
8+
pip install {{ cookiecutter.package_name }}
9+
```
10+
11+
## Usage instructions
12+
13+
```python
14+
import streamlit as st
15+
16+
from {{ cookiecutter.import_name }} import {{ cookiecutter.import_name }}
17+
18+
value = {{ cookiecutter.import_name }}()
19+
20+
st.write(value)
21+
```
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import streamlit as st
2+
from {{ cookiecutter.import_name }} import {{ cookiecutter.import_name }}
3+
4+
# Add some test code to play with the component while it's in development.
5+
# During development, we can run this just as we would any other Streamlit
6+
# app: `$ streamlit run {{ cookiecutter.import_name }}/example.py`
7+
8+
st.subheader("Component with constant args")
9+
10+
# Create an instance of our component with a constant `name` arg, and
11+
# print its output value.
12+
result = {{ cookiecutter.import_name }}("World")
13+
st.markdown("You've clicked %s times!" % int(result["num_clicks"]))
14+
15+
st.markdown("---")
16+
st.subheader("Component with variable args")
17+
18+
# Create a second instance of our component whose `name` arg will vary
19+
# based on a text_input widget.
20+
#
21+
# We use the special "key" argument to assign a fixed identity to this
22+
# component instance. By default, when a component's arguments change,
23+
# it is considered a new instance and will be re-mounted on the frontend
24+
# and lose its current state. In this case, we want to vary the component's
25+
# "name" argument without having it get recreated.
26+
name_input = st.text_input("Enter a name", value="Streamlit")
27+
result = {{ cookiecutter.import_name }}(name_input, key="foo")
28+
st.markdown("You've clicked %s times!" % int(result["num_clicks"]))
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
[build-system]
2+
requires = ["setuptools>=61.0", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "{{ cookiecutter.package_name }}"
7+
version = "0.0.1"
8+
description = "{{ cookiecutter.description }}"
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
authors = [
12+
{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" },
13+
]
14+
# TODO: Restore this
15+
# dependencies = ["streamlit >= 1.51"]
16+
17+
[project.optional-dependencies]
18+
devel = [
19+
"wheel",
20+
"pytest==7.4.0",
21+
"playwright==1.48.0",
22+
"requests==2.31.0",
23+
"pytest-playwright-snapshot==1.0",
24+
"pytest-rerunfailures==12.0",
25+
]
26+
27+
[project.license]
28+
file = "LICENSE"
29+
30+
[tool.setuptools.packages.find]
31+
include = ["{{ cookiecutter.import_name }}*"]
32+
33+
[tool.setuptools]
34+
include-package-data = true
35+
36+
[tool.setuptools.package-data]
37+
{{ cookiecutter.import_name }} = ["frontend/build/**/*", "pyproject.toml"]
38+
39+
[[tool.streamlit.component.components]]
40+
name = "{{ cookiecutter.import_name }}"
41+
asset_dir = "{{ cookiecutter.import_name }}/frontend/build"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from typing import TYPE_CHECKING
2+
3+
import streamlit as st
4+
5+
if TYPE_CHECKING:
6+
from streamlit.components.v2.bidi_component import BidiComponentResult
7+
8+
out = st.components.v2.component(
9+
"{{ cookiecutter.package_name }}.{{ cookiecutter.import_name }}",
10+
js="index-*.js",
11+
html='<div class="react-root"></div>',
12+
)
13+
14+
15+
def on_num_clicks_change():
16+
"""Callback function for when the number of clicks changes in the frontend."""
17+
pass
18+
19+
20+
# Create a wrapper function for the component.
21+
#
22+
# This is an optional best practice. We could simply expose the component
23+
# function returned by `st.components.v2.component` and call it done.
24+
#
25+
# The wrapper allows us to customize our component's API: we can pre-process its
26+
# input args, post-process its output value, and add a docstring for users.
27+
def {{ cookiecutter.import_name }}(name, key=None):
28+
"""Create a new instance of "{{ cookiecutter.import_name }}".
29+
30+
Parameters
31+
----------
32+
name: str
33+
The name of the thing we're saying hello to. The component will display
34+
the text "Hello, {name}!"
35+
key: str or None
36+
An optional key that uniquely identifies this component.
37+
38+
Returns
39+
-------
40+
int
41+
The number of times the component's "Click Me" button has been clicked.
42+
(This is the value passed to `Streamlit.setComponentValue` on the
43+
frontend.)
44+
45+
"""
46+
# Call through to our private component function. Arguments we pass here
47+
# will be sent to the frontend, where they'll be available in an "args"
48+
# dictionary.
49+
#
50+
# "default" is a special argument that specifies the initial return
51+
# value of the component before the user has interacted with it.
52+
component_value = out(
53+
name=name,
54+
key=key,
55+
default={"num_clicks": 0},
56+
data={"name": name},
57+
on_num_clicks_change=on_num_clicks_change,
58+
)
59+
60+
# We could modify the value returned from the component if we wanted.
61+
# There's no need to do this in our simple example - but it's an option.
62+
return component_value

0 commit comments

Comments
 (0)