Repository: geopandas/geopandas

Issue:
ENH: support ~ (home dir) expansion in file paths?
From https://stackoverflow.com/questions/64423707/python-geopandas-unable-to-locate-shapefile, pandas supports passing a file path with a `~`, which gives the expectation that geopandas also supports this. 

So we could consider supporting this as well. 
But, we also need to be careful about not fully replicating pandas complex file handling code (but it's also not officially public to reuse ..). Although in this case it might be a straightforward `if isinstance(path, str): os.path.expanduser(path)`

Discussion:
Imanflow: Changes done. 
martinfleis: Can you also add tests for these new cases? Even though codecov sees it as covered, it is not tested. 
Imanflow: Done! 
Imanflow: I added the tests, but the CI is failing for something unrelated seems to me


Patch:
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index 9d2ebdcd..9aab7c3e 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -8,7 +8,7 @@ from geopandas._compat import import_optional_dependency
 from geopandas.array import from_wkb
 from geopandas import GeoDataFrame
 import geopandas
-
+from .file import _expand_user
 
 METADATA_VERSION = "0.1.0"
 # reference: https://github.com/geopandas/geo-arrow-spec
@@ -243,6 +243,7 @@ def _to_parquet(df, path, index=None, compression="snappy", **kwargs):
         "pyarrow.parquet", extra="pyarrow is required for Parquet support."
     )
 
+    path = _expand_user(path)
     table = _geopandas_to_arrow(df, index=index)
     parquet.write_table(table, path, compression=compression, **kwargs)
 
@@ -290,6 +291,7 @@ def _to_feather(df, path, index=None, compression=None, **kwargs):
     if pyarrow.__version__ < LooseVersion("0.17.0"):
         raise ImportError("pyarrow >= 0.17 required for Feather support")
 
+    path = _expand_user(path)
     table = _geopandas_to_arrow(df, index=index)
     feather.write_feather(table, path, compression=compression, **kwargs)
 
@@ -452,6 +454,7 @@ def _read_parquet(path, columns=None, storage_options=None, **kwargs):
         path, filesystem=filesystem, storage_options=storage_options
     )
 
+    path = _expand_user(path)
     kwargs["use_pandas_metadata"] = True
     table = parquet.read_table(path, columns=columns, filesystem=filesystem, **kwargs)
 
@@ -513,5 +516,6 @@ def _read_feather(path, columns=None, **kwargs):
     if pyarrow.__version__ < LooseVersion("0.17.0"):
         raise ImportError("pyarrow >= 0.17 required for Feather support")
 
+    path = _expand_user(path)
     table = feather.read_table(path, columns=columns, **kwargs)
     return _arrow_to_geopandas(table)
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index acae8b3c..27095bcd 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -1,7 +1,8 @@
+import os
 from distutils.version import LooseVersion
 from pathlib import Path
-
 import warnings
+
 import numpy as np
 import pandas as pd
 
@@ -60,6 +61,15 @@ _EXTENSION_TO_DRIVER = {
 }
 
 
+def _expand_user(path):
+    """Expand paths that use ~."""
+    if isinstance(path, str):
+        path = os.path.expanduser(path)
+    elif isinstance(path, Path):
+        path = path.expanduser()
+    return path
+
+
 def _check_fiona(func):
     if fiona is None:
         raise ImportError(
@@ -149,6 +159,8 @@ def _read_file(filename, bbox=None, mask=None, rows=None, **kwargs):
     by using the encoding keyword parameter, e.g. ``encoding='utf-8'``.
     """
     _check_fiona("'read_file' function")
+    filename = _expand_user(filename)
+
     if _is_url(filename):
         req = _urlopen(filename)
         path_or_bytes = req.read()
@@ -336,6 +348,8 @@ def _to_file(
     by using the encoding keyword parameter, e.g. ``encoding='utf-8'``.
     """
     _check_fiona("'to_file' method")
+    filename = _expand_user(filename)
+
     if index is None:
         # Determine if index attribute(s) should be saved to file
         index = list(df.index.names) != [None] or type(df.index) not in (
diff --git a/geopandas/tools/overlay.py b/geopandas/tools/overlay.py
index ef47fa1e..ca516f81 100644
--- a/geopandas/tools/overlay.py
+++ b/geopandas/tools/overlay.py
@@ -185,8 +185,8 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
     1       2.0       1.0  POLYGON ((2.00000 2.00000, 2.00000 3.00000, 3....
     2       2.0       2.0  POLYGON ((4.00000 4.00000, 4.00000 3.00000, 3....
     3       1.0       NaN  POLYGON ((2.00000 0.00000, 0.00000 0.00000, 0....
-    4       2.0       NaN  MULTIPOLYGON (((4.00000 3.00000, 4.00000 2.000...
-    5       NaN       1.0  MULTIPOLYGON (((3.00000 2.00000, 3.00000 1.000...
+    4       2.0       NaN  MULTIPOLYGON (((3.00000 3.00000, 4.00000 3.000...
+    5       NaN       1.0  MULTIPOLYGON (((2.00000 2.00000, 3.00000 2.000...
     6       NaN       2.0  POLYGON ((3.00000 5.00000, 5.00000 5.00000, 5....
 
     >>> geopandas.overlay(df1, df2, how='intersection')
@@ -198,8 +198,8 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
     >>> geopandas.overlay(df1, df2, how='symmetric_difference')
        df1_data  df2_data                                           geometry
     0       1.0       NaN  POLYGON ((2.00000 0.00000, 0.00000 0.00000, 0....
-    1       2.0       NaN  MULTIPOLYGON (((4.00000 3.00000, 4.00000 2.000...
-    2       NaN       1.0  MULTIPOLYGON (((3.00000 2.00000, 3.00000 1.000...
+    1       2.0       NaN  MULTIPOLYGON (((3.00000 3.00000, 4.00000 3.000...
+    2       NaN       1.0  MULTIPOLYGON (((2.00000 2.00000, 3.00000 2.000...
     3       NaN       2.0  POLYGON ((3.00000 5.00000, 5.00000 5.00000, 5....
 
     >>> geopandas.overlay(df1, df2, how='difference')
@@ -213,7 +213,7 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
     1       2.0       1.0  POLYGON ((2.00000 2.00000, 2.00000 3.00000, 3....
     2       2.0       2.0  POLYGON ((4.00000 4.00000, 4.00000 3.00000, 3....
     3       1.0       NaN  POLYGON ((2.00000 0.00000, 0.00000 0.00000, 0....
-    4       2.0       NaN  MULTIPOLYGON (((4.00000 3.00000, 4.00000 2.000...
+    4       2.0       NaN  MULTIPOLYGON (((3.00000 3.00000, 4.00000 3.000...
 
     See also
     --------
@@ -317,32 +317,64 @@ def overlay(df1, df2, how="intersection", keep_geom_type=None, make_valid=True):
             result = dfunion[dfunion["__idx1"].notnull()].copy()
 
     if keep_geom_type:
-        key_order = result.keys()
-        exploded = result.reset_index(drop=True).explode()
-        exploded = exploded.reset_index(level=0)
+        geom_type = df1.geom_type.iloc[0]
+
+        # First we filter the geometry types inside GeometryCollections objects
+        # (e.g. GeometryCollection([polygon, point]) -> polygon)
+        # we do this separately on only the relevant rows, as this is an expensive
+        # operation (an expensive no-op for geometry types other than collections)
+        is_collection = result.geom_type == "GeometryCollection"
+        if is_collection.any():
+            geom_col = result._geometry_column_name
+            collections = result[[geom_col]][is_collection]
+
+            exploded = collections.reset_index(drop=True).explode()
+            exploded = exploded.reset_index(level=0)
+
+            orig_num_geoms_exploded = exploded.shape[0]
+            if geom_type in polys:
+                exploded = exploded.loc[exploded.geom_type.isin(polys)]
+            elif geom_type in lines:
+                exploded = exploded.loc[exploded.geom_type.isin(lines)]
+            elif geom_type in points:
+                exploded = exploded.loc[exploded.geom_type.isin(points)]
+            else:
+                raise TypeError(
+                    "`keep_geom_type` does not support {}.".format(geom_type)
+                )
+            num_dropped_collection = orig_num_geoms_exploded - exploded.shape[0]
+
+            # level_0 created with above reset_index operation
+            # and represents the original geometry collections
+            # TODO avoiding dissolve to call unary_union in this case could further
+            # improve performance (we only need to collect geometries in their
+            # respective Multi version)
+            dissolved = exploded.dissolve(by="level_0")
+            result.loc[is_collection, geom_col] = dissolved[geom_col].values
+        else:
+            num_dropped_collection = 0
 
+        # Now we filter all geometries (in theory we don't need to do this
+        # again for the rows handled above for GeometryCollections, but filtering
+        # them out is probably more expensive as simply including them when this
+        # is typically about only a few rows)
         orig_num_geoms = result.shape[0]
-        geom_type = df1.geom_type.iloc[0]
         if geom_type in polys:
-            exploded = exploded.loc[exploded.geom_type.isin(polys)]
+            result = result.loc[result.geom_type.isin(polys)]
         elif geom_type in lines:
-            exploded = exploded.loc[exploded.geom_type.isin(lines)]
+            result = result.loc[result.geom_type.isin(lines)]
         elif geom_type in points:
-            exploded = exploded.loc[exploded.geom_type.isin(points)]
+            result = result.loc[result.geom_type.isin(points)]
         else:
             raise TypeError("`keep_geom_type` does not support {}.".format(geom_type))
+        num_dropped = orig_num_geoms - result.shape[0]
 
-        # level_0 created with above reset_index operation
-        # and represents the original geometry collections
-        result = exploded.dissolve(by="level_0")[key_order]
-
-        if (result.shape[0] != orig_num_geoms) and keep_geom_type_warning:
-            num_dropped = orig_num_geoms - result.shape[0]
+        if (num_dropped > 0 or num_dropped_collection > 0) and keep_geom_type_warning:
             warnings.warn(
                 "`keep_geom_type=True` in overlay resulted in {} dropped "
                 "geometries of different geometry types than df1 has. "
                 "Set `keep_geom_type=False` to retain all "
-                "geometries".format(num_dropped),
+                "geometries".format(num_dropped + num_dropped_collection),
                 UserWarning,
                 stacklevel=2,
             )


Test patch:
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index 81d6132b..4732599a 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -7,6 +7,7 @@ import pytest
 from pandas import DataFrame, read_parquet as pd_read_parquet
 from pandas.testing import assert_frame_equal
 import numpy as np
+from shapely.geometry import box
 
 import geopandas
 from geopandas import GeoDataFrame, read_file, read_parquet, read_feather
@@ -541,3 +542,21 @@ def test_non_fsspec_url_with_storage_options_raises():
 def test_prefers_pyarrow_fs():
     filesystem, _ = _get_filesystem_path("file:///data.parquet")
     assert isinstance(filesystem, pyarrow.fs.LocalFileSystem)
+
+
+def test_write_read_parquet_expand_user():
+    gdf = geopandas.GeoDataFrame(geometry=[box(0, 0, 10, 10)], crs="epsg:4326")
+    test_file = "~/test_file.parquet"
+    gdf.to_parquet(test_file)
+    pq_df = geopandas.read_parquet(test_file)
+    assert_geodataframe_equal(gdf, pq_df, check_crs=True)
+    os.remove(os.path.expanduser(test_file))
+
+
+def test_write_read_feather_expand_user():
+    gdf = geopandas.GeoDataFrame(geometry=[box(0, 0, 10, 10)], crs="epsg:4326")
+    test_file = "~/test_file.feather"
+    gdf.to_feather(test_file)
+    f_df = geopandas.read_feather(test_file)
+    assert_geodataframe_equal(gdf, f_df, check_crs=True)
+    os.remove(os.path.expanduser(test_file))
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index ee304fc1..3267b202 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -837,3 +837,14 @@ def test_to_file__undetermined_driver(tmp_path, df_nybb):
     df_nybb.to_file(shpdir)
     assert shpdir.is_dir()
     assert list(shpdir.glob("*.shp"))
+
+
+@pytest.mark.parametrize(
+    "test_file", [(pathlib.Path("~/test_file.geojson")), "~/test_file.geojson"]
+)
+def test_write_read_file(test_file):
+    gdf = geopandas.GeoDataFrame(geometry=[box(0, 0, 10, 10)], crs=_CRS)
+    gdf.to_file(test_file, driver="GeoJSON")
+    df_json = geopandas.read_file(test_file)
+    assert_geodataframe_equal(gdf, df_json, check_crs=True)
+    os.remove(os.path.expanduser(test_file))
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index a07bf62b..ec9b6820 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -575,6 +575,11 @@ def test_keep_geom_type_geometry_collection():
     df1 = read_file(os.path.join(DATA, "geom_type", "df1.geojson"))
     df2 = read_file(os.path.join(DATA, "geom_type", "df2.geojson"))
 
+    with pytest.warns(UserWarning, match="`keep_geom_type=True` in overlay"):
+        intersection = overlay(df1, df2, keep_geom_type=None)
+    assert len(intersection) == 1
+    assert (intersection.geom_type == "Polygon").all()
+
     intersection = overlay(df1, df2, keep_geom_type=True)
     assert len(intersection) == 1
     assert (intersection.geom_type == "Polygon").all()
@@ -584,6 +589,44 @@ def test_keep_geom_type_geometry_collection():
     assert (intersection.geom_type == "GeometryCollection").all()
 
 
+def test_keep_geom_type_geometry_collection2():
+    polys1 = [
+        box(0, 0, 1, 1),
+        box(1, 1, 3, 3).union(box(1, 3, 5, 5)),
+    ]
+
+    polys2 = [
+        box(0, 0, 1, 1),
+        box(3, 1, 4, 2).union(box(4, 1, 5, 4)),
+    ]
+    df1 = GeoDataFrame({"left": [0, 1], "geometry": polys1})
+    df2 = GeoDataFrame({"right": [0, 1], "geometry": polys2})
+
+    result1 = overlay(df1, df2, keep_geom_type=True)
+    expected1 = GeoDataFrame(
+        {
+            "left": [0, 1],
+            "right": [0, 1],
+            "geometry": [box(0, 0, 1, 1), box(4, 3, 5, 4)],
+        }
+    )
+    assert_geodataframe_equal(result1, expected1)
+
+    result1 = overlay(df1, df2, keep_geom_type=False)
+    expected1 = GeoDataFrame(
+        {
+            "left": [0, 1, 1],
+            "right": [0, 0, 1],
+            "geometry": [
+                box(0, 0, 1, 1),
+                Point(1, 1),
+                GeometryCollection([box(4, 3, 5, 4), LineString([(3, 1), (3, 2)])]),
+            ],
+        }
+    )
+    assert_geodataframe_equal(result1, expected1)
+
+
 @pytest.mark.parametrize("make_valid", [True, False])
 def test_overlap_make_valid(make_valid):
     bowtie = Polygon([(1, 1), (9, 9), (9, 1), (1, 9), (1, 1)])