Repository: geopandas/geopandas (python)

Issue:
REGR: apply over rows raises AttributeError when accessing single element
When using `apply` on a GeoDataFrame, accessing a single element within the loop raises an error. It is a new error coming from latest commits, possibly related to #2282.

```py
import geopandas

df = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres"))
df.apply(lambda row: row.continent, axis=1)

---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
/var/folders/2f/fhks6w_d0k556plcv3rfmshw0000gn/T/ipykernel_17461/1449003612.py in <module>
      2 
      3 df = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres"))
----> 4 df.apply(lambda row: row.continent, axis=1)

~/Git/geopandas/geopandas/geodataframe.py in apply(self, func, axis, raw, result_type, args, **kwargs)
   1409         )
   1410         # Reconstruct gdf if it was lost by apply
-> 1411         if self._geometry_column_name in result.columns:
   1412             # axis=1 apply will split GeometryDType to object, try and cast back
   1413             try:

~/mambaforge/envs/geo_dev/lib/python3.9/site-packages/pandas/core/generic.py in __getattr__(self, name)
   5485         ):
   5486             return self[name]
-> 5487         return object.__getattribute__(self, name)
   5488 
   5489     def __setattr__(self, name: str, value) -> None:

AttributeError: 'Series' object has no attribute 'columns'
```

The expected output here is a Series as 0.10.2 returns:
```py
0            Oceania
1             Africa
2             Africa
3      North America
4      North America
           ...      
172           Europe
173           Europe
174           Europe
175    North America
176           Africa
Length: 177, dtype: object
```

cc @m-richards 

Pull request description:
REGR: fix bug in apply when returning a series
Closes #2283 
> it's good that we didn't end up merging that just before the 0.10 release :)

Sorry about this and the other bug, hopefully there's not more.

This one at least is easy to fix, basically exactly what @jorisvandenbossche suggested. The one change is doing an isinstance check on `DataFrame` rather than `GeoDataFrame` -  to get `test_crs.py::test_apply_geodataframe` to work. I suppose this was probably why that check got taken out when I did #2060, it's still a pretty big oversight by me. 

I'm now looking into why that GeoDataFrame -> DataFrame change is neccessary - it means the raw results of apply in pandas aren't preserving the GeoDataFrame although it used to, which probably isn't great because it means the geometry column is becoming an object type column as an intermediate step.


Patch:
diff --git a/README.md b/README.md
index 226e2f0b..b66c11f2 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ for all details. GeoPandas depends on the following packages:
 - ``shapely``
 - ``fiona``
 - ``pyproj``
+- ``packaging``
 
 Further, ``matplotlib`` is an optional dependency, required
 for plotting, and [``rtree``](https://github.com/Toblerity/rtree) is an optional
diff --git a/ci/envs/38-dev.yaml b/ci/envs/310-dev.yaml
similarity index 96%
rename from ci/envs/38-dev.yaml
rename to ci/envs/310-dev.yaml
index 55cffdea..52575dbd 100644
--- a/ci/envs/38-dev.yaml
+++ b/ci/envs/310-dev.yaml
@@ -2,13 +2,14 @@ name: test
 channels:
   - conda-forge
 dependencies:
-  - python=3.8
+  - python=3.10
   - cython
   # required
   - shapely
   - fiona
   - pyproj
   - geos
+  - packaging
   # testing
   - pytest
   - pytest-cov
diff --git a/ci/envs/37-minimal.yaml b/ci/envs/37-minimal.yaml
index 97fe9782..5fd07140 100644
--- a/ci/envs/37-minimal.yaml
+++ b/ci/envs/37-minimal.yaml
@@ -9,6 +9,7 @@ dependencies:
   - pandas==0.25
   - shapely=1.6
   - fiona=1.8.13
+  - packaging
   #- pyproj
   # testing
   - pytest
diff --git a/ci/envs/37-pd10.yaml b/ci/envs/37-pd10.yaml
index a42aee38..83cfb24f 100644
--- a/ci/envs/37-pd10.yaml
+++ b/ci/envs/37-pd10.yaml
@@ -10,6 +10,7 @@ dependencies:
   - numpy=<1.19
   #- pyproj
   - geos
+  - packaging
   # testing
   - pytest
   - pytest-cov
diff --git a/ci/envs/38-no-optional-deps.yaml b/ci/envs/38-no-optional-deps.yaml
index d1d3f0cc..7261cc6f 100644
--- a/ci/envs/38-no-optional-deps.yaml
+++ b/ci/envs/38-no-optional-deps.yaml
@@ -8,6 +8,7 @@ dependencies:
   - shapely
   - fiona
   - pyproj
+  - packaging
   # testing
   - pytest
   - pytest-cov
diff --git a/ci/envs/39-rc.yaml b/ci/envs/39-rc.yaml
new file mode 100644
index 00000000..83dbb4bd
--- /dev/null
+++ b/ci/envs/39-rc.yaml
@@ -0,0 +1,32 @@
+name: test
+channels:
+  - conda-forge
+dependencies:
+  - python=3.9
+  - cython
+  # required
+  - shapely
+  - fiona
+  - pyproj
+  - packaging
+  # testing
+  - pytest
+  - pytest-cov
+  - pytest-xdist
+  - fsspec
+  # optional
+  - rtree
+  - matplotlib-base
+  - mapclassify
+  - folium
+  - xyzservices
+  - scipy
+  - geopy
+  #- geopy
+  - SQLalchemy
+  - libspatialite
+  - pyarrow
+  - pip
+  - pip:
+    # dev versions of packages
+    - git+https://github.com/pydata/pandas.git@1.4.x
diff --git a/doc/environment.yml b/doc/environment.yml
index 25fc07e4..bd8b4aef 100644
--- a/doc/environment.yml
+++ b/doc/environment.yml
@@ -39,6 +39,7 @@ dependencies:
   - libpysal=4.5.1
   - pygeos=0.10.2
   - xyzservices=2021.9.1
+  - packaging=21.0
   - pip
   - pip:
       - sphinx-toggleprompt
diff --git a/doc/source/community/contributing.rst b/doc/source/community/contributing.rst
index dfae1e1e..21444db8 100644
--- a/doc/source/community/contributing.rst
+++ b/doc/source/community/contributing.rst
@@ -155,7 +155,23 @@ An easy way to create a *GeoPandas* development environment is as follows:
 - Make sure that you have :ref:`cloned the repository <contributing.forking>`
 - ``cd`` to the *geopandas** source directory
 
-Tell conda to create a new environment, named ``geopandas_dev``, or any other name you would like
+Using the provided environment
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+*GeoPandas* provides an environment which includes the required dependencies for development.
+The environment file is located in the top level of the repo and is named ``environment-dev.yml``.
+You can create this environment by navigating to the the *GeoPandas* source directory
+and running::
+
+      conda env create -f environment-dev.yml
+
+This will create a new conda environment named ``geopandas_dev``.
+
+Creating the environment manually
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Alternatively, it is possible to create a development environment manually.  To do this,
+tell conda to create a new environment named ``geopandas_dev``, or any other name you would like
 for this environment, by running::
 
       conda create -n geopandas_dev python
@@ -163,6 +179,9 @@ for this environment, by running::
 This will create the new environment, and not touch any of your existing environments,
 nor any existing python installation.
 
+Working with the environment
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
 To work in this environment, you need to ``activate`` it. The instructions below
 should work for both Windows, Mac and Linux::
 
@@ -183,12 +202,14 @@ See the full conda docs `here <http://conda.pydata.org/docs>`__.
 
 At this point you can easily do a *development* install, as detailed in the next sections.
 
+
 3) Installing Dependencies
 --------------------------
 
 To run *GeoPandas* in an development environment, you must first install
