From 28b69943f631a3d99bd4e2b79e762e7b2cb2c11e Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Thu, 4 Mar 2021 14:19:45 +1300 Subject: [PATCH 1/9] Add intersphinx mapping to geopandas docs --- doc/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/conf.py b/doc/conf.py index 9583ed7f8a4..e312c136208 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -48,6 +48,7 @@ # intersphinx configuration intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), + "geopandas": ("https://geopandas.org/", None), "numpy": ("https://numpy.org/doc/stable/", None), "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None), "xarray": ("https://xarray.pydata.org/en/stable/", None), From 7d0a3a73856727bf4c83b363fce3110db6c6d7db Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Thu, 4 Mar 2021 18:01:48 +1300 Subject: [PATCH 2/9] Create tempfile_from_geojson function to handle __geo_interface__ Initial stab at allowing PyGMT to accept Python objects that implement __geo_interface__, i.e. a GeoJSON-like format. Works by conversion to a temporary OGR_GMT file, which can then be read natively by GMT . This is currently tested via `pygmt.info` in test_geopandas.py on a geopandas.GeoDataFrame object only. Will handle raw GeoJSON dict-like objects properly via fiona in subsequent iterations. --- pygmt/clib/session.py | 14 +++++++--- pygmt/helpers/__init__.py | 2 +- pygmt/helpers/tempfile.py | 26 +++++++++++++++++++ pygmt/helpers/utils.py | 2 ++ pygmt/tests/test_geopandas.py | 48 +++++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 pygmt/tests/test_geopandas.py diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py index 64bcd55cf4e..85c8ce6e444 100644 --- a/pygmt/clib/session.py +++ b/pygmt/clib/session.py @@ -25,7 +25,7 @@ GMTInvalidInput, GMTVersionError, ) -from pygmt.helpers import data_kind, dummy_context +from pygmt.helpers import data_kind, dummy_context, tempfile_from_geojson FAMILIES = [ "GMT_IS_DATASET", @@ -1412,12 +1412,18 @@ def virtualfile_from_data(self, check_kind=None, data=None, x=None, y=None, z=No if check_kind == "raster" and kind not in ("file", "grid"): raise GMTInvalidInput(f"Unrecognized data type for grid: {type(data)}") - if check_kind == "vector" and kind not in ("file", "matrix", "vectors"): - raise GMTInvalidInput(f"Unrecognized data type: {type(data)}") + if check_kind == "vector" and kind not in ( + "file", + "matrix", + "vectors", + "geojson", + ): + raise GMTInvalidInput(f"Unrecognized data type for vector: {type(data)}") # Decide which virtualfile_from_ function to use _virtualfile_from = { "file": dummy_context, + "geojson": tempfile_from_geojson, "grid": self.virtualfile_from_grid, # Note: virtualfile_from_matrix is not used because a matrix can be # converted to vectors instead, and using vectors allows for better @@ -1427,7 +1433,7 @@ def virtualfile_from_data(self, check_kind=None, data=None, x=None, y=None, z=No }[kind] # Ensure the data is an iterable (Python list or tuple) - if kind in ("file", "grid"): + if kind in ("file", "geojson", "grid"): _data = (data,) elif kind == "vectors": _data = (x, y, z) diff --git a/pygmt/helpers/__init__.py b/pygmt/helpers/__init__.py index 84aaad0fb9a..5e070a7ddb1 100644 --- a/pygmt/helpers/__init__.py +++ b/pygmt/helpers/__init__.py @@ -2,7 +2,7 @@ Functions, classes, decorators, and context managers to help wrap GMT modules. """ from pygmt.helpers.decorators import fmt_docstring, kwargs_to_strings, use_alias -from pygmt.helpers.tempfile import GMTTempFile, unique_name +from pygmt.helpers.tempfile import GMTTempFile, tempfile_from_geojson, unique_name from pygmt.helpers.utils import ( args_in_kwargs, build_arg_string, diff --git a/pygmt/helpers/tempfile.py b/pygmt/helpers/tempfile.py index 6b14d769015..d4557f80ef9 100644 --- a/pygmt/helpers/tempfile.py +++ b/pygmt/helpers/tempfile.py @@ -3,6 +3,7 @@ """ import os import uuid +from contextlib import contextmanager from tempfile import NamedTemporaryFile import numpy as np @@ -104,3 +105,28 @@ def loadtxt(self, **kwargs): Data read from the text file. """ return np.loadtxt(self.name, **kwargs) + + +@contextmanager +def tempfile_from_geojson(geojson): + """ + Saves any geo-like Python object which implements ``__geo_interface__`` + (e.g. a geopandas GeoDataFrame) to a temporary OGR_GMT text file. + + Parameters + ---------- + geojson : geopandas.GeoDataFrame + A geopandas GeoDataFrame, or any geo-like Python object which + implements __geo_interface__, i.e. a GeoJSON + + Yields + ------ + tmpfilename : str + A temporary OGR_GMT format file holding the geographical data. + E.g. 'track-1a2b3c4.tsv'. + """ + with GMTTempFile(suffix=".gmt") as tmpfile: + os.remove(tmpfile.name) # ensure file is deleted first + # Using geopandas.to_file to directly export to OGR_GMT format + geojson.to_file(filename=tmpfile.name, driver="OGR_GMT", mode="w") + yield tmpfile.name diff --git a/pygmt/helpers/utils.py b/pygmt/helpers/utils.py index 749ebe63e5b..b5a9df2e048 100644 --- a/pygmt/helpers/utils.py +++ b/pygmt/helpers/utils.py @@ -67,6 +67,8 @@ def data_kind(data, x=None, y=None, z=None): kind = "file" elif isinstance(data, xr.DataArray): kind = "grid" + elif hasattr(data, "__geo_interface__"): + kind = "geojson" elif data is not None: kind = "matrix" else: diff --git a/pygmt/tests/test_geopandas.py b/pygmt/tests/test_geopandas.py new file mode 100644 index 00000000000..f1e034a106f --- /dev/null +++ b/pygmt/tests/test_geopandas.py @@ -0,0 +1,48 @@ +""" +Tests on integration with geopandas. +""" +import numpy.testing as npt +import pytest +from pygmt import info + +gpd = pytest.importorskip("geopandas") +shapely = pytest.importorskip("shapely") + + +@pytest.fixture(scope="module", name="gdf") +def fixture_gdf(): + """ + Create a sample geopandas GeoDataFrame object with shapely geometries of + different types. + """ + linestring = shapely.geometry.LineString([(20, 15), (30, 15)]) + polygon = shapely.geometry.Polygon([(20, 10), (23, 10), (23, 14), (20, 14)]) + multipolygon = shapely.geometry.shape( + { + "type": "MultiPolygon", + "coordinates": [ + [ + [[0, 0], [20, 0], [10, 20], [0, 0]], # Counter-clockwise + [[3, 2], [10, 16], [17, 2], [3, 2]], # Clockwise + ], + [[[6, 4], [14, 4], [10, 12], [6, 4]]], # Counter-clockwise + [[[25, 5], [30, 10], [35, 5], [25, 5]]], + ], + } + ) + # Multipolygon first so the OGR_GMT file has @GMULTIPOLYGON in the header + gdf = gpd.GeoDataFrame( + index=["multipolygon", "polygon", "linestring"], + geometry=[multipolygon, polygon, linestring], + ) + + return gdf + + +def test_geopandas_info_geodataframe(gdf): + """ + Check that info can return the bounding box region from a + geopandas.GeoDataFrame. + """ + output = info(table=gdf, per_column=True) + npt.assert_allclose(actual=output, desired=[0.0, 35.0, 0.0, 20.0]) From e7012d4230729331dac9497d565ef0a25f1f5e51 Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Fri, 5 Mar 2021 14:54:01 +1300 Subject: [PATCH 3/9] Add geopandas.GeoDataFrame to list of allow table inputs to info [skip ci] --- pygmt/src/info.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pygmt/src/info.py b/pygmt/src/info.py index 34a7a59787e..5ba3d074266 100644 --- a/pygmt/src/info.py +++ b/pygmt/src/info.py @@ -31,7 +31,8 @@ def info(table, **kwargs): Parameters ---------- - table : str or np.ndarray or pandas.DataFrame or xarray.Dataset + table : str or numpy.ndarray or pandas.DataFrame or xarray.Dataset or + geopandas.GeoDataFrame Pass in either a file name to an ASCII data table, a 1D/2D numpy array, a pandas dataframe, or an xarray dataset made up of 1D xarray.DataArray data variables. From 2acb0ab67ecaa7acadd4ec715b5f2e6d2ed9c93f Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Wed, 10 Mar 2021 18:03:41 +1300 Subject: [PATCH 4/9] Handle generic __geo_interface__ objects via fiona and geopandas Hacky attempt to handle non-geopandas objects (e.g. shapely.geometry) that have a __geo_interface__ dictionary property associated with it. Still assumes that geopandas is installed (along with fiona), so not a perfect solution. Also added another test with different geometry types. --- pygmt/helpers/tempfile.py | 30 ++++++++++++++++++++++++++++-- pygmt/tests/test_geopandas.py | 18 ++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/pygmt/helpers/tempfile.py b/pygmt/helpers/tempfile.py index d4557f80ef9..3ed1af826bb 100644 --- a/pygmt/helpers/tempfile.py +++ b/pygmt/helpers/tempfile.py @@ -127,6 +127,32 @@ def tempfile_from_geojson(geojson): """ with GMTTempFile(suffix=".gmt") as tmpfile: os.remove(tmpfile.name) # ensure file is deleted first - # Using geopandas.to_file to directly export to OGR_GMT format - geojson.to_file(filename=tmpfile.name, driver="OGR_GMT", mode="w") + ogrgmt_kwargs = dict(filename=tmpfile.name, driver="OGR_GMT", mode="w") + try: + # Using geopandas.to_file to directly export to OGR_GMT format + geojson.to_file(**ogrgmt_kwargs) + except AttributeError: + # pylint: disable=import-outside-toplevel + # Other 'geo' formats which implement __geo_interface__ + import json + + import fiona + import geopandas as gpd + + with fiona.Env(): + jsontext = json.dumps(geojson.__geo_interface__) + # Do Input/Output via Fiona virtual memory + with fiona.io.MemoryFile(file_or_bytes=jsontext.encode()) as memfile: + geoseries = gpd.GeoSeries.from_file(filename=memfile) + geoseries.to_file(**ogrgmt_kwargs) + + # with memfile.open(driver="GeoJSON") as collection: + # # Get schema from GeoJSON + # schema = collection.schema + # # Write to temporary OGR_GMT format file + # with fiona.open( + # fp=tmpfile.name, mode="w", driver="OGR_GMT", schema=schema + # ) as ogrgmtfile: + # ogrgmtfile.write(geojson.__geo_interface__) + yield tmpfile.name diff --git a/pygmt/tests/test_geopandas.py b/pygmt/tests/test_geopandas.py index f1e034a106f..d7f04e6cb45 100644 --- a/pygmt/tests/test_geopandas.py +++ b/pygmt/tests/test_geopandas.py @@ -46,3 +46,21 @@ def test_geopandas_info_geodataframe(gdf): """ output = info(table=gdf, per_column=True) npt.assert_allclose(actual=output, desired=[0.0, 35.0, 0.0, 20.0]) + + +@pytest.mark.parametrize( + "geomtype,desired", + [ + ("multipolygon", [0.0, 35.0, 0.0, 20.0]), + ("polygon", [20.0, 23.0, 10.0, 14.0]), + ("linestring", [20.0, 30.0, 15.0, 15.0]), + ], +) +def test_geopandas_info_shapely(gdf, geomtype, desired): + """ + Check that info can return the bounding box region from a shapely.geometry + object that has a __geo_interface__ property. + """ + geom = gdf.loc[geomtype].geometry + output = info(table=geom, per_column=True) + npt.assert_allclose(actual=output, desired=desired) From 329e4802e060c8f7bdabd84bb0465404208e534f Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Mon, 5 Apr 2021 09:32:42 +1200 Subject: [PATCH 5/9] Install geopandas on the Python 3.9/NumPy 1.20 test build only --- .github/workflows/ci_tests.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci_tests.yaml b/.github/workflows/ci_tests.yaml index 328de2ced94..fd09cf99b98 100644 --- a/.github/workflows/ci_tests.yaml +++ b/.github/workflows/ci_tests.yaml @@ -45,11 +45,14 @@ jobs: # python-version: 3.7 # isDraft: true # Pair Python 3.7 with NumPy 1.17 and Python 3.9 with NumPy 1.20 + # Only install geopandas on Python 3.9/NumPy 1.20 include: - python-version: 3.7 numpy-version: '1.17' + geopandas: '' - python-version: 3.9 numpy-version: '1.20' + geopandas: 'geopandas' defaults: run: shell: bash -l {0} @@ -87,7 +90,7 @@ jobs: - name: Install dependencies run: | conda install gmt=6.1.1 numpy=${{ matrix.numpy-version }} \ - pandas xarray netCDF4 packaging \ + pandas xarray netCDF4 packaging ${{ matrix.geopandas }} \ codecov coverage[toml] dvc ipython make \ pytest-cov pytest-mpl pytest>=6.0 \ sphinx-gallery From a662f7dd1a0c252b7905e9b72f80f9eed28907c5 Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Mon, 5 Apr 2021 09:49:07 +1200 Subject: [PATCH 6/9] Mention optional geopandas dependency in install and maintenance docs --- MAINTENANCE.md | 9 ++++++--- doc/install.rst | 5 +++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/MAINTENANCE.md b/MAINTENANCE.md index f493eda2b39..08f6b7a8269 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -78,9 +78,12 @@ There are 9 configuration files located in `.github/workflows`: This is run on every commit to the *master* and Pull Request branches. It is also scheduled to run daily on the *master* branch. - In draft Pull Requests, only two jobs on Linux (minimum NEP29 Python/NumPy versions - and latest Python/NumPy versions) are triggered to save on Continuous Integration - resources. + In draft Pull Requests, only two jobs on Linux are triggered to save on + Continuous Integration resources: + + - Minimum [NEP29](https://numpy.org/neps/nep-0029-deprecation_policy) + Python/NumPy versions + - Latest Python/NumPy versions + GeoPandas 3. `ci_docs.yml` (Build documentation on Linux/macOS/Windows) diff --git a/doc/install.rst b/doc/install.rst index b3a000b1143..0432105d035 100644 --- a/doc/install.rst +++ b/doc/install.rst @@ -86,9 +86,10 @@ PyGMT requires the following libraries to be installed: * `netCDF4 `__ * `packaging `__ -The following are optional (but recommended) dependencies: +The following are optional dependencies: -* `IPython `__: For embedding the figures in Jupyter notebooks. +* `IPython `__: For embedding the figures in Jupyter notebooks (recommended). +* `GeoPandas `__: For using and plotting GeoDataFrame objects. Installing GMT and other dependencies ------------------------------------- From c8eedb2b0af4f9702347874cbb8c46728041aff9 Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Mon, 10 May 2021 12:03:17 +1200 Subject: [PATCH 7/9] Future proof wording to allow other optional packages besides geopandas Taking a hint from db3641051ca5e4a13c1baedce9b07a7ca096f589. --- .github/workflows/ci_tests.yaml | 9 +++++---- doc/maintenance.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci_tests.yaml b/.github/workflows/ci_tests.yaml index a07d5827d4d..8967ad675dc 100644 --- a/.github/workflows/ci_tests.yaml +++ b/.github/workflows/ci_tests.yaml @@ -45,14 +45,14 @@ jobs: # python-version: 3.7 # isDraft: true # Pair Python 3.7 with NumPy 1.17 and Python 3.9 with NumPy 1.20 - # Only install geopandas on Python 3.9/NumPy 1.20 + # Only install optional packages on Python 3.9/NumPy 1.20 include: - python-version: 3.7 numpy-version: '1.17' - geopandas: '' + optional-packages: '' - python-version: 3.9 numpy-version: '1.20' - geopandas: 'geopandas' + optional-packages: 'geopandas' defaults: run: shell: bash -l {0} @@ -91,7 +91,8 @@ jobs: run: | conda install -c conda-forge/label/dev gmt=6.2.0rc1 conda install numpy=${{ matrix.numpy-version }} \ - pandas xarray netCDF4 packaging ${{ matrix.geopandas }} \ + pandas xarray netCDF4 packaging \ + ${{ matrix.optional-packages }} \ codecov coverage[toml] dvc ipython make \ pytest-cov pytest-mpl pytest>=6.0 \ sphinx-gallery diff --git a/doc/maintenance.md b/doc/maintenance.md index d90f8a52783..ea5f1892e4b 100644 --- a/doc/maintenance.md +++ b/doc/maintenance.md @@ -70,7 +70,7 @@ There are 9 configuration files located in `.github/workflows`: - Minimum [NEP29](https://numpy.org/neps/nep-0029-deprecation_policy) Python/NumPy versions - - Latest Python/NumPy versions + GeoPandas + - Latest Python/NumPy versions + optional packages (e.g. GeoPandas) 3. `ci_docs.yml` (Build documentation on Linux/macOS/Windows) From 34de789e30e1c2b71fff11840baeb0e504bdd734 Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Thu, 13 May 2021 11:16:25 +1200 Subject: [PATCH 8/9] Tweak tempfile_from_geojson docstring and remove unused fiona code Co-Authored-By: Michael Grund --- pygmt/helpers/tempfile.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/pygmt/helpers/tempfile.py b/pygmt/helpers/tempfile.py index 3ed1af826bb..4f243a100fa 100644 --- a/pygmt/helpers/tempfile.py +++ b/pygmt/helpers/tempfile.py @@ -111,19 +111,20 @@ def loadtxt(self, **kwargs): def tempfile_from_geojson(geojson): """ Saves any geo-like Python object which implements ``__geo_interface__`` - (e.g. a geopandas GeoDataFrame) to a temporary OGR_GMT text file. + (e.g. a geopandas.GeoDataFrame or shapely.geometry) to a temporary OGR_GMT + text file. Parameters ---------- geojson : geopandas.GeoDataFrame A geopandas GeoDataFrame, or any geo-like Python object which - implements __geo_interface__, i.e. a GeoJSON + implements __geo_interface__, i.e. a GeoJSON. Yields ------ tmpfilename : str A temporary OGR_GMT format file holding the geographical data. - E.g. 'track-1a2b3c4.tsv'. + E.g. '1a2b3c4d5e6.gmt'. """ with GMTTempFile(suffix=".gmt") as tmpfile: os.remove(tmpfile.name) # ensure file is deleted first @@ -146,13 +147,4 @@ def tempfile_from_geojson(geojson): geoseries = gpd.GeoSeries.from_file(filename=memfile) geoseries.to_file(**ogrgmt_kwargs) - # with memfile.open(driver="GeoJSON") as collection: - # # Get schema from GeoJSON - # schema = collection.schema - # # Write to temporary OGR_GMT format file - # with fiona.open( - # fp=tmpfile.name, mode="w", driver="OGR_GMT", schema=schema - # ) as ogrgmtfile: - # ogrgmtfile.write(geojson.__geo_interface__) - yield tmpfile.name From bf72a622cbb041ecefed60182f4ac4b7f5d0c2ca Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Mon, 17 May 2021 11:31:34 +1200 Subject: [PATCH 9/9] Add geopandas.GeoDataFrame to standardized table-classes filler text --- pygmt/helpers/decorators.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/pygmt/helpers/decorators.py b/pygmt/helpers/decorators.py index 6d359ed2c54..d3d66a6cb98 100644 --- a/pygmt/helpers/decorators.py +++ b/pygmt/helpers/decorators.py @@ -203,11 +203,12 @@ def fmt_docstring(module_func): Parameters ---------- - data : str or numpy.ndarray or pandas.DataFrame or xarray.Dataset + data : str or numpy.ndarray or pandas.DataFrame or xarray.Dataset or geo... Pass in either a file name to an ASCII data table, a 2D - :class:`numpy.ndarray`, a :class:`pandas.DataFrame`, or an + :class:`numpy.ndarray`, a :class:`pandas.DataFrame`, an :class:`xarray.Dataset` made up of 1D :class:`xarray.DataArray` - data variables containing the tabular data. + data variables, or a :class:`geopandas.GeoDataFrame` containing the + tabular data. region : str or list *Required if this is the first plot command*. *xmin/xmax/ymin/ymax*\ [**+r**][**+u**\ *unit*]. @@ -241,9 +242,10 @@ def fmt_docstring(module_func): ] ) filler_text["table-classes"] = ( - ":class:`numpy.ndarray`, a :class:`pandas.DataFrame`, or an\n" + ":class:`numpy.ndarray`, a :class:`pandas.DataFrame`, an\n" " :class:`xarray.Dataset` made up of 1D :class:`xarray.DataArray`\n" - " data variables containing the tabular data" + " data variables, or a :class:`geopandas.GeoDataFrame` containing the\n" + " tabular data" ) for marker, text in COMMON_OPTIONS.items():