Repository: geopandas/geopandas (python)

Issue:
BUG: dissolve with multiple aggregations produces FutureWarning
- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the latest version of geopandas.

- [x] (optional) I have confirmed this bug exists on the main branch of geopandas.

---

#### Summary

When a list (or dict of lists) is passed into the `aggfunc` argument of `GeoDataFrame.dissolve`, it produces a `FutureWarning`.

#### Code Sample, a copy-pastable example

```python
import warnings
warnings.simplefilter("always")
import geopandas
world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres'))
continents = world.dissolve(by='continent', aggfunc=
{
    'pop_est': ['sum', 'min', 'max'],
    'name': 'count',
    'gdp_md_est': ['sum', 'min', 'max'],
})
```

#### Problem description

The code above produces a `FutureWarning`:
```
/Users/martina/Library/Caches/pypoetry/virtualenvs/geopandas-J5P32w_s-py3.9/lib/python3.9/site-packages/pandas/core/frame.py:9130: FutureWarning: merging between different levels is deprecated and will be removed in a future version. (1 levels on the left,2 on the right)
  return merge(
```

#### Expected Output

This should not produce a warning. This code will break in a future version of Pandas.

While the warning is suppressed by default, it appears when enabled, e.g. in unit tests, documentation builds (PR #2303 adds such code as an example in the documentation.)

#### Output of ``geopandas.show_versions()``

<details>


SYSTEM INFO
-----------
python     : 3.9.9 (main, Nov 21 2021, 03:23:44)  [Clang 13.0.0 (clang-1300.0.29.3)]
executable : /Users/martina/Library/Caches/pypoetry/virtualenvs/geopandas-J5P32w_s-py3.9/bin/python
machine    : macOS-12.1-x86_64-i386-64bit

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.10.1
GEOS lib   : /usr/local/Cellar/geos/3.10.1_1/lib/libgeos_c.dylib
GDAL       : 3.3.0
GDAL data dir: /Users/martina/Library/Caches/pypoetry/virtualenvs/geopandas-J5P32w_s-py3.9/lib/python3.9/site-packages/fiona/gdal_data
PROJ       : 8.2.0
PROJ data dir: /Users/martina/Library/Caches/pypoetry/virtualenvs/geopandas-J5P32w_s-py3.9/lib/python3.9/site-packages/pyproj/proj_dir/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.10.2+29.g4bd0eb9
pandas     : 1.3.5
fiona      : 1.8.20
numpy      : 1.22.1
shapely    : 1.8.0
rtree      : 0.9.7
pyproj     : 3.3.0
matplotlib : 3.5.1
mapclassify: 2.4.3
geopy      : 2.2.0
psycopg2   : None
geoalchemy2: None
pyarrow    : None
pygeos     : None

</details>


Pull request:
BUG: Fix FutureWarning with multiple aggregations in dissolve
Explicitly flatten the index to avoid a Pandas FutureWarning.
This is a no-op if the index is already flat.

Fixes #2304

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/310-dev.yaml b/ci/envs/310-dev.yaml
index abff1cfd..52575dbd 100644
--- a/ci/envs/310-dev.yaml
+++ b/ci/envs/310-dev.yaml
@@ -9,6 +9,7 @@ dependencies:
   - 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
index 8b080a54..83dbb4bd 100644
--- a/ci/envs/39-rc.yaml
+++ b/ci/envs/39-rc.yaml
@@ -8,6 +8,7 @@ dependencies:
   - shapely
   - fiona
   - pyproj
+  - packaging
   # testing
   - pytest
   - pytest-cov
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/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 000db2a6..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,11 +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_14 = str(pd.__version__) >= LooseVersion("1.4.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")
 
 
 # -----------------------------------------------------------------------------
@@ -27,9 +28,9 @@ PANDAS_GE_14 = str(pd.__version__) >= LooseVersion("1.4.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)
 
@@ -48,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),"
@@ -93,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__
@@ -151,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():
@@ -225,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..b6872fa1 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
@@ -1552,6 +1561,7 @@ individually so that features may have different properties
         # Process non-spatial component
         data = self.drop(labels=self.geometry.name, axis=1)
         aggregated_data = data.groupby(**groupby_kwargs).agg(aggfunc)
+        aggregated_data.columns = aggregated_data.columns.to_flat_index()
 
         # Process spatial component
         def merge_geometries(block):
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
index 1f3bde69..5468718c 100644
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -913,7 +913,7 @@ class GeoSeries(GeoPandasBase, Series):
 
         index = []
         geometries = []
-        for idx, s in self.geometry.iteritems():
+        for idx, s in self.geometry.items():
             if s.type.startswith("Multi") or s.type == "GeometryCollection":
                 geoms = s.geoms
                 idxs = [(idx, i) for i in range(len(geoms))]
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 ba6ef8ae..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:
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/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 340d4b80..6d2348f1 100644
--- a/ci/envs/38-latest-conda-forge.yaml
+++ b/ci/envs/38-latest-conda-forge.yaml
@@ -9,6 +9,7 @@ dependencies:
   - 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 74795607..49c7f642 100644
--- a/ci/envs/39-latest-conda-forge.yaml
+++ b/ci/envs/39-latest-conda-forge.yaml
@@ -9,6 +9,7 @@ dependencies:
   - 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/testing.py b/geopandas/testing.py
index 0c897302..72ba71d8 100644
--- a/geopandas/testing.py
+++ b/geopandas/testing.py
@@ -316,7 +316,7 @@ def assert_geodataframe_equal(
     )
 
     # geometry comparison
-    for col, dtype in left.dtypes.iteritems():
+    for col, dtype in left.dtypes.items():
         if isinstance(dtype, GeometryDtype):
             assert_geoseries_equal(
                 left[col],
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_dissolve.py b/geopandas/tests/test_dissolve.py
index 20e628f4..2d887614 100644
--- a/geopandas/tests/test_dissolve.py
+++ b/geopandas/tests/test_dissolve.py
@@ -8,6 +8,8 @@ from geopandas import _compat as compat
 from pandas.testing import assert_frame_equal
 import pytest
 
+from geopandas.testing import assert_geodataframe_equal
+
 
 @pytest.fixture
 def nybb_polydf():
@@ -71,7 +73,7 @@ def test_dissolve_retains_nonexisting_crs(nybb_polydf):
     assert test.crs is None
 
 
-def first_dissolve(nybb_polydf, first):
+def test_first_dissolve(nybb_polydf, first):
     test = nybb_polydf.dissolve("manhattan_bronx")
     assert_frame_equal(first, test, check_column_type=False)
 
@@ -297,3 +299,21 @@ def test_dissolve_dropna_warn(nybb_polydf):
         UserWarning, match="dropna kwarg is not supported for pandas < 1.1.0"
     ):
         nybb_polydf.dissolve(dropna=False)
+
+
+def test_dissolve_multi_agg(nybb_polydf, merged_shapes):
+
+    merged_shapes[("BoroCode", "min")] = [3, 1]
+    merged_shapes[("BoroCode", "max")] = [5, 2]
+    merged_shapes[("BoroName", "count")] = [3, 2]
+
+    with pytest.warns(None) as record:
+        test = nybb_polydf.dissolve(
+            by="manhattan_bronx",
+            aggfunc={
+                "BoroCode": ["min", "max"],
+                "BoroName": "count",
+            },
+        )
+    assert_geodataframe_equal(test, merged_shapes)
+    assert len(record) == 0
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 ed080af4..72dbcffc 100644
--- a/geopandas/tests/test_pandas_methods.py
+++ b/geopandas/tests/test_pandas_methods.py
@@ -558,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):
@@ -614,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 55016870..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:
@@ -1843,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: