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")