Repository: geopandas/geopandas

Issue:
ENH: Write vector files with metadata (tagging)
It would be very useful to be able to tag certain metadata (ex: `version=1.3.0`) in the vector files. Something along the lines of how `rasterio` allows tagging rasters with dictionaries.

https://rasterio.readthedocs.io/en/latest/topics/tags.html

Currently you can assign a property `gdf['version'] = '1.3.0'`, but this gets assigned to every single geometry, which seems wasteful for something like this.

I believe `DataFrame.attrs` does not propagate through operations on the dataframe and are not written to file, so it would probably take a lot of work to go that route.

Looks like `Fiona` implemented a tagging solution for `>=v1.9.0`.

https://github.com/Toblerity/Fiona/issues/713

A solution might use a `tags` dictionary:
```
gdf.tags = {'version': '1.3.0'}
gdf.to_file('foo/bar.gpkg', driver='GPKG', layer='baz', tags={'version': '1.3.0'}) 
```




Discussion:
DahnJ: This seems to fail because the environment [has fiona 1.8.20](https://github.com/geopandas/geopandas/actions/runs/4552930065/jobs/8028906520?pr=2850#step:4:46) which doesn't yet support metadata/tags ([added in 1.9](https://github.com/Toblerity/Fiona/issues/713))
m-richards: Hey @DahnJ rather than updating the the environments, you could instead use use the `FIONA_GE_19` defined in `geopandas/io/file.py`. You probably want to do this check as part of your implementation, but also use it as part of the tests - we should skip the round trip tests you added for old versions of fiona, but also add a test that an appropriate exception is thrown for old versions of fiona too.
ferreteleco: Any update on this?

DahnJ: Sorry for the delay on this.

@m-richards thanks for the suggestion. I've tried to follow it in https://github.com/geopandas/geopandas/pull/2850/commits/8899ddec419162a7bfc99bab152528a98d83ae0a and the tests are now looking good.

In the meantime, Pyogrio started [supporting metadata as well](https://github.com/geopandas/pyogrio/pull/237). I would probably define a new variable `PYOGRIO_GE_06` analogously to `FIONA_GE_19` and allow to write metadata using Pyogrio, too. What do you think, @m-richards ? 

cc: @jorisvandenbossche 
martinfleis: >  I would probably define a new variable PYOGRIO_GE_06 analogously to FIONA_GE_19 and allow to write metadata using Pyogrio, too.

Yes please.
DahnJ: This is almost ready for review, but there's a single [failing test](https://github.com/geopandas/geopandas/actions/runs/5563505077/jobs/10162505006?pr=2850#step:5:2984) ` geopandas/tests/test_pandas_methods.py::test_astype `. Not sure if that's got anything to do with the changes I've introduced here. Any tips on it appreciated 🙏 
martinfleis: Not related. 
DahnJ: > Not related.

In that case this is ready to review from my side 👍 
DahnJ: @m-richards sorry for dropping the ball on this and thanks for picking it up! Very happy for you to finalize this 🙏 
m-richards: In the PR setting pyogrio as default we seemed to have consensus on using 0.7 as the minimum so I took those tests out, but they can go back if we don't see that as a hard minimum 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed259f6d..4391142c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -69,6 +69,7 @@ files based on a bounding box, and write out a bounding box column to parquet fi
 - `GeoSeries.set_crs` can now be used to remove CRS information by passing
   `crs=None, allow_override=True` (#3316).
 - Added ``autolim`` keyword argument to ``GeoSeries.plot()`` and ``GeoDataFrame.plot()`` (#2817).
+- Added `metadata` parameter to `GeoDataFrame.to_file` (#2850)
 - Updated documentation to clarify that passing a named (Geo)Series as the `geometry`
   argument to the GeoDataFrame constructor will not use the name but will always  
   produce a GeoDataFrame with an active geometry column named "geometry" (#3337).
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 2b35629d..78104b1f 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1483,6 +1483,9 @@ properties': {'col1': 'name1'}, 'geometry': {'type': 'Point', 'coordinates': (1.
             The underlying library that is used to write the file. Currently, the
             supported options are "pyogrio" and "fiona". Defaults to "pyogrio" if
             installed, otherwise tries "fiona".
+        metadata : dict[str, str], default None
+            Optional metadata to be stored in the file. Keys and values must be
+            strings. Supported only for "GPKG" driver.
         **kwargs :
             Keyword args to be passed to the engine, and can be used to write
             to multi-layer data, store data within archives (zip files), etc.
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index 4d39ffdb..dae1f46b 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 import os
 import urllib.request
 import warnings
@@ -26,7 +28,6 @@ _VALID_URLS.discard("")
 # file:// URIs are supported by fiona/pyogrio -> don't already open + read the file here
 _VALID_URLS.discard("file")
 
-
 fiona = None
 fiona_env = None
 fiona_import_error = None
@@ -103,6 +104,20 @@ def _check_pyogrio(func):
         )
 
 
+def _check_metadata_supported(metadata: str | None, engine: str, driver: str) -> None:
+    if metadata is None:
+        return
+    if driver != "GPKG":
+        raise NotImplementedError(
+            "The 'metadata' keyword is only supported for the GPKG driver."
+        )
+
+    if engine == "fiona" and not FIONA_GE_19:
+        raise NotImplementedError(
+            "The 'metadata' keyword is only supported for Fiona >= 1.9."
+        )
+
+
 def _check_engine(engine, func):
     # if not specified through keyword or option, then default to "pyogrio" if
     # installed, otherwise try fiona
@@ -565,6 +580,7 @@ def _to_file(
     mode="w",
     crs=None,
     engine=None,
+    metadata=None,
     **kwargs,
 ):
     """
@@ -620,6 +636,10 @@ def _to_file(
         supported options are "pyogrio" and "fiona". Defaults to "pyogrio" if
         installed, otherwise tries "fiona". Engine can also be set globally
         with the ``geopandas.options.io_engine`` option.
+    metadata : dict[str, str], default None
+        Optional metadata to be stored in the file. Keys and values must be
+        strings. Only supported for the "GPKG" driver
+        (requires Fiona >= 1.9 or pyogrio >= 0.6).
     **kwargs :
         Keyword args to be passed to the engine, and can be used to write
         to multi-layer data, store data within archives (zip files), etc.
@@ -663,19 +683,20 @@ def _to_file(
             "to a supported format like a well-known text (WKT) using "
             "`GeoSeries.to_wkt()`.",
         )
+    _check_metadata_supported(metadata, engine, driver)
 
     if mode not in ("w", "a"):
         raise ValueError(f"'mode' should be one of 'w' or 'a', got '{mode}' instead")
 
     if engine == "pyogrio":
-        _to_file_pyogrio(df, filename, driver, schema, crs, mode, **kwargs)
+        _to_file_pyogrio(df, filename, driver, schema, crs, mode, metadata, **kwargs)
     elif engine == "fiona":
-        _to_file_fiona(df, filename, driver, schema, crs, mode, **kwargs)
+        _to_file_fiona(df, filename, driver, schema, crs, mode, metadata, **kwargs)
     else:
         raise ValueError(f"unknown engine '{engine}'")
 
 
-def _to_file_fiona(df, filename, driver, schema, crs, mode, **kwargs):
+def _to_file_fiona(df, filename, driver, schema, crs, mode, metadata, **kwargs):
     if not HAS_PYPROJ and crs:
         raise ImportError(
             "The 'pyproj' package is required to write a file with a CRS, but it is not"
@@ -707,10 +728,12 @@ def _to_file_fiona(df, filename, driver, schema, crs, mode, **kwargs):
         with fiona.open(
             filename, mode=mode, driver=driver, crs_wkt=crs_wkt, schema=schema, **kwargs
         ) as colxn:
+            if metadata is not None:
+                colxn.update_tags(metadata)
             colxn.writerecords(df.iterfeatures())
 
 
-def _to_file_pyogrio(df, filename, driver, schema, crs, mode, **kwargs):
+def _to_file_pyogrio(df, filename, driver, schema, crs, mode, metadata, **kwargs):
     import pyogrio
 
     if schema is not None:
@@ -728,7 +751,7 @@ def _to_file_pyogrio(df, filename, driver, schema, crs, mode, **kwargs):
     if not df.columns.is_unique:
         raise ValueError("GeoDataFrame cannot contain duplicated column names.")
 
-    pyogrio.write_dataframe(df, filename, driver=driver, **kwargs)
+    pyogrio.write_dataframe(df, filename, driver=driver, metadata=metadata, **kwargs)
 
 
 def infer_schema(df):


Test patch:
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index 71036746..8072cf6c 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -26,7 +26,6 @@ from pandas.testing import assert_frame_equal, assert_series_equal
 
 try:
     import pyogrio
-
 except ImportError:
     pyogrio = False
 
@@ -1298,6 +1297,54 @@ def test_write_read_file(test_file, engine):
     os.remove(os.path.expanduser(test_file))
 
 
+@pytest.mark.skipif(fiona is False, reason="Fiona not available")
+@pytest.mark.skipif(FIONA_GE_19, reason="Fiona >= 1.9 supports metadata")
+def test_to_file_metadata_unsupported_fiona_version(tmp_path, df_points):
+    metadata = {"title": "test"}
+    tmp_file = tmp_path / "test.gpkg"
+    match = "'metadata' keyword is only supported for Fiona >= 1.9"
+    with pytest.raises(NotImplementedError, match=match):
+        df_points.to_file(tmp_file, driver="GPKG", engine="fiona", metadata=metadata)
+
+
+@pytest.mark.skipif(not FIONA_GE_19, reason="only Fiona >= 1.9 supports metadata")
+def test_to_file_metadata_supported_fiona_version(tmp_path, df_points):
+    metadata = {"title": "test"}
+    tmp_file = tmp_path / "test.gpkg"
+
+    df_points.to_file(tmp_file, driver="GPKG", engine="fiona", metadata=metadata)
+
+    # Check that metadata is written to the file
+    with fiona.open(tmp_file) as src:
+        tags = src.tags()
+        assert tags == metadata
+
+
+@pytest.mark.skipif(pyogrio is False, reason="Pyogrio not available")
+def test_to_file_metadata_pyogrio(tmp_path, df_points):
+    metadata = {"title": "test"}
+    tmp_file = tmp_path / "test.gpkg"
+
+    df_points.to_file(tmp_file, driver="GPKG", engine="pyogrio", metadata=metadata)
+
+    # Check that metadata is written to the file
+    info = pyogrio.read_info(tmp_file)
+    layer_metadata = info["layer_metadata"]
+    assert layer_metadata == metadata
+
+
+@pytest.mark.parametrize(
+    "driver, ext", [("ESRI Shapefile", ".shp"), ("GeoJSON", ".geojson")]
+)
+def test_to_file_metadata_unsupported_driver(driver, ext, tmpdir, df_points, engine):
+    metadata = {"title": "Test"}
+    tempfilename = os.path.join(str(tmpdir), "test" + ext)
+    with pytest.raises(
+        NotImplementedError, match="'metadata' keyword is only supported for"
+    ):
+        df_points.to_file(tempfilename, driver=driver, metadata=metadata)
+
+
 def test_multiple_geom_cols_error(tmpdir, df_nybb):
     df_nybb["geom2"] = df_nybb.geometry
     with pytest.raises(ValueError, match="GeoDataFrame contains multiple geometry"):