Skip to content

Commit 283a70b

Browse files
committed
Merge branch 'master' into sensible_info_outputs
2 parents b87ceb3 + 53eb1b5 commit 283a70b

23 files changed

+251
-93
lines changed

.github/workflows/cache_data.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
# Install GMT
2424
- name: Install GMT
2525
shell: bash -l {0}
26-
run: conda install -c conda-forge gmt=6.1.0
26+
run: conda install -c conda-forge gmt=6.1.1
2727

2828
# Download remote files
2929
- name: Download remote data

.github/workflows/ci_tests.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@ jobs:
6565

6666
# Setup Miniconda
6767
- name: Setup Miniconda
68-
uses: goanpeca/setup-miniconda@v1.6.0
68+
uses: conda-incubator/setup-miniconda@v1.7.0
6969
with:
7070
python-version: ${{ matrix.python-version }}
7171
channels: conda-forge
72+
miniconda-version: "latest"
7273

7374
# Install GMT and other required dependencies from conda-forge
7475
- name: Install GMT and required dependencies
@@ -77,7 +78,7 @@ jobs:
7778
requirements_file=full-conda-requirements.txt
7879
cat requirements.txt requirements-dev.txt > $requirements_file
7980
cat << EOF >> $requirements_file
80-
gmt=6.1.0
81+
gmt=6.1.1
8182
make
8283
codecov
8384
EOF

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ env:
2626
# The file with the listed requirements to be installed by conda
2727
- CONDA_REQUIREMENTS=requirements.txt
2828
- CONDA_REQUIREMENTS_DEV=requirements-dev.txt
29-
- CONDA_INSTALL_EXTRA="codecov twine gmt=6.1.0"
29+
- CONDA_INSTALL_EXTRA="codecov twine gmt=6.1.1"
3030
# These variables control which actions are performed in a build
3131
- DEPLOY=false
3232

CONTRIBUTING.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,38 @@ Leave a comment in the PR and we'll help you out.
310310

311311
### Testing plots
312312