-*GeoPandas*'s dependencies. We suggest doing so using the following commands
-(executed after your development environment has been activated)::
+*GeoPandas*'s dependencies. If you used the provided environment in section 2, skip this
+step and continue to section 4. If you created the environment manually, we suggest installing
+dependencies using the following commands (executed after your development environment has been activated)::
 
     conda install -c conda-forge pandas fiona shapely pyproj rtree pytest
 
@@ -314,8 +335,8 @@ to auto-format your code. Additionally, many editors have plugins that will
 apply ``black`` as you edit files.
 
 Optionally (but recommended), you can setup `pre-commit hooks <https://pre-commit.com/>`_
-to automatically run ``black`` and ``flake8`` when you make a git commit. This
-can be done by installing ``pre-commit``::
+to automatically run ``black`` and ``flake8`` when you make a git commit. If you did not
+use the provided development environment in ``environment-dev.yml``, you must first install ``pre-commit``::
 
    $ python -m pip install pre-commit
 
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 443c28e8..3ce5df8c 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -86,7 +86,7 @@ master_doc = "index"
 
 # General information about the project.
 project = u"GeoPandas"
-copyright = u"2013–2021, GeoPandas developers"
+copyright = u"2013–2022, GeoPandas developers"
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
diff --git a/doc/source/docs/user_guide/aggregation_with_dissolve.rst b/doc/source/docs/user_guide/aggregation_with_dissolve.rst
index f6952dd3..c8a10cd3 100644
--- a/doc/source/docs/user_guide/aggregation_with_dissolve.rst
+++ b/doc/source/docs/user_guide/aggregation_with_dissolve.rst
@@ -75,3 +75,25 @@ However it also accepts other summary statistic options as allowed by :meth:`pan
 * 'sum'
 * 'mean'
 * 'median'
+* function
+* string function name
+* list of functions and/or function names, e.g. [np.sum, 'mean']
+* dict of axis labels -> functions, function names or list of such.
+
+For example, to get the number of contries on each continent, 
+as well as the populations of the largest and smallest country of each,
+we can aggregate the ``'name'`` column using ``'count'``,
+and the ``'pop_est'`` column using ``'min'`` and ``'max'``:
+
+.. ipython:: python
+
+    world = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres"))
+    continents = world.dissolve(
+        by="continent",
+        aggfunc={
+            "name": "count",
+            "pop_est": ["min", "max"],
+        },
+    )
+
+   continents.head()
\ No newline at end of file
diff --git a/doc/source/getting_started/install.rst b/doc/source/getting_started/install.rst
index 2f65ad71..a9b889d8 100644
--- a/doc/source/getting_started/install.rst
+++ b/doc/source/getting_started/install.rst
@@ -142,6 +142,7 @@ Required dependencies:
 - `shapely`_ (interface to `GEOS`_)
 - `fiona`_ (interface to `GDAL`_)
 - `pyproj`_ (interface to `PROJ`_; version 2.2.0 or later)
+- `packaging`_
 
 Further, optional dependencies are:
 
@@ -242,3 +243,5 @@ More specifically, whether the speedups are used or not is determined by:
 .. _PROJ: https://proj.org/
 
 .. _PyGEOS: https://github.com/pygeos/pygeos/
+
+.. _packaging: https://packaging.pypa.io/en/latest/
\ No newline at end of file
diff --git a/environment-dev.yml b/environment-dev.yml
index 8b619a9a..2487d601 100644
--- a/environment-dev.yml
+++ b/environment-dev.yml
@@ -2,31 +2,41 @@ name: geopandas-dev
 channels:
     - conda-forge
 dependencies:
+    - python
     # required
     - fiona>=1.8
     - pandas>=0.25
+    - pygeos
     - pyproj>=2.2.0
     - shapely>=1.6
+    - packaging
 
-    # geodatabase access
-    - psycopg2>=2.5.1
-    - SQLAlchemy>=0.8.3
+    # testing
+    - pytest>=3.1.0
+    - pytest-cov
+    - pytest-xdist
+    - fsspec
+    - codecov
+    # styling
+    - black
+    - pre-commit
 
+    # optional
+    - folium
+    - xyzservices
+    - scipy
+    - libspatialite
+    - geoalchemy2
+    - pyarrow
+    # doctest testing
+    - pytest-doctestplus
     # geocoding
     - geopy
-
+    # geodatabase access
+    - psycopg2>=2.5.1
+    - SQLAlchemy>=0.8.3
     # plotting
     - matplotlib>=2.2
     - mapclassify
-
-    # testing
-    - pytest>=3.1.0
-    - pytest-cov
-    - codecov
-
     # spatial access methods
     - rtree>=0.8
-
-    # styling
-    - black
-    - pre-commit
diff --git a/environment.yml b/environment.yml
index e6fe56ed..3bd63d13 100644
--- a/environment.yml
+++ b/environment.yml
@@ -18,3 +18,4 @@ dependencies:
   - rasterio
   - geoplot
   - folium
+  - packaging
diff --git a/geopandas/_compat.py b/geopandas/_compat.py
index e5ac045d..b9142e67 100644
--- a/geopandas/_compat.py
+++ b/geopandas/_compat.py
@@ -1,5 +1,5 @@
 import contextlib
-from distutils.version import LooseVersion
+from packaging.version import Version
 import importlib
 import os
 import warnings
@@ -15,10 +15,12 @@ import shapely.geos
 # pandas compat
 # -----------------------------------------------------------------------------
 
-PANDAS_GE_10 = str(pd.__version__) >= LooseVersion("1.0.0")
-PANDAS_GE_11 = str(pd.__version__) >= LooseVersion("1.1.0")
-PANDAS_GE_115 = str(pd.__version__) >= LooseVersion("1.1.5")
-PANDAS_GE_12 = str(pd.__version__) >= LooseVersion("1.2.0")
+PANDAS_GE_10 = Version(pd.__version__) >= Version("1.0.0")
+PANDAS_GE_11 = Version(pd.__version__) >= Version("1.1.0")
+PANDAS_GE_115 = Version(pd.__version__) >= Version("1.1.5")
+PANDAS_GE_12 = Version(pd.__version__) >= Version("1.2.0")
+PANDAS_GE_13 = Version(pd.__version__) >= Version("1.3.0")
+PANDAS_GE_14 = Version(pd.__version__) >= Version("1.4.0rc0")
 
 
 # -----------------------------------------------------------------------------
@@ -26,9 +28,9 @@ PANDAS_GE_12 = str(pd.__version__) >= LooseVersion("1.2.0")
 # -----------------------------------------------------------------------------
 
 
-SHAPELY_GE_17 = str(shapely.__version__) >= LooseVersion("1.7.0")
-SHAPELY_GE_18 = str(shapely.__version__) >= LooseVersion("1.8")
-SHAPELY_GE_20 = str(shapely.__version__) >= LooseVersion("2.0")
+SHAPELY_GE_17 = Version(shapely.__version__) >= Version("1.7.0")
+SHAPELY_GE_18 = Version(shapely.__version__) >= Version("1.8")
+SHAPELY_GE_20 = Version(shapely.__version__) >= Version("2.0")
 
 GEOS_GE_390 = shapely.geos.geos_version >= (3, 9, 0)
 
@@ -47,10 +49,10 @@ try:
     import pygeos  # noqa
 
     # only automatically use pygeos if version is high enough
-    if str(pygeos.__version__) >= LooseVersion("0.8"):
+    if Version(pygeos.__version__) >= Version("0.8"):
         HAS_PYGEOS = True
-        PYGEOS_GE_09 = str(pygeos.__version__) >= LooseVersion("0.9")
-        PYGEOS_GE_010 = str(pygeos.__version__) >= LooseVersion("0.10")
+        PYGEOS_GE_09 = Version(pygeos.__version__) >= Version("0.9")
+        PYGEOS_GE_010 = Version(pygeos.__version__) >= Version("0.10")
     else:
         warnings.warn(
             "The installed version of PyGEOS is too old ({0} installed, 0.8 required),"
@@ -92,7 +94,7 @@ def set_use_pygeos(val=None):
             import pygeos  # noqa
 
             # validate the pygeos version
-            if not str(pygeos.__version__) >= LooseVersion("0.8"):
+            if not Version(pygeos.__version__) >= Version("0.8"):
                 raise ImportError(
                     "PyGEOS >= 0.8 is required, version {0} is installed".format(
                         pygeos.__version__
@@ -150,7 +152,7 @@ if shapely_warning is not None and not SHAPELY_GE_20:
             yield
 
 
-elif (str(np.__version__) >= LooseVersion("1.21")) and not SHAPELY_GE_20:
+elif (Version(np.__version__) >= Version("1.21")) and not SHAPELY_GE_20:
 
     @contextlib.contextmanager
     def ignore_shapely2_warnings():
@@ -224,5 +226,5 @@ except ImportError:
 # pyproj compat
 # -----------------------------------------------------------------------------
 
-PYPROJ_LT_3 = LooseVersion(pyproj.__version__) < LooseVersion("3")
-PYPROJ_GE_31 = LooseVersion(pyproj.__version__) >= LooseVersion("3.1")
+PYPROJ_LT_3 = Version(pyproj.__version__) < Version("3")
+PYPROJ_GE_31 = Version(pyproj.__version__) >= Version("3.1")
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
index e29b2213..f39f8754 100644
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -1408,7 +1408,10 @@ individually so that features may have different properties
             func, axis=axis, raw=raw, result_type=result_type, args=args, **kwargs
         )
         # Reconstruct gdf if it was lost by apply
-        if self._geometry_column_name in result.columns:
+        if (
+            isinstance(result, DataFrame)
+            and self._geometry_column_name in result.columns
+        ):
             # axis=1 apply will split GeometryDType to object, try and cast back
             try:
                 result = result.set_geometry(self._geometry_column_name)
@@ -1476,6 +1479,12 @@ individually so that features may have different properties
         aggfunc : function or string, default "first"
             Aggregation function for manipulation of data associated
             with each group. Passed to pandas `groupby.agg` method.
+            Accepted combinations are:
+
+            - function
+            - string function name
+            - list of functions and/or function names, e.g. [np.sum, 'mean']
+            - dict of axis labels -> functions, function names or list of such.
         as_index : boolean, default True
             If true, groupby columns become index of result.
         level : int or str or sequence of int or sequence of str, default None
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
index 9aab7c3e..857b82f0 100644
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -1,4 +1,4 @@
-from distutils.version import LooseVersion
+from packaging.version import Version
 import json
 import warnings
 
@@ -288,7 +288,7 @@ def _to_feather(df, path, index=None, compression=None, **kwargs):
     # TODO move this into `import_optional_dependency`
     import pyarrow
 
-    if pyarrow.__version__ < LooseVersion("0.17.0"):
+    if Version(pyarrow.__version__) < Version("0.17.0"):
         raise ImportError("pyarrow >= 0.17 required for Feather support")
 
     path = _expand_user(path)
@@ -361,7 +361,7 @@ def _get_filesystem_path(path, filesystem=None, storage_options=None):
         isinstance(path, str)
         and storage_options is None
         and filesystem is None
-        and LooseVersion(pyarrow.__version__) >= "5.0.0"
+        and Version(pyarrow.__version__) >= Version("5.0.0")
     ):
         # Use the native pyarrow filesystem if possible.
         try:
@@ -513,7 +513,7 @@ def _read_feather(path, columns=None, **kwargs):
     # TODO move this into `import_optional_dependency`
     import pyarrow
 
-    if pyarrow.__version__ < LooseVersion("0.17.0"):
+    if Version(pyarrow.__version__) < Version("0.17.0"):
         raise ImportError("pyarrow >= 0.17 required for Feather support")
 
     path = _expand_user(path)
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
index a4c9e55c..423ca860 100644
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -1,5 +1,5 @@
 import os
-from distutils.version import LooseVersion
+from packaging.version import Version
 from pathlib import Path
 import warnings
 
@@ -394,7 +394,7 @@ def _to_file(
             gdal_version = fiona.env.get_gdal_release_name()
         except AttributeError:
             gdal_version = "2.0.0"  # just assume it is not the latest
-        if LooseVersion(gdal_version) >= LooseVersion("3.0.0") and crs:
+        if Version(gdal_version) >= Version("3.0.0") and crs:
             crs_wkt = crs.to_wkt()
         elif crs:
             crs_wkt = crs.to_wkt("WKT1_GDAL")
diff --git a/geopandas/plotting.py b/geopandas/plotting.py
index e17a4a3a..603b8b87 100644
--- a/geopandas/plotting.py
+++ b/geopandas/plotting.py
@@ -6,7 +6,7 @@ from pandas.plotting import PlotAccessor
 
 import geopandas
 
-from distutils.version import LooseVersion
+from packaging.version import Version
 
 from ._decorator import doc
 
@@ -70,8 +70,8 @@ def _expand_kwargs(kwargs, multiindex):
     from matplotlib.colors import is_color_like
     from typing import Iterable
 
-    mpl = matplotlib.__version__
-    if mpl >= LooseVersion("3.4") or (mpl > LooseVersion("3.3.2") and "+" in mpl):
+    mpl = Version(matplotlib.__version__)
+    if mpl >= Version("3.4") or (mpl > Version("3.3.2") and "+" in mpl):
         # alpha is supported as array argument with matplotlib 3.4+
         scalar_kwargs = ["marker", "path_effects"]
     else:
@@ -737,7 +737,7 @@ GON (((-122.84000 49.00000, -120.0000...
         except ImportError:
             raise ImportError(mc_err)
 
-        if mapclassify.__version__ < LooseVersion("2.4.0"):
+        if Version(mapclassify.__version__) < Version("2.4.0"):
             raise ImportError(mc_err)
 
         if classification_kwds is None:
@@ -860,7 +860,8 @@ GON (((-122.84000 49.00000, -120.0000...
             **style_kwds,
         )
 
-    if missing_kwds is not None and not expl_series[nan_idx].empty:
+    missing_data = not expl_series[nan_idx].empty
+    if missing_kwds is not None and missing_data:
         if color:
             if "color" not in missing_kwds:
                 missing_kwds["color"] = color
@@ -902,7 +903,7 @@ GON (((-122.84000 49.00000, -120.0000...
                         markeredgewidth=0,
                     )
                 )
-            if missing_kwds is not None:
+            if missing_kwds is not None and missing_data:
                 if "color" in merged_kwds:
                     merged_kwds["facecolor"] = merged_kwds["color"]
                 patches.append(
diff --git a/readthedocs.yml b/readthedocs.yml
index 6e710093..af979a2f 100644
--- a/readthedocs.yml
+++ b/readthedocs.yml
@@ -1,9 +1,12 @@
 version: 2
-formats: []
-conda:
-    environment: doc/environment.yml
+build:
+  os: ubuntu-20.04
+  tools:
+    python: mambaforge-4.10
 python:
-    version: 3
-    install:
-        - method: pip
-          path: .
+  install:
+  - method: pip
+    path: .
+conda:
+  environment: doc/environment.yml
+formats: []
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 07be51c3..8adc89ed 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -3,6 +3,7 @@ fiona>=1.8
 pandas>=0.25
 pyproj>=2.2.0
 shapely>=1.6
+packaging
 
 # geodatabase access
 psycopg2>=2.5.1
diff --git a/setup.py b/setup.py
index f029a5fc..2a58918a 100644
--- a/setup.py
+++ b/setup.py
@@ -34,6 +34,7 @@ else:
         "shapely >= 1.6",
         "fiona >= 1.8",
         "pyproj >= 2.2.0",
+        "packaging",
     ]
 
 # get all data dirs in the datasets module


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 6de9213a..e86739a3 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -60,7 +60,10 @@ jobs:
             os: windows-latest
             postgis: false
             dev: false
-          - env: ci/envs/38-dev.yaml
+          - env: ci/envs/39-rc.yaml
+            os: ubuntu-latest
+            dev: true
+          - env: ci/envs/310-dev.yaml
             os: ubuntu-latest
             dev: true
 
diff --git a/ci/envs/310-latest-conda-forge.yaml b/ci/envs/310-latest-conda-forge.yaml
index b6c94f89..eb4fe0ce 100644
--- a/ci/envs/310-latest-conda-forge.yaml
+++ b/ci/envs/310-latest-conda-forge.yaml
@@ -9,6 +9,7 @@ dependencies:
   - fiona
   - pyproj
   - pygeos
+  - packaging
   # testing
   - pytest
   - pytest-cov
diff --git a/ci/envs/37-latest-conda-forge.yaml b/ci/envs/37-latest-conda-forge.yaml
index d9f37abe..dadd2c40 100644
--- a/ci/envs/37-latest-conda-forge.yaml
+++ b/ci/envs/37-latest-conda-forge.yaml
@@ -9,6 +9,7 @@ dependencies:
   - fiona
   - pyproj
   - pygeos
+  - packaging
   # testing
   - pytest
   - pytest-cov
@@ -25,4 +26,3 @@ dependencies:
   - SQLalchemy
   - libspatialite
   - pyarrow
-
diff --git a/ci/envs/37-latest-defaults.yaml b/ci/envs/37-latest-defaults.yaml
index 7a9a1fb2..071d3eac 100644
--- a/ci/envs/37-latest-defaults.yaml
+++ b/ci/envs/37-latest-defaults.yaml
@@ -9,6 +9,7 @@ dependencies:
   - fiona
   - pyproj
   - geos
+  - packaging
   # testing
   - pytest
   - pytest-cov
diff --git a/ci/envs/38-latest-conda-forge.yaml b/ci/envs/38-latest-conda-forge.yaml
index cf84fcf9..6d2348f1 100644
--- a/ci/envs/38-latest-conda-forge.yaml
+++ b/ci/envs/38-latest-conda-forge.yaml
@@ -4,11 +4,12 @@ channels:
 dependencies:
   - python=3.8
   # required
-  - pandas=1.3.2  # temporary pin because 1.3.3 has regression for overlay (GH2101)
+  - pandas=1.2
   - shapely
   - fiona
   - pyproj
   - pygeos
+  - packaging
   # testing
   - pytest
   - pytest-cov
@@ -30,4 +31,4 @@ dependencies:
   - geoalchemy2
   - pyarrow
   # doctest testing
-  - pytest-doctestplus
\ No newline at end of file
+  - pytest-doctestplus
diff --git a/ci/envs/39-latest-conda-forge.yaml b/ci/envs/39-latest-conda-forge.yaml
index 05825b20..49c7f642 100644
--- a/ci/envs/39-latest-conda-forge.yaml
+++ b/ci/envs/39-latest-conda-forge.yaml
@@ -4,11 +4,12 @@ channels:
 dependencies:
   - python=3.9
   # required
-  - pandas
+  - pandas=1.3
   - shapely
   - fiona
   - pyproj
   - pygeos
+  - packaging
   # testing
   - pytest
   - pytest-cov
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
index 4732599a..438719ed 100644
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -1,6 +1,6 @@
 from __future__ import absolute_import
 
-from distutils.version import LooseVersion
+from packaging.version import Version
 import os
 
 import pytest
@@ -38,7 +38,7 @@ pytestmark = pytest.mark.filterwarnings("ignore:.*initial implementation of Parq
         pytest.param(
             "feather",
             marks=pytest.mark.skipif(
-                pyarrow.__version__ < LooseVersion("0.17.0"),
+                Version(pyarrow.__version__) < Version("0.17.0"),
                 reason="needs pyarrow >= 0.17",
             ),
         ),
@@ -271,7 +271,7 @@ def test_parquet_compression(compression, tmpdir):
 
 
 @pytest.mark.skipif(
-    pyarrow.__version__ < LooseVersion("0.17.0"),
+    Version(pyarrow.__version__) < Version("0.17.0"),
     reason="Feather only supported for pyarrow >= 0.17",
 )
 @pytest.mark.parametrize("compression", ["uncompressed", "lz4", "zstd"])
@@ -489,7 +489,7 @@ def test_missing_crs(tmpdir, file_format):
 
 
 @pytest.mark.skipif(
-    pyarrow.__version__ >= LooseVersion("0.17.0"),
+    Version(pyarrow.__version__) >= Version("0.17.0"),
     reason="Feather only supported for pyarrow >= 0.17",
 )
 def test_feather_arrow_version(tmpdir):
@@ -536,7 +536,7 @@ def test_non_fsspec_url_with_storage_options_raises():
 
 
 @pytest.mark.skipif(
-    pyarrow.__version__ < LooseVersion("5.0.0"),
+    Version(pyarrow.__version__) < Version("5.0.0"),
     reason="pyarrow.fs requires pyarrow>=5.0.0",
 )
 def test_prefers_pyarrow_fs():
diff --git a/geopandas/io/tests/test_file.py b/geopandas/io/tests/test_file.py
index 7c13d119..16f26787 100644
--- a/geopandas/io/tests/test_file.py
+++ b/geopandas/io/tests/test_file.py
@@ -1,6 +1,6 @@
 from collections import OrderedDict
 import datetime
-from distutils.version import LooseVersion
+from packaging.version import Version
 import io
 import os
 import pathlib
@@ -24,7 +24,7 @@ from geopandas.tests.util import PACKAGE_DIR, validate_boro_df
 import pytest
 
 
-FIONA_GE_1814 = str(fiona.__version__) >= LooseVersion("1.8.14")  # datetime roundtrip
+FIONA_GE_1814 = Version(fiona.__version__) >= Version("1.8.14")  # datetime roundtrip
 
 
 _CRS = "epsg:4326"
diff --git a/geopandas/io/tests/test_pickle.py b/geopandas/io/tests/test_pickle.py
index bd27adad..85bbcba5 100644
--- a/geopandas/io/tests/test_pickle.py
+++ b/geopandas/io/tests/test_pickle.py
@@ -3,7 +3,7 @@ See generate_legacy_storage_files.py for the creation of the legacy files.
 
 """
 from contextlib import contextmanager
-from distutils.version import LooseVersion
+from packaging.version import Version
 import glob
 import os
 import pathlib
@@ -48,7 +48,7 @@ def with_use_pygeos(option):
 
 
 @pytest.mark.skipif(
-    compat.USE_PYGEOS or (str(pyproj.__version__) < LooseVersion("2.4")),
+    compat.USE_PYGEOS or (Version(pyproj.__version__) < Version("2.4")),
     reason=(
         "pygeos-based unpickling currently only works for pygeos-written files; "
         "old pyproj versions can't read pickles from newer pyproj versions"
diff --git a/geopandas/tests/test_crs.py b/geopandas/tests/test_crs.py
index ca237b0c..b7be6663 100644
--- a/geopandas/tests/test_crs.py
+++ b/geopandas/tests/test_crs.py
@@ -1,4 +1,4 @@
-from distutils.version import LooseVersion
+from packaging.version import Version
 import os
 
 import random
@@ -18,7 +18,7 @@ import pytest
 
 # pyproj 2.3.1 fixed a segfault for the case working in an environment with
 # 'init' dicts (https://github.com/pyproj4/pyproj/issues/415)
-PYPROJ_LT_231 = LooseVersion(pyproj.__version__) < LooseVersion("2.3.1")
+PYPROJ_LT_231 = Version(pyproj.__version__) < Version("2.3.1")
 
 
 def _create_df(x, y=None, crs=None):
diff --git a/geopandas/tests/test_explore.py b/geopandas/tests/test_explore.py
index 25a34fb6..b95d0969 100644
--- a/geopandas/tests/test_explore.py
+++ b/geopandas/tests/test_explore.py
@@ -2,7 +2,7 @@ import geopandas as gpd
 import numpy as np
 import pandas as pd
 import pytest
-from distutils.version import LooseVersion
+from packaging.version import Version
 
 folium = pytest.importorskip("folium")
 branca = pytest.importorskip("branca")
@@ -13,7 +13,7 @@ import matplotlib.cm as cm  # noqa
 import matplotlib.colors as colors  # noqa
 from branca.colormap import StepColormap  # noqa
 
-BRANCA_05 = str(branca.__version__) > LooseVersion("0.4.2")
+BRANCA_05 = Version(branca.__version__) > Version("0.4.2")
 
 
 class TestExplore:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
index 120ffb36..ef35434f 100644
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -2,7 +2,7 @@ import json
 import os
 import shutil
 import tempfile
-from distutils.version import LooseVersion
+from packaging.version import Version
 
 import numpy as np
 import pandas as pd
@@ -24,9 +24,9 @@ from pandas.testing import assert_frame_equal, assert_index_equal, assert_series
 import pytest
 
 
-PYPROJ_LT_3 = LooseVersion(pyproj.__version__) < LooseVersion("3")
+PYPROJ_LT_3 = Version(pyproj.__version__) < Version("3")
 TEST_NEAREST = compat.PYGEOS_GE_010 and compat.USE_PYGEOS
-pandas_133 = pd.__version__ == LooseVersion("1.3.3")
+pandas_133 = Version(pd.__version__) == Version("1.3.3")
 
 
 @pytest.fixture
diff --git a/geopandas/tests/test_overlay.py b/geopandas/tests/test_overlay.py
index f1458937..14ea914a 100644
--- a/geopandas/tests/test_overlay.py
+++ b/geopandas/tests/test_overlay.py
@@ -1,5 +1,5 @@
 import os
-from distutils.version import LooseVersion
+from packaging.version import Version
 
 import numpy as np
 import pandas as pd
@@ -18,7 +18,7 @@ DATA = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data", "overlay
 
 
 pytestmark = pytest.mark.skip_no_sindex
-pandas_133 = pd.__version__ == LooseVersion("1.3.3")
+pandas_133 = Version(pd.__version__) == Version("1.3.3")
 
 
 @pytest.fixture
diff --git a/geopandas/tests/test_pandas_methods.py b/geopandas/tests/test_pandas_methods.py
index 709ad3da..72dbcffc 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -445,33 +445,44 @@ def test_unique():
     assert_array_equal(s.unique(), exp)
 
 
+def pd14_compat_index(index):
+    if compat.PANDAS_GE_14:
+        return from_shapely(index)
+    else:
+        return index
+
+
 def test_value_counts():
     # each object is considered unique
     s = GeoSeries([Point(0, 0), Point(1, 1), Point(0, 0)])
     res = s.value_counts()
     with compat.ignore_shapely2_warnings():
-        exp = pd.Series([2, 1], index=[Point(0, 0), Point(1, 1)])
+        exp = pd.Series([2, 1], index=pd14_compat_index([Point(0, 0), Point(1, 1)]))
     assert_series_equal(res, exp)
     # Check crs doesn't make a difference - note it is not kept in output index anyway
     s2 = GeoSeries([Point(0, 0), Point(1, 1), Point(0, 0)], crs="EPSG:4326")
     res2 = s2.value_counts()
     assert_series_equal(res2, exp)
+    if compat.PANDAS_GE_14:
+        # TODO should/ can we fix CRS being lost
+        assert s2.value_counts().index.array.crs is None
 
     # check mixed geometry
     s3 = GeoSeries([Point(0, 0), LineString([[1, 1], [2, 2]]), Point(0, 0)])
     res3 = s3.value_counts()
+    index = pd14_compat_index([Point(0, 0), LineString([[1, 1], [2, 2]])])
     with compat.ignore_shapely2_warnings():
-        exp3 = pd.Series([2, 1], index=[Point(0, 0), LineString([[1, 1], [2, 2]])])
+        exp3 = pd.Series([2, 1], index=index)
     assert_series_equal(res3, exp3)
 
     # check None is handled
     s4 = GeoSeries([Point(0, 0), None, Point(0, 0)])
     res4 = s4.value_counts(dropna=True)
     with compat.ignore_shapely2_warnings():
-        exp4_dropna = pd.Series([2], index=[Point(0, 0)])
+        exp4_dropna = pd.Series([2], index=pd14_compat_index([Point(0, 0)]))
     assert_series_equal(res4, exp4_dropna)
     with compat.ignore_shapely2_warnings():
-        exp4_keepna = pd.Series([2, 1], index=[Point(0, 0), None])
+        exp4_keepna = pd.Series([2, 1], index=pd14_compat_index([Point(0, 0), None]))
     res4_keepna = s4.value_counts(dropna=False)
     assert_series_equal(res4_keepna, exp4_keepna)
 
@@ -547,6 +558,44 @@ def test_groupby_groups(df):
     assert_frame_equal(res, exp)
 
 
+@pytest.mark.skipif(
+    compat.PANDAS_GE_13 and not compat.PANDAS_GE_14,
+    reason="this was broken in pandas 1.3.5 (GH-2294)",
+)
+@pytest.mark.parametrize("crs", [None, "EPSG:4326"])
+def test_groupby_metadata(crs):
+    # https://github.com/geopandas/geopandas/issues/2294
+    df = GeoDataFrame(
+        {
+            "geometry": [Point(0, 0), Point(1, 1), Point(0, 0)],
+            "value1": np.arange(3, dtype="int64"),
+            "value2": np.array([1, 2, 1], dtype="int64"),
+        },
+        crs=crs,
+    )
+
+    # dummy test asserting we can access the crs
+    def func(group):
+        assert isinstance(group, GeoDataFrame)
+        assert group.crs == crs
+
+    df.groupby("value2").apply(func)
+
+    # actual test with functionality
+    res = df.groupby("value2").apply(
+        lambda x: geopandas.sjoin(x, x[["geometry", "value1"]], how="inner")
+    )
+
+    expected = (
+        df.take([0, 2, 0, 2, 1])
+        .set_index("value2", drop=False, append=True)
+        .swaplevel()
+        .rename(columns={"value1": "value1_left"})
+        .assign(value1_right=[0, 0, 2, 2, 1])
+    )
+    assert_geodataframe_equal(res.drop(columns=["index_right"]), expected)
+
+
 def test_apply(s):
     # function that returns geometry preserves GeoSeries class
     def geom_func(geom):
@@ -603,6 +652,15 @@ def test_apply_preserves_geom_col_name(df):
     assert result.geometry.name == "geom"
 
 
+def test_df_apply_returning_series(df):
+    # https://github.com/geopandas/geopandas/issues/2283
+    result = df.apply(lambda row: row.geometry, axis=1)
+    assert_geoseries_equal(result, df.geometry, check_crs=False)
+
+    result = df.apply(lambda row: row.value1, axis=1)
+    assert_series_equal(result, df["value1"].rename(None))
+
+
 @pytest.mark.skipif(not compat.PANDAS_GE_10, reason="attrs introduced in pandas 1.0")
 def test_preserve_attrs(df):
     # https://github.com/geopandas/geopandas/issues/1654
diff --git a/geopandas/tests/test_plotting.py b/geopandas/tests/test_plotting.py
index d1d88519..d2cac730 100644
--- a/geopandas/tests/test_plotting.py
+++ b/geopandas/tests/test_plotting.py
@@ -1,4 +1,4 @@
-from distutils.version import LooseVersion
+from packaging.version import Version
 import itertools
 import warnings
 
@@ -33,7 +33,7 @@ import matplotlib.pyplot as plt  # noqa
 try:  # skipif and importorskip do not work for decorators
     from matplotlib.testing.decorators import check_figures_equal
 
-    if matplotlib.__version__ >= LooseVersion("3.3.0"):
+    if Version(matplotlib.__version__) >= Version("3.3.0"):
 
         MPL_DECORATORS = True
     else:
@@ -404,7 +404,7 @@ class TestPointPlotting:
         ):
             self.df.plot(column="cats", categories=["cat1"])
 
-    def test_misssing(self):
+    def test_missing(self):
         self.df.loc[0, "values"] = np.nan
         ax = self.df.plot("values")
         cmap = plt.get_cmap()
@@ -428,6 +428,12 @@ class TestPointPlotting:
         np.testing.assert_array_equal(point_colors[0], leg_colors[0])
         np.testing.assert_array_equal(nan_color[0], leg_colors1[0])
 
+    def test_no_missing_and_missing_kwds(self):
+        # GH2210
+        df = self.df.copy()
+        df["category"] = df["values"].astype("str")
+        df.plot("category", missing_kwds={"facecolor": "none"}, legend=True)
+
 
 class TestPointZPlotting:
     def setup_method(self):
@@ -1837,7 +1843,7 @@ def _get_ax(fig, label):
     Previously, we did `fig.axes[1]`, but in matplotlib 3.4 the order switched
     and the colorbar ax was first and subplot ax second.
     """
-    if matplotlib.__version__ < LooseVersion("3.0.0"):
+    if Version(matplotlib.__version__) < Version("3.0.0"):
         if label == "<colorbar>":
             return fig.axes[1]
         elif label == "":
diff --git a/geopandas/tools/tests/test_clip.py b/geopandas/tools/tests/test_clip.py
index f71ed394..80767478 100644
--- a/geopandas/tools/tests/test_clip.py
+++ b/geopandas/tools/tests/test_clip.py
@@ -1,7 +1,7 @@
 """Tests for the clip module."""
 
 import warnings
-from distutils.version import LooseVersion
+from packaging.version import Version
 
 import numpy as np
 import pandas as pd
@@ -24,7 +24,7 @@ import pytest
 
 
 pytestmark = pytest.mark.skip_no_sindex
-pandas_133 = pd.__version__ == LooseVersion("1.3.3")
+pandas_133 = Version(pd.__version__) == Version("1.3.3")
 
 
 @pytest.fixture
diff --git a/geopandas/tools/tests/test_sjoin.py b/geopandas/tools/tests/test_sjoin.py
index e7c6eae3..c14367c2 100644
--- a/geopandas/tools/tests/test_sjoin.py
+++ b/geopandas/tools/tests/test_sjoin.py
@@ -1,4 +1,4 @@
-from distutils.version import LooseVersion
+from packaging.version import Version
 import math
 from typing import Sequence
 from geopandas.testing import assert_geodataframe_equal
@@ -354,7 +354,7 @@ class TestSpatialJoin:
             exp.index.names = df2.index.names
 
         # GH 1364 fix of behaviour was done in pandas 1.1.0
-        if predicate == "within" and str(pd.__version__) >= LooseVersion("1.1.0"):
+        if predicate == "within" and Version(pd.__version__) >= Version("1.1.0"):
             exp = exp.sort_index()
 
         assert_frame_equal(res, exp, check_index_type=False)
diff --git a/geopandas/tools/tests/test_tools.py b/geopandas/tools/tests/test_tools.py
index b7b9faf7..7117a2a2 100644
--- a/geopandas/tools/tests/test_tools.py
+++ b/geopandas/tools/tests/test_tools.py
@@ -1,4 +1,4 @@
-from distutils.version import LooseVersion
+from packaging.version import Version
 
 from shapely.geometry import LineString, MultiPoint, Point
 import pyproj
@@ -13,7 +13,7 @@ import pytest
 
 # pyproj 2.3.1 fixed a segfault for the case working in an environment with
 # 'init' dicts (https://github.com/pyproj4/pyproj/issues/415)
-PYPROJ_LT_231 = LooseVersion(pyproj.__version__) < LooseVersion("2.3.1")
+PYPROJ_LT_231 = Version(pyproj.__version__) < Version("2.3.1")
 
 
 class TestTools: