diff --git a/.github/workflows/save_coverage.yml b/.github/workflows/save_coverage.yml new file mode 100644 index 000000000..5e4641ec9 --- /dev/null +++ b/.github/workflows/save_coverage.yml @@ -0,0 +1,34 @@ +name: Upload coverage + +on: + workflow_run: + workflows: ['Code Tests', 'Geopandas tests', 'Code Tests with Latest branca', 'Selenium Tests', 'Run Snapshot Tests', 'Run Streamlit Folium Tests'] + types: [completed] + +jobs: + run: + runs-on: ubuntu-latest + + steps: + - name: Download coverage files from previous steps + id: download-artifacts + uses: actions/download-artifact@v4 + with: + path: combined-coverage + pattern: coverage-* + merge-multiple: true + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Combine coverage + run: coverage combine + + - name: Generate report + run: coverage html --skip-covered + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: combined-coverage + path: htmlcov/** + fail-on-empty: false diff --git a/.github/workflows/test_code.yml b/.github/workflows/test_code.yml index e29dd68f2..ab966c9e2 100644 --- a/.github/workflows/test_code.yml +++ b/.github/workflows/test_code.yml @@ -41,4 +41,12 @@ jobs: pip install pixelmatch - name: Code tests - run: python -m pytest -vv --ignore=tests/selenium --ignore=tests/playwright --ignore=tests/snapshots + run: coverage run -p -m pytest -vv --ignore=tests/selenium --ignore=tests/playwright --ignore=tests/snapshots + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-test-code + path: | + .coverage* diff --git a/.github/workflows/test_geopandas.yml b/.github/workflows/test_geopandas.yml index a7ed868b4..219b3db19 100644 --- a/.github/workflows/test_geopandas.yml +++ b/.github/workflows/test_geopandas.yml @@ -46,4 +46,12 @@ jobs: - name: Run Geopandas tests run: | cd geopandas - pytest -r a geopandas/tests/test_explore.py + coverage run -p -m pytest -r a geopandas/tests/test_explore.py + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-test-geopandas + path: | + .coverage* diff --git a/.github/workflows/test_latest_branca.yml b/.github/workflows/test_latest_branca.yml index c7ea3aaa1..0f0f4ea70 100644 --- a/.github/workflows/test_latest_branca.yml +++ b/.github/workflows/test_latest_branca.yml @@ -33,4 +33,12 @@ 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 --ignore=tests/playwright --ignore=tests/snapshots + coverage run -p -m pytest -vv --ignore=tests/selenium --ignore=tests/playwright --ignore=tests/snapshots + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-test-branca + path: | + .coverage* diff --git a/.github/workflows/test_selenium.yml b/.github/workflows/test_selenium.yml index 61a87df02..e1d876d33 100644 --- a/.github/workflows/test_selenium.yml +++ b/.github/workflows/test_selenium.yml @@ -34,4 +34,12 @@ jobs: - name: Selenium tests shell: bash -l {0} - run: python -m pytest tests/selenium -vv + run: coverage run -p -m pytest tests/selenium -vv + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-test-selenium + path: | + .coverage* diff --git a/.github/workflows/test_snapshots.yml b/.github/workflows/test_snapshots.yml index 9555eff44..14cac9202 100644 --- a/.github/workflows/test_snapshots.yml +++ b/.github/workflows/test_snapshots.yml @@ -36,7 +36,7 @@ jobs: - name: Test with pytest shell: bash -l {0} run: | - python -m pytest tests/snapshots -s --junit-xml=test-results.xml + coverage run -p -m pytest tests/snapshots -s --junit-xml=test-results.xml - name: Surface failing tests if: always() @@ -53,3 +53,11 @@ jobs: path: | /tmp/screenshot_*_*.png /tmp/folium_map_*.html + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-test-snapshots + path: | + .coverage* diff --git a/.github/workflows/test_streamlit_folium.yml b/.github/workflows/test_streamlit_folium.yml index d821a0c14..bf7821743 100644 --- a/.github/workflows/test_streamlit_folium.yml +++ b/.github/workflows/test_streamlit_folium.yml @@ -13,17 +13,14 @@ jobs: runs-on: ubuntu-latest steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - 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 - - name: Checkout Streamlit Folium uses: actions/checkout@v4 with: @@ -41,7 +38,9 @@ jobs: - name: Install streamlit_folium dev dependencies shell: bash -l {0} run: | - conda install --file streamlit_folium/tests/requirements.txt + cd streamlit_folium + python -m pip install --upgrade pip + pip install -r tests/requirements.txt - name: Install streamlit-folium shell: bash -l {0} @@ -55,18 +54,18 @@ jobs: playwright install --with-deps - name: Install annotate-failures-plugin - run: pip install pytest-github-actions-annotate-failures + run: pip install pytest-github-actions-annotate-failures coverage - name: Install folium from source shell: bash -l {0} run: | - python -m pip install -e . --no-deps --force-reinstall + python -m pip install -e . --force-reinstall - name: Test with pytest and retry flaky tests up to 3 times shell: bash -l {0} run: | cd streamlit_folium - pytest tests/test_frontend.py --browser chromium -s --reruns 3 -k "not test_layer_control_dynamic_update" --junit-xml=test-results.xml + python -m pytest tests/test_frontend.py --browser chromium -s --reruns 3 -k "not test_layer_control_dynamic_update" - name: Surface failing tests if: always() @@ -74,3 +73,12 @@ jobs: with: path: streamlit_folium/test-results.xml fail-on-empty: false + + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-test-streamlit-folium + path: | + .coverage* diff --git a/README.rst b/README.rst index afabaf11a..244d7ffdf 100644 --- a/README.rst +++ b/README.rst @@ -82,3 +82,4 @@ Plugins: - https://github.com/onaci/folium-glify-layer: provide fast webgl rendering for large GeoJSON FeatureCollections - https://github.com/carlosign/Folium.ControlCredits-Plugin: displaying credits in the corner. Display an image/logo, clicking it will expand to show a brief message with credits and links. - https://github.com/JohnyCarrot/folium-geocoder-own-locations: a geocoder that accepts a list of suggestions at creation time. +- https://github.com/iwpnd/folium-vectortilelayer: a tile layer that zooms and stretches beyond the maximum and minimum of the tile provider diff --git a/docs/_static/switcher.json b/docs/_static/switcher.json index 1c75e1316..601d81a7a 100644 --- a/docs/_static/switcher.json +++ b/docs/_static/switcher.json @@ -4,10 +4,14 @@ "url": "https://python-visualization.github.io/folium/dev/" }, { - "name": "latest (0.19.6)", - "version": "0.19.6", + "name": "latest (0.19.7)", + "version": "0.19.7", "url": "https://python-visualization.github.io/folium/latest/" }, + { + "version": "0.19.6", + "url": "https://python-visualization.github.io/folium/v0.19.6/" + }, { "version": "0.19.5", "url": "https://python-visualization.github.io/folium/v0.19.5/" diff --git a/docs/advanced_guide.rst b/docs/advanced_guide.rst index 579eada3d..51ba5720b 100644 --- a/docs/advanced_guide.rst +++ b/docs/advanced_guide.rst @@ -15,3 +15,4 @@ Advanced guide advanced_guide/piechart_icons advanced_guide/polygons_from_list_of_points advanced_guide/customize_javascript_and_css + advanced_guide/override_leaflet_class_methods diff --git a/docs/advanced_guide/override_leaflet_class_methods.md b/docs/advanced_guide/override_leaflet_class_methods.md new file mode 100644 index 000000000..de605aee5 --- /dev/null +++ b/docs/advanced_guide/override_leaflet_class_methods.md @@ -0,0 +1,46 @@ +# Overriding Leaflet class methods + +```{code-cell} ipython3 +--- +nbsphinx: hidden +--- +import folium +``` + +## Customizing Leaflet behavior +Sometimes you want to override Leaflet's javascript behavior. This can be done using the `Class.include` statement. This mimics Leaflet's +`L.Class.include` method. See [here](https://leafletjs.com/examples/extending/extending-1-classes.html) for more details. + +### Example: adding an authentication header to a TileLayer +One such use case is if you need to override the `createTile` on `L.TileLayer`, because your tiles are hosted on an oauth2 protected +server. This can be done like this: + +```{code-cell} +create_tile = folium.JsCode(""" + function(coords, done) { + const url = this.getTileUrl(coords); + const img = document.createElement('img'); + fetch(url, { + headers: { + "Authorization": "Bearer " + }, + }) + .then((response) => { + img.src = URL.createObjectURL(response.body); + done(null, img); + }) + return img; + } +""") + +folium.TileLayer.include(create_tile=create_tile) +tiles = folium.TileLayer( + tiles="OpenStreetMap", +) +m = folium.Map( + tiles=tiles, +) + + +m = folium.Map() +``` diff --git a/folium/__init__.py b/folium/__init__.py index c6fa376e4..67489f8c5 100644 --- a/folium/__init__.py +++ b/folium/__init__.py @@ -46,13 +46,16 @@ try: from ._version import __version__ -except ImportError: +except ImportError: # pragma: no cover __version__ = "unknown" if branca.__version__ != "unknown" and tuple( int(x) for x in branca.__version__.split(".")[:2] -) < (0, 3): +) < ( + 0, + 3, +): # pragma: no cover raise ImportError( "branca version 0.3.0 or higher is required. " "Update branca with e.g. `pip install branca --upgrade`." diff --git a/folium/elements.py b/folium/elements.py index c99e35474..f52e8b6fa 100644 --- a/folium/elements.py +++ b/folium/elements.py @@ -1,3 +1,4 @@ +from functools import wraps from typing import List, Tuple from branca.element import ( @@ -9,7 +10,15 @@ ) from folium.template import Template -from folium.utilities import JsCode +from folium.utilities import JsCode, camelize + + +def leaflet_method(fn): + @wraps(fn) + def inner(self, *args, **kwargs): + self.add_child(MethodCall(self, fn.__name__, *args, **kwargs)) + + return inner class JSCSSMixin(MacroElement): @@ -148,3 +157,47 @@ def __init__(self, element_name: str, element_parent_name: str): super().__init__() self.element_name = element_name self.element_parent_name = element_parent_name + + +class IncludeStatement(MacroElement): + """Generate an include statement on a class.""" + + _template = Template( + """ + {{ this.leaflet_class_name }}.include( + {{ this.options | tojavascript }} + ) + """ + ) + + def __init__(self, leaflet_class_name: str, **kwargs): + super().__init__() + self.leaflet_class_name = leaflet_class_name + self.options = kwargs + + def render(self, *args, **kwargs): + return super().render(*args, **kwargs) + + +class MethodCall(MacroElement): + """Abstract class to add an element to another element.""" + + _template = Template( + """ + {% macro script(this, kwargs) %} + {{ this.target }}.{{ this.method }}( + {% for arg in this.args %} + {{ arg | tojavascript }}, + {% endfor %} + {{ this.kwargs | tojavascript }} + ); + {% endmacro %} + """ + ) + + def __init__(self, target: MacroElement, method: str, *args, **kwargs): + super().__init__() + self.target = target.get_name() + self.method = camelize(method) + self.args = args + self.kwargs = kwargs diff --git a/folium/features.py b/folium/features.py index 982b7b3e5..8f7e5230c 100644 --- a/folium/features.py +++ b/folium/features.py @@ -36,7 +36,7 @@ from folium.elements import JSCSSMixin from folium.folium import Map -from folium.map import FeatureGroup, Icon, Layer, Marker, Popup, Tooltip +from folium.map import Class, FeatureGroup, Icon, Layer, Marker, Popup, Tooltip from folium.template import Template from folium.utilities import ( JsCode, @@ -2023,7 +2023,7 @@ def __init__( self.add_child(PolyLine(val, color=key, weight=weight, opacity=opacity)) -class Control(JSCSSMixin, MacroElement): +class Control(JSCSSMixin, Class): """ Add a Leaflet Control object to the map diff --git a/folium/map.py b/folium/map.py index 0d57822d3..278b97a1b 100644 --- a/folium/map.py +++ b/folium/map.py @@ -4,12 +4,12 @@ """ import warnings -from collections import OrderedDict -from typing import TYPE_CHECKING, Optional, Sequence, Union, cast +from collections import OrderedDict, defaultdict +from typing import TYPE_CHECKING, DefaultDict, Optional, Sequence, Union, cast from branca.element import Element, Figure, Html, MacroElement -from folium.elements import ElementAddToElement, EventHandler +from folium.elements import ElementAddToElement, EventHandler, IncludeStatement from folium.template import Template from folium.utilities import ( JsCode, @@ -22,11 +22,58 @@ validate_location, ) + +class classproperty: + def __init__(self, f): + self.f = f + + def __get__(self, obj, owner): + return self.f(owner) + + if TYPE_CHECKING: from folium.features import CustomIcon, DivIcon -class Evented(MacroElement): +class Class(MacroElement): + """The root class of the leaflet class hierarchy""" + + _includes: DefaultDict[str, dict] = defaultdict(dict) + + @classmethod + def include(cls, **kwargs): + cls._includes[cls].update(**kwargs) + + @classproperty + def includes(cls): + return cls._includes[cls] + + @property + def leaflet_class_name(self): + # TODO: I did not check all Folium classes to see if + # this holds up. This breaks at least for CustomIcon. + return f"L.{self._name}" + + def render(self, **kwargs): + figure = self.get_root() + assert isinstance( + figure, Figure + ), "You cannot render this Element if it is not in a Figure." + if self.includes: + stmt = IncludeStatement(self.leaflet_class_name, **self.includes) + # A bit weird. I tried adding IncludeStatement directly to both + # figure and script, but failed. So we render this ourself. + figure.script.add_child( + Element(stmt._template.render(this=stmt, kwargs=self.includes)), + # make sure each class include gets rendered only once + name=self._name + "_includes", + # make sure this renders before the element itself + index=-1, + ) + super().render(**kwargs) + + +class Evented(Class): """The base class for Layer and Map Adds the `on` and `once` methods for event handling capabilities. diff --git a/folium/plugins/geoman.py b/folium/plugins/geoman.py index c975d9877..dc4c05be4 100644 --- a/folium/plugins/geoman.py +++ b/folium/plugins/geoman.py @@ -1,6 +1,6 @@ from branca.element import MacroElement -from folium.elements import JSCSSMixin +from folium.elements import JSCSSMixin, leaflet_method from folium.template import Template from folium.utilities import remove_empty @@ -22,6 +22,8 @@ class GeoMan(JSCSSMixin, MacroElement): _template = Template( """ {% macro script(this, kwargs) %} + /* ensure the name is usable */ + var {{this.get_name()}} = {{this._parent.get_name()}}.pm; {%- if this.feature_group %} var drawnItems_{{ this.get_name() }} = {{ this.feature_group.get_name() }}; @@ -32,12 +34,12 @@ class GeoMan(JSCSSMixin, MacroElement): {{ this._parent.get_name() }} ); {%- endif %} - /* The global varianble below is needed to prevent streamlit-folium + /* The global variable below is needed to prevent streamlit-folium from barfing :-( */ var drawnItems = drawnItems_{{ this.get_name() }}; - {{this._parent.get_name()}}.pm.addControls( + {{this.get_name()}}.addControls( {{this.options|tojavascript}} ) drawnItems_{{ this.get_name() }}.eachLayer(function(layer){ @@ -60,12 +62,6 @@ class GeoMan(JSCSSMixin, MacroElement): {{handler}} ); {%- endfor %} - drawnItems_{{ this.get_name() }}.addLayer(layer); - }); - {{ this._parent.get_name() }}.on("pm:remove", function(e) { - var layer = e.layer, - type = e.layerType; - drawnItems_{{ this.get_name() }}.removeLayer(layer); }); {% endmacro %} @@ -85,17 +81,65 @@ class GeoMan(JSCSSMixin, MacroElement): ) ] - def __init__( - self, - position="topleft", - feature_group=None, - on=None, - **kwargs, - ): + def __init__(self, position="topleft", feature_group=None, on=None, **kwargs): super().__init__() self._name = "GeoMan" self.feature_group = feature_group self.on = on or {} - self.options = remove_empty( - position=position, layer_group=feature_group, **kwargs - ) + self.options = remove_empty(position=position, **kwargs) + + @leaflet_method + def set_global_options(self, **kwargs): + pass + + @leaflet_method + def enable_draw(self, shape, /, **kwargs): + pass + + @leaflet_method + def disable_draw(self): + pass + + @leaflet_method + def set_path_options(self, *, options_modifier, **options): + pass + + @leaflet_method + def enable_global_edit_mode(self, **options): + pass + + @leaflet_method + def disable_global_edit_mode(self): + pass + + @leaflet_method + def enable_global_drag_mode(self): + pass + + @leaflet_method + def disable_global_drag_mode(self): + pass + + @leaflet_method + def enable_global_removal_mode(self): + pass + + @leaflet_method + def disable_global_removal_mode(self): + pass + + @leaflet_method + def enable_global_cut_mode(self): + pass + + @leaflet_method + def disable_global_cut_mode(self): + pass + + @leaflet_method + def enable_global_rotation_mode(self): + pass + + @leaflet_method + def disable_global_rotation_mode(self): + pass diff --git a/tests/plugins/test_geoman.py b/tests/plugins/test_geoman.py index fe6f5d30f..09f5cdd41 100644 --- a/tests/plugins/test_geoman.py +++ b/tests/plugins/test_geoman.py @@ -20,7 +20,7 @@ def test_geoman(): # the map tmpl = Template( """ - {{this._parent.get_name()}}.pm.addControls( + {{this.get_name()}}.addControls( {{this.options|tojavascript}} ) """ diff --git a/tests/snapshots/modules/geoman_customizations.py b/tests/snapshots/modules/geoman_customizations.py new file mode 100644 index 000000000..9a6c4e5a7 --- /dev/null +++ b/tests/snapshots/modules/geoman_customizations.py @@ -0,0 +1,62 @@ +import folium +from folium import JsCode +from folium.plugins import GeoMan, MousePosition + +m = folium.Map(tiles=None, location=[39.949610, -75.150282], zoom_start=5) +MousePosition().add_to(m) + +# This can be used to test the connection to streamlit +# by returning the resulting GeoJson +handler = JsCode( + """ + (e) => { + var map = %(map)s; + var layers = L.PM.Utils.findLayers(map); + var lg = L.layerGroup(layers); + console.log(lg.toGeoJSON()); + } + """ # noqa: UP031 + % dict(map=m.get_name()) +) + +# For manual testing +click = JsCode( + """ + (e) => { + console.log(e.target); + console.log(e.target.toGeoJSON()); + } + """ +) + +# Just a few customizations for the snapshot tests +# The test succeeds if the position is to the right +# and if the buttons for markers and circles are not +# shown. +gm = GeoMan( + position="topright", draw_marker=False, draw_circle=False, on={"click": click} +).add_to(m) + +# For manual testing of the global options +gm.set_global_options( + { + "snappable": True, + "snapDistance": 20, + } +) + +# Make rectangles green +gm.enable_draw("Rectangle", path_options={"color": "green"}) +gm.disable_draw() + +# On any event that updates the layers, we trigger the handler +event_handlers = { + "pm:create": handler, + "pm:remove": handler, + "pm:update": handler, + "pm:rotateend": handler, + "pm:cut": handler, + "pm:undoremove": handler, +} + +m.on(**event_handlers) diff --git a/tests/snapshots/screenshots/screenshot_geoman_customizations.png b/tests/snapshots/screenshots/screenshot_geoman_customizations.png new file mode 100644 index 000000000..569a129bb Binary files /dev/null and b/tests/snapshots/screenshots/screenshot_geoman_customizations.png differ diff --git a/tests/snapshots/test_snapshots.py b/tests/snapshots/test_snapshots.py index 84c20211b..8249bc168 100644 --- a/tests/snapshots/test_snapshots.py +++ b/tests/snapshots/test_snapshots.py @@ -35,7 +35,7 @@ def test_screenshot(path: str): m.save(f"/tmp/folium_map_{path}.html") assert mismatch < 200 - else: + else: # pragma: no cover shutil.copy( f"/tmp/screenshot_new_{path}.png", f"tests/snapshots/screenshots/screenshot_{path}.png", diff --git a/tests/test_map.py b/tests/test_map.py index cc3728586..cf6635a0b 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -10,8 +10,8 @@ import pytest from folium import GeoJson, Map, TileLayer -from folium.map import CustomPane, Icon, LayerControl, Marker, Popup -from folium.utilities import normalize +from folium.map import Class, CustomPane, Icon, LayerControl, Marker, Popup +from folium.utilities import JsCode, normalize tmpl = """
" + }, + }) + .then((response) => { + img.src = URL.createObjectURL(response.body); + done(null, img); + }) + return img; + } + """ + TileLayer.include(create_tile=JsCode(create_tile)) + tiles = TileLayer( + tiles="OpenStreetMap", + ) + m = Map( + tiles=tiles, + ) + rendered = m.get_root().render() + Class._includes.clear() + expected = """ + L.TileLayer.include({ + "createTile": + function(coords, done) { + const url = this.getTileUrl(coords); + const img = document.createElement('img'); + fetch(url, { + headers: { + "Authorization": "Bearer " + }, + }) + .then((response) => { + img.src = URL.createObjectURL(response.body); + done(null, img); + }) + return img; + }, + }) + """ + assert normalize(expected) in normalize(rendered) + + +def test_include_once(): + abc = "MY BEAUTIFUL SENTINEL" + TileLayer.include(abc=abc) + tiles = TileLayer( + tiles="OpenStreetMap", + ) + m = Map( + tiles=tiles, + ) + TileLayer( + tiles="OpenStreetMap", + ).add_to(m) + + rendered = m.get_root().render() + Class._includes.clear() + + assert rendered.count(abc) == 1, "Includes should happen only once per class" + + def test_popup_backticks(): m = Map() popup = Popup("back`tick`tick").add_to(m)