diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml index 244d541cfa..2df76c4590 100644 --- a/.github/workflows/docs-build.yml +++ b/.github/workflows/docs-build.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.13"] env: PLANET_API_KEY: ${{ secrets.PLANET_API_KEY }} @@ -51,18 +51,18 @@ jobs: - name: Test import run: | - uv run python -c "import leafmap; print('leafmap import successful')" - # uv run python -c "from osgeo import gdal; print('gdal import successful')" - # uv run gdalinfo --version + .venv/bin/python -c "import leafmap; print('leafmap import successful')" + # .venv/bin/python -c "from osgeo import gdal; print('gdal import successful')" + # .venv/bin/gdalinfo --version - name: Running pytest run: | - uv run pytest . --verbose + .venv/bin/pytest . --verbose - name: Install mkdocs run: | uv pip install -r requirements_docs.txt - uv run mkdocs build + .venv/bin/mkdocs build - name: Deploy to Netlify uses: nwtgck/actions-netlify@v4.0 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 16f1e37409..f846c4347f 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.13"] env: PLANET_API_KEY: ${{ secrets.PLANET_API_KEY }} @@ -50,15 +50,15 @@ jobs: - name: Test import run: | - uv run python -c "import leafmap; print('leafmap import successful')" - # uv run python -c "from osgeo import gdal; print('gdal import successful')" - # uv run gdalinfo --version + .venv/bin/python -c "import leafmap; print('leafmap import successful')" + # .venv/bin/python -c "from osgeo import gdal; print('gdal import successful')" + # .venv/bin/gdalinfo --version - name: Running pytest run: | - uv run pytest . --verbose + .venv/bin/pytest . --verbose - name: Install mkdocs run: | uv pip install -r requirements_docs.txt - uv run mkdocs gh-deploy --force + .venv/bin/mkdocs gh-deploy --force diff --git a/.github/workflows/installation.yml b/.github/workflows/installation.yml index 87259ed057..853af4ee81 100644 --- a/.github/workflows/installation.yml +++ b/.github/workflows/installation.yml @@ -13,14 +13,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12"] + python-version: ["3.13", "3.14"] steps: - uses: actions/checkout@v7 - name: Install uv uses: astral-sh/setup-uv@v7 with: - version: "0.4.16" + version: "latest" # enable-cache: true - name: Set up Python ${{ matrix.python-version }} @@ -32,4 +32,4 @@ jobs: uv pip install . - name: Test import - run: uv run python -c "import leafmap; print('leafmap import successful')" + run: .venv/bin/python -c "import leafmap; print('leafmap import successful')" diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 4f197fb59d..6169ce17fc 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -15,7 +15,8 @@ jobs: fail-fast: false matrix: config: - - { os: macOS-latest, py: "3.12" } + - { os: macOS-latest, py: "3.13" } + - { os: macOS-latest, py: "3.14" } env: SDKROOT: /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk steps: @@ -24,7 +25,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: - version: "0.4.16" + version: "latest" # enable-cache: true - name: Set up Python ${{ matrix.config.py }} @@ -37,4 +38,4 @@ jobs: - name: Test import run: | - uv run python -c "import leafmap; print('leafmap import successful')" + .venv/bin/python -c "import leafmap; print('leafmap import successful')" diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index cf0b6a8699..11f022ebe8 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13", "3.14"] env: PLANET_API_KEY: ${{ secrets.PLANET_API_KEY }} @@ -33,7 +33,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: - version: "0.4.16" + version: "latest" # enable-cache: true - name: Set up Python ${{ matrix.python-version }} @@ -52,10 +52,10 @@ jobs: - name: Test import run: | - uv run python -c "import leafmap; print('leafmap import successful')" - # uv run python -c "from osgeo import gdal; print('gdal import successful')" - # uv run gdalinfo --version + .venv/bin/python -c "import leafmap; print('leafmap import successful')" + # .venv/bin/python -c "from osgeo import gdal; print('gdal import successful')" + # .venv/bin/gdalinfo --version - name: Running pytest run: | - uv run pytest . --verbose + .venv/bin/pytest . --verbose diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 415e14f255..9830d3dddf 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -12,7 +12,7 @@ jobs: runs-on: windows-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.13", "3.14"] env: PLANET_API_KEY: ${{ secrets.PLANET_API_KEY }} @@ -29,7 +29,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: - version: "0.4.16" + version: "latest" # enable-cache: true - name: Set up Python ${{ matrix.python-version }} @@ -42,4 +42,4 @@ jobs: - name: Test import run: | - uv run python -c "import leafmap; print('leafmap import successful')" + .venv\Scripts\python -c "import leafmap; print('leafmap import successful')" diff --git a/leafmap/common.py b/leafmap/common.py index ff5a302367..5214bab563 100644 --- a/leafmap/common.py +++ b/leafmap/common.py @@ -1516,11 +1516,15 @@ def kml_to_shp(in_kml, out_shp): check_package(name="geopandas", URL="https://geopandas.org") - import fiona import geopandas as gpd - # print(fiona.supported_drivers) - fiona.drvsupport.supported_drivers["KML"] = "rw" + try: + import fiona + + fiona.drvsupport.supported_drivers["KML"] = "rw" + except ImportError: + # fiona is optional; the default pyogrio engine reads KML natively. + pass df = gpd.read_file(in_kml, driver="KML") df.to_file(out_shp) @@ -1555,12 +1559,15 @@ def kml_to_geojson(in_kml, out_geojson=None): check_package(name="geopandas", URL="https://geopandas.org") - import fiona import geopandas as gpd - # import fiona - # print(fiona.supported_drivers) - fiona.drvsupport.supported_drivers["KML"] = "rw" + try: + import fiona + + fiona.drvsupport.supported_drivers["KML"] = "rw" + except ImportError: + # fiona is optional; the default pyogrio engine reads KML natively. + pass gdf = gpd.read_file(in_kml, driver="KML") if out_geojson is not None: @@ -1693,7 +1700,6 @@ def vector_to_geojson( warnings.filterwarnings("ignore") check_package(name="geopandas", URL="https://geopandas.org") - import fiona import geopandas as gpd if not filename.startswith("http"): @@ -1702,7 +1708,13 @@ def vector_to_geojson( filename = "zip://" + filename ext = os.path.splitext(filename)[1].lower() if ext == ".kml": - fiona.drvsupport.supported_drivers["KML"] = "rw" + try: + import fiona + + fiona.drvsupport.supported_drivers["KML"] = "rw" + except ImportError: + # fiona is optional; the default pyogrio engine reads KML natively. + pass df = gpd.read_file( filename, bbox=bbox, @@ -3950,11 +3962,47 @@ def geojson_to_gdf(in_geojson, encoding="utf-8", **kwargs: Any): with open(out_file, "w") as f: json.dump(in_geojson, f) in_geojson = out_file + elif isinstance(in_geojson, str) and in_geojson.startswith("http"): + try: + return gpd.read_file(in_geojson, encoding=encoding, **kwargs) + except Exception: + response = requests.get(in_geojson, timeout=30) + response.raise_for_status() + data = response.json() + if isinstance(data, dict) and data.get("type") == "Feature": + return gpd.GeoDataFrame.from_features([data], **kwargs) + return gpd.GeoDataFrame.from_features(data, **kwargs) gdf = gpd.read_file(in_geojson, encoding=encoding, **kwargs) return gdf +def sanitize_geojson(obj: Any) -> Any: + """Recursively converts NumPy types in a GeoJSON-like object to native Python types. + + GeoDataFrame.__geo_interface__ can leave NumPy arrays/scalars in feature + properties (e.g. list-valued columns are read back as ndarrays), which are + not JSON serializable and break widget serialization. This makes the object + safe for ``json.dumps``. + + Args: + obj (Any): A GeoJSON dict, list, or scalar that may contain NumPy types. + + Returns: + Any: The same structure with NumPy arrays converted to lists and NumPy + scalars to native Python types. + """ + if isinstance(obj, np.ndarray): + return obj.tolist() + elif isinstance(obj, np.generic): + return obj.item() + elif isinstance(obj, dict): + return {key: sanitize_geojson(value) for key, value in obj.items()} + elif isinstance(obj, (list, tuple)): + return [sanitize_geojson(value) for value in obj] + return obj + + def geojson_to_df(in_geojson, encoding="utf-8", drop_geometry=True): """Converts a GeoJSON object to a pandas DataFrame. diff --git a/leafmap/foliumap.py b/leafmap/foliumap.py index 50c58ca12b..124c510377 100644 --- a/leafmap/foliumap.py +++ b/leafmap/foliumap.py @@ -2046,10 +2046,10 @@ def add_geojson( with open(output, "r") as fd: data = json.load(fd) else: - gdf = gpd.read_file(in_geojson, encoding=encoding) + gdf = geojson_to_gdf(in_geojson, encoding=encoding) else: - gdf = gpd.read_file(in_geojson, encoding=encoding) + gdf = geojson_to_gdf(in_geojson, encoding=encoding) elif isinstance(in_geojson, dict): gdf = gpd.GeoDataFrame.from_features(in_geojson) @@ -2067,7 +2067,7 @@ def add_geojson( gdf.crs = "EPSG:4326" elif gdf.crs != "EPSG:4326": gdf = gdf.to_crs("EPSG:4326") - data = gdf.__geo_interface__ + data = common.sanitize_geojson(gdf.__geo_interface__) # interchangeable parameters between ipyleaflet and folium. @@ -2411,11 +2411,16 @@ def add_vector( opacity (float, optional): The opacity of the layer. Defaults to 1.0. """ - import fiona import geopandas as gpd if isinstance(filename, str) and filename.endswith(".kml"): - fiona.drvsupport.supported_drivers["KML"] = "rw" + try: + import fiona + + fiona.drvsupport.supported_drivers["KML"] = "rw" + except ImportError: + # fiona is optional; the default pyogrio engine reads KML natively. + pass gdf = gpd.read_file( filename, bbox=bbox, diff --git a/leafmap/leafmap.py b/leafmap/leafmap.py index a2388a1a57..1fdd417563 100644 --- a/leafmap/leafmap.py +++ b/leafmap/leafmap.py @@ -266,6 +266,24 @@ def set_center(self, lon, lat, zoom=None) -> None: if zoom is not None: self.zoom = zoom + def fit_bounds(self, bounds) -> None: + """Fits the map view to the given bounds. + + Wraps ipyleaflet's ``fit_bounds``, which schedules an asyncio task via + ``asyncio.ensure_future``. On Python 3.14+ there is no implicit current + event loop outside a running loop, so ensure one exists first. + + Args: + bounds (list): Bounds in the form [[south, west], [north, east]]. + """ + import asyncio + + try: + asyncio.get_event_loop() + except RuntimeError: + asyncio.set_event_loop(asyncio.new_event_loop()) + super().fit_bounds(bounds) + def zoom_to_bounds(self, bounds) -> None: """Zooms to a bounding box in the form of [minx, miny, maxx, maxy]. @@ -3337,10 +3355,10 @@ def add_geojson( with open(output, "r") as fd: data = json.load(fd) else: - gdf = gpd.read_file(in_geojson, encoding=encoding) + gdf = geojson_to_gdf(in_geojson, encoding=encoding) else: - gdf = gpd.read_file(in_geojson, encoding=encoding) + gdf = geojson_to_gdf(in_geojson, encoding=encoding) elif isinstance(in_geojson, dict): if in_geojson.get("type") == "Feature": @@ -3361,7 +3379,7 @@ def add_geojson( gdf.crs = "EPSG:4326" elif gdf.crs != "EPSG:4326": gdf = gdf.to_crs("EPSG:4326") - data = gdf.__geo_interface__ + data = common.sanitize_geojson(gdf.__geo_interface__) try: first_feature = data["features"][0] @@ -3961,12 +3979,17 @@ def add_vector( encoding (str, optional): The encoding to use to read the file. Defaults to "utf-8". """ - import fiona import geopandas as gpd if isinstance(filename, str) and filename.endswith(".kml"): - fiona.drvsupport.supported_drivers["KML"] = "rw" kwargs["driver"] = "KML" + try: + import fiona + + fiona.drvsupport.supported_drivers["KML"] = "rw" + except ImportError: + # fiona is optional; the default pyogrio engine reads KML natively. + pass gdf = gpd.read_file( filename, bbox=bbox, mask=mask, rows=rows, encoding=encoding, **kwargs @@ -4076,7 +4099,6 @@ def add_point_layer( warnings.filterwarnings("ignore") common.check_package(name="geopandas", URL="https://geopandas.org") - import fiona import geopandas as gpd self.default_style = {"cursor": "wait"} @@ -4088,7 +4110,13 @@ def add_point_layer( filename = os.path.abspath(filename) ext = os.path.splitext(filename)[1].lower() if ext == ".kml": - fiona.drvsupport.supported_drivers["KML"] = "rw" + try: + import fiona + + fiona.drvsupport.supported_drivers["KML"] = "rw" + except ImportError: + # fiona is optional; the default pyogrio engine reads KML natively. + pass gdf = gpd.read_file(filename, driver="KML", **kwargs) else: gdf = gpd.read_file(filename, **kwargs) diff --git a/leafmap/maplibregl.py b/leafmap/maplibregl.py index b358c8a346..ce1ec7f441 100644 --- a/leafmap/maplibregl.py +++ b/leafmap/maplibregl.py @@ -1555,7 +1555,7 @@ def add_geojson( if isinstance(data, str): if os.path.isfile(data) or data.startswith("http"): - data = gpd.read_file(data).__geo_interface__ + data = geojson_to_gdf(data).__geo_interface__ if fit_bounds: bounds = get_bounds(data) source = GeoJSONSource(data=data, **source_args) @@ -1678,7 +1678,7 @@ def add_vector( """ if not isinstance(data, gpd.GeoDataFrame): - data = gpd.read_file(data).__geo_interface__ + data = geojson_to_gdf(data).__geo_interface__ else: data = data.__geo_interface__ @@ -4339,7 +4339,7 @@ def add_symbol( elif source in self.source_names: source_name = source else: - geojson = gpd.read_file(source).__geo_interface__ + geojson = geojson_to_gdf(source).__geo_interface__ geojson_source = {"type": "geojson", "data": geojson} source_name = common.get_unique_name( "source", self.source_names, overwrite=False @@ -7830,6 +7830,8 @@ def add_vector_editor( ext = ext.lower() if ext in [".parquet", ".pq", ".geoparquet"]: gdf = gpd.read_parquet(filename) + elif filename.startswith("http"): + gdf = geojson_to_gdf(filename) else: gdf = gpd.read_file(filename) elif isinstance(filename, dict): @@ -9652,7 +9654,7 @@ def create_vector_data( } if geojson is not None and isinstance(geojson, str): - geojson = gpd.read_file(geojson).__geo_interface__ + geojson = geojson_to_gdf(geojson).__geo_interface__ setattr(m, "geojson", geojson) setattr(m, "draw_feature_collection_initial", geojson) @@ -9966,6 +9968,8 @@ def edit_vector_data( ext = ext.lower() if ext in [".parquet", ".pq", ".geoparquet"]: gdf = gpd.read_parquet(filename) + elif filename.startswith("http"): + gdf = geojson_to_gdf(filename) else: gdf = gpd.read_file(filename) elif isinstance(filename, dict): @@ -11103,7 +11107,7 @@ def __init__( if source is None: gdfs.append(None) continue - gdf = gpd.read_file(source) + gdf = geojson_to_gdf(source) gdfs.append(gdf) style = styles[names[index]] diff --git a/requirements_dev.txt b/requirements_dev.txt index dcd6aaa21d..20132445b0 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,7 +4,7 @@ cogeo-mosaic d2spy earthaccess ffmpeg-python -fiona +fiona; python_version < "3.14" geopandas>=1.0.0 gradio h3