From b97053b73dbdfb322bd6a42b91da42a32812efbf Mon Sep 17 00:00:00 2001 From: Hans Then Date: Mon, 9 Jun 2025 21:41:28 +0200 Subject: [PATCH 1/8] Fix for issue 1359 This includes a structural change. I added a Class object with a include method. This follows leaflet's `L.Class.include` statement. This allows users to override Leaflet class behavior. The motivating example for this can be found in the added `test_include` in `test_map.py`. Using an include, users can override the `createTile` method of `L.TileLayer` and add a headers. --- folium/elements.py | 20 ++++++++++++++++++ folium/features.py | 4 ++-- folium/map.py | 42 +++++++++++++++++++++++++++++++++++--- tests/test_map.py | 51 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/folium/elements.py b/folium/elements.py index c99e35474b..fd37921650 100644 --- a/folium/elements.py +++ b/folium/elements.py @@ -148,3 +148,23 @@ 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( + """ + L.{{ this.class_name }}.include( + {{ this.options | tojavascript }} + ) + """ + ) + + def __init__(self, class_name: str, **kwargs): + super().__init__() + self.class_name = class_name + self.options = kwargs + + def render(self, *args, **kwargs): + return super().render(*args, **kwargs) diff --git a/folium/features.py b/folium/features.py index 982b7b3e54..8f7e5230c7 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 0d57822d37..4d2605ff27 100644 --- a/folium/map.py +++ b/folium/map.py @@ -4,12 +4,12 @@ """ import warnings -from collections import OrderedDict +from collections import OrderedDict, defaultdict from typing import TYPE_CHECKING, 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,47 @@ 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(dict) + + @classmethod + def include(cls, **kwargs): + cls._includes[cls].update(**kwargs) + + @classproperty + def includes(cls): + return cls._includes[cls] + + 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._name, **self.includes) + figure.script.add_child( + Element(stmt._template.render(this=stmt, kwargs=self.includes)), + 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/tests/test_map.py b/tests/test_map.py index cc3728586a..cae0520663 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -11,7 +11,7 @@ from folium import GeoJson, Map, TileLayer from folium.map import CustomPane, Icon, LayerControl, Marker, Popup -from folium.utilities import normalize +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() + expected = """ + L.TileLayer.include({ + "createTile": + function(coords, done) { + console.log("creating tile"); + 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_popup_backticks(): m = Map() popup = Popup("back`tick`tick").add_to(m) From f3df6f00c6df70ad68aea175a3b57cb619b9721a Mon Sep 17 00:00:00 2001 From: Hans Then Date: Tue, 10 Jun 2025 07:55:04 +0200 Subject: [PATCH 2/8] Close #1359 Add an include statement, that will allow users to override specific methods at Leaflet level. This allows users to customize the "createTile" method using a JsCode object. --- folium/elements.py | 6 +++--- folium/map.py | 13 ++++++++++++- tests/test_map.py | 26 ++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/folium/elements.py b/folium/elements.py index fd37921650..9e3abf90e0 100644 --- a/folium/elements.py +++ b/folium/elements.py @@ -155,15 +155,15 @@ class IncludeStatement(MacroElement): _template = Template( """ - L.{{ this.class_name }}.include( + {{ this.leaflet_class_name }}.include( {{ this.options | tojavascript }} ) """ ) - def __init__(self, class_name: str, **kwargs): + def __init__(self, leaflet_class_name: str, **kwargs): super().__init__() - self.class_name = class_name + self.leaflet_class_name = leaflet_class_name self.options = kwargs def render(self, *args, **kwargs): diff --git a/folium/map.py b/folium/map.py index 4d2605ff27..fbc0d6c5f6 100644 --- a/folium/map.py +++ b/folium/map.py @@ -48,15 +48,26 @@ def include(cls, **kwargs): 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._name, **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) diff --git a/tests/test_map.py b/tests/test_map.py index cae0520663..c56780f3ff 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -10,7 +10,7 @@ import pytest from folium import GeoJson, Map, TileLayer -from folium.map import CustomPane, Icon, LayerControl, Marker, Popup +from folium.map import Class, CustomPane, Icon, LayerControl, Marker, Popup from folium.utilities import JsCode, normalize tmpl = """ @@ -173,11 +173,11 @@ def test_include(): tiles=tiles, ) rendered = m.get_root().render() + Class._includes.clear() expected = """ L.TileLayer.include({ "createTile": function(coords, done) { - console.log("creating tile"); const url = this.getTileUrl(coords); const img = document.createElement('img'); fetch(url, { @@ -193,10 +193,32 @@ def test_include(): }, }) """ + print(expected) + print("-----") + print(rendered) 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) From db56f6ac192e4760ca80fc53d267a4dacbefa295 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Tue, 10 Jun 2025 19:48:01 +0200 Subject: [PATCH 3/8] Add typing annotations --- folium/map.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/folium/map.py b/folium/map.py index fbc0d6c5f6..278b97a1bb 100644 --- a/folium/map.py +++ b/folium/map.py @@ -5,7 +5,7 @@ import warnings from collections import OrderedDict, defaultdict -from typing import TYPE_CHECKING, Optional, Sequence, Union, cast +from typing import TYPE_CHECKING, DefaultDict, Optional, Sequence, Union, cast from branca.element import Element, Figure, Html, MacroElement @@ -38,7 +38,7 @@ def __get__(self, obj, owner): class Class(MacroElement): """The root class of the leaflet class hierarchy""" - _includes = defaultdict(dict) + _includes: DefaultDict[str, dict] = defaultdict(dict) @classmethod def include(cls, **kwargs): From e677dbecbe7cddff26598f511ca69a4b94ab8796 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Mon, 16 Jun 2025 20:02:19 +0200 Subject: [PATCH 4/8] Fix formatting issue --- folium/elements.py | 1 + 1 file changed, 1 insertion(+) diff --git a/folium/elements.py b/folium/elements.py index 7bc36d289e..f52e8b6fa0 100644 --- a/folium/elements.py +++ b/folium/elements.py @@ -178,6 +178,7 @@ def __init__(self, leaflet_class_name: str, **kwargs): def render(self, *args, **kwargs): return super().render(*args, **kwargs) + class MethodCall(MacroElement): """Abstract class to add an element to another element.""" From b2f98cd442b904458ef518d026fac8afc482d275 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Mon, 16 Jun 2025 20:23:36 +0200 Subject: [PATCH 5/8] Add documentation --- .../override_leaflet_class_methods.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/advanced_guide/override_leaflet_class_methods.md 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 0000000000..3388f09b39 --- /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 = 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() +``` From e1ea6b27d04ad972025e1345ea26aa080c1844ab Mon Sep 17 00:00:00 2001 From: Hans Then Date: Mon, 16 Jun 2025 20:24:52 +0200 Subject: [PATCH 6/8] Add link to new doc --- docs/advanced_guide.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/advanced_guide.rst b/docs/advanced_guide.rst index 579eada3d7..51ba5720b0 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 From 13899c8aaddc45c9fb8e778bed79c82e686598cd Mon Sep 17 00:00:00 2001 From: Hans Then Date: Mon, 16 Jun 2025 20:27:04 +0200 Subject: [PATCH 7/8] Removed debugging code --- tests/test_map.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_map.py b/tests/test_map.py index c56780f3ff..cf6635a0b9 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -193,10 +193,6 @@ def test_include(): }, }) """ - print(expected) - print("-----") - print(rendered) - assert normalize(expected) in normalize(rendered) From 9d6e3733142e2b8328883d267f3633c80cde01e4 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Mon, 16 Jun 2025 20:30:20 +0200 Subject: [PATCH 8/8] Fix doc issue --- docs/advanced_guide/override_leaflet_class_methods.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced_guide/override_leaflet_class_methods.md b/docs/advanced_guide/override_leaflet_class_methods.md index 3388f09b39..de605aee52 100644 --- a/docs/advanced_guide/override_leaflet_class_methods.md +++ b/docs/advanced_guide/override_leaflet_class_methods.md @@ -16,7 +16,7 @@ One such use case is if you need to override the `createTile` on `L.TileLayer`, server. This can be done like this: ```{code-cell} -create_tile = JsCode(""" +create_tile = folium.JsCode(""" function(coords, done) { const url = this.getTileUrl(coords); const img = document.createElement('img');