Repository: geopandas/geopandas

Issue:
BUG: writing/reading GeoDataFrame without geometry column to Parquet no longer gives informative error message
#### Code Sample, a copy-pastable example

```python
import geopandas

gdf = geopandas.GeoDataFrame(
    {"col": [1, 2, 3]}, geometry=geopandas.points_from_xy([1, 2, 3], [1, 2, 3]), crs="EPSG:4326"
)
del gdf["geometry"]

gdf.to_parquet("test_no_geometry.parquet")
geopandas.read_parquet("test_no_geometry.parquet")

```
Using geopandas 0.14, this gave an informative error message:

```
In [5]: geopandas.read_parquet("test_no_geometry.parquet")
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[5], line 1
----> 1 geopandas.read_parquet("test_no_geometry.parquet")

File ~/conda/envs/geopandas-014/lib/python3.11/site-packages/geopandas/io/arrow.py:623, in _read_parquet(path, columns, storage_options, **kwargs)
    620     except Exception:
    621         pass
--> 623 return _arrow_to_geopandas(table, metadata)

File ~/conda/envs/geopandas-014/lib/python3.11/site-packages/geopandas/io/arrow.py:428, in _arrow_to_geopandas(table, metadata)
    425 geometry_columns = df.columns.intersection(metadata["columns"])
    427 if not len(geometry_columns):
--> 428     raise ValueError(
    429         """No geometry columns are included in the columns read from
    430         the Parquet/Feather file.  To read this file without geometry columns,
    431         use pandas.read_parquet/read_feather() instead."""
    432     )
    434 geometry = metadata["primary_column"]
    436 # Missing geometry likely indicates a subset of columns was read;
    437 # promote the first available geometry to the primary geometry.

ValueError: No geometry columns are included in the columns read from
            the Parquet/Feather file.  To read this file without geometry columns,
            use pandas.read_parquet/read_feather() instead.
```

But now with geopandas 1.0, we get:

```
In [5]: geopandas.read_parquet("test_no_geometry.parquet")
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[5], line 1
----> 1 geopandas.read_parquet("test_no_geometry_v1.parquet")

File ~/scipy/repos/geopandas/geopandas/io/arrow.py:771, in _read_parquet(path, columns, storage_options, bbox, to_pandas_kwargs, **kwargs)
    765 geo_metadata = _validate_and_decode_metadata(metadata)
    767 bbox_filter = (
    768     _get_parquet_bbox_filter(geo_metadata, bbox) if bbox is not None else None
    769 )
--> 771 if_bbox_column_exists = _check_if_covering_in_geo_metadata(geo_metadata)
    773 # by default, bbox column is not read in, so must specify which
    774 # columns are read in if it exists.
    775 if not columns and if_bbox_column_exists:

File ~/scipy/repos/geopandas/geopandas/io/arrow.py:900, in _check_if_covering_in_geo_metadata(geo_metadata)
    898 def _check_if_covering_in_geo_metadata(geo_metadata):
    899     primary_column = geo_metadata["primary_column"]
--> 900     return "covering" in geo_metadata["columns"][primary_column].keys()

KeyError: 'geometry'
```

