Repository: geopandas/geopandas
Pull request #3616 (2025-07-07 19:41:22)

[pre-commit.ci] pre-commit autoupdate
<!--pre-commit.ci start-->
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.4 → v0.12.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.4...v0.12.2)
<!--pre-commit.ci end-->


Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f4805c45..b9de378b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -5,7 +5,7 @@ ci:
 files: 'geopandas\/'
 repos:
     - repo: https://github.com/astral-sh/ruff-pre-commit
-      rev: "v0.11.4"
+      rev: "v0.12.2"
       hooks:
         - id: ruff-format
         - id: ruff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 059450d0..e00a5afb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## Version 1.2.0
+
+Bug fixes:
+
+- Fix an issue that caused an error in `GeoDataFrame.from_features` when there is no `properties` field (#3599).
+
 ## Version 1.1.1 (June 27, 2025)
 
 Bug fixes:
@@ -167,6 +173,7 @@ files based on a bounding box, and write out a bounding box column to parquet fi
   instead of its current behaviour of assuming EPSG. In the event the spiatal_ref_sys
   table is not present, or the SRID is not present, `read_postgis` will fallback
   on assuming EPSG CRS authority. (#3329)
+- Added ``GeoDataFrame.active_geometry_name`` property returning the active geometry column's name or None if no active geometry column is set (#2943).
 
 Backwards incompatible API changes:
 
@@ -309,7 +316,6 @@ New methods:
 New features and improvements:
 
 - Added ``exclusive`` parameter to ``sjoin_nearest`` method for Shapely >= 2.0 (#2877)
-- Added ``GeoDataFrame.active_geometry_name`` property returning the active geometry column's name or None if no active geometry column is set.
 - The ``to_file()`` method will now automatically detect the FlatGeoBuf driver
   for files with the `.fgb` extension (#2958)
 
diff --git a/geopandas/_config.py b/geopandas/_config.py
index 5f699818..5ecba51f 100644
--- a/geopandas/_config.py
+++ b/geopandas/_config.py
@@ -16,7 +16,7 @@ Option = namedtuple("Option", "key default_value doc validator callback")
 class Options:
     """Provide attribute-style access to configuration dict."""
 
-    def __init__(self, options):
+    def __init__(self, options: dict[str, Option]) -> None:
         super().__setattr__("_options", options)
         # populate with default values
         config = {}
@@ -25,7 +25,7 @@ class Options:
 
         super().__setattr__("_config", config)
 
-    def __setattr__(self, key, value):
+    def __setattr__(self, key, value) -> None:
         # you can't set new keys
         if key in self._config:
             option = self._options[key]
@@ -44,10 +44,10 @@ class Options:
         except KeyError:
             raise AttributeError("No such option")
 
-    def __dir__(self):
+    def __dir__(self) -> list[str]:
         return list(self._config.keys())
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         cls = self.__class__.__name__
         description = ""
         for key, option in self._options.items():
@@ -67,7 +67,7 @@ class Options:
         return f"{cls}({space}{description})"
 
 
-def _validate_display_precision(value):
+def _validate_display_precision(value: int) -> None:
     if value is not None:
         if not isinstance(value, int) or not (0 <= value <= 16):
             raise ValueError("Invalid value, needs to be an integer [0-16]")
@@ -87,7 +87,7 @@ display_precision = Option(
 )
 
 
-def _warn_use_pygeos_deprecated(_value):
+def _warn_use_pygeos_deprecated(_value) -> None:
     warnings.warn(
         "pygeos support was removed in 1.0. "
         "geopandas.use_pygeos is a no-op and will be removed in geopandas 1.1.",
@@ -95,7 +95,7 @@ def _warn_use_pygeos_deprecated(_value):
     )
 
 
-def _validate_io_engine(value):
+def _validate_io_engine(value: str) -> None:
     if value is not None:
         if value not in ("pyogrio", "fiona"):
             raise ValueError(f"Expected 'pyogrio' or 'fiona', got '{value}'")
diff --git a/geopandas/array.py b/geopandas/array.py
index 9f75908a..199f33e8 100644
--- a/geopandas/array.py
+++ b/geopandas/array.py
@@ -3,6 +3,7 @@ import numbers
 import operator
 import warnings
 from functools import lru_cache
+from typing import ClassVar
 
 import numpy as np
 import pandas as pd
@@ -1806,6 +1807,12 @@ class GeometryArray(ExtensionArray):
     def __eq__(self, other):
         return self._binop(other, operator.eq)
 
+    # https://github.com/python/typeshed/issues/2148#issuecomment-520783318
+    # Incompatible types in assignment (expression has type "None", base class
+    # "object" defined the type as "Callable[[object], int]")
+    # (Explicitly mirrored from pandas to declare non hashable)
+    __hash__: ClassVar[None]  # type: ignore[assignment]
+
     def __ne__(self, other):
         return self._binop(other, operator.ne)
 
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index 89245123..5cf37474 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -815,9 +815,7 @@ class GeoDataFrame(GeoPandasBase, DataFrame):
                 "geometry": shape(feature["geometry"]) if feature["geometry"] else None
             }
             # load properties
-            properties = feature["properties"]
-            if properties is None:
-                properties = {}
+            properties = feature.get("properties") or {}
             row.update(properties)
             rows.append(row)
         return cls(rows, columns=columns, crs=crs)
diff --git a/geopandas/io/_geoarrow.py b/geopandas/io/_geoarrow.py
index e6617faa..451cfa3f 100644
--- a/geopandas/io/_geoarrow.py
+++ b/geopandas/io/_geoarrow.py
@@ -111,7 +111,7 @@ def geopandas_to_arrow(
         struct type.
     include_z : bool, default None
         Only relevant for 'geoarrow' encoding (for WKB, the dimensionality
-        of the individial geometries is preserved).
+        of the individual geometries is preserved).
         If False, return 2D geometries. If True, include the third dimension
         in the output (if a geometry has no third dimension, the z-coordinates
         will be NaN). By default, will infer the dimensionality from the
diff --git a/pyproject.toml b/pyproject.toml
index c53a8951..d0c78f48 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -131,7 +131,7 @@ select = [
     # implicit string concatenation
     "ISC",
     # type-checking imports
-    "TCH",
+    "TC",
     # comprehensions
     "C4",
     # Ruff-specific rules
@@ -203,7 +203,10 @@ ignore = [
     # collection-literal-concatenation
     "RUF005",
     # Mutable class attributes should be annotated with `typing.ClassVar`,
-    "RUF012"
+    "RUF012",
+    # import should be at the top-level of a file
+    "PLC0415"
+
 ]
 
 [tool.ruff.lint.per-file-ignores]


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 61eac57b..698251c6 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -774,6 +774,26 @@ class TestDataFrame:
         gdf2 = GeoDataFrame.from_features(gjson_null)
 
         assert_frame_equal(gdf1, gdf2)
+        assert "properties" not in gdf1.columns
+
+    def test_from_features_no_properties(self):
+        data = {
+            "type": "FeatureCollection",
+            "features": [
+                {
+                    "type": "Feature",
+                    "geometry": {"type": "Point", "coordinates": [0.0, 90.0]},
+                },
+                {
+                    "type": "Feature",
+                    "geometry": {"type": "Point", "coordinates": [0.0, -90.0]},
+                },
+            ],
+        }
+
+        gdf = GeoDataFrame.from_features(data)
+        assert gdf.shape == (2, 1)
+        assert "properties" not in gdf.columns
 
     def test_from_features_geom_interface_feature(self):
         class Placemark: