From 18df2990ea96ee4edc2cc50dad05e1b3a49195a5 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 14:14:33 +0100 Subject: [PATCH 01/17] Add example streamlit program + playwright tests disabled the docstring-is-first test for the examples directory, since it gives false positives due to streamlit's magic strings. --- .pre-commit-config.yaml | 1 + examples/pages/draw_support.py | 39 +++ examples/pages/dynamic_layer_control.py | 86 +++++ examples/pages/dynamic_map_vs_rerender.py | 82 +++++ examples/pages/dynamic_updates.py | 184 ++++++++++ examples/pages/geojson_popup.py | 119 +++++++ examples/pages/geojson_styles.py | 37 ++ examples/pages/grouped_layer_control.py | 34 ++ examples/pages/image_overlay.py | 46 +++ examples/pages/limit_data_return.py | 55 +++ examples/pages/misc_examples.py | 73 ++++ examples/pages/realtime.py | 96 ++++++ examples/pages/responsive.py | 26 ++ examples/pages/simple_popup.py | 29 ++ examples/pages/states.csv | 51 +++ examples/pages/static_map.py | 35 ++ examples/pages/vector_grid.py | 17 + examples/streamlit_app.py | 80 +++++ tests/test_streamlit.py | 395 ++++++++++++++++++++++ 19 files changed, 1485 insertions(+) create mode 100644 examples/pages/draw_support.py create mode 100644 examples/pages/dynamic_layer_control.py create mode 100644 examples/pages/dynamic_map_vs_rerender.py create mode 100644 examples/pages/dynamic_updates.py create mode 100644 examples/pages/geojson_popup.py create mode 100644 examples/pages/geojson_styles.py create mode 100644 examples/pages/grouped_layer_control.py create mode 100644 examples/pages/image_overlay.py create mode 100644 examples/pages/limit_data_return.py create mode 100644 examples/pages/misc_examples.py create mode 100644 examples/pages/realtime.py create mode 100644 examples/pages/responsive.py create mode 100644 examples/pages/simple_popup.py create mode 100644 examples/pages/states.csv create mode 100644 examples/pages/static_map.py create mode 100644 examples/pages/vector_grid.py create mode 100644 examples/streamlit_app.py create mode 100644 tests/test_streamlit.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e06a532635..4075956129 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,7 @@ repos: - id: debug-statements - id: end-of-file-fixer - id: check-docstring-first + exclude: examples/* - id: check-added-large-files - id: requirements-txt-fixer - id: file-contents-sorter diff --git a/examples/pages/draw_support.py b/examples/pages/draw_support.py new file mode 100644 index 0000000000..74252c3999 --- /dev/null +++ b/examples/pages/draw_support.py @@ -0,0 +1,39 @@ +import streamlit as st + +st.set_page_config( + page_title="streamlit-folium documentation: Draw Support", + page_icon=":pencil:", + layout="wide", +) + +""" +# streamlit-folium: Draw Support + +Folium supports some of the [most popular leaflet +plugins](https://python-visualization.github.io/folium/plugins.html). In this example, +we can add the +[`Draw`](https://python-visualization.github.io/folium/plugins.html#folium.plugins.Draw) +plugin to our map, which allows for drawing geometric shapes on the map. + +When a shape is drawn on the map, the coordinates that represent that shape are passed +back as a geojson feature via the `all_drawings` and `last_active_drawing` data fields. + +Draw something below to see the return value back to Streamlit! +""" + +with st.echo(code_location="below"): + import streamlit as st + from streamlit_folium import st_folium + + import folium + from folium.plugins import Draw + + m = folium.Map(location=[39.949610, -75.150282], zoom_start=5) + Draw(export=True).add_to(m) + + c1, c2 = st.columns(2) + with c1: + output = st_folium(m, width=700, height=500) + + with c2: + st.write(output) diff --git a/examples/pages/dynamic_layer_control.py b/examples/pages/dynamic_layer_control.py new file mode 100644 index 0000000000..f452ae37a4 --- /dev/null +++ b/examples/pages/dynamic_layer_control.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import geopandas as gpd +import shapely +import streamlit as st +from streamlit_folium import st_folium + +import folium +import folium.features + +st.set_page_config(layout="wide") + +st.write("## Dynamic layer control updates") + +START_LOCATION = [37.7944347109497, -122.398077892527] +START_ZOOM = 17 + +if "feature_group" not in st.session_state: + st.session_state["feature_group"] = None + +wkt1 = ( + "POLYGON ((-122.399077892527 37.7934347109497, -122.398922660838 " + "37.7934544916178, -122.398980265018 37.7937266504805, -122.399133972495 " + "37.7937070646238, -122.399077892527 37.7934347109497))" +) +wkt2 = ( + "POLYGON ((-122.397416 37.795017, -122.397137 37.794712, -122.396332 37.794983," + " -122.396171 37.795483, -122.396858 37.795695, -122.397652 37.795466, " + "-122.397759 37.79511, -122.397416 37.795017))" +) + +polygon_1 = shapely.wkt.loads(wkt1) +polygon_2 = shapely.wkt.loads(wkt2) + +gdf1 = gpd.GeoDataFrame(geometry=[polygon_1]).set_crs(epsg=4326) +gdf2 = gpd.GeoDataFrame(geometry=[polygon_2]).set_crs(epsg=4326) + +style_parcels = { + "fillColor": "#1100f8", + "color": "#1100f8", + "fillOpacity": 0.13, + "weight": 2, +} +style_buildings = { + "color": "#ff3939", + "fillOpacity": 0, + "weight": 3, + "opacity": 1, + "dashArray": "5, 5", +} + +polygon_folium1 = folium.GeoJson(data=gdf1, style_function=lambda x: style_parcels) +polygon_folium2 = folium.GeoJson(data=gdf2, style_function=lambda x: style_buildings) + +map = folium.Map( + location=START_LOCATION, + zoom_start=START_ZOOM, + tiles="OpenStreetMap", + max_zoom=21, +) + +fg1 = folium.FeatureGroup(name="Parcels") +fg1.add_child(polygon_folium1) + +fg2 = folium.FeatureGroup(name="Buildings") +fg2.add_child(polygon_folium2) + +fg_dict = {"Parcels": fg1, "Buildings": fg2, "None": None, "Both": [fg1, fg2]} + +control = folium.LayerControl(collapsed=False) + +fg = st.radio("Feature Group", ["Parcels", "Buildings", "None", "Both"]) + +layer = st.radio("Layer Control", ["yes", "no"]) + +layer_dict = {"yes": control, "no": None} + +st_folium( + map, + width=800, + height=450, + returned_objects=[], + feature_group_to_add=fg_dict[fg], + debug=True, + layer_control=layer_dict[layer], +) diff --git a/examples/pages/dynamic_map_vs_rerender.py b/examples/pages/dynamic_map_vs_rerender.py new file mode 100644 index 0000000000..11fa022356 --- /dev/null +++ b/examples/pages/dynamic_map_vs_rerender.py @@ -0,0 +1,82 @@ +import random + +import streamlit as st +from streamlit_folium import st_folium + +import folium + +st.set_page_config(layout="wide") + + +CENTER_START = [39.949610, -75.150282] +ZOOM_START = 8 + +if "center" not in st.session_state: + st.session_state["center"] = [39.949610, -75.150282] +if "zoom" not in st.session_state: + st.session_state["zoom"] = 8 +if "markers" not in st.session_state: + st.session_state["markers"] = [] + +col1, col2, col3 = st.columns(3) + +if col1.button("Shift center"): + random_shift_y = (random.random() - 0.5) * 0.3 + random_shift_x = (random.random() - 0.5) * 0.3 + st.session_state["center"] = [ + st.session_state["center"][0] + random_shift_y, + st.session_state["center"][1] + random_shift_x, + ] + +if col2.button("Shift zoom"): + st.session_state["zoom"] = st.session_state["zoom"] + 1 + if st.session_state["zoom"] >= 10: + st.session_state["zoom"] = 5 + +if col3.button("Add random marker"): + random_lat = random.random() * 0.5 + 39.8 + random_lon = random.random() * 0.5 - 75.2 + random_marker = folium.Marker( + location=[random_lat, random_lon], + popup=f"Random marker at {random_lat:.2f}, {random_lon:.2f}", + ) + st.session_state["markers"].append(random_marker) + +col1, col2 = st.columns(2) + +with col1: + "# New method" + "### Pass `center`, `zoom`, and `feature_group_to_add` to `st_folium`" + with st.echo(code_location="below"): + m = folium.Map(location=CENTER_START, zoom_start=8) + fg = folium.FeatureGroup(name="Markers") + for marker in st.session_state["markers"]: + fg.add_child(marker) + + st_folium( + m, + center=st.session_state["center"], + zoom=st.session_state["zoom"], + key="new", + feature_group_to_add=fg, + height=400, + width=700, + ) + +with col2: + "# Old method" + "### Update the map before passing it to `st_folium`" + with st.echo(code_location="below"): + m = folium.Map( + location=st.session_state["center"], zoom_start=st.session_state["zoom"] + ) + fg = folium.FeatureGroup(name="Markers") + for marker in st.session_state["markers"]: + fg.add_child(marker) + m.add_child(fg) + st_folium( + m, + key="old", + height=400, + width=700, + ) diff --git a/examples/pages/dynamic_updates.py b/examples/pages/dynamic_updates.py new file mode 100644 index 0000000000..817bbf99bd --- /dev/null +++ b/examples/pages/dynamic_updates.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from pathlib import Path + +import geopandas as gpd +import pandas as pd +import requests +import shapely +import streamlit as st +from streamlit_folium import st_folium + +import folium +import folium.features + +p = Path(__file__).parent / "states.csv" +STATE_DATA = pd.read_csv(p) + + +st.set_page_config(layout="wide") + +"# Dynamic Updates -- Click on a marker" + +st.subheader( + "Use new arguments `center`, `zoom`, and `feature_group_to_add` to update the map " + "without re-rendering it." +) + + +@st.cache_data +def _get_all_state_bounds() -> dict: + url = "https://raw.githubusercontent.com/PublicaMundi/MappingAPI/master/data/geojson/us-states.json" + data = requests.get(url).json() + return data + + +@st.cache_data +def get_state_bounds(state: str) -> dict: + data = _get_all_state_bounds() + state_entry = [f for f in data["features"] if f["properties"]["name"] == state][0] + return {"type": "FeatureCollection", "features": [state_entry]} + + +def get_state_from_lat_lon(lat: float, lon: float) -> str: + state_row = STATE_DATA[ + STATE_DATA.latitude.between(lat - 0.0001, lat + 0.0001) + & STATE_DATA.longitude.between(lon - 0.0001, lon + 0.0001) + ].iloc[0] + return state_row["state"] + + +def get_population(state: str) -> int: + return STATE_DATA.set_index("state").loc[state]["population"] + + +def main(): + if "last_object_clicked" not in st.session_state: + st.session_state["last_object_clicked"] = None + if "selected_state" not in st.session_state: + st.session_state["selected_state"] = "Indiana" + + bounds = get_state_bounds(st.session_state["selected_state"]) + + st.write(f"## {st.session_state['selected_state']}") + population = get_population(st.session_state["selected_state"]) + st.write(f"### Population: {population:,}") + + center = None + if st.session_state["last_object_clicked"]: + center = st.session_state["last_object_clicked"] + + with st.echo(code_location="below"): + m = folium.Map(location=[39.8283, -98.5795], zoom_start=5) + + # If you want to dynamically add or remove items from the map, + # add them to a FeatureGroup and pass it to st_folium + fg = folium.FeatureGroup(name="State bounds") + fg.add_child(folium.features.GeoJson(bounds)) + + capitals = STATE_DATA + + for capital in capitals.itertuples(): + fg.add_child( + folium.Marker( + location=[capital.latitude, capital.longitude], + popup=f"{capital.capital}, {capital.state}", + tooltip=f"{capital.capital}, {capital.state}", + icon=( + folium.Icon(color="green") + if capital.state == st.session_state["selected_state"] + else None + ), + ) + ) + + out = st_folium( + m, + feature_group_to_add=fg, + center=center, + width=1200, + height=500, + ) + + if ( + out["last_object_clicked"] + and out["last_object_clicked"] != st.session_state["last_object_clicked"] + ): + st.session_state["last_object_clicked"] = out["last_object_clicked"] + state = get_state_from_lat_lon(*out["last_object_clicked"].values()) + st.session_state["selected_state"] = state + st.rerun() + + st.write("## Dynamic feature group updates") + + START_LOCATION = [37.7944347109497, -122.398077892527] + START_ZOOM = 17 + + if "feature_group" not in st.session_state: + st.session_state["feature_group"] = None + + wkt1 = ( + "POLYGON ((-122.399077892527 37.7934347109497, -122.398922660838 " + "37.7934544916178, -122.398980265018 37.7937266504805, -122.399133972495 " + "37.7937070646238, -122.399077892527 37.7934347109497))" + ) + wkt2 = ( + "POLYGON ((-122.397416 37.795017, -122.397137 37.794712, -122.396332 37.794983," + " -122.396171 37.795483, -122.396858 37.795695, -122.397652 37.795466, " + "-122.397759 37.79511, -122.397416 37.795017))" + ) + + polygon_1 = shapely.wkt.loads(wkt1) + polygon_2 = shapely.wkt.loads(wkt2) + + gdf1 = gpd.GeoDataFrame(geometry=[polygon_1]).set_crs(epsg=4326) + gdf2 = gpd.GeoDataFrame(geometry=[polygon_2]).set_crs(epsg=4326) + + style_parcels = { + "fillColor": "#1100f8", + "color": "#1100f8", + "fillOpacity": 0.13, + "weight": 2, + } + style_buildings = { + "color": "#ff3939", + "fillOpacity": 0, + "weight": 3, + "opacity": 1, + "dashArray": "5, 5", + } + + polygon_folium1 = folium.GeoJson(data=gdf1, style_function=lambda x: style_parcels) + polygon_folium2 = folium.GeoJson( + data=gdf2, style_function=lambda x: style_buildings + ) + + map = folium.Map( + location=START_LOCATION, + zoom_start=START_ZOOM, + tiles="OpenStreetMap", + max_zoom=21, + ) + + fg1 = folium.FeatureGroup(name="Parcels") + fg1.add_child(polygon_folium1) + + fg2 = folium.FeatureGroup(name="Buildings") + fg2.add_child(polygon_folium2) + + fg_dict = {"Parcels": fg1, "Buildings": fg2, "None": None, "Both": [fg1, fg2]} + + fg = st.radio("Feature Group", ["Parcels", "Buildings", "None", "Both"]) + + st_folium( + map, + width=800, + height=450, + returned_objects=[], + feature_group_to_add=fg_dict[fg], + debug=True, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/pages/geojson_popup.py b/examples/pages/geojson_popup.py new file mode 100644 index 0000000000..91537a388f --- /dev/null +++ b/examples/pages/geojson_popup.py @@ -0,0 +1,119 @@ +import branca +import geopandas +import pandas as pd +import requests +import streamlit as st +from streamlit_folium import st_folium + +import folium +from folium.features import GeoJsonPopup, GeoJsonTooltip + +st.write("# GeoJson Popup") +st.write( + "See [original](https://github.com/python-visualization/folium/blob/main/examples/GeoJsonPopupAndTooltip.ipynb)" +) + + +@st.cache_resource +def get_df() -> pd.DataFrame: + response = requests.get( + "https://raw.githubusercontent.com/python-visualization/folium/main/examples/data/us-states.json" + ) + data = response.json() + states = geopandas.GeoDataFrame.from_features(data, crs="EPSG:4326") + + income = pd.read_csv( + "https://raw.githubusercontent.com/pri-data/50-states/master/data/income-counties-states-national.csv", + dtype={"fips": str}, + ) + income["income-2015"] = pd.to_numeric(income["income-2015"], errors="coerce") + + response = requests.get( + "https://gist.githubusercontent.com/tvpmb/4734703/raw/" + "b54d03154c339ed3047c66fefcece4727dfc931a/US%2520State%2520List" + ) + abbrs = pd.read_json(response.text) + + statesmerge = states.merge(abbrs, how="left", left_on="name", right_on="name") + statesmerge["geometry"] = statesmerge.geometry.simplify(0.05) + + income.groupby("state")["income-2015"].median().head() + + statesmerge["medianincome"] = statesmerge.merge( + income.groupby("state")["income-2015"].median(), + how="left", + left_on="alpha-2", + right_on="state", + )["income-2015"] + statesmerge["change"] = statesmerge.merge( + income.groupby("state")["change"].median(), + how="left", + left_on="alpha-2", + right_on="state", + )["change"] + + return statesmerge + + +df = get_df() + +colormap = branca.colormap.LinearColormap( + vmin=df["change"].quantile(0.0), + vmax=df["change"].quantile(1), + colors=["red", "orange", "lightblue", "green", "darkgreen"], + caption="State Level Median County Household Income (%)", +) + +m = folium.Map(location=[35.3, -97.6], zoom_start=4) + +popup = GeoJsonPopup( + fields=["name", "change"], + aliases=["State", "% Change"], + localize=True, + labels=True, + style="background-color: yellow;", +) + +tooltip = GeoJsonTooltip( + fields=["name", "medianincome", "change"], + aliases=["State:", "2015 Median Income(USD):", "Median % Change:"], + localize=True, + sticky=False, + labels=True, + style=""" + background-color: #F0EFEF; + border: 2px solid black; + border-radius: 3px; + box-shadow: 3px; + """, + max_width=800, +) + +folium.GeoJson( + df, + style_function=lambda x: { + "fillColor": ( + colormap(x["properties"]["change"]) + if x["properties"]["change"] is not None + else "transparent" + ), + "color": "black", + "fillOpacity": 0.4, + }, + tooltip=tooltip, + popup=popup, +).add_to(m) + +colormap.add_to(m) + +return_on_hover = st.checkbox("Return on hover?", True) + +output = st_folium(m, width=700, height=500, return_on_hover=return_on_hover) + +left, right = st.columns(2) +with left: + st.write("## Tooltip") + st.write(output["last_object_clicked_tooltip"]) +with right: + st.write("## Popup") + st.write(output["last_object_clicked_popup"]) diff --git a/examples/pages/geojson_styles.py b/examples/pages/geojson_styles.py new file mode 100644 index 0000000000..0f20f66b08 --- /dev/null +++ b/examples/pages/geojson_styles.py @@ -0,0 +1,37 @@ +import geopandas as gpd +import shapely +import streamlit as st +from streamlit_folium import st_folium + +import folium + +st.title("GeoJSON Styling") + +START_LOCATION = [37.7934347109497, -122.399077892527] +START_ZOOM = 18 + +wkt = ( + "POLYGON ((-122.399077892527 37.7934347109497, -122.398922660838 " + "37.7934544916178, -122.398980265018 37.7937266504805, -122.399133972495 " + "37.7937070646238, -122.399077892527 37.7934347109497))" +) +polygon_ = shapely.wkt.loads(wkt) +gdf = gpd.GeoDataFrame(geometry=[polygon_]).set_crs(epsg=4326) + +style_parcels = {"fillColor": "red", "fillOpacity": 0.2} + +polygon_folium = folium.GeoJson(data=gdf, style_function=lambda x: style_parcels) + +map = folium.Map( + location=START_LOCATION, zoom_start=START_ZOOM, tiles="OpenStreetMap", max_zoom=21 +) +fg = folium.FeatureGroup(name="Parcels") +fg = fg.add_child(polygon_folium) + +st_folium( + map, + width=800, + height=450, + feature_group_to_add=fg, + debug=True, +) diff --git a/examples/pages/grouped_layer_control.py b/examples/pages/grouped_layer_control.py new file mode 100644 index 0000000000..f59b756a30 --- /dev/null +++ b/examples/pages/grouped_layer_control.py @@ -0,0 +1,34 @@ +import streamlit as st +from streamlit_folium import st_folium + +import folium +from folium.plugins import GroupedLayerControl + +st.set_page_config( + page_title="streamlit-folium documentation: Grouped Layer Control", + page_icon=":pencil:", + layout="wide", +) + +st.title("streamlit-folium: Grouped Layer Control") + +m = folium.Map([40.0, 70.0], zoom_start=6) + +fg1 = folium.FeatureGroup(name="g1") +fg2 = folium.FeatureGroup(name="g2") +fg3 = folium.FeatureGroup(name="g3") +folium.Marker([40, 74]).add_to(fg1) +folium.Marker([38, 72]).add_to(fg2) +folium.Marker([40, 72]).add_to(fg3) +m.add_child(fg1) +m.add_child(fg2) +m.add_child(fg3) + +folium.LayerControl(collapsed=False).add_to(m) + +GroupedLayerControl( + groups={"groups1": [fg1, fg2]}, + collapsed=False, +).add_to(m) + +st_folium(m) diff --git a/examples/pages/image_overlay.py b/examples/pages/image_overlay.py new file mode 100644 index 0000000000..5d4cd07bff --- /dev/null +++ b/examples/pages/image_overlay.py @@ -0,0 +1,46 @@ +import streamlit as st +from streamlit_folium import st_folium + +import folium + +st.set_page_config( + layout="wide", + page_title="streamlit-folium documentation: Misc Examples", + page_icon="random", +) +""" +# streamlit-folium: Image Overlay + +By default, st_folium renders images using browser image rendering mechanism. +Use st_folium(map, pixelated=True) in order to see image pixels without resample. +""" + +url_image = "https://i.postimg.cc/kG2FSxSR/image.png" +image_bounds = [[-20.664910, -46.538223], [-20.660001, -46.532977]] + +m = folium.Map() +m1 = folium.Map() + +folium.raster_layers.ImageOverlay( + image=url_image, + name="image overlay", + opacity=1, + bounds=image_bounds, +).add_to(m) +folium.raster_layers.ImageOverlay( + image=url_image, + name="image overlay", + opacity=1, + bounds=image_bounds, +).add_to(m1) + +m.fit_bounds(image_bounds, padding=(0, 0)) +m1.fit_bounds(image_bounds, padding=(0, 0)) + +col1, col2 = st.columns(2) +with col1: + st.markdown("## Pixelated off") + st_folium(m, use_container_width=True, pixelated=False, key="pixelated_off") +with col2: + st.markdown("## Pixelated on") + st_folium(m1, use_container_width=True, pixelated=True, key="pixelated_on") diff --git a/examples/pages/limit_data_return.py b/examples/pages/limit_data_return.py new file mode 100644 index 0000000000..797f28bde7 --- /dev/null +++ b/examples/pages/limit_data_return.py @@ -0,0 +1,55 @@ +import streamlit as st + +st.set_page_config( + page_title="streamlit-folium documentation: Limit Data Return", + page_icon="🤏", + layout="wide", +) + +""" +# streamlit-folium: Limit Data Return + +By default, st_folium returns quite a few data fields (zoom, bounds, last active +drawing, + all drawings, etc). If you only need a subset of these fields, you can pass a list of + the fields you want returned to the `returned_objects` parameter. +""" + +""" +### Example: Only return the last object clicked on the map + +Try clicking on the tooltips below. Note that clicking elsewhere on the map, or zooming +or scrolling will not cause the app to rerun. + +""" + +with st.echo(code_location="below"): + import streamlit as st + from streamlit_folium import st_folium + + import folium + + m = folium.Map(location=[39.949610, -75.150282], zoom_start=13) + + folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip="Liberty Bell" + ).add_to(m) + folium.Marker( + [39.95887, -75.150026], + popup="Independence Hall", + tooltip="Independence Hall", + ).add_to(m) + folium.Marker( + [39.965570, -75.180966], + popup="Philadelphia Museum of Art", + tooltip="Philadelphia Museum of Art", + ).add_to(m) + + c1, c2 = st.columns(2) + with c1: + output = st_folium( + m, width=700, height=500, returned_objects=["last_object_clicked"] + ) + + with c2: + st.write(output) diff --git a/examples/pages/misc_examples.py b/examples/pages/misc_examples.py new file mode 100644 index 0000000000..dba3164dda --- /dev/null +++ b/examples/pages/misc_examples.py @@ -0,0 +1,73 @@ +import branca +import streamlit as st +from streamlit_folium import st_folium + +import folium +import folium.plugins + +st.set_page_config( + layout="wide", + page_title="streamlit-folium documentation: Misc Examples", + page_icon="random", +) + +page = st.radio("Select map type", ["Single map", "Dual map", "Branca figure"], index=0) + +# center on Liberty Bell, add marker +if page == "Single map": + m = folium.Map(location=[39.949610, -75.150282], zoom_start=16) + tooltip = "Liberty Bell" + folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip=tooltip + ).add_to(m) + st.code( + """ +m = folium.Map(location=[39.949610, -75.150282], zoom_start=16) +tooltip = "Liberty Bell" +folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip=tooltip +).add_to(m) +""", + language="python", + ) + +elif page == "Dual map": + m = folium.plugins.DualMap(location=[39.949610, -75.13], zoom_start=16) + tooltip = "Liberty Bell" + folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip=tooltip + ).add_to(m) + st.code( + """ +m = folium.plugins.DualMap(location=[39.949610, -75.13], zoom_start=16) +tooltip = "Liberty Bell" +folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip=tooltip +).add_to(m) +""", + language="python", + ) +else: + m = branca.element.Figure() + fm = folium.Map(location=[39.949610, -75.150282], zoom_start=16) + tooltip = "Liberty Bell" + folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip=tooltip + ).add_to(fm) + m.add_child(fm) + st.code( + """ +m = branca.element.Figure() +fm = folium.Map(location=[39.949610, -75.150282], zoom_start=16) +tooltip = "Liberty Bell" +folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip=tooltip +).add_to(fm) +m.add_child(fm) +""", + language="python", + ) + +with st.echo(): + # call to render Folium map in Streamlit + st_folium(m, width=2000, height=500, returned_objects=[], debug=True) diff --git a/examples/pages/realtime.py b/examples/pages/realtime.py new file mode 100644 index 0000000000..90a7545ec6 --- /dev/null +++ b/examples/pages/realtime.py @@ -0,0 +1,96 @@ +import streamlit as st +from streamlit_folium import st_folium + +import folium +from folium.plugins import Realtime + +""" +# streamlit-folium: Realtime Support + +streamlit-folium supports the Realtime plugin, which can pull geo data +periodically from a datasource. The example below shows a map that +displays the current location of the International Space Station. + +Since Realtime fetches data from an external source the actual +contents of the data is unknown when creating the map. If you want +to react to map events, such as clicking on a Feature, you can pass +a `JsCode` object to the plugin. + +Inside the `JsCode` object you have access to the Streamlit object, +which allows you to set a return value for your Streamlit app. +""" + +with st.echo(): + m = folium.Map() + source = folium.JsCode( + """ + function(responseHandler, errorHandler) { + var url = 'https://api.wheretheiss.at/v1/satellites/25544'; + + fetch(url) + .then((response) => { + return response.json().then((data) => { + var { id, timestamp, longitude, latitude } = data; + + return { + 'type': 'FeatureCollection', + 'features': [{ + 'type': 'Feature', + 'geometry': { + 'type': 'Point', + 'coordinates': [longitude, latitude] + }, + 'properties': { + 'id': id, + 'timestamp': timestamp + } + }] + }; + }) + }) + .then(responseHandler) + .catch(errorHandler); + } + """ + ) + + on_each_feature = folium.JsCode( + """ + (feature, layer) => { + layer.bindTooltip(`${feature.properties.timestamp}`); + layer.on("click", (event) => { + Streamlit.setComponentValue({ + id: feature.properties.id, + // Be careful, on_each_feature binds only once. + // You need to extract the current location from + // the event. + location: event.sourceTarget.feature.geometry + }); + }); + + } + """ + ) + + update_feature = folium.JsCode( + """ + (feature, layer) => { + L.Realtime.prototype.options.updateFeature(feature, layer); + if(layer) { + layer.unbindTooltip(); + layer.bindTooltip(`${feature.properties.timestamp}`); + } + } + """ + ) + + Realtime( + source, + on_each_feature=on_each_feature, + update_feature=update_feature, + interval=10000, + ).add_to(m) + + data = st_folium(m, returned_objects=[], debug=False) + + st.write(data) diff --git a/examples/pages/responsive.py b/examples/pages/responsive.py new file mode 100644 index 0000000000..384246ea08 --- /dev/null +++ b/examples/pages/responsive.py @@ -0,0 +1,26 @@ +import streamlit as st +from streamlit_folium import st_folium + +import folium + +st.set_page_config(layout="wide") + +m = folium.Map(location=(45, -90), zoom_start=5) + +left, right = st.columns([2, 1]) +with left: + st_folium(m, use_container_width=True, key="1", debug=True) +with right: + st_folium(m, use_container_width=True, key="2", debug=True) + +st_folium(m, use_container_width=True, key="3", debug=True) + + +col1, col2, col3 = st.columns([1, 2, 3]) + +with col1: + st_folium(m, use_container_width=True, key="4", debug=True) +with col2: + st_folium(m, use_container_width=True, key="5", debug=True) +with col3: + st_folium(m, use_container_width=True, key="6", debug=True) diff --git a/examples/pages/simple_popup.py b/examples/pages/simple_popup.py new file mode 100644 index 0000000000..e6da41ca69 --- /dev/null +++ b/examples/pages/simple_popup.py @@ -0,0 +1,29 @@ +import streamlit as st +from streamlit_folium import st_folium + +import folium +from folium.features import Marker, Popup + +st.write("# Simple Popup & Tooltip") + +return_on_hover = st.checkbox("Return on hover?") + +with st.echo("below"): + m = folium.Map(location=[45, -122], zoom_start=4) + + Marker( + location=[45.5, -122], + popup=Popup("Popup!", parse_html=False), + tooltip="Tooltip!", + ).add_to(m) + + Marker( + location=[45.5, -112], + popup=Popup("Popup 2!", parse_html=False), + tooltip="Tooltip 2!", + ).add_to(m) + + out = st_folium(m, height=200, return_on_hover=return_on_hover) + + st.write("Popup:", out["last_object_clicked_popup"]) + st.write("Tooltip:", out["last_object_clicked_tooltip"]) diff --git a/examples/pages/states.csv b/examples/pages/states.csv new file mode 100644 index 0000000000..10f1ebc9a4 --- /dev/null +++ b/examples/pages/states.csv @@ -0,0 +1,51 @@ +state,capital,latitude,longitude,population +Alabama,Montgomery,32.377716,-86.300568,4893186 +Alaska,Juneau,58.301598,-134.420212,736990 +Arizona,Phoenix,33.448143,-112.096962,7174064 +Arkansas,Little Rock,34.746613,-92.288986,3011873 +California,Sacramento,38.576668,-121.493629,39346023 +Colorado,Denver,39.739227,-104.984856,5684926 +Connecticut,Hartford
,41.764046,-72.682198,3570549 +Delaware,Dover,39.157307,-75.519722,967679 +Hawaii,Honolulu,21.307442,-157.857376,1420074 +Florida,Tallahassee,30.438118,-84.281296,21216924 +Georgia,Atlanta
,33.749027,-84.388229,10516579 +Idaho,Boise,43.617775,-116.199722,1754367 +Illinois,Springfield,39.798363,-89.654961,12716164 +Indiana,Indianapolis,39.768623,-86.162643,6696893 +Iowa,Des Moines,41.591087,-93.603729,3150011 +Kansas,Topeka,39.048191,-95.677956,2912619 +Kentucky,Frankfort,38.186722,-84.875374,4461952 +Louisiana,Baton Rouge,30.457069,-91.187393,4664616 +Maine,Augusta,44.307167,-69.781693,1340825 +Maryland,Annapolis,38.978764,-76.490936,6037624 +Massachusetts,Boston,42.358162,-71.063698,6873003 +Michigan,Lansing,42.733635,-84.555328,9973907 +Minnesota,St. Paul,44.955097,-93.102211,5600166 +Mississippi,Jackson,32.303848,-90.182106,2981835 +Missouri,Jefferson City,38.579201,-92.172935,6124160 +Montana,Helena,46.585709,-112.018417,1061705 +Nebraska,Lincoln,40.808075,-96.699654,1923826 +Nevada,Carson City,39.163914,-119.766121,3030281 +New Hampshire,Concord,43.206898,-71.537994,1355244 +New Jersey,Trenton,40.220596,-74.769913,8885418 +New Mexico,Santa Fe,35.68224,-105.939728,2097021 +North Carolina,Raleigh,35.78043,-78.639099,10386227 +North Dakota,Bismarck,46.82085,-100.783318,760394 +New York,Albany,42.652843,-73.757874,19514849 +Ohio,Columbus,39.961346,-82.999069,11675275 +Oklahoma,Oklahoma City,35.492207,-97.503342,3949342 +Oregon,Salem,44.938461,-123.030403,4176346 +Pennsylvania,Harrisburg,40.264378,-76.883598,12794885 +Rhode Island,Providence,41.830914,-71.414963,1057798 +South Carolina,Columbia,34.000343,-81.033211,5091517 +South Dakota,Pierre,44.367031,-100.346405,879336 +Tennessee,Nashville,36.16581,-86.784241,6772268 +Texas,Austin,30.27467,-97.740349,28635442 +Utah,Salt Lake City,40.777477,-111.888237,3151239 +Vermont,Montpelier,44.262436,-72.580536,624340 +Virginia,Richmond,37.538857,-77.43364,8509358 +Washington,Olympia,47.035805,-122.905014,7512465 +West Virginia,Charleston,38.336246,-81.612328,1807426 +Wisconsin,Madison,43.074684,-89.384445,5806975 +Wyoming,Cheyenne,41.140259,-104.820236,581348 diff --git a/examples/pages/static_map.py b/examples/pages/static_map.py new file mode 100644 index 0000000000..bd44c4ac40 --- /dev/null +++ b/examples/pages/static_map.py @@ -0,0 +1,35 @@ +import streamlit as st + +st.set_page_config( + page_title="streamlit-folium documentation: Static Map", + page_icon=":ice:", + layout="wide", +) + +""" +# streamlit-folium: Non-interactive Map + +If you don't need any data returned from the map, you can just +pass returned_objects=[] to st_folium. The streamlit app will not rerun +when the user interacts with the map, and you will not get any data back from the map. + +--- + +""" +"### Basic `returned_objects=[]` Example" + +with st.echo(): + import streamlit as st + from streamlit_folium import st_folium + + import folium + + # center on Liberty Bell, add marker + m = folium.Map(location=[39.949610, -75.150282], zoom_start=16) + folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip="Liberty Bell" + ).add_to(m) + + # call to render Folium map in Streamlit, but don't get any data back + # from the map (so that it won't rerun the app when the user interacts) + st_folium(m, width=725, returned_objects=[]) diff --git a/examples/pages/vector_grid.py b/examples/pages/vector_grid.py new file mode 100644 index 0000000000..7913909032 --- /dev/null +++ b/examples/pages/vector_grid.py @@ -0,0 +1,17 @@ +import streamlit as st +from streamlit_folium import st_folium + +import folium +from folium.plugins import VectorGridProtobuf + +st.set_page_config( + page_title="streamlit-folium documentation: Vector Grid", +) + +with st.echo(code_location="below"): + m = folium.Map(location=(30, 20), zoom_start=4) + folium.Marker(location=(30, 20), popup="test").add_to(m) + url = "https://area.uqcom.jp/api2/rakuten/{z}/{x}/{y}.mvt" + vc = VectorGridProtobuf(url, "test").add_to(m) + + st_folium(m, returned_objects=[]) diff --git a/examples/streamlit_app.py b/examples/streamlit_app.py new file mode 100644 index 0000000000..f9f67dd0df --- /dev/null +++ b/examples/streamlit_app.py @@ -0,0 +1,80 @@ +import streamlit as st + +st.set_page_config( + page_title="streamlit-folium documentation", + page_icon=":world_map:️", + layout="wide", +) + +"# streamlit-folium" + +"""streamlit-folium integrates two great open-source projects in the Python ecosystem: +[Streamlit](https://streamlit.io) and +[Folium](https://python-visualization.github.io/folium/)!""" + +""" +Currently, there are two functions defined: + +- `st_folium()`: a bi-directional Component, taking a Folium/Branca object and plotting + to the Streamlit app. Upon mount/interaction with the Streamlit app, st_folium() + returns a Dict with selected information including the bounding box and items clicked + on + +- `folium_static()`: takes a folium.Map, folium.Figure, or branca.element.Figure object + and displays it in a Streamlit app. +""" + +""" +On its own, Folium is limited to _display-only_ visualizations; the Folium API generates +the proper [leaflet.js](https://leafletjs.com/) specification, as HTML and displays it. +Some interactivity is provided (depending on how the Folium API is utilized), but the +biggest drawback is that the interactivity from the visualization isn't passed back to +Python, and as such, you can't make full use of the functionality provided by the +leaflet.js library. + +`streamlit-folium` builds upon the convenient [Folium +API](https://python-visualization.github.io/folium/modules.html) for building geospatial +visualizations by adding a _bi-directional_ data transfer functionality. This not only +allows for increased interactivity between the web browser and Python, but also the use +of larger datasets through intelligent querying. + +### Bi-directional data model +""" +left, right = st.columns(2) + + +with left: + """ + If we take a look at the example from the Home page, it might seem trivial. We + define a single point with a marker and pop-up and display it: + """ + with st.echo(): + import streamlit as st + from streamlit_folium import st_folium + + import folium + + # center on Liberty Bell, add marker + m = folium.Map(location=[39.949610, -75.150282], zoom_start=16) + folium.Marker( + [39.949610, -75.150282], popup="Liberty Bell", tooltip="Liberty Bell" + ).add_to(m) + + # call to render Folium map in Streamlit + st_data = st_folium(m, width=725) + +with right: + """ + But behind the scenes, a lot more is happening _by default_. The return value of + `st_folium` is set to `st_data`, and within this Python variable is information + about what is being displayed on the screen: + """ + + st_data + + """ + As the user interacts with the data visualization, the values for `bounds` are + constantly updating, along with `zoom`. With these values available in Python, we + can now limit queries based on bounding box, change the marker size based on the + `zoom` value and much more! + """ diff --git a/tests/test_streamlit.py b/tests/test_streamlit.py new file mode 100644 index 0000000000..8e57b39540 --- /dev/null +++ b/tests/test_streamlit.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +from contextlib import contextmanager +from time import sleep + +import pytest +from playwright.sync_api import Page, Response, expect + +LOCAL_TEST = False + +PORT = "8503" if LOCAL_TEST else "8699" + + +@pytest.fixture(scope="module", autouse=True) +def before_module(): + # Run the streamlit app before each module + with run_streamlit(): + yield + + +@pytest.fixture(scope="function", autouse=True) +def before_test(page: Page): + page.goto(f"localhost:{PORT}") + page.set_viewport_size({"width": 2000, "height": 2000}) + expect.set_options(timeout=5_000) + + +# Take screenshot of each page if there are failures for this session +@pytest.fixture(scope="function", autouse=True) +def after_test(page: Page, request): + yield + if request.node.rep_call.failed: + page.screenshot(path=f"screenshot-{request.node.name}.png", full_page=True) + + +@contextmanager +def run_streamlit(): + """Run the streamlit app at examples/streamlit_app.py on port 8599""" + import subprocess + + if LOCAL_TEST: + try: + yield 1 + finally: + pass + else: + p = subprocess.Popen( + [ + "streamlit", + "run", + "examples/streamlit_app.py", + "--server.port", + PORT, + "--server.headless", + "true", + ] + ) + + sleep(5) + + try: + yield 1 + finally: + p.kill() + + +def click_button_or_marker(page: Page, nth: int = 0, locator: str | None = None): + """For some reason, there's a discrepancy between how the map markers are + selectable locally and on github actions, perhaps related some error in loading + the actual marker images. This tries both ways to select a marker""" + + frame = page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') + if locator is not None: + frame = frame.locator(locator) + try: + frame.get_by_role("button", name="Marker").nth(nth).click(timeout=5_000) + except Exception: + frame.get_by_role("img").nth(nth).click(timeout=5_000) + + +def test_marker_click(page: Page): + def check_for_404(response: Response): + if not response.ok: + print(response) + print(response.text()) + print(response.url) + print(response.status) + raise Exception("404") + + page.on("response", check_for_404) + + # Check page title + expect(page).to_have_title("streamlit-folium documentation") + + expect(page.get_by_text('"last_object_clicked":NULL')).to_be_visible() + + # Click marker + try: + click_button_or_marker(page) + except Exception as e: + page.screenshot(path="screenshot-test-marker-click.png", full_page=True) + raise e + + expect(page.get_by_text('"last_object_clicked":NULL')).to_be_hidden() + + +def test_draw(page: Page): + # Test draw support + page.get_by_role("link", name="draw support").click() + # Click again to see if it resolves timeout issues + page.get_by_role("link", name="draw support").click() + + expect(page).to_have_title("streamlit-folium documentation: Draw Support") + + page.frame_locator( + 'internal:attr=[title="streamlit_folium.st_folium"i]' + ).get_by_role("link", name="Draw a polygon").click() + + # Should be no drawings + expect(page.get_by_text('"all_drawings":NULL')).to_be_visible() + + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_role( + "link", name="Draw a marker" + ).click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( + ".leaflet-marker-icon" + ).first.click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( + "#map_div" + ).click() + + # Should be one item in drawings after having placed a marker + expect(page.get_by_text('"all_drawings":NULL')).to_be_hidden() + + +def test_limit_data(page: Page): + # Test limit data support + page.get_by_role("link", name="limit data return").click() + # Click again to see if it resolves timeout issues + page.get_by_role("link", name="limit data return").click() + + expect(page).to_have_title("streamlit-folium documentation: Limit Data Return") + + expect(page.get_by_text('{"last_object_clicked":NULL}')).to_be_visible() + + # Click marker + click_button_or_marker(page, 2) + + # Have to click a second time for some reason, maybe because it doesn't load right + # away + click_button_or_marker(page, 2) + + expect(page.get_by_text('{"last_object_clicked":{"lat":39.96')).to_be_visible() + + +def test_dual_map(page: Page): + page.get_by_role("link", name="misc examples").click() + # Click again to see if it resolves timeout issues + page.get_by_role("link", name="misc examples").click() + + expect(page).to_have_title("streamlit-folium documentation: Misc Examples") + + page.locator("label").filter(has_text="Dual map").click() + page.locator("label").filter(has_text="Dual map").click() + + # Click marker on left map + try: + click_button_or_marker(page, 0, "#map_div") + click_button_or_marker(page, 0, "#map_div2") + except Exception as e: + page.screenshot(path="screenshot-dual-map.png", full_page=True) + raise e + + +def test_vector_grid(page: Page): + page.get_by_role("link", name="vector grid").click() + page.get_by_role("link", name="vector grid").click() + + expect(page).to_have_title("streamlit-folium documentation: Vector Grid") + + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( + ".leaflet-marker-icon" + ).click() + + +def test_tooltip_click(page: Page): + expect(page.get_by_text('"last_object_clicked_tooltip":NULL')).to_be_visible() + + # Click marker on map + click_button_or_marker(page) + + expect( + page.get_by_text('"last_object_clicked_tooltip":"Liberty Bell"') + ).to_be_visible() + + +def test_popup_text(page: Page): + page.get_by_role("link", name="simple popup").click() + page.get_by_role("link", name="simple popup").click() + + expect(page.get_by_text("Popup: None")).to_be_visible() + expect(page.get_by_text("Tooltip: None")).to_be_visible() + + click_button_or_marker(page) + + try: + expect(page.get_by_text("Popup: Popup!")).to_be_visible() + expect(page.get_by_text("Tooltip: Tooltip!")).to_be_visible() + except Exception as e: + page.screenshot(path="screenshot-popup.png") + raise e + + +def test_return_on_hover(page: Page): + page.get_by_role("link", name="simple popup").click() + page.get_by_role("link", name="simple popup").click() + + expect(page.get_by_text("Popup: None")).to_be_visible() + expect(page.get_by_text("Tooltip: None")).to_be_visible() + + page.get_by_text("Return on hover?").click() + + click_button_or_marker(page, 1) + + try: + expect(page.get_by_text("Popup: Popup 2!")).to_be_visible() + expect(page.get_by_text("Tooltip: Tooltip 2!")).to_be_visible() + except Exception as e: + page.screenshot(path="screenshot-popup2.png") + raise e + + +def test_responsiveness(page: Page): + page.get_by_role("link", name="responsive").click() + page.get_by_role("link", name="responsive").click() + + page.set_viewport_size({"width": 500, "height": 3000}) + + try: + initial_bbox = ( + page.frame_locator("iframe").nth(2).locator("#map_div").bounding_box() + ) + except Exception as e: + page.screenshot(path="screenshot-responsive.png", full_page=True) + raise e + + page.set_viewport_size({"width": 1000, "height": 3000}) + + sleep(1) + + new_bbox = page.query_selector_all("iframe")[2].bounding_box() + + print(initial_bbox) + print(new_bbox) + + assert initial_bbox is not None + + assert new_bbox is not None + + assert new_bbox["width"] > initial_bbox["width"] + 300 + + # Check that the iframe is reasonably tall, which makes sure it hasn't failed to + # render at all + assert new_bbox["height"] > 100 + + page.set_viewport_size({"width": 2000, "height": 2000}) + + +def test_geojson_styles(page: Page): + page.get_by_role("link", name="geojson styles").click() + page.get_by_role("link", name="geojson styles").click() + + page.get_by_text("Show generated code").click() + expect(page.get_by_text('"fillOpacity"')).to_be_visible() + + +def test_grouped_layer_control(page: Page): + page.get_by_role("link", name="grouped layer control").click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( + "label" + ).filter(has_text="g2").click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_label( + "g2" + ).check() + + +def test_geojson_popup(page: Page): + page.get_by_role("link", name="geojson popup").click() + + expect(page.get_by_text("AttributeError")).to_be_hidden() + + +@pytest.mark.skip(reason="too flaky") +def test_dynamic_feature_group_update(page: Page): + page.get_by_role("link", name="dynamic updates").click() + page.get_by_text("Show generated code").click() + + # Test showing only Parcel layer + page.get_by_test_id("stRadio").get_by_text("Parcels").click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .locator("path") + .first + ).to_be_visible() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .get_by_role("img") + .locator("svg") + ).to_be_hidden() + expect( + page.get_by_text('"fillColor"') + ).to_be_visible() # fillColor only present in parcel style + expect( + page.get_by_text('"dashArray"') + ).to_be_hidden() # dashArray only present in building style + + # Test showing only Building layer + page.get_by_test_id("stRadio").get_by_text("Buildings").click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .locator("path") + .first + ).to_be_visible() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .get_by_role("img") + .locator("svg") + ).to_be_hidden() + expect(page.get_by_text("fillColor")).to_be_hidden() + expect(page.get_by_text("dashArray")).to_be_visible() + + # Test showing no layers + page.get_by_test_id("stRadio").get_by_text("None").click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .get_by_role("img") + .locator("svg") + ).to_be_hidden() + expect(page.get_by_text("fillColor")).to_be_hidden() + expect(page.get_by_text("dashArray")).to_be_hidden() + + # Test showing both layers + page.get_by_test_id("stRadio").get_by_text("Both").click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .locator("path") + .first + ).to_be_visible() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .locator("path") + .nth(1) + ).to_be_visible() + expect(page.get_by_text("fillColor")).to_be_visible() + expect(page.get_by_text("dashArray")).to_be_visible() + + +def test_layer_control_dynamic_update(page: Page): + page.get_by_role("link", name="dynamic layer control").click() + # page.get_by_text("Show generated code").click() + + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Parcels" + ).click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Parcels" + ) + ).not_to_be_checked() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') + .get_by_role("img") + .locator("svg") + ).to_be_hidden() + expect(page.get_by_text("dashArray")).to_be_hidden() + + page.get_by_test_id("stRadio").get_by_text("Both").click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Parcels" + ).click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Parcels" + ) + ).not_to_be_checked() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Buildings" + ) + ).to_be_checked() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') + .get_by_role("img") + .locator("path") + ).to_be_visible() From cffda6756c147b3d401964ad6c62fa6e80513020 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 16:09:53 +0100 Subject: [PATCH 02/17] Fix pre-commit --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4075956129..ae3241944f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: - id: debug-statements - id: end-of-file-fixer - id: check-docstring-first - exclude: examples/* + exclude: examples/.* - id: check-added-large-files - id: requirements-txt-fixer - id: file-contents-sorter From 3de82bb5c5d388cce3bf36c79a2feaf0890a1a2f Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 16:17:47 +0100 Subject: [PATCH 03/17] Add required packages --- requirements-dev.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3834e1e8ba..48f39a0324 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -29,10 +29,13 @@ pre-commit pycodestyle pydata-sphinx-theme pytest +pytest-playwright scipy selenium<4.27.0 setuptools_scm sphinx +streamlit +streamlit-folium types-requests vega_datasets vincent From 65a0ef423f1a9e5dae2aff549be05f2295f89b40 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 16:59:06 +0100 Subject: [PATCH 04/17] Add streamlit/playwright tests to workflows --- .github/workflows/test_code.yml | 2 +- .github/workflows/test_streamlit.yml | 39 +++ tests/test_streamlit.py | 395 --------------------------- 3 files changed, 40 insertions(+), 396 deletions(-) create mode 100644 .github/workflows/test_streamlit.yml delete mode 100644 tests/test_streamlit.py diff --git a/.github/workflows/test_code.yml b/.github/workflows/test_code.yml index ee75fe41e3..1a17de5940 100644 --- a/.github/workflows/test_code.yml +++ b/.github/workflows/test_code.yml @@ -34,4 +34,4 @@ jobs: run: python -m pip install -e . --no-deps --force-reinstall - name: Code tests - run: python -m pytest -vv --ignore=tests/selenium + run: python -m pytest -vv --ignore=tests/selenium --ignore=tests/streamlit diff --git a/.github/workflows/test_streamlit.yml b/.github/workflows/test_streamlit.yml new file mode 100644 index 0000000000..5d20bbf957 --- /dev/null +++ b/.github/workflows/test_streamlit.yml @@ -0,0 +1,39 @@ +name: Selenium Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + run: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.9", "3.13" ] + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Setup Micromamba env + uses: mamba-org/setup-micromamba@v2 + with: + environment-name: TEST + create-args: >- + python=3 + --file requirements.txt + --file requirements-dev.txt + + - name: Install folium from source + shell: bash -l {0} + run: python -m pip install -e . --no-deps --force-reinstall + + - name: Install playwright dependencies + run: | + playwright install --with-deps + + - name: Streamlit tests + shell: bash -l {0} + run: python -m pytest tests/streamlit -vv diff --git a/tests/test_streamlit.py b/tests/test_streamlit.py deleted file mode 100644 index 8e57b39540..0000000000 --- a/tests/test_streamlit.py +++ /dev/null @@ -1,395 +0,0 @@ -from __future__ import annotations - -from contextlib import contextmanager -from time import sleep - -import pytest -from playwright.sync_api import Page, Response, expect - -LOCAL_TEST = False - -PORT = "8503" if LOCAL_TEST else "8699" - - -@pytest.fixture(scope="module", autouse=True) -def before_module(): - # Run the streamlit app before each module - with run_streamlit(): - yield - - -@pytest.fixture(scope="function", autouse=True) -def before_test(page: Page): - page.goto(f"localhost:{PORT}") - page.set_viewport_size({"width": 2000, "height": 2000}) - expect.set_options(timeout=5_000) - - -# Take screenshot of each page if there are failures for this session -@pytest.fixture(scope="function", autouse=True) -def after_test(page: Page, request): - yield - if request.node.rep_call.failed: - page.screenshot(path=f"screenshot-{request.node.name}.png", full_page=True) - - -@contextmanager -def run_streamlit(): - """Run the streamlit app at examples/streamlit_app.py on port 8599""" - import subprocess - - if LOCAL_TEST: - try: - yield 1 - finally: - pass - else: - p = subprocess.Popen( - [ - "streamlit", - "run", - "examples/streamlit_app.py", - "--server.port", - PORT, - "--server.headless", - "true", - ] - ) - - sleep(5) - - try: - yield 1 - finally: - p.kill() - - -def click_button_or_marker(page: Page, nth: int = 0, locator: str | None = None): - """For some reason, there's a discrepancy between how the map markers are - selectable locally and on github actions, perhaps related some error in loading - the actual marker images. This tries both ways to select a marker""" - - frame = page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') - if locator is not None: - frame = frame.locator(locator) - try: - frame.get_by_role("button", name="Marker").nth(nth).click(timeout=5_000) - except Exception: - frame.get_by_role("img").nth(nth).click(timeout=5_000) - - -def test_marker_click(page: Page): - def check_for_404(response: Response): - if not response.ok: - print(response) - print(response.text()) - print(response.url) - print(response.status) - raise Exception("404") - - page.on("response", check_for_404) - - # Check page title - expect(page).to_have_title("streamlit-folium documentation") - - expect(page.get_by_text('"last_object_clicked":NULL')).to_be_visible() - - # Click marker - try: - click_button_or_marker(page) - except Exception as e: - page.screenshot(path="screenshot-test-marker-click.png", full_page=True) - raise e - - expect(page.get_by_text('"last_object_clicked":NULL')).to_be_hidden() - - -def test_draw(page: Page): - # Test draw support - page.get_by_role("link", name="draw support").click() - # Click again to see if it resolves timeout issues - page.get_by_role("link", name="draw support").click() - - expect(page).to_have_title("streamlit-folium documentation: Draw Support") - - page.frame_locator( - 'internal:attr=[title="streamlit_folium.st_folium"i]' - ).get_by_role("link", name="Draw a polygon").click() - - # Should be no drawings - expect(page.get_by_text('"all_drawings":NULL')).to_be_visible() - - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_role( - "link", name="Draw a marker" - ).click() - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( - ".leaflet-marker-icon" - ).first.click() - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( - "#map_div" - ).click() - - # Should be one item in drawings after having placed a marker - expect(page.get_by_text('"all_drawings":NULL')).to_be_hidden() - - -def test_limit_data(page: Page): - # Test limit data support - page.get_by_role("link", name="limit data return").click() - # Click again to see if it resolves timeout issues - page.get_by_role("link", name="limit data return").click() - - expect(page).to_have_title("streamlit-folium documentation: Limit Data Return") - - expect(page.get_by_text('{"last_object_clicked":NULL}')).to_be_visible() - - # Click marker - click_button_or_marker(page, 2) - - # Have to click a second time for some reason, maybe because it doesn't load right - # away - click_button_or_marker(page, 2) - - expect(page.get_by_text('{"last_object_clicked":{"lat":39.96')).to_be_visible() - - -def test_dual_map(page: Page): - page.get_by_role("link", name="misc examples").click() - # Click again to see if it resolves timeout issues - page.get_by_role("link", name="misc examples").click() - - expect(page).to_have_title("streamlit-folium documentation: Misc Examples") - - page.locator("label").filter(has_text="Dual map").click() - page.locator("label").filter(has_text="Dual map").click() - - # Click marker on left map - try: - click_button_or_marker(page, 0, "#map_div") - click_button_or_marker(page, 0, "#map_div2") - except Exception as e: - page.screenshot(path="screenshot-dual-map.png", full_page=True) - raise e - - -def test_vector_grid(page: Page): - page.get_by_role("link", name="vector grid").click() - page.get_by_role("link", name="vector grid").click() - - expect(page).to_have_title("streamlit-folium documentation: Vector Grid") - - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( - ".leaflet-marker-icon" - ).click() - - -def test_tooltip_click(page: Page): - expect(page.get_by_text('"last_object_clicked_tooltip":NULL')).to_be_visible() - - # Click marker on map - click_button_or_marker(page) - - expect( - page.get_by_text('"last_object_clicked_tooltip":"Liberty Bell"') - ).to_be_visible() - - -def test_popup_text(page: Page): - page.get_by_role("link", name="simple popup").click() - page.get_by_role("link", name="simple popup").click() - - expect(page.get_by_text("Popup: None")).to_be_visible() - expect(page.get_by_text("Tooltip: None")).to_be_visible() - - click_button_or_marker(page) - - try: - expect(page.get_by_text("Popup: Popup!")).to_be_visible() - expect(page.get_by_text("Tooltip: Tooltip!")).to_be_visible() - except Exception as e: - page.screenshot(path="screenshot-popup.png") - raise e - - -def test_return_on_hover(page: Page): - page.get_by_role("link", name="simple popup").click() - page.get_by_role("link", name="simple popup").click() - - expect(page.get_by_text("Popup: None")).to_be_visible() - expect(page.get_by_text("Tooltip: None")).to_be_visible() - - page.get_by_text("Return on hover?").click() - - click_button_or_marker(page, 1) - - try: - expect(page.get_by_text("Popup: Popup 2!")).to_be_visible() - expect(page.get_by_text("Tooltip: Tooltip 2!")).to_be_visible() - except Exception as e: - page.screenshot(path="screenshot-popup2.png") - raise e - - -def test_responsiveness(page: Page): - page.get_by_role("link", name="responsive").click() - page.get_by_role("link", name="responsive").click() - - page.set_viewport_size({"width": 500, "height": 3000}) - - try: - initial_bbox = ( - page.frame_locator("iframe").nth(2).locator("#map_div").bounding_box() - ) - except Exception as e: - page.screenshot(path="screenshot-responsive.png", full_page=True) - raise e - - page.set_viewport_size({"width": 1000, "height": 3000}) - - sleep(1) - - new_bbox = page.query_selector_all("iframe")[2].bounding_box() - - print(initial_bbox) - print(new_bbox) - - assert initial_bbox is not None - - assert new_bbox is not None - - assert new_bbox["width"] > initial_bbox["width"] + 300 - - # Check that the iframe is reasonably tall, which makes sure it hasn't failed to - # render at all - assert new_bbox["height"] > 100 - - page.set_viewport_size({"width": 2000, "height": 2000}) - - -def test_geojson_styles(page: Page): - page.get_by_role("link", name="geojson styles").click() - page.get_by_role("link", name="geojson styles").click() - - page.get_by_text("Show generated code").click() - expect(page.get_by_text('"fillOpacity"')).to_be_visible() - - -def test_grouped_layer_control(page: Page): - page.get_by_role("link", name="grouped layer control").click() - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( - "label" - ).filter(has_text="g2").click() - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_label( - "g2" - ).check() - - -def test_geojson_popup(page: Page): - page.get_by_role("link", name="geojson popup").click() - - expect(page.get_by_text("AttributeError")).to_be_hidden() - - -@pytest.mark.skip(reason="too flaky") -def test_dynamic_feature_group_update(page: Page): - page.get_by_role("link", name="dynamic updates").click() - page.get_by_text("Show generated code").click() - - # Test showing only Parcel layer - page.get_by_test_id("stRadio").get_by_text("Parcels").click() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') - .locator("path") - .first - ).to_be_visible() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') - .get_by_role("img") - .locator("svg") - ).to_be_hidden() - expect( - page.get_by_text('"fillColor"') - ).to_be_visible() # fillColor only present in parcel style - expect( - page.get_by_text('"dashArray"') - ).to_be_hidden() # dashArray only present in building style - - # Test showing only Building layer - page.get_by_test_id("stRadio").get_by_text("Buildings").click() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') - .locator("path") - .first - ).to_be_visible() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') - .get_by_role("img") - .locator("svg") - ).to_be_hidden() - expect(page.get_by_text("fillColor")).to_be_hidden() - expect(page.get_by_text("dashArray")).to_be_visible() - - # Test showing no layers - page.get_by_test_id("stRadio").get_by_text("None").click() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') - .get_by_role("img") - .locator("svg") - ).to_be_hidden() - expect(page.get_by_text("fillColor")).to_be_hidden() - expect(page.get_by_text("dashArray")).to_be_hidden() - - # Test showing both layers - page.get_by_test_id("stRadio").get_by_text("Both").click() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') - .locator("path") - .first - ).to_be_visible() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') - .locator("path") - .nth(1) - ).to_be_visible() - expect(page.get_by_text("fillColor")).to_be_visible() - expect(page.get_by_text("dashArray")).to_be_visible() - - -def test_layer_control_dynamic_update(page: Page): - page.get_by_role("link", name="dynamic layer control").click() - # page.get_by_text("Show generated code").click() - - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( - "Parcels" - ).click() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( - "Parcels" - ) - ).not_to_be_checked() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') - .get_by_role("img") - .locator("svg") - ).to_be_hidden() - expect(page.get_by_text("dashArray")).to_be_hidden() - - page.get_by_test_id("stRadio").get_by_text("Both").click() - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( - "Parcels" - ).click() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( - "Parcels" - ) - ).not_to_be_checked() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( - "Buildings" - ) - ).to_be_checked() - expect( - page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') - .get_by_role("img") - .locator("path") - ).to_be_visible() From b00bb7140f1079931dc8d08bb23fee43b1b2d415 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 17:15:38 +0100 Subject: [PATCH 05/17] Fix name of tests --- .github/workflows/test_streamlit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_streamlit.yml b/.github/workflows/test_streamlit.yml index 5d20bbf957..e5558407ab 100644 --- a/.github/workflows/test_streamlit.yml +++ b/.github/workflows/test_streamlit.yml @@ -1,4 +1,4 @@ -name: Selenium Tests +name: Streamlit Tests on: pull_request: From 8b61f79045b3a48d67079700d0acf91f1fc6eaa9 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 17:21:21 +0100 Subject: [PATCH 06/17] Try to install playwright like this --- .github/workflows/test_streamlit.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test_streamlit.yml b/.github/workflows/test_streamlit.yml index e5558407ab..d23a508e75 100644 --- a/.github/workflows/test_streamlit.yml +++ b/.github/workflows/test_streamlit.yml @@ -30,6 +30,11 @@ jobs: shell: bash -l {0} run: python -m pip install -e . --no-deps --force-reinstall + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest pytest-playwright + - name: Install playwright dependencies run: | playwright install --with-deps From 2b30c5bb17318f5184306b07e741a7b00cdc3f38 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 17:24:54 +0100 Subject: [PATCH 07/17] Add missing tests --- tests/streamlit/test_streamlit.py | 395 ++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 tests/streamlit/test_streamlit.py diff --git a/tests/streamlit/test_streamlit.py b/tests/streamlit/test_streamlit.py new file mode 100644 index 0000000000..8e57b39540 --- /dev/null +++ b/tests/streamlit/test_streamlit.py @@ -0,0 +1,395 @@ +from __future__ import annotations + +from contextlib import contextmanager +from time import sleep + +import pytest +from playwright.sync_api import Page, Response, expect + +LOCAL_TEST = False + +PORT = "8503" if LOCAL_TEST else "8699" + + +@pytest.fixture(scope="module", autouse=True) +def before_module(): + # Run the streamlit app before each module + with run_streamlit(): + yield + + +@pytest.fixture(scope="function", autouse=True) +def before_test(page: Page): + page.goto(f"localhost:{PORT}") + page.set_viewport_size({"width": 2000, "height": 2000}) + expect.set_options(timeout=5_000) + + +# Take screenshot of each page if there are failures for this session +@pytest.fixture(scope="function", autouse=True) +def after_test(page: Page, request): + yield + if request.node.rep_call.failed: + page.screenshot(path=f"screenshot-{request.node.name}.png", full_page=True) + + +@contextmanager +def run_streamlit(): + """Run the streamlit app at examples/streamlit_app.py on port 8599""" + import subprocess + + if LOCAL_TEST: + try: + yield 1 + finally: + pass + else: + p = subprocess.Popen( + [ + "streamlit", + "run", + "examples/streamlit_app.py", + "--server.port", + PORT, + "--server.headless", + "true", + ] + ) + + sleep(5) + + try: + yield 1 + finally: + p.kill() + + +def click_button_or_marker(page: Page, nth: int = 0, locator: str | None = None): + """For some reason, there's a discrepancy between how the map markers are + selectable locally and on github actions, perhaps related some error in loading + the actual marker images. This tries both ways to select a marker""" + + frame = page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') + if locator is not None: + frame = frame.locator(locator) + try: + frame.get_by_role("button", name="Marker").nth(nth).click(timeout=5_000) + except Exception: + frame.get_by_role("img").nth(nth).click(timeout=5_000) + + +def test_marker_click(page: Page): + def check_for_404(response: Response): + if not response.ok: + print(response) + print(response.text()) + print(response.url) + print(response.status) + raise Exception("404") + + page.on("response", check_for_404) + + # Check page title + expect(page).to_have_title("streamlit-folium documentation") + + expect(page.get_by_text('"last_object_clicked":NULL')).to_be_visible() + + # Click marker + try: + click_button_or_marker(page) + except Exception as e: + page.screenshot(path="screenshot-test-marker-click.png", full_page=True) + raise e + + expect(page.get_by_text('"last_object_clicked":NULL')).to_be_hidden() + + +def test_draw(page: Page): + # Test draw support + page.get_by_role("link", name="draw support").click() + # Click again to see if it resolves timeout issues + page.get_by_role("link", name="draw support").click() + + expect(page).to_have_title("streamlit-folium documentation: Draw Support") + + page.frame_locator( + 'internal:attr=[title="streamlit_folium.st_folium"i]' + ).get_by_role("link", name="Draw a polygon").click() + + # Should be no drawings + expect(page.get_by_text('"all_drawings":NULL')).to_be_visible() + + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_role( + "link", name="Draw a marker" + ).click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( + ".leaflet-marker-icon" + ).first.click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( + "#map_div" + ).click() + + # Should be one item in drawings after having placed a marker + expect(page.get_by_text('"all_drawings":NULL')).to_be_hidden() + + +def test_limit_data(page: Page): + # Test limit data support + page.get_by_role("link", name="limit data return").click() + # Click again to see if it resolves timeout issues + page.get_by_role("link", name="limit data return").click() + + expect(page).to_have_title("streamlit-folium documentation: Limit Data Return") + + expect(page.get_by_text('{"last_object_clicked":NULL}')).to_be_visible() + + # Click marker + click_button_or_marker(page, 2) + + # Have to click a second time for some reason, maybe because it doesn't load right + # away + click_button_or_marker(page, 2) + + expect(page.get_by_text('{"last_object_clicked":{"lat":39.96')).to_be_visible() + + +def test_dual_map(page: Page): + page.get_by_role("link", name="misc examples").click() + # Click again to see if it resolves timeout issues + page.get_by_role("link", name="misc examples").click() + + expect(page).to_have_title("streamlit-folium documentation: Misc Examples") + + page.locator("label").filter(has_text="Dual map").click() + page.locator("label").filter(has_text="Dual map").click() + + # Click marker on left map + try: + click_button_or_marker(page, 0, "#map_div") + click_button_or_marker(page, 0, "#map_div2") + except Exception as e: + page.screenshot(path="screenshot-dual-map.png", full_page=True) + raise e + + +def test_vector_grid(page: Page): + page.get_by_role("link", name="vector grid").click() + page.get_by_role("link", name="vector grid").click() + + expect(page).to_have_title("streamlit-folium documentation: Vector Grid") + + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( + ".leaflet-marker-icon" + ).click() + + +def test_tooltip_click(page: Page): + expect(page.get_by_text('"last_object_clicked_tooltip":NULL')).to_be_visible() + + # Click marker on map + click_button_or_marker(page) + + expect( + page.get_by_text('"last_object_clicked_tooltip":"Liberty Bell"') + ).to_be_visible() + + +def test_popup_text(page: Page): + page.get_by_role("link", name="simple popup").click() + page.get_by_role("link", name="simple popup").click() + + expect(page.get_by_text("Popup: None")).to_be_visible() + expect(page.get_by_text("Tooltip: None")).to_be_visible() + + click_button_or_marker(page) + + try: + expect(page.get_by_text("Popup: Popup!")).to_be_visible() + expect(page.get_by_text("Tooltip: Tooltip!")).to_be_visible() + except Exception as e: + page.screenshot(path="screenshot-popup.png") + raise e + + +def test_return_on_hover(page: Page): + page.get_by_role("link", name="simple popup").click() + page.get_by_role("link", name="simple popup").click() + + expect(page.get_by_text("Popup: None")).to_be_visible() + expect(page.get_by_text("Tooltip: None")).to_be_visible() + + page.get_by_text("Return on hover?").click() + + click_button_or_marker(page, 1) + + try: + expect(page.get_by_text("Popup: Popup 2!")).to_be_visible() + expect(page.get_by_text("Tooltip: Tooltip 2!")).to_be_visible() + except Exception as e: + page.screenshot(path="screenshot-popup2.png") + raise e + + +def test_responsiveness(page: Page): + page.get_by_role("link", name="responsive").click() + page.get_by_role("link", name="responsive").click() + + page.set_viewport_size({"width": 500, "height": 3000}) + + try: + initial_bbox = ( + page.frame_locator("iframe").nth(2).locator("#map_div").bounding_box() + ) + except Exception as e: + page.screenshot(path="screenshot-responsive.png", full_page=True) + raise e + + page.set_viewport_size({"width": 1000, "height": 3000}) + + sleep(1) + + new_bbox = page.query_selector_all("iframe")[2].bounding_box() + + print(initial_bbox) + print(new_bbox) + + assert initial_bbox is not None + + assert new_bbox is not None + + assert new_bbox["width"] > initial_bbox["width"] + 300 + + # Check that the iframe is reasonably tall, which makes sure it hasn't failed to + # render at all + assert new_bbox["height"] > 100 + + page.set_viewport_size({"width": 2000, "height": 2000}) + + +def test_geojson_styles(page: Page): + page.get_by_role("link", name="geojson styles").click() + page.get_by_role("link", name="geojson styles").click() + + page.get_by_text("Show generated code").click() + expect(page.get_by_text('"fillOpacity"')).to_be_visible() + + +def test_grouped_layer_control(page: Page): + page.get_by_role("link", name="grouped layer control").click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').locator( + "label" + ).filter(has_text="g2").click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_label( + "g2" + ).check() + + +def test_geojson_popup(page: Page): + page.get_by_role("link", name="geojson popup").click() + + expect(page.get_by_text("AttributeError")).to_be_hidden() + + +@pytest.mark.skip(reason="too flaky") +def test_dynamic_feature_group_update(page: Page): + page.get_by_role("link", name="dynamic updates").click() + page.get_by_text("Show generated code").click() + + # Test showing only Parcel layer + page.get_by_test_id("stRadio").get_by_text("Parcels").click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .locator("path") + .first + ).to_be_visible() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .get_by_role("img") + .locator("svg") + ).to_be_hidden() + expect( + page.get_by_text('"fillColor"') + ).to_be_visible() # fillColor only present in parcel style + expect( + page.get_by_text('"dashArray"') + ).to_be_hidden() # dashArray only present in building style + + # Test showing only Building layer + page.get_by_test_id("stRadio").get_by_text("Buildings").click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .locator("path") + .first + ).to_be_visible() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .get_by_role("img") + .locator("svg") + ).to_be_hidden() + expect(page.get_by_text("fillColor")).to_be_hidden() + expect(page.get_by_text("dashArray")).to_be_visible() + + # Test showing no layers + page.get_by_test_id("stRadio").get_by_text("None").click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .get_by_role("img") + .locator("svg") + ).to_be_hidden() + expect(page.get_by_text("fillColor")).to_be_hidden() + expect(page.get_by_text("dashArray")).to_be_hidden() + + # Test showing both layers + page.get_by_test_id("stRadio").get_by_text("Both").click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .locator("path") + .first + ).to_be_visible() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"] >> nth=1') + .locator("path") + .nth(1) + ).to_be_visible() + expect(page.get_by_text("fillColor")).to_be_visible() + expect(page.get_by_text("dashArray")).to_be_visible() + + +def test_layer_control_dynamic_update(page: Page): + page.get_by_role("link", name="dynamic layer control").click() + # page.get_by_text("Show generated code").click() + + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Parcels" + ).click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Parcels" + ) + ).not_to_be_checked() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') + .get_by_role("img") + .locator("svg") + ).to_be_hidden() + expect(page.get_by_text("dashArray")).to_be_hidden() + + page.get_by_test_id("stRadio").get_by_text("Both").click() + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Parcels" + ).click() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Parcels" + ) + ).not_to_be_checked() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]').get_by_text( + "Buildings" + ) + ).to_be_checked() + expect( + page.frame_locator('iframe[title="streamlit_folium\\.st_folium"]') + .get_by_role("img") + .locator("path") + ).to_be_visible() From de78a77428c8d98a392b9d0356092f426f7dd827 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 17:37:03 +0100 Subject: [PATCH 08/17] Add separate test that does not use conda --- .github/workflows/test_playwright.yml | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/test_playwright.yml diff --git a/.github/workflows/test_playwright.yml b/.github/workflows/test_playwright.yml new file mode 100644 index 0000000000..a9ad7b8d68 --- /dev/null +++ b/.github/workflows/test_playwright.yml @@ -0,0 +1,55 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Run playwright/streamlit tests + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.13"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/setup-node@v3 + with: + node-version: 20 + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest pytest-playwright + pip install -e . --force-reinstall + - uses: pre-commit/action@v2.0.3 + - name: Install playwright dependencies + run: | + playwright install --with-deps + - name: Install annotate-failures-plugin + run: pip install pytest-github-actions-annotate-failures + + - name: Test with pytest and retry flaky tests up to 3 times + run: | + pytest --browser chromium -s --reruns 3 --junit-xml=test-results.xml + + - name: Surface failing tests + if: always() + uses: pmeier/pytest-results-action@main + with: + path: test-results.xml + fail-on-empty: false + + - uses: actions/upload-artifact@v3 + if: failure() + with: + name: screenshots + path: screenshot*.png From a310e6a188a1182f6bdfe762d4e6a1acbceab67b Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 17:41:05 +0100 Subject: [PATCH 09/17] Remove streamlit tests from testing with branca --- .github/workflows/test_latest_branca.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_latest_branca.yml b/.github/workflows/test_latest_branca.yml index 73b0254027..77287c6396 100644 --- a/.github/workflows/test_latest_branca.yml +++ b/.github/workflows/test_latest_branca.yml @@ -31,4 +31,4 @@ jobs: run: | micromamba remove branca --yes --force python -m pip install git+https://github.com/python-visualization/branca.git - python -m pytest -vv --ignore=tests/selenium + python -m pytest -vv --ignore=tests/selenium --ignore=tests/streamlit From 1ba6e22f5a35bb8dc4fadf3950e2870a1ae3b093 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 17:46:00 +0100 Subject: [PATCH 10/17] Make sure we use a recent pytest version --- .github/workflows/test_playwright.yml | 1 + requirements-dev.txt | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test_playwright.yml b/.github/workflows/test_playwright.yml index a9ad7b8d68..edd7ad41a3 100644 --- a/.github/workflows/test_playwright.yml +++ b/.github/workflows/test_playwright.yml @@ -29,6 +29,7 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 pytest pytest-playwright + if [ -f tests/requirements.txt ]; then pip install -r tests/requirements.txt; fi pip install -e . --force-reinstall - uses: pre-commit/action@v2.0.3 - name: Install playwright dependencies diff --git a/requirements-dev.txt b/requirements-dev.txt index 48f39a0324..2523d651a0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -28,8 +28,6 @@ pillow pre-commit pycodestyle pydata-sphinx-theme -pytest -pytest-playwright scipy selenium<4.27.0 setuptools_scm From f8e1804cf578a41fff7acc4888a5860d30326140 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 17:51:57 +0100 Subject: [PATCH 11/17] Remove failing variant of test --- .github/workflows/test_streamlit.yml | 44 ---------------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/test_streamlit.yml diff --git a/.github/workflows/test_streamlit.yml b/.github/workflows/test_streamlit.yml deleted file mode 100644 index d23a508e75..0000000000 --- a/.github/workflows/test_streamlit.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Streamlit Tests - -on: - pull_request: - push: - branches: - - main - -jobs: - run: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [ "3.9", "3.13" ] - fail-fast: false - - steps: - - uses: actions/checkout@v4 - - - name: Setup Micromamba env - uses: mamba-org/setup-micromamba@v2 - with: - environment-name: TEST - create-args: >- - python=3 - --file requirements.txt - --file requirements-dev.txt - - - name: Install folium from source - shell: bash -l {0} - run: python -m pip install -e . --no-deps --force-reinstall - - - name: Install python dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest pytest-playwright - - - name: Install playwright dependencies - run: | - playwright install --with-deps - - - name: Streamlit tests - shell: bash -l {0} - run: python -m pytest tests/streamlit -vv From f5160f96080a32ce2b4408516b6b834eb6cd3996 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 17:54:24 +0100 Subject: [PATCH 12/17] Install streamlit requirements --- .github/workflows/test_playwright.yml | 2 +- tests/streamlit/requirements.txt | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 tests/streamlit/requirements.txt diff --git a/.github/workflows/test_playwright.yml b/.github/workflows/test_playwright.yml index edd7ad41a3..a6b5e7c901 100644 --- a/.github/workflows/test_playwright.yml +++ b/.github/workflows/test_playwright.yml @@ -29,7 +29,7 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 pytest pytest-playwright - if [ -f tests/requirements.txt ]; then pip install -r tests/requirements.txt; fi + if [ -f tests/streamlit/requirements.txt ]; then pip install -r tests/streamlit/requirements.txt; fi pip install -e . --force-reinstall - uses: pre-commit/action@v2.0.3 - name: Install playwright dependencies diff --git a/tests/streamlit/requirements.txt b/tests/streamlit/requirements.txt new file mode 100644 index 0000000000..eaae73fe3c --- /dev/null +++ b/tests/streamlit/requirements.txt @@ -0,0 +1,5 @@ +geopandas +pytest>=7.1.2 +pytest-playwright +pytest-rerunfailures +streamlit>=1.13.0,!=1.34.0 From 6eb969f4f464a898bd5ef791bec42ffb7b5caf08 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 18:01:48 +0100 Subject: [PATCH 13/17] Change tabs into spaces --- .github/workflows/test_playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_playwright.yml b/.github/workflows/test_playwright.yml index a6b5e7c901..63ba6c30fe 100644 --- a/.github/workflows/test_playwright.yml +++ b/.github/workflows/test_playwright.yml @@ -29,7 +29,7 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 pytest pytest-playwright - if [ -f tests/streamlit/requirements.txt ]; then pip install -r tests/streamlit/requirements.txt; fi + if [ -f tests/streamlit/requirements.txt ]; then pip install -r tests/streamlit/requirements.txt; fi pip install -e . --force-reinstall - uses: pre-commit/action@v2.0.3 - name: Install playwright dependencies From 25060bec319e91e258b030266c82727198a99f53 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 18:07:29 +0100 Subject: [PATCH 14/17] Scope playwright to only streamlit based tests --- .github/workflows/test_playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_playwright.yml b/.github/workflows/test_playwright.yml index 63ba6c30fe..6f4145f750 100644 --- a/.github/workflows/test_playwright.yml +++ b/.github/workflows/test_playwright.yml @@ -40,7 +40,7 @@ jobs: - name: Test with pytest and retry flaky tests up to 3 times run: | - pytest --browser chromium -s --reruns 3 --junit-xml=test-results.xml + pytest --browser chromium -s --reruns 3 --junit-xml=test-results.xml tests/streamlit - name: Surface failing tests if: always() From 81d87206bf355ab8bd552b63c93fbd86360b93c8 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 18:14:06 +0100 Subject: [PATCH 15/17] Add streamlit-folium as requirement --- .github/workflows/test_playwright.yml | 2 +- tests/streamlit/requirements.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_playwright.yml b/.github/workflows/test_playwright.yml index 6f4145f750..1fc9972c9e 100644 --- a/.github/workflows/test_playwright.yml +++ b/.github/workflows/test_playwright.yml @@ -28,7 +28,7 @@ jobs: - name: Install python dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest pytest-playwright + pip install flake8 pytest pytest-playwrigh if [ -f tests/streamlit/requirements.txt ]; then pip install -r tests/streamlit/requirements.txt; fi pip install -e . --force-reinstall - uses: pre-commit/action@v2.0.3 diff --git a/tests/streamlit/requirements.txt b/tests/streamlit/requirements.txt index eaae73fe3c..888db52500 100644 --- a/tests/streamlit/requirements.txt +++ b/tests/streamlit/requirements.txt @@ -3,3 +3,4 @@ pytest>=7.1.2 pytest-playwright pytest-rerunfailures streamlit>=1.13.0,!=1.34.0 +streamlit-folium From 9f38d99d1a71887c8eb913e5c6fd0ce389990a99 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 18:38:17 +0100 Subject: [PATCH 16/17] Fix typo --- .github/workflows/test_playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_playwright.yml b/.github/workflows/test_playwright.yml index 1fc9972c9e..6f4145f750 100644 --- a/.github/workflows/test_playwright.yml +++ b/.github/workflows/test_playwright.yml @@ -28,7 +28,7 @@ jobs: - name: Install python dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest pytest-playwrigh + pip install flake8 pytest pytest-playwright if [ -f tests/streamlit/requirements.txt ]; then pip install -r tests/streamlit/requirements.txt; fi pip install -e . --force-reinstall - uses: pre-commit/action@v2.0.3 From c2d32256d13016d1dfd8728c2a375fbd075c4c43 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Sat, 18 Jan 2025 18:43:45 +0100 Subject: [PATCH 17/17] Rename test to be more consistent --- .github/workflows/test_playwright.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_playwright.yml b/.github/workflows/test_playwright.yml index 6f4145f750..08656c1ed0 100644 --- a/.github/workflows/test_playwright.yml +++ b/.github/workflows/test_playwright.yml @@ -1,7 +1,7 @@ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -name: Run playwright/streamlit tests +name: Playwright tests on: pull_request: