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:
Ah, I did not know pandas supports that. Yeah, would be nice to have a consistency here, but if there is a wide range of cases like this we would have to cover, it may be better to have such code in fiona.
Is this also an issue with the parquet format?
Do we want to add this in the end?
I think we do. For the sake of consistency and convenience. 

> Is this also an issue with the parquet format?

Yes, for both parquet and feather and both read and write. If we want to implement `~` support, it should cover all these cases.
Before I'm too brave, would this be to add
```
        if isinstance(path, str):
            path = os.path.expanduser(path)
        elif isinstance(path, Path):
            path = path.expanduser()
```

before of this line for each of the `to_xxx` functions of geodataframe? 
https://github.com/geopandas/geopandas/blob/b6174f4855693e08f3ee987e2a966b3c5ee01c35/geopandas/geodataframe.py#L974
I think so. In `to_file`, `to_parquet` and `to_feather` as well as their `read_*` counterparts. This should ideally happen in `geopandas/io/file.py` and `geopandas/io/arrow.py`, not in `geogataframe.py`.
Do you think it would be worthwhile to add a utility function for this and use it everywhere?
Good suggestion. Something like `_expand_path(path)` in `file.py` (and import it from `arrow.py`) would be a good solution.

Resolving patch:
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -8,7 +8,7 @@
 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
--- 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 @@
 }
 
 
+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 (


Test patch:
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -7,6 +7,7 @@
 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
--- 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))