Repository: GenericMappingTools/pygmt

Issue:
Support passing in pathlib.Path into pygmt modules
**Description of the desired feature**

_Originally posted by @liamtoney in https://github.com/GenericMappingTools/pygmt/issues/949#issuecomment-790796932_

> I'd add `Path` objects if they're not already supported. :)

The [`pathlib.Path`](https://docs.python.org/3.9/library/pathlib.html) is a cross-platform way of handling filepaths. Currently PyGMT supports passing in filenames as a `str`, but it would be good to ensure that `pathlib.Path` works too.

<!-- Please be as detailed as you can in your description. If possible, include an example of how you would like to use this feature (even better if it's a code example). -->

## What needs to be done :ballot_box_with_check:

1. This if-statement needs to be modified:

https://github.com/GenericMappingTools/pygmt/blob/11c13012c2c8a70006ac86d62e6f97f437451f82/pygmt/helpers/utils.py#L69-L70

2. Add some unit tests with `pathlib.Path` inputs, best placed in https://github.com/GenericMappingTools/pygmt/blob/master/pygmt/tests/test_info.py.

## How to make the change :monocle_face:

1. First, read up our contributing guidelines at https://www.pygmt.org/v0.4.0/contributing.html#editing-the-documentation
2. Leave a comment below, stating that you will tackle this issue. We will assign you to this issue and you can then start working on it.
3. To edit the utils.py file, go to https://github.com/GenericMappingTools/pygmt/edit/master/pygmt/helpers/utils.py and make the change!. Or you may want to do this in your code editor locally.
4. Submit a pull request, and be sure to fill it up with as much detail as possible.


**Are you willing to help implement and maintain this feature?** Happy to guide someone with some familiarity with Python Path objects to work on this. Will also be a great opportunity for someone wanting to learn how to write unit tests.

<!-- Every feature we add is code that we will have to maintain and keep updated. This takes a lot of effort. If you are willing to be involved in the project and help maintain your feature, it will make it easier for us to accept it. -->


Discussion:
welcome[bot]: 💖 Thanks for opening this pull request! 💖

Please make sure you read our [contributing guidelines](https://github.com/GenericMappingTools/pygmt/blob/master/CONTRIBUTING.md) and abide by our [code of conduct](https://github.com/GenericMappingTools/pygmt/blob/master/CODE_OF_CONDUCT.md).

A few things to keep in mind:

* If you need help writing tests, take a look at the existing ones for inspiration. If you don't know where to start, let us know and we'll walk you through it.
* All new features should be documented. It helps to write the docstrings for your functions/classes before writing the code. This will help you think about your code design and results in better code.
* No matter what, we are really grateful that you put in the effort to do this! 🎉

weiji14: Hmm, supporting pathlib.Path might not be as simple as I thought. We'll need to convert the `pathlib.Path` object into a `str` in order for the GMT C API to understand it. Do you want to walk through the `virtualfile_from_data` function at https://github.com/GenericMappingTools/pygmt/blob/master/pygmt/clib/session.py and see if you can find a good place to do the `pathlib.Path`->`str` conversion? Or we could change the line at https://github.com/GenericMappingTools/pygmt/blob/60a92b3ee2cc37cf9277a2fd623cffd2b52abcba/pygmt/helpers/utils.py#L106 to say `yield str(arg)` perhaps?
aitorres: I'm sorry for the delay! I've examined the `virtualfile_from_data` function and modified the conditional statement to cast `data` to `str` in presence of `"file"` data kind, instead of modifying the `dummy_context` function.

I've pushed my changes to my branch already, and will keep an eye on the tests in case something fails again. Thanks for your help!
seisman: Ping @aitorres to finalize this PR if you have time. Otherwise, I'll take over this PR and make edits to this branch.

Patch:
diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py
index 89026a71..a775a119 100644
--- a/pygmt/clib/session.py
+++ b/pygmt/clib/session.py
@@ -1376,9 +1376,10 @@ class Session:
         check_kind : str
             Used to validate the type of data that can be passed in. Choose
             from 'raster', 'vector' or None. Default is None (no validation).
-        data : str or xarray.DataArray or {table-like} or None
-            Any raster or vector data format. This could be a file name, a
-            raster grid, a vector matrix/arrays, or other supported data input.
+        data : str or pathlib.Path or xarray.DataArray or {table-like} or None
+            Any raster or vector data format. This could be a file name or
+            path, a raster grid, a vector matrix/arrays, or other supported
+            data input.
         x/y/z : 1d arrays or None
             x, y and z columns as numpy arrays.
         extra_arrays : list of 1d arrays
@@ -1439,8 +1440,11 @@ class Session:
         }[kind]
 
         # Ensure the data is an iterable (Python list or tuple)
-        if kind in ("file", "geojson", "grid"):
+        if kind in ("geojson", "grid"):
             _data = (data,)
+        elif kind == "file":
+            # Useful to handle `pathlib.Path` and string file path alike
+            _data = (str(data),)
         elif kind == "vectors":
             _data = [np.atleast_1d(x), np.atleast_1d(y)]
             if z is not None:
diff --git a/pygmt/helpers/utils.py b/pygmt/helpers/utils.py
index 177e3d23..fe4910c2 100644
--- a/pygmt/helpers/utils.py
+++ b/pygmt/helpers/utils.py
@@ -2,6 +2,7 @@
 Utilities and common tasks for wrapping the GMT modules.
 """
 import os
+import pathlib
 import shutil
 import subprocess
 import sys
@@ -21,6 +22,7 @@ def data_kind(data, x=None, y=None, z=None):
     Possible types:
 
     * a file name provided as 'data'
+    * a pathlib.Path provided as 'data'
     * an xarray.DataArray provided as 'data'
     * a matrix provided as 'data'
     * 1D arrays x and y (and z, optionally)
@@ -30,9 +32,9 @@ def data_kind(data, x=None, y=None, z=None):
 
     Parameters
     ----------
-    data : str or xarray.DataArray or {table-like} or None
-        Pass in either a file name to an ASCII data table, an
-        :class:`xarray.DataArray`, a 1D/2D
+    data : str or pathlib.Path or xarray.DataArray or {table-like} or None
+        Pass in either a file name or :class:`pathlib.Path` to an ASCII data
+        table, an :class:`xarray.DataArray`, a 1D/2D
         {table-classes}.
     x/y : 1d arrays or None
         x and y columns as numpy arrays.
@@ -50,12 +52,15 @@ def data_kind(data, x=None, y=None, z=None):
 
     >>> import numpy as np
     >>> import xarray as xr
+    >>> import pathlib
     >>> data_kind(data=None, x=np.array([1, 2, 3]), y=np.array([4, 5, 6]))
     'vectors'
     >>> data_kind(data=np.arange(10).reshape((5, 2)), x=None, y=None)
     'matrix'
     >>> data_kind(data="my-data-file.txt", x=None, y=None)
     'file'
+    >>> data_kind(data=pathlib.Path("my-data-file.txt"), x=None, y=None)
+    'file'
     >>> data_kind(data=xr.DataArray(np.random.rand(4, 3)))
     'grid'
     """
@@ -66,7 +71,7 @@ def data_kind(data, x=None, y=None, z=None):
     if data is None and (x is None or y is None):
         raise GMTInvalidInput("Must provided both x and y.")
 
-    if isinstance(data, str):
+    if isinstance(data, (str, pathlib.PurePath)):
         kind = "file"
     elif isinstance(data, xr.DataArray):
         kind = "grid"


Test patch:
diff --git a/pygmt/tests/test_info.py b/pygmt/tests/test_info.py
index b364a33b..e5b593c9 100644
--- a/pygmt/tests/test_info.py
+++ b/pygmt/tests/test_info.py
@@ -2,6 +2,8 @@
 Tests for gmtinfo.
 """
 import os
+import pathlib
+import sys
 
 import numpy as np
 import numpy.testing as npt
@@ -29,6 +31,40 @@ def test_info():
     assert output == expected_output
 
 
+@pytest.mark.parametrize(
+    "table",
+    [
+        pathlib.Path(POINTS_DATA),
+        pytest.param(
+            pathlib.PureWindowsPath(POINTS_DATA),
+            marks=pytest.mark.skipif(
+                sys.platform != "win32",
+                reason="PureWindowsPath is only available on Windows",
+            ),
+        ),
+        pytest.param(
+            pathlib.PurePosixPath(POINTS_DATA),
+            marks=pytest.mark.skipif(
+                sys.platform == "win32",
+                reason="PurePosixPath is not available on Windows",
+            ),
+        ),
+    ],
+)
+def test_info_path(table):
+    """
+    Make sure info works on a pathlib.Path input.
+    """
+    output = info(table=table)
+    expected_output = (
+        f"{POINTS_DATA}: N = 20 "
+        "<11.5309/61.7074> "
+        "<-2.9289/7.8648> "
+        "<0.1412/0.9338>\n"
+    )
+    assert output == expected_output
+
+
 def test_info_2d_list():
     """
     Make sure info works on a 2d list.