diff --git a/.github/workflows/save_versions.yml b/.github/workflows/save_versions.yml new file mode 100644 index 0000000000..a7f787081e --- /dev/null +++ b/.github/workflows/save_versions.yml @@ -0,0 +1,43 @@ +name: Save package versions + +on: + pull_request: + push: + branches: + - main + +jobs: + run: + runs-on: ubuntu-latest + + steps: + - name: Checkout Folium + 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: Create versions.txt + shell: bash -l {0} + run: | + conda list > /tmp/versions.txt + chromium --version >> /tmp/versions.txt + + - name: Save versions.txt + if: always() + uses: actions/upload-artifact@v4 + with: + name: versions.txt + path: /tmp/versions.txt + fail-on-empty: false diff --git a/.github/workflows/test_code.yml b/.github/workflows/test_code.yml index 0c98fdbcfa..e29dd68f20 100644 --- a/.github/workflows/test_code.yml +++ b/.github/workflows/test_code.yml @@ -35,5 +35,10 @@ jobs: - name: Install folium from source run: python -m pip install -e . --no-deps --force-reinstall + - name: Install pixelmatch + shell: bash -l {0} + run: | + pip install pixelmatch + - name: Code tests - run: python -m pytest -vv --ignore=tests/selenium + run: python -m pytest -vv --ignore=tests/selenium --ignore=tests/playwright --ignore=tests/snapshots diff --git a/.github/workflows/test_latest_branca.yml b/.github/workflows/test_latest_branca.yml index fd0c267c64..c7ea3aaa19 100644 --- a/.github/workflows/test_latest_branca.yml +++ b/.github/workflows/test_latest_branca.yml @@ -33,4 +33,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/playwright --ignore=tests/snapshots diff --git a/.github/workflows/test_snapshots.yml b/.github/workflows/test_snapshots.yml new file mode 100644 index 0000000000..9555eff44e --- /dev/null +++ b/.github/workflows/test_snapshots.yml @@ -0,0 +1,55 @@ +name: Run Snapshot Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + run: + runs-on: ubuntu-latest + + steps: + - name: Checkout Folium + 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 pytest plugins and pixelmatch + shell: bash -l {0} + run: | + pip install pixelmatch pytest-github-actions-annotate-failures pytest-rerunfailures + + - name: Install folium from source + shell: bash -l {0} + run: | + python -m pip install -e . --no-deps --force-reinstall + + - name: Test with pytest + shell: bash -l {0} + run: | + python -m pytest tests/snapshots -s --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 + + - name: Upload screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: screenshots + path: | + /tmp/screenshot_*_*.png + /tmp/folium_map_*.html diff --git a/.github/workflows/test_streamlit_folium.yml b/.github/workflows/test_streamlit_folium.yml index 206279fd44..056685a729 100644 --- a/.github/workflows/test_streamlit_folium.yml +++ b/.github/workflows/test_streamlit_folium.yml @@ -66,7 +66,7 @@ jobs: shell: bash -l {0} run: | cd streamlit_folium - pytest tests/test_frontend.py --browser chromium -s --reruns 3 --junit-xml=test-results.xml + pytest tests/test_frontend.py --browser chromium -s --reruns 5 --junit-xml=test-results.xml - name: Surface failing tests if: always() diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c6cf96a772..e48acb475f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,9 @@ repos: - id: debug-statements - id: end-of-file-fixer - id: check-docstring-first + exclude: ^examples/streamlit - id: check-added-large-files + args: ['--maxkb=1024'] - id: requirements-txt-fixer - id: file-contents-sorter files: requirements-dev.txt diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 0000000000..e5204f1710 --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,2 @@ +[server] +enableStaticServing = true diff --git a/examples/streamlit/main.py b/examples/streamlit/main.py new file mode 100644 index 0000000000..d7ffe411bd --- /dev/null +++ b/examples/streamlit/main.py @@ -0,0 +1,11 @@ +import streamlit as st + +st.logo("https://python-visualization.github.io/folium/latest/_static/folium_logo.png") +st.title("Python data, leaflet.js maps") + +""" +Folium builds on the data wrangling strengths of the Python ecosystem +and the mapping strengths of the Leaflet.js library. + +Manipulate your data in Python, then visualize it in a Leaflet map via +Folium.""" diff --git a/examples/streamlit/pages/draw_feature_group.py b/examples/streamlit/pages/draw_feature_group.py new file mode 100644 index 0000000000..a65e08e442 --- /dev/null +++ b/examples/streamlit/pages/draw_feature_group.py @@ -0,0 +1,43 @@ +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, png_enabled=True) + items = folium.FeatureGroup() + marker = folium.Marker(location=[38, -83]).add_to(items) + items.add_to(m) + + Draw(export=False, feature_group=items, show_geometry_on_click=False).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/streamlit/pages/issue_1770.py b/examples/streamlit/pages/issue_1770.py new file mode 100644 index 0000000000..a3d8ddd0e0 --- /dev/null +++ b/examples/streamlit/pages/issue_1770.py @@ -0,0 +1,46 @@ +import json + +import streamlit as st +from streamlit_folium import st_folium + +import folium + +st.set_page_config(layout="wide") + +geojson = """ +{"type": "FeatureCollection", + "features": [ + {"id": "0", "type": "Feature", "properties": {"foo": 0}, + "geometry": {"type": "Point", "coordinates": [0.0, 0.0]} + }, + {"id": "1", "type": "Feature", "properties": {"foo": 1}, + "geometry": {"type": "MultiPoint", "coordinates": [[1.0, 1.0]]}}, + {"id": "2", "type": "Feature", + "properties": {"foo": 2}, "geometry": {"type": "MultiPoint", "coordinates": + [[2.0, 2.0]]}}, {"id": "3", "type": "Feature", "properties": {"foo": 3}, + "geometry": {"type": "MultiPoint", "coordinates": [[3.0, 3.0]]}}, {"id": "4", + "type": "Feature", "properties": {"foo": 4}, "geometry": {"type": + "MultiPoint", "coordinates": [[4.0, 4.0]]}}]}""" + +geojson = json.loads(geojson) + +on_each_feature = folium.JsCode( + """ + (feature, layer) => { + layer.bindPopup("hello world"); + } +""" +) +m = folium.Map( + zoom_start=5, + location=(0, 0), +) +folium.GeoJson( + geojson, on_each_feature=on_each_feature, marker=folium.CircleMarker(radius=20) +).add_to(m) + +st_folium(m, width=700, height=500) +# st_folium(m, width=700, height=500, returned_objects=[]) + +html = m.get_root().render() +st.code(html) diff --git a/examples/streamlit/pages/issue_1989.py b/examples/streamlit/pages/issue_1989.py new file mode 100644 index 0000000000..1fc0d625aa --- /dev/null +++ b/examples/streamlit/pages/issue_1989.py @@ -0,0 +1,101 @@ +import io + +import branca +import geopandas +import pandas as pd +import requests +import streamlit as st +from streamlit_folium import st_folium + +import folium + +st.set_page_config(layout="wide") + +response = requests.get( + "https://gist.githubusercontent.com/tvpmb/4734703/raw/" + "b54d03154c339ed3047c66fefcece4727dfc931a/US%2520State%2520List" +) +abbrs = pd.read_json(io.StringIO(response.text)) + +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") + +data = requests.get( + "https://raw.githubusercontent.com/python-visualization/folium-example-data/main/us_states.json" +).json() + +states = geopandas.GeoDataFrame.from_features(data, crs="EPSG:4326") +statesmerge = states.merge(abbrs, how="left", left_on="name", right_on="name") +statesmerge["geometry"] = statesmerge.geometry.simplify(0.05) + +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"] + +statesmerge["empty"] = None + +colormap = branca.colormap.LinearColormap( + vmin=statesmerge["change"].quantile(0.0), + vmax=statesmerge["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 = folium.GeoJsonPopup( + fields=["name", "change"], + aliases=["State", "% Change"], + localize=True, + labels=True, + style="background-color: yellow;", +) + +tooltip = folium.GeoJsonTooltip( + fields=["name", "medianincome", "change", "empty"], + aliases=["State:", "2015 Median Income(USD):", "Median % Change:", "empty"], + localize=True, + sticky=False, + labels=True, + style=""" + background-color: #F0EFEF; + border: 2px solid black; + border-radius: 3px; + box-shadow: 3px; + """, + max_width=800, +) + +g = folium.GeoJson( + statesmerge, + 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) + +html = m.get_root().render() +st_folium(m, width=700, height=500) + +st.code(html) diff --git a/examples/streamlit/pages/realtime-iss.py b/examples/streamlit/pages/realtime-iss.py new file mode 100644 index 0000000000..7fb11181f6 --- /dev/null +++ b/examples/streamlit/pages/realtime-iss.py @@ -0,0 +1,78 @@ +import streamlit as st +from streamlit_folium import st_folium + +import folium +from folium.plugins import Realtime + +st.set_page_config(page_title="iss", layout="wide") + +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.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 + }); + }); + } +""" +) + +realtime = Realtime(source, on_each_feature=on_each_feature, interval=1000).add_to(m) +realtime.on( + update=folium.JsCode( + """ + (e) => { + console.log('update ', e.target._map); + var map = e.target._map; + var realtime = e.target; + map.fitBounds(realtime.getBounds(), {maxZoom: 8}); + } + """ + ) +) + +left, right = st.columns(2) +with left: + data = st_folium(m, width=1000, returned_objects=[], debug=True) + +with right: + st.write(data) diff --git a/folium/map.py b/folium/map.py index 77ec702a83..0d57822d37 100644 --- a/folium/map.py +++ b/folium/map.py @@ -445,6 +445,7 @@ def add_child(self, child, name=None, index=None): self.set_icon(child) else: super().add_child(child, name, index) + return self class Popup(MacroElement): diff --git a/folium/plugins/search.py b/folium/plugins/search.py index ee7bd6bdb5..29f7eeaa5c 100644 --- a/folium/plugins/search.py +++ b/folium/plugins/search.py @@ -1,7 +1,10 @@ from branca.element import MacroElement +from folium import FeatureGroup, GeoJson, TopoJson from folium.elements import JSCSSMixin -from folium.features import FeatureGroup, GeoJson, TopoJson + +# from folium.map import FeatureGroup +# from folium.features import GeoJson, TopoJson from folium.folium import Map from folium.plugins import MarkerCluster from folium.template import Template diff --git a/tests/playwright/test_playwright.py b/tests/playwright/test_playwright.py new file mode 100644 index 0000000000..d6bfbbf468 --- /dev/null +++ b/tests/playwright/test_playwright.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from contextlib import contextmanager +from time import sleep + +import pytest +from playwright.sync_api import Page, 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(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(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/main.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_draw(page: Page): + # Test draw support + page.get_by_role("link", name="draw feature group").click() + m = page.locator('[data-testid="stCustomComponentV1"]').content_frame + + # This is the marker that was drawn beforehand + expect(m.get_by_role("button", name="Marker")).to_be_visible() + + # Start drawing a rectangle + m.get_by_role("link", name="Draw a rectangle").click() + + # I could not record this + # so some trickery to get the mouse movements correct + bbox = page.locator('[data-testid="stCustomComponentV1"]').bounding_box() + + # One of the few times I miss javascript + x = bbox["x"] + y = bbox["y"] + width = bbox["width"] + height = bbox["height"] + + # careful, my first click attempt triggered the zoom button + page.mouse.click(x + 100, y + 100) + page.mouse.click(x + width - 100, y + height - 100) + + # Now check if streamlit shows a Polygon result + expect(page.get_by_text('"Polygon"').first).to_be_visible() + + +def test_issue_1989(page: Page): + # Test removal of null values + page.get_by_role("link", name="issue 1989").click() + m = page.locator('[data-testid="stCustomComponentV1"]').content_frame + + # Apparently Montana is the 26th state, hover to show the tooltip + m.locator("path:nth-child(26)").hover() + expect(m.get_by_text("Montana")).to_be_visible() + expect(m.get_by_text("Georgia")).to_be_visible(visible=False) + + # Expect the text empty to be visible + expect(m.get_by_text("empty")).to_be_visible() + + # But not null + expect(m.get_by_text("null")).to_be_visible(visible=False) + + +def test_issue_1770(page: Page): + # Test popup using on_each_feature + # This test is broken. Using, dispatch_event, the popup shows, but not in + # the right location. Using click, the map starts moving up and down. + page.get_by_role("link", name="issue 1770").click() + m = page.locator('[data-testid="stCustomComponentV1"]').content_frame + + # Click to show the popup + # m.get_by_role("img").locator("path").click(force=True) + # m.get_by_role("img").locator("path").first.click() + m.get_by_role("img").locator("path").first.dispatch_event("click") + expect(m.get_by_text("hello world")).to_be_visible() diff --git a/tests/screenshots/screenshot_issue_1885.png b/tests/screenshots/screenshot_issue_1885.png new file mode 100644 index 0000000000..5f9f5e3358 Binary files /dev/null and b/tests/screenshots/screenshot_issue_1885.png differ diff --git a/tests/screenshots/screenshot_issue_1885_add_child.png b/tests/screenshots/screenshot_issue_1885_add_child.png new file mode 100644 index 0000000000..5f9f5e3358 Binary files /dev/null and b/tests/screenshots/screenshot_issue_1885_add_child.png differ diff --git a/tests/screenshots/screenshot_issue_1885_add_to.png b/tests/screenshots/screenshot_issue_1885_add_to.png new file mode 100644 index 0000000000..5f9f5e3358 Binary files /dev/null and b/tests/screenshots/screenshot_issue_1885_add_to.png differ diff --git a/tests/screenshots/screenshot_issue_1885_set_icon.png b/tests/screenshots/screenshot_issue_1885_set_icon.png new file mode 100644 index 0000000000..5f9f5e3358 Binary files /dev/null and b/tests/screenshots/screenshot_issue_1885_set_icon.png differ diff --git a/tests/screenshots/screenshot_issue_1989.png b/tests/screenshots/screenshot_issue_1989.png new file mode 100644 index 0000000000..8eff4ad9c3 Binary files /dev/null and b/tests/screenshots/screenshot_issue_1989.png differ diff --git a/tests/screenshots/screenshot_issue_2109.png b/tests/screenshots/screenshot_issue_2109.png new file mode 100644 index 0000000000..8f03795240 Binary files /dev/null and b/tests/screenshots/screenshot_issue_2109.png differ diff --git a/tests/screenshots/screenshot_issue_2122.png b/tests/screenshots/screenshot_issue_2122.png new file mode 100644 index 0000000000..5358577851 Binary files /dev/null and b/tests/screenshots/screenshot_issue_2122.png differ diff --git a/tests/snapshots/modules/issue_1885.py b/tests/snapshots/modules/issue_1885.py new file mode 100644 index 0000000000..a510528f6b --- /dev/null +++ b/tests/snapshots/modules/issue_1885.py @@ -0,0 +1,45 @@ +import folium + +# Library of Congress coordinates (latitude, longitude) +loc_coordinates = (38.8886, -77.0047) + +# Create a Folium map centered around the Library of Congress +m = folium.Map(tiles=None, location=loc_coordinates, zoom_start=15) + +# Define the DivIcon with the custom icon. This variable can be used in one marker successfully, but will fail if we use it in two markers. + + +svg = """ +""" + +icon = folium.DivIcon( + icon_anchor=(15, 15), + html=f"