313-
We use the [pytest-mpl](https://github.com/matplotlib/pytest-mpl) plug-in to test plot
314-
generating code.
313+
Writing an image-based test is only slightly more difficult than a simple test.
314+
The main consideration is that you must specify the "baseline" or reference
315+
image, and compare it with a "generated" or test image. This is handled using
316+
the *decorator* functions `@check_figures_equal` and
317+
`@pytest.mark.mpl_image_compare` whose usage are further described below.
318+
319+
#### Using check_figures_equal
320+
321+
This approach draws the same figure using two different methods (the reference
322+
method and the tested method), and checks that both of them are the same.
323+
It takes two `pygmt.Figure` objects ('fig_ref' and 'fig_test'), generates a png
324+
image, and checks for the Root Mean Square (RMS) error between the two.
325+
Here's an example:
326+
327+
```python
328+
@check_figures_equal()
329+
def test_my_plotting_case(fig_ref, fig_test):
330+
"Test that my plotting function works"
331+
fig_ref.grdimage("@earth_relief_01d_g", projection="W120/15c", cmap="geo")
332+
fig_test.grdimage(grid, projection="W120/15c", cmap="geo")
333+
```
334+
335+
Note: This is the recommended way to test plots whenever possible, such as when
336+
we want to compare a reference GMT plot created from NetCDF files with one
337+
generated by PyGMT that passes through several layers of virtualfile machinery.
338+
Using this method will help save space in the git repository by not having to
339+
store baseline images as with the other method below.
340+
341+
#### Using mpl_image_compare
342+
343+
This method uses the [pytest-mpl](https://github.com/matplotlib/pytest-mpl)
344+
plug-in to test plot generating code.
315345
Every time the tests are run, `pytest-mpl` compares the generated plots with known
316346
correct ones stored in `pygmt/tests/baseline`.
317347
If your test created a `pygmt.Figure` object, you can test it by adding a *decorator* and

Makefile

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,7 @@ test:
2929
@echo ""
3030
@cd $(TESTDIR); python -c "import $(PROJECT); $(PROJECT).show_versions()"
3131
@echo ""
32-
# There are two steps to the test here because `test_grdimage_over_dateline`
33-
# passes only when it runs before the other tests.
34-
# See also https://github.com/GenericMappingTools/pygmt/pull/476
35-
cd $(TESTDIR); pytest -m runfirst $(PYTEST_ARGS) $(PROJECT)
36-
cd $(TESTDIR); pytest -m 'not runfirst' $(PYTEST_ARGS) $(PROJECT)
32+
cd $(TESTDIR); pytest $(PYTEST_ARGS) $(PROJECT)
3733
cp $(TESTDIR)/coverage.xml .
3834
cp -r $(TESTDIR)/htmlcov .
3935
rm -r $(TESTDIR)

doc/install.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ Which GMT?
3131
PyGMT requires Generic Mapping Tools (GMT) version 6 as a minimum, which is the latest
3232
released version that can be found at
3333
the `GMT official site <https://www.generic-mapping-tools.org>`__.
34-
We need the latest GMT (>=6.1.0) since there are many changes being made to GMT itself in
34+
We need the latest GMT (>=6.1.1) since there are many changes being made to GMT itself in
3535
response to the development of PyGMT, mainly the new
3636
`modern execution mode <https://docs.generic-mapping-tools.org/latest/cookbook/introduction.html#modern-and-classic-mode>`__.
3737

environment.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ channels:
55
dependencies:
66
- python=3.7
77
- pip
8-
- gmt=6.1.0
8+
- gmt=6.1.1
99
- numpy
1010
- pandas
1111
- xarray

examples/gallery/grid/track_sampling.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
fig = pygmt.Figure()
2626
# Plot the earth relief grid on Cylindrical Stereographic projection, masking land areas
27-
fig.basemap(region="d", frame=True, projection="Cyl_stere/8i")
27+
fig.basemap(region="g", frame=True, projection="Cyl_stere/150/-20/8i")
2828
fig.grdimage(grid=grid, cmap="gray")
2929
fig.coast(land="#666666")
3030
# Plot using circles (c) of 0.15cm, the sampled bathymetry points

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"scripts": {
33
"build:miniconda": "curl -o ~/miniconda.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh && bash ~/miniconda.sh -b -p $HOME/miniconda",
4-
"build:pygmt": "conda env create -f environment.yml && source activate pygmt && conda install -c conda-forge -y gmt==6.1.0 && make install",
4+
"build:pygmt": "conda env create -f environment.yml && source activate pygmt && conda install -c conda-forge -y gmt==6.1.1 && make install",
55
"build:docs": "source activate pygmt && cd doc && make all && mv _build/html ../public",
66
"build": "export PATH=$HOME/miniconda/bin:$PATH && npm run build:miniconda && npm run build:pygmt && npm run build:docs"
77
}

pygmt/base_plotting.py

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
Does not define any special non-GMT methods (savefig, show, etc).
44
"""
55
import contextlib
6-
import csv
76
import numpy as np
87
import pandas as pd
98

@@ -14,7 +13,6 @@
1413
dummy_context,
1514
data_kind,
1615
fmt_docstring,
17-
GMTTempFile,
1816
use_alias,
1917
kwargs_to_strings,
2018
)
@@ -986,28 +984,16 @@ def text(
986984
if position is not None and isinstance(position, str):
987985
kwargs["F"] += f'+c{position}+t"{text}"'
988986

989-
with GMTTempFile(suffix=".txt") as tmpfile:
990-
with Session() as lib:
991-
fname = textfiles if kind == "file" else ""
992-
if kind == "vectors":
993-
if position is not None:
994-
fname = ""
995-
else:
996-
pd.DataFrame.from_dict(
997-
{
998-
"x": np.atleast_1d(x),
999-
"y": np.atleast_1d(y),
1000-
"text": np.atleast_1d(text),
1001-
}
1002-
).to_csv(
1003-
tmpfile.name,
1004-
sep="\t",
1005-
header=False,
1006-
index=False,
1007-
quoting=csv.QUOTE_NONE,
1008-
)
1009-
fname = tmpfile.name
1010-
987+
with Session() as lib:
988+
file_context = dummy_context(textfiles) if kind == "file" else ""
989+
if kind == "vectors":
990+
if position is not None:
991+
file_context = dummy_context("")
992+
else:
993+
file_context = lib.virtualfile_from_vectors(
994+
np.atleast_1d(x), np.atleast_1d(y), np.atleast_1d(text)
995+
)
996+
with file_context as fname:
1011997
arg_str = " ".join([fname, build_arg_string(kwargs)])
1012998
lib.call_module("text", arg_str)
1013999

pygmt/clib/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ class Session:
119119
"""
120120

121121
# The minimum version of GMT required
122-
required_version = "6.1.0"
122+
required_version = "6.1.1"
123123

124124
@property
125125
def session_pointer(self):

pygmt/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,9 @@ class GMTVersionError(GMTError):
4444
"""
4545
Raised when an incompatible version of GMT is being used.
4646
"""
47+
48+
49+
class GMTImageComparisonFailure(AssertionError):
50+
"""
51+
Raised when a comparison between two images fails.
52+
"""

pygmt/figure.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -316,14 +316,6 @@ def shift_origin(self, xshift=None, yshift=None):
316316
Shift plot origin in x direction.
317317
yshift : str
318318
Shift plot origin in y direction.
319-
320-
Notes
321-
-----
322-
For GMT 6.1.0, this function can't be used as the first plotting
323-
function of :meth:`pygmt.Figure`, since it relies the *region* and
324-
*projection* settings from previous commands.
325-
326-
.. TODO: Remove the notes when PyGMT bumps to GMT>=6.1.1.
327319
"""
328320
self._preprocess()
329321
args = ["-T"]

pygmt/helpers/testing.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""
2+
Helper functions for testing.
3+
"""
4+
5+
import inspect
6+
import os
7+
8+
from matplotlib.testing.compare import compare_images
9+
10+
from ..exceptions import GMTImageComparisonFailure
11+
from ..figure import Figure
12+
13+
14+
def check_figures_equal(*, tol=0.0, result_dir="result_images"):
15+
"""
16+
Decorator for test cases that generate and compare two figures.
17+
18+
The decorated function must take two arguments, *fig_ref* and *fig_test*,
19+
and draw the reference and test images on them. After the function
20+
returns, the figures are saved and compared.
21+
22+
This decorator is practically identical to matplotlib's check_figures_equal
23+
function, but adapted for PyGMT figures. See also the original code at
24+
https://matplotlib.org/3.3.1/api/testing_api.html#
25+
matplotlib.testing.decorators.check_figures_equal
26+
27+
Parameters
28+
----------
29+
tol : float
30+
The RMS threshold above which the test is considered failed.
31+
result_dir : str
32+
The directory where the figures will be stored.
33+
34+
Examples
35+
--------
36+
37+
>>> import pytest
38+
>>> import shutil
39+
40+
>>> @check_figures_equal(result_dir="tmp_result_images")
41+
... def test_check_figures_equal(fig_ref, fig_test):
42+
... fig_ref.basemap(projection="X5c", region=[0, 5, 0, 5], frame=True)
43+
... fig_test.basemap(projection="X5c", region=[0, 5, 0, 5], frame="af")
44+
>>> test_check_figures_equal()
45+
>>> assert len(os.listdir("tmp_result_images")) == 0
46+
>>> shutil.rmtree(path="tmp_result_images") # cleanup folder if tests pass
47+
48+
>>> @check_figures_equal(result_dir="tmp_result_images")
49+
... def test_check_figures_unequal(fig_ref, fig_test):
50+
... fig_ref.basemap(projection="X5c", region=[0, 5, 0, 5], frame=True)
51+
... fig_test.basemap(projection="X5c", region=[0, 3, 0, 3], frame=True)
52+
>>> with pytest.raises(GMTImageComparisonFailure):
53+
... test_check_figures_unequal()
54+
>>> for suffix in ["", "-expected", "-failed-diff"]:
55+
... assert os.path.exists(
56+
... os.path.join(
57+
... "tmp_result_images",
58+
... f"test_check_figures_unequal{suffix}.png",
59+
... )
60+
... )
61+
>>> shutil.rmtree(path="tmp_result_images") # cleanup folder if tests pass
62+
"""
63+
64+
def decorator(func):
65+
66+
os.makedirs(result_dir, exist_ok=True)
67+
old_sig = inspect.signature(func)
68+
69+
def wrapper(*args, **kwargs):
70+
try:
71+
fig_ref = Figure()
72+
fig_test = Figure()
73+
func(*args, fig_ref=fig_ref, fig_test=fig_test, **kwargs)
74+
ref_image_path = os.path.join(
75+
result_dir, func.__name__ + "-expected.png"
76+
)
77+
test_image_path = os.path.join(result_dir, func.__name__ + ".png")
78+
fig_ref.savefig(ref_image_path)
79+
fig_test.savefig(test_image_path)
80+
81+
# Code below is adapted for PyGMT, and is originally based on
82+
# matplotlib.testing.decorators._raise_on_image_difference
83+
err = compare_images(
84+
expected=ref_image_path,
85+
actual=test_image_path,
86+
tol=tol,
87+
in_decorator=True,
88+
)
89+
if err is None: # Images are the same
90+
os.remove(ref_image_path)
91+
os.remove(test_image_path)
92+
else: # Images are not the same
93+
for key in ["actual", "expected", "diff"]:
94+
err[key] = os.path.relpath(err[key])
95+
raise GMTImageComparisonFailure(
96+
"images not close (RMS %(rms).3f):\n\t%(actual)s\n\t%(expected)s "
97+
% err
98+
)
99+
finally:
100+
del fig_ref
101+
del fig_test
102+
103+
parameters = [
104+
param
105+
for param in old_sig.parameters.values()
106+
if param.name not in {"fig_test", "fig_ref"}
107+
]
108+
new_sig = old_sig.replace(parameters=parameters)
109+
wrapper.__signature__ = new_sig
110+
111+
return wrapper
112+
113+
return decorator

pygmt/helpers/utils.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def data_kind(data, x=None, y=None, z=None):
2020
Possible types:
2121
2222
* a file name provided as 'data'
23+
* an xarray.DataArray provided as 'data'
2324
* a matrix provided as 'data'
2425
* 1D arrays x and y (and z, optionally)
2526
@@ -28,8 +29,8 @@ def data_kind(data, x=None, y=None, z=None):
2829
2930
Parameters
3031
----------
31-
data : str, 2d array, or None
32-
Data file name or numpy array.
32+
data : str, xarray.DataArray, 2d array, or None
33+
Data file name, xarray.DataArray or numpy array.
3334
x/y : 1d arrays or None
3435
x and y columns as numpy arrays.
3536
z : 1d array or None
@@ -39,18 +40,21 @@ def data_kind(data, x=None, y=None, z=None):
3940
Returns
4041
-------
4142
kind : str
42-
One of: ``'file'``, ``'matrix'``, ``'vectors'``.
43+
One of: ``'file'``, ``'grid'``, ``'matrix'``, ``'vectors'``.
4344
4445
Examples
4546
--------
4647
4748
>>> import numpy as np
49+
>>> import xarray as xr
4850
>>> data_kind(data=None, x=np.array([1, 2, 3]), y=np.array([4, 5, 6]))
4951
'vectors'
5052
>>> data_kind(data=np.arange(10).reshape((5, 2)), x=None, y=None)
5153
'matrix'
5254
>>> data_kind(data='my-data-file.txt', x=None, y=None)
5355
'file'
56+
>>> data_kind(data=xr.DataArray(np.random.rand(4, 3)))
57+
'grid'
5458
5559
"""
5660
if data is None and x is None and y is None:

0 commit comments

Comments
 (0)