Hints from issue comments:
@m-richards: Definitely makes sense to fix the read side of this, but this is slightly odd also because the metadata we right is also a bit strange (if you'd done gdf.drop(columns='geometry') it'd be a pd.DataFrame, so no issue. `_create_metadata` assumes that df._geometry_column_name is always valid. `primary_column` is a required field in the geoparquet spec, so arguably this should fail to write as well? Opened #3564 for the read side

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 311300f9..4fa3c6f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ New features and improvements:
 - Added `is_valid_coverage` and `invalid_coverage_edges` to GeoSeries/GeoDataFrame to allow validation of polygonal coverage (#3545).
 - Added `maximum_inscribed_circle` method from shapely to GeoSeries/GeoDataframe (#3544).
 - Added `minimum_clearance_line` method from shapely to GeoSeries/GeoDataframe (#3543).
+- Added `orient_polygons` method from shapely to GeoSeries/GeoDataFrame (#3559).
 - Added ``method`` and ``keep_collapsed`` argument to ``make_valid`` (#3548).
 - Added `simplify_coverage` method for topological simplification of polygonal coverages
   to GeoSeries/GeoDataframe (#3541).
diff --git a/doc/source/docs/reference/geoseries.rst b/doc/source/docs/reference/geoseries.rst
index 5020e51f..4782d141 100644
--- a/doc/source/docs/reference/geoseries.rst
+++ b/doc/source/docs/reference/geoseries.rst
@@ -117,6 +117,7 @@ Constructive methods and attributes
    GeoSeries.minimum_clearance_line
    GeoSeries.minimum_rotated_rectangle
    GeoSeries.normalize
+   GeoSeries.orient_polygons
    GeoSeries.remove_repeated_points
    GeoSeries.reverse
    GeoSeries.sample_points
diff --git a/geopandas/array.py b/geopandas/array.py
index 51df9dc4..1f93b606 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -695,6 +695,13 @@ class GeometryArray(ExtensionArray):
     def normalize(self):
         return GeometryArray(shapely.normalize(self._data), crs=self.crs)
 
+    def orient_polygons(self, exterior_cw=False):
+        if not SHAPELY_GE_21:
+            raise ImportError("'orient_polygons' requires shapely>=2.1.")
+        return GeometryArray(
+            shapely.orient_polygons(self._data, exterior_cw=exterior_cw), crs=self.crs
+        )
+
     def make_valid(self, method="linework", keep_collapsed=True):
         kwargs = {}
         if SHAPELY_GE_21:
diff --git a/geopandas/base.py b/geopandas/base.py
index 010c2fca..ba775f52 100644
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -2036,6 +2036,58 @@ GeometryCollection
         """
         return _delegate_geo_method("normalize", self)
 
+    def orient_polygons(self, exterior_cw=False):
+        """Returns a ``GeoSeries`` of geometries with enforced ring orientation.
+
+        Enforce a ring orientation on all polygonal elements in the ``GeoSeries``.
+
+        Forces (Multi)Polygons to use a counter-clockwise orientation for their exterior
+        ring, and a clockwise orientation for their interior rings (or the oppposite if
+        ``exterior_cw=True``).
+
+        Also processes geometries inside a GeometryCollection in the same way. Other
+        geometries are returned unchanged.
+
+        Parameters
+        ----------
+        exterior_cw : bool
+            If ``True``, exterior rings will be clockwise and interior rings will be
+            counter-clockwise.
+
+        Examples
+        --------
+
+        >>> from shapely.geometry import Polygon, LineString, Point
+        >>> s = geopandas.GeoSeries(
+        ...     [
+        ...         Polygon(
+        ...             [(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)],
+        ...             holes=[[(2, 2), (2, 4), (4, 4), (4, 2), (2, 2)]],
+        ...     ),
+        ...         LineString([(0, 0), (1, 1), (1, 0)]),
+        ...         Point(0, 0),
+        ...     ],
+        ... )
+        >>> s
+        0    POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), (2 2, ...
+        1                           LINESTRING (0 0, 1 1, 1 0)
+        2                                          POINT (0 0)
+        dtype: geometry
+
+        >>> s.orient_polygons()
+        0    POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, ...
+        1                           LINESTRING (0 0, 1 1, 1 0)
+        2                                          POINT (0 0)
+        dtype: geometry
+
+        >>> s.orient_polygons(exterior_cw=True)
+        0    POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0), (2 2, ...
+        1                           LINESTRING (0 0, 1 1, 1 0)
+        2                                          POINT (0 0)
+        dtype: geometry
+        """
+        return _delegate_geo_method("orient_polygons", self, exterior_cw=exterior_cw)
+
     def make_valid(self, method="linework", keep_collapsed=True):
         """Repairs invalid geometries.
 
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index 7ced15a9..30a48bbc 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -763,6 +763,12 @@ def _read_parquet(
     schema, metadata = _read_parquet_schema_and_metadata(path, filesystem)
 
     geo_metadata = _validate_and_decode_metadata(metadata)
+    if len(geo_metadata["columns"]) == 0:
+        raise ValueError(
+            """No geometry columns are included in the columns read from
+            the Parquet/Feather file.  To read this file without geometry columns,
+            use pandas.read_parquet/read_feather() instead."""
+        )
 
     bbox_filter = (
         _get_parquet_bbox_filter(geo_metadata, bbox) if bbox is not None else None


Test patch:
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index ad5fd3dc..5d9c4830 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -1321,3 +1321,20 @@ def test_read_parquet_bbox_points(tmp_path):
     assert len(result) == 10
     result = geopandas.read_parquet(tmp_path / "test.parquet", bbox=(3, 3, 5, 5))
     assert len(result) == 3
+
+
+def test_non_geo_parquet_read_with_proper_error(tmp_path):
+    # https://github.com/geopandas/geopandas/issues/3556
+
+    gdf = geopandas.GeoDataFrame(
+        {"col": [1, 2, 3]},
+        geometry=geopandas.points_from_xy([1, 2, 3], [1, 2, 3]),
+        crs="EPSG:4326",
+    )
+    del gdf["geometry"]
+
+    gdf.to_parquet(tmp_path / "test_no_geometry.parquet")
+    with pytest.raises(
+        ValueError, match="No geometry columns are included in the columns read"
+    ):
+        geopandas.read_parquet(tmp_path / "test_no_geometry.parquet")
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
index 42d39af9..433e91f6 100644
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -1013,6 +1013,30 @@ class TestGeomMethods:
         expected = GeoSeries([polygon2, linestring, point])
         assert_geoseries_equal(series.normalize(), expected)
 
+    @pytest.mark.skipif(not SHAPELY_GE_21, reason="requires Shapely>=2.1")
+    def test_orient_polygons(self):
+        polygon = Polygon(
+            [(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)],
+            holes=[[(2, 2), (2, 4), (4, 4), (4, 2), (2, 2)]],
+        )
+        linestring = LineString([(0, 0), (1, 1), (1, 0)])
+        point = Point(0, 0)
+        series = GeoSeries([polygon, linestring, point])
+
+        polygon2 = Polygon(
+            [(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)],
+            holes=[[(2, 2), (2, 4), (4, 4), (4, 2), (2, 2)]],
+        )
+        expected = GeoSeries([polygon2, linestring, point])
+        assert_geoseries_equal(series.orient_polygons(), expected)
+
+        polygon_cw = Polygon(
+            [(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)],
+            holes=[[(2, 2), (2, 4), (4, 4), (4, 2), (2, 2)]],
+        )
+        expected = GeoSeries([polygon_cw, linestring, point])
+        assert_geoseries_equal(series.orient_polygons(True), expected)
+
     def test_make_valid(self):
         polygon1 = Polygon([(0, 0), (0, 2), (1, 1), (2, 2), (2, 0), (1, 1), (0, 0)])
         polygon2 = Polygon([(0, 2), (0, 1), (2, 0), (0, 0), (0, 2)])