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..d821a0c140 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 3 -k "not test_layer_control_dynamic_update" --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/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/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"
{svg}
",
+)
+
+
+folium.Marker(
+ location=(38.886970844230866, -77.00471380332),
+ popup="Library of Congress: James Madison Building",
+ icon=icon,
+).add_to(m)
+
+folium.Marker(location=loc_coordinates, popup="Library of Congress", icon=icon).add_to(
+ m
+)
diff --git a/tests/snapshots/modules/issue_1885_add_child.py b/tests/snapshots/modules/issue_1885_add_child.py
new file mode 100644
index 0000000000..5773b6aad4
--- /dev/null
+++ b/tests/snapshots/modules/issue_1885_add_child.py
@@ -0,0 +1,50 @@
+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"{svg}
",
+)
+
+
+m1 = (
+ folium.Marker(
+ location=(38.886970844230866, -77.00471380332),
+ popup="Library of Congress: James Madison Building",
+ )
+ .add_child(icon)
+ .add_to(m)
+)
+
+m2 = (
+ folium.Marker(location=loc_coordinates, popup="Library of Congress")
+ .add_child(icon)
+ .add_to(m)
+)
diff --git a/tests/snapshots/modules/issue_1885_add_to.py b/tests/snapshots/modules/issue_1885_add_to.py
new file mode 100644
index 0000000000..f31fca365d
--- /dev/null
+++ b/tests/snapshots/modules/issue_1885_add_to.py
@@ -0,0 +1,47 @@
+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"{svg}
",
+)
+
+m1 = folium.Marker(
+ location=(38.886970844230866, -77.00471380332),
+ popup="Library of Congress: James Madison Building",
+).add_to(m)
+
+m2 = folium.Marker(
+ location=loc_coordinates,
+ popup="Library of Congress",
+).add_to(m)
+# if we save here, everything will be fine.
+
+icon.add_to(m1)
+icon.add_to(m2)
diff --git a/tests/snapshots/modules/issue_1885_set_icon.py b/tests/snapshots/modules/issue_1885_set_icon.py
new file mode 100644
index 0000000000..a1a48f0012
--- /dev/null
+++ b/tests/snapshots/modules/issue_1885_set_icon.py
@@ -0,0 +1,46 @@
+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"{svg}
",
+)
+
+marker1 = folium.Marker(
+ location=(38.886970844230866, -77.00471380332),
+ popup="Library of Congress: James Madison Building",
+).add_to(m)
+
+marker2 = folium.Marker(
+ location=loc_coordinates, popup="Library of Congress", icon=icon
+).add_to(m)
+
+marker1.set_icon(icon)
+marker2.set_icon(icon)
diff --git a/tests/snapshots/modules/issue_1989.py b/tests/snapshots/modules/issue_1989.py
new file mode 100644
index 0000000000..1a6524a655
--- /dev/null
+++ b/tests/snapshots/modules/issue_1989.py
@@ -0,0 +1,92 @@
+import io
+
+import branca
+import geopandas
+import pandas as pd
+import requests
+
+import folium
+
+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(tiles=None, 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)
diff --git a/tests/snapshots/modules/issue_2109.py b/tests/snapshots/modules/issue_2109.py
new file mode 100644
index 0000000000..28efbb74c8
--- /dev/null
+++ b/tests/snapshots/modules/issue_2109.py
@@ -0,0 +1,74 @@
+import geopandas as gpd
+import pandas as pd
+
+import folium
+
+data = {
+ "warehouses": {
+ 1: ("Allentown", "Allentown", "PA", "18101", 40.602812, -75.470433),
+ 2: ("Atlanta", "Atlanta", "GA", "30301", 33.753693, -84.389544),
+ 3: ("Baltimore", "Baltimore", "MD", "21201", 39.294398, -76.622747),
+ 4: ("Boston", "Boston", "MA", "02101", 42.36097, -71.05344),
+ 5: ("Chicago", "Chicago", "IL", "60602", 41.88331, -87.624713),
+ },
+ "customers": {
+ 1: ("Akron", "Akron", "OH", " ", 41.08, -81.52),
+ 2: ("Albuquerque", "Albuquerque", "NM", " ", 35.12, -106.62),
+ 3: ("Alexandria", "Alexandria", "VA", " ", 38.82, -77.09),
+ 4: ("Amarillo", "Amarillo", "TX", " ", 35.2, -101.82),
+ 5: ("Anaheim", "Anaheim", "CA", " ", 33.84, -117.87),
+ 6: ("Brownfield", "Brownfield", "TX", " ", 33.18101, -102.27066),
+ 7: ("Arlington", "Arlington", "TX", " ", 32.69, -97.13),
+ 8: ("Arlington", "Arlington", "VA", " ", 38.88, -77.1),
+ 9: ("Atlanta", "Atlanta", "GA", " ", 33.76, -84.42),
+ 10: ("Augusta-Richmond", "Augusta-Richmond", "GA", " ", 33.46, -81.99),
+ },
+}
+
+df_customer = pd.DataFrame(
+ list(data["customers"].values()),
+ columns=["Facility Name", "City", "State", "Zip", "Latitude", "Longitude"],
+)
+df_customer["Facility Name"] = (
+ "CUST_" + df_customer["Facility Name"] + "_" + df_customer["State"]
+)
+
+df_customer_geometry = gpd.points_from_xy(df_customer.Longitude, df_customer.Latitude)
+gdf_customer = gpd.GeoDataFrame(
+ df_customer, crs="EPSG:4326", geometry=df_customer_geometry
+)
+gdf_customer["Facility Type"] = "Customer"
+
+df_warehouse = pd.DataFrame(
+ list(data["warehouses"].values()),
+ columns=["Facility Name", "City", "State", "Zip", "Latitude", "Longitude"],
+)
+df_warehouse["Facility Name"] = (
+ "WH_" + df_warehouse["Facility Name"] + "_" + df_customer["State"]
+)
+
+df_warehouse_geometry = gpd.points_from_xy(
+ df_warehouse.Longitude, df_warehouse.Latitude
+)
+gdf_warehouse = gpd.GeoDataFrame(
+ df_warehouse, crs="EPSG:4326", geometry=df_warehouse_geometry
+)
+gdf_warehouse["Facility Type"] = "Warehouse"
+
+m = folium.Map([40, -100.0], zoom_start=5, tiles=None)
+
+gdf_warehouse.explore(
+ m=m,
+ marker_type="marker",
+ marker_kwds=dict(icon=folium.Icon(color="red", icon="warehouse", prefix="fa")),
+ name="Warehouse",
+)
+
+gdf_customer.explore(
+ m=m,
+ marker_type="marker",
+ marker_kwds=dict(icon=folium.Icon(color="green", icon="tent", prefix="fa")),
+ name="Customers",
+)
+
+folium.LayerControl().add_to(m)
diff --git a/tests/snapshots/modules/issue_2122.py b/tests/snapshots/modules/issue_2122.py
new file mode 100644
index 0000000000..02a7fe1e72
--- /dev/null
+++ b/tests/snapshots/modules/issue_2122.py
@@ -0,0 +1,32 @@
+import folium
+from folium import Control, TileLayer
+
+m = folium.Map(location=[39.949610, -75.150282], zoom_start=5, zoom_control=False)
+tiles = TileLayer(
+ tiles="OpenStreetMap",
+ show=False,
+ control=False,
+)
+tiles.add_to(m)
+
+minimap = Control("MiniMap", tiles)
+minimap.add_js_link(
+ "minimap_js",
+ "https://cdnjs.cloudflare.com/ajax/libs/leaflet-minimap/3.6.1/Control.MiniMap.min.js",
+)
+minimap.add_css_link(
+ "minimap_css",
+ "https://cdnjs.cloudflare.com/ajax/libs/leaflet-minimap/3.6.1/Control.MiniMap.css",
+)
+minimap.add_to(m)
+
+ruler = Control("Ruler", tiles)
+ruler.add_js_link(
+ "ruler_js",
+ "https://cdn.rawgit.com/gokertanrisever/leaflet-ruler/master/src/leaflet-ruler.js",
+)
+ruler.add_css_link(
+ "ruler_css",
+ "https://cdn.rawgit.com/gokertanrisever/leaflet-ruler/master/src/leaflet-ruler.css",
+)
+ruler.add_to(m)
diff --git a/tests/snapshots/screenshots/screenshot_issue_1885.png b/tests/snapshots/screenshots/screenshot_issue_1885.png
new file mode 100644
index 0000000000..5f9f5e3358
Binary files /dev/null and b/tests/snapshots/screenshots/screenshot_issue_1885.png differ
diff --git a/tests/snapshots/screenshots/screenshot_issue_1885_add_child.png b/tests/snapshots/screenshots/screenshot_issue_1885_add_child.png
new file mode 100644
index 0000000000..5f9f5e3358
Binary files /dev/null and b/tests/snapshots/screenshots/screenshot_issue_1885_add_child.png differ
diff --git a/tests/snapshots/screenshots/screenshot_issue_1885_add_to.png b/tests/snapshots/screenshots/screenshot_issue_1885_add_to.png
new file mode 100644
index 0000000000..5f9f5e3358
Binary files /dev/null and b/tests/snapshots/screenshots/screenshot_issue_1885_add_to.png differ
diff --git a/tests/snapshots/screenshots/screenshot_issue_1885_set_icon.png b/tests/snapshots/screenshots/screenshot_issue_1885_set_icon.png
new file mode 100644
index 0000000000..5f9f5e3358
Binary files /dev/null and b/tests/snapshots/screenshots/screenshot_issue_1885_set_icon.png differ
diff --git a/tests/snapshots/screenshots/screenshot_issue_1989.png b/tests/snapshots/screenshots/screenshot_issue_1989.png
new file mode 100644
index 0000000000..8eff4ad9c3
Binary files /dev/null and b/tests/snapshots/screenshots/screenshot_issue_1989.png differ
diff --git a/tests/snapshots/screenshots/screenshot_issue_2109.png b/tests/snapshots/screenshots/screenshot_issue_2109.png
new file mode 100644
index 0000000000..8f03795240
Binary files /dev/null and b/tests/snapshots/screenshots/screenshot_issue_2109.png differ
diff --git a/tests/snapshots/screenshots/screenshot_issue_2122.png b/tests/snapshots/screenshots/screenshot_issue_2122.png
new file mode 100644
index 0000000000..5358577851
Binary files /dev/null and b/tests/snapshots/screenshots/screenshot_issue_2122.png differ
diff --git a/tests/snapshots/test_snapshots.py b/tests/snapshots/test_snapshots.py
new file mode 100644
index 0000000000..84c20211ba
--- /dev/null
+++ b/tests/snapshots/test_snapshots.py
@@ -0,0 +1,43 @@
+import importlib
+import io
+import os
+import shutil
+
+import pytest
+from PIL import Image
+from pixelmatch.contrib.PIL import pixelmatch
+from selenium import webdriver
+
+options = webdriver.chrome.options.Options()
+options.add_argument("--headless")
+
+
+paths = os.listdir("tests/snapshots/modules")
+paths = [p.replace(".py", "") for p in paths if p.endswith(".py")]
+
+
+@pytest.mark.parametrize("path", paths)
+def test_screenshot(path: str):
+ driver = webdriver.Chrome(options=options)
+ m = importlib.import_module(f"tests.snapshots.modules.{path}").m
+ img_data = m._to_png(3, driver=driver, size=(800, 800))
+ img_a = Image.open(io.BytesIO(img_data))
+ img_a.save(f"/tmp/screenshot_new_{path}.png")
+
+ if os.path.exists(f"tests/snapshots/screenshots/screenshot_{path}.png"):
+ img_b = Image.open(f"tests/snapshots/screenshots/screenshot_{path}.png")
+
+ img_diff = Image.new("RGBA", img_a.size)
+ # note how there is no need to specify dimensions
+ mismatch = pixelmatch(img_a, img_b, img_diff, threshold=0.2, includeAA=False)
+
+ img_diff.save(f"/tmp/screenshot_diff_{path}.png")
+ m.save(f"/tmp/folium_map_{path}.html")
+ assert mismatch < 200
+
+ else:
+ shutil.copy(
+ f"/tmp/screenshot_new_{path}.png",
+ f"tests/snapshots/screenshots/screenshot_{path}.png",
+ )
+ raise Exception("no screenshot available, generating new")