From e3ca7baf0891842f99a630e869528f41ec162794 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 3 Jun 2025 20:04:36 +0200 Subject: [PATCH 01/10] docs: Update switcher.json for v0.19.7 (#2155) Co-authored-by: hansthen <2202342+hansthen@users.noreply.github.com> --- docs/_static/switcher.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/_static/switcher.json b/docs/_static/switcher.json index 1c75e13168..601d81a7a8 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/" From 0f04eeb4f03b46d8d3aa394379753c0b819ba352 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Wed, 11 Jun 2025 20:08:28 +0200 Subject: [PATCH 02/10] Add leaflet_method decorator (#2157) * Add leaflet_method decorator This allows javascript method calls to be generated using an empty method body and a signature. Useful for writing plugins. As an example (and the driving motivation) I implemented this on the geoman plugin. * Fix tests * Implement all useful leaflet methods on geoman Only the free modules for now * Add a snapshot test --- folium/elements.py | 35 +++++++- folium/plugins/geoman.py | 82 ++++++++++++++---- tests/plugins/test_geoman.py | 2 +- .../modules/geoman_customizations.py | 62 +++++++++++++ .../screenshot_geoman_customizations.png | Bin 0 -> 11849 bytes 5 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 tests/snapshots/modules/geoman_customizations.py create mode 100644 tests/snapshots/screenshots/screenshot_geoman_customizations.png diff --git a/folium/elements.py b/folium/elements.py index c99e35474b..8344abc3a8 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,27 @@ 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 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/plugins/geoman.py b/folium/plugins/geoman.py index c975d98777..dc4c05be4c 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 fe6f5d30fc..09f5cdd416 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 0000000000..9a6c4e5a78 --- /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 0000000000000000000000000000000000000000..569a129bbe75e6fc9b00baf2730665e28935b79c GIT binary patch literal 11849 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 literal 0 HcmV?d00001 From 22aa1e55bff5953d0ab1098b363e4f06bb765fc3 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Wed, 11 Jun 2025 20:37:27 +0200 Subject: [PATCH 03/10] Update README to point to the extra plugin --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index afabaf11a3..244d7ffdfc 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 From 79f548fd53a8366b63dfc6ce3ab2cb1f03c7c08f Mon Sep 17 00:00:00 2001 From: Hans Then Date: Wed, 11 Jun 2025 22:17:31 +0200 Subject: [PATCH 04/10] Try fixing failing CI --- .github/workflows/test_streamlit_folium.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test_streamlit_folium.yml b/.github/workflows/test_streamlit_folium.yml index d821a0c140..3d5eaf0143 100644 --- a/.github/workflows/test_streamlit_folium.yml +++ b/.github/workflows/test_streamlit_folium.yml @@ -41,7 +41,8 @@ jobs: - name: Install streamlit_folium dev dependencies shell: bash -l {0} run: | - conda install --file streamlit_folium/tests/requirements.txt + python -m pip install --upgrade pip + pip install -r tests/requirements.txt - name: Install streamlit-folium shell: bash -l {0} From 5e7da4b9b5fcbe708b920ed1da0a1f6a91f3e46c Mon Sep 17 00:00:00 2001 From: Hans Then Date: Wed, 11 Jun 2025 22:20:51 +0200 Subject: [PATCH 05/10] More trial and error --- .github/workflows/test_streamlit_folium.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test_streamlit_folium.yml b/.github/workflows/test_streamlit_folium.yml index 3d5eaf0143..091f51eae8 100644 --- a/.github/workflows/test_streamlit_folium.yml +++ b/.github/workflows/test_streamlit_folium.yml @@ -41,6 +41,7 @@ jobs: - name: Install streamlit_folium dev dependencies shell: bash -l {0} run: | + cd streamlit_folium python -m pip install --upgrade pip pip install -r tests/requirements.txt From b8af05f10eb11dcbe01234670c289874cd7dc013 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Thu, 12 Jun 2025 19:28:56 +0200 Subject: [PATCH 06/10] Updated after review comment --- .github/workflows/test_streamlit_folium.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/test_streamlit_folium.yml b/.github/workflows/test_streamlit_folium.yml index 091f51eae8..4325d5145e 100644 --- a/.github/workflows/test_streamlit_folium.yml +++ b/.github/workflows/test_streamlit_folium.yml @@ -16,14 +16,6 @@ jobs: - 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: @@ -62,7 +54,7 @@ jobs: - 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} From 5904750b5f2d7788e708c7883e77027cc2ff73a7 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Fri, 13 Jun 2025 05:45:48 +0200 Subject: [PATCH 07/10] Update after review comments --- .github/workflows/test_streamlit_folium.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test_streamlit_folium.yml b/.github/workflows/test_streamlit_folium.yml index 4325d5145e..4aaa8c3a6f 100644 --- a/.github/workflows/test_streamlit_folium.yml +++ b/.github/workflows/test_streamlit_folium.yml @@ -13,6 +13,11 @@ jobs: runs-on: ubuntu-latest steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + - name: Checkout Folium uses: actions/checkout@v4 From 3af17e9dbbae6a4ab37f6802f879549b028a5736 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 06:20:05 +0000 Subject: [PATCH 08/10] Bump actions/setup-python from 4 to 5 in the github-actions group Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/setup-python` from 4 to 5 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/test_streamlit_folium.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_streamlit_folium.yml b/.github/workflows/test_streamlit_folium.yml index 4aaa8c3a6f..80988a8fb8 100644 --- a/.github/workflows/test_streamlit_folium.yml +++ b/.github/workflows/test_streamlit_folium.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' From 2de8ff26c74c77b7554a3f75cf1d96241c4f4b08 Mon Sep 17 00:00:00 2001 From: Hans Then Date: Mon, 16 Jun 2025 22:19:11 +0200 Subject: [PATCH 09/10] Coverage testing (#2158) * Add code coverage to test runs --- .github/workflows/save_coverage.yml | 34 +++++++++++++++++++++ .github/workflows/test_code.yml | 10 +++++- .github/workflows/test_geopandas.yml | 10 +++++- .github/workflows/test_latest_branca.yml | 10 +++++- .github/workflows/test_selenium.yml | 10 +++++- .github/workflows/test_snapshots.yml | 10 +++++- .github/workflows/test_streamlit_folium.yml | 13 ++++++-- folium/__init__.py | 7 +++-- tests/snapshots/test_snapshots.py | 2 +- 9 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/save_coverage.yml diff --git a/.github/workflows/save_coverage.yml b/.github/workflows/save_coverage.yml new file mode 100644 index 0000000000..5e4641ec9c --- /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 e29dd68f20..ab966c9e29 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 a7ed868b47..219b3db194 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 c7ea3aaa19..0f0f4ea70e 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 61a87df02c..e1d876d33c 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 9555eff44e..14cac92028 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 80988a8fb8..bf7821743d 100644 --- a/.github/workflows/test_streamlit_folium.yml +++ b/.github/workflows/test_streamlit_folium.yml @@ -54,7 +54,7 @@ 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} @@ -65,7 +65,7 @@ jobs: 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() @@ -73,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/folium/__init__.py b/folium/__init__.py index c6fa376e4c..67489f8c50 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/tests/snapshots/test_snapshots.py b/tests/snapshots/test_snapshots.py index 84c20211ba..8249bc1686 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", From 5c460674cbe9c2c20f2b81fd3233c26e0a42676e Mon Sep 17 00:00:00 2001 From: Hans Then Date: Mon, 16 Jun 2025 22:20:02 +0200 Subject: [PATCH 10/10] Issue 1359 (#2159) * 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. * 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. * Add documentation --- docs/advanced_guide.rst | 1 + .../override_leaflet_class_methods.md | 46 ++++++++++++ folium/elements.py | 20 ++++++ folium/features.py | 4 +- folium/map.py | 55 ++++++++++++-- tests/test_map.py | 71 ++++++++++++++++++- 6 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 docs/advanced_guide/override_leaflet_class_methods.md 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 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..de605aee52 --- /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/elements.py b/folium/elements.py index 8344abc3a8..f52e8b6fa0 100644 --- a/folium/elements.py +++ b/folium/elements.py @@ -159,6 +159,26 @@ def __init__(self, element_name: str, element_parent_name: str): 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.""" 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..278b97a1bb 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/tests/test_map.py b/tests/test_map.py index cc3728586a..cf6635a0b9 